Skip to content

Instantly share code, notes, and snippets.

@YurySolovyov
Created November 12, 2018 10:36
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save YurySolovyov/4f2a7af96b3ad4f22461133f7395e8cd to your computer and use it in GitHub Desktop.
Save YurySolovyov/4f2a7af96b3ad4f22461133f7395e8cd to your computer and use it in GitHub Desktop.
Split Reentrant A/B Ext.
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
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