Created
November 12, 2018 10:36
-
-
Save YurySolovyov/4f2a7af96b3ad4f22461133f7395e8cd to your computer and use it in GitHub Desktop.
Split Reentrant A/B Ext.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
rails_env = ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development' | |
Split.redis = YAML.load_file(Rails.root.join('config/split.yml')).fetch(rails_env) | |
Split.configure do |config| | |
adapter = Split::Persistence::RedisAdapter.with_config( | |
lookup_by: :params_user_id, | |
namespace: 'abtesting_participant_id', | |
expire_seconds: 2_592_000, # 30 days | |
) | |
config.persistence = adapter | |
config.store_override = true # manually assign a certain version to a user per request | |
config.experiments = { | |
foo_copy_v0: { | |
alternatives: %w(long_description short_description) | |
} | |
} | |
on_experiment_clear = -> (experiment) { SplitExtension::Participations.clear_experiment(experiment) } | |
config.on_experiment_reset = on_experiment_clear | |
config.on_experiment_delete = on_experiment_clear | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
module SplitExtension | |
module Participations | |
module State | |
STARTED = 'started'.freeze | |
FINISHED = 'finished'.freeze | |
SUCCEEDED = 'succeeded'.freeze | |
end | |
module_function | |
# here and in `on_new_completion`, experiment_key comes with "version" after | |
# a colon, like: | |
# | |
# experiment_foo:9. | |
# | |
# This is not a user-related thing | |
def on_new_participation(user, experiment_key, participation_key) | |
key = participation_hash_key(user, experiment_key) | |
is_new_participation = Split.redis.hset(key, participation_key, State::STARTED) | |
if is_new_participation && block_given? | |
yield | |
end | |
end | |
def on_new_completion(user, experiment_key, participation_key, is_positive_outcome) | |
key = participation_hash_key(user, experiment_key) | |
final_state = is_positive_outcome ? State::SUCCEEDED : State::FINISHED | |
already_completed = Split.redis.hget(key, participation_key) != State::STARTED | |
Split.redis.hset(key, participation_key, final_state) | |
if !already_completed && is_positive_outcome && block_given? | |
yield | |
end | |
end | |
def clear_experiment(experiment) | |
namespace = Split::Persistence::RedisAdapter.config[:namespace] | |
# we are looking for string(s) like | |
# "foo_copy_v0:1:abtesting_participant_id:12:participations" or | |
# "foo_copy_v0:abtesting_participant_id:11:participations" | |
participation_key_pattern = /^#{experiment.name}(:\d+)?:#{namespace}:\d+:participations$/ | |
participation_keys = Split.redis.keys.select { |key| key =~ participation_key_pattern } | |
Split.redis.del(participation_keys) unless participation_keys.empty? | |
end | |
def participation_hash_key(user, experiment_key) | |
"#{experiment_key}:#{user.redis_key}:participations" | |
end | |
end | |
class ReentrantTrial < Split::Trial | |
def choose!(context = nil, options) | |
@user.cleanup_old_experiments! | |
# Only run the process once | |
return alternative if @alternative_choosen | |
if override_is_alternative? | |
self.alternative = @options[:override] | |
if should_store_alternative? && !@user[@experiment.key] | |
self.alternative.increment_participation | |
end | |
elsif @options[:disabled] || Split.configuration.disabled? | |
self.alternative = @experiment.control | |
elsif @experiment.has_winner? | |
self.alternative = @experiment.winner | |
else | |
cleanup_old_versions | |
if exclude_user? | |
self.alternative = @experiment.control | |
else | |
value = @user[@experiment.key] | |
if value | |
self.alternative = value | |
else | |
self.alternative = @experiment.next_alternative | |
run_callback context, Split.configuration.on_trial_choose | |
end | |
handle_participation_increment(options) | |
end | |
end | |
if should_store_alternative? | |
@user[@experiment.key] = alternative.name | |
end | |
@alternative_choosen = true | |
run_callback context, Split.configuration.on_trial unless @options[:disabled] || Split.configuration.disabled? | |
alternative | |
end | |
private | |
def handle_participation_increment(options) | |
participation_key = options[:participation_key] | |
if participation_key | |
Participations.on_new_participation(@user.user, @experiment.key, participation_key) do | |
self.alternative.increment_participation | |
end | |
else | |
self.alternative.increment_participation | |
end | |
end | |
end | |
end | |
module Split | |
module Helper | |
module_function | |
def ab_reentrant_test(metric_descriptor, *alternatives, options: {}) | |
control, *alternatives = alternatives | |
begin | |
experiment = Split::ExperimentCatalog.find_or_initialize(metric_descriptor, control, *alternatives) | |
alternative = if Split.configuration.enabled | |
experiment.save | |
unless (Split.configuration.experiments || {}).fetch(experiment.name.to_sym, {})[:combined_experiments].nil? | |
raise(Split::InvalidExperimentsFormatError) | |
end | |
trial = SplitExtension::ReentrantTrial.new( | |
user: ab_user, | |
experiment: experiment, | |
override: override_alternative(experiment.name), | |
exclude: exclude_visitor?, | |
disabled: split_generically_disabled? | |
) | |
alt = trial.choose!(self, options) | |
alt ? alt.name : nil | |
else | |
control_variable(experiment.control) | |
end | |
rescue Errno::ECONNREFUSED, Redis::BaseError, SocketError => e | |
raise(e) unless Split.configuration.db_failover | |
Split.configuration.db_failover_on_db_error.call(e) | |
if Split.configuration.db_failover_allow_parameter_override | |
alternative = override_alternative(experiment.name) if override_present?(experiment.name) | |
alternative = control_variable(experiment.control) if split_generically_disabled? | |
end | |
ensure | |
alternative ||= control_variable(experiment.control) | |
end | |
if block_given? | |
metadata = trial ? trial.metadata : {} | |
yield(alternative, metadata) | |
else | |
alternative | |
end | |
end | |
def ab_complete_reentrant(metric_descriptor, options = { reset: true }) | |
return if exclude_visitor? || Split.configuration.disabled? | |
metric_descriptor, goals = normalize_metric(metric_descriptor) | |
experiments = Split::Metric.possible_experiments(metric_descriptor) | |
if experiments.any? | |
experiments.each do |experiment| | |
complete_reentrant_experiment(experiment, options.merge(goals: goals)) | |
end | |
end | |
rescue => e | |
raise unless Split.configuration.db_failover | |
Split.configuration.db_failover_on_db_error.call(e) | |
end | |
def complete_reentrant_experiment(experiment, options = { reset: true }) | |
if experiment.has_winner? | |
Rails.logger.info("AB: already has winner") | |
return true | |
end | |
should_reset = experiment.resettable? && options[:reset] | |
if ab_user[experiment.finished_key] && !should_reset | |
Rails.logger.info("AB: has finished key but shouldn't reset") | |
return true | |
else | |
alternative_name = ab_user[experiment.key] | |
trial = SplitExtension::ReentrantTrial.new( | |
user: ab_user, | |
experiment: experiment, | |
alternative: alternative_name | |
) | |
participation_key = options[:participation_key] | |
participation_outcome = options[:outcome].present? | |
Rails.logger.info("AB: participation outcome #{participation_outcome}") | |
SplitExtension::Participations.on_new_completion(ab_user.user, experiment.key, participation_key, participation_outcome) do | |
trial.complete!(options[:goals], self) | |
end | |
unless trial.is_a?(SplitExtension::ReentrantTrial) | |
if should_reset | |
Rails.logger.info("AB: resetting") | |
reset!(experiment) | |
else | |
Rails.logger.info("AB: finishing experiment for ab_user") | |
ab_user[experiment.finished_key] = true | |
end | |
end | |
end | |
end | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment