From d9a69eac61cf7b395907811edd648d8a2c8908bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Luis=20Leal=20Cardoso=20Junior?= Date: Fri, 2 Jan 2026 10:51:12 -0300 Subject: [PATCH 1/9] Introduce ExperimentStorage to start decoupling redis calls from Experiment --- lib/split/experiment.rb | 65 +++-------------- lib/split/experiment_storage.rb | 124 ++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 54 deletions(-) create mode 100644 lib/split/experiment_storage.rb diff --git a/lib/split/experiment.rb b/lib/split/experiment.rb index 16c149a4..73c5303c 100644 --- a/lib/split/experiment.rb +++ b/lib/split/experiment.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "split/experiment_storage" + module Split class Experiment attr_accessor :name @@ -26,6 +28,9 @@ def initialize(name, options = {}) @name = name.to_s + @config_storage = ExperimentStorage::ConfigStorage.new(name) + @redis_storage = ExperimentStorage::RedisStorage.new(name) + extract_alternatives_from_options(options) end @@ -50,23 +55,16 @@ def extract_alternatives_from_options(options) if alts.length == 1 if alts[0].is_a? Hash - alts = alts[0].map { |k, v| { k => v } } - end - end - - if alts.empty? - exp_config = Split.configuration.experiment_for(name) - if exp_config - alts = load_alternatives_from_configuration - options[:goals] = Split::GoalsCollection.new(@name).load_from_configuration - options[:metadata] = load_metadata_from_configuration - options[:resettable] = exp_config[:resettable] - options[:algorithm] = exp_config[:algorithm] + alts[0].map! { |k, v| { k => v } } end end options[:alternatives] = alts + if alts.empty? && @config_storage.exists? + options.merge!(@config_storage.load) + end + set_alternatives_and_options(options) # calculate probability that each alternative is the winner @@ -257,17 +255,7 @@ def delete_metadata end def load_from_redis - exp_config = redis.hgetall(experiment_config_key) - - options = { - resettable: exp_config["resettable"], - algorithm: exp_config["algorithm"], - alternatives: load_alternatives_from_redis, - goals: Split::GoalsCollection.new(@name).load_from_redis, - metadata: load_metadata_from_redis - } - - set_alternatives_and_options(options) + set_alternatives_and_options(@redis_storage.load) end def can_calculate_winning_alternatives? @@ -430,37 +418,6 @@ def experiment_config_key "experiment_configurations/#{@name}" end - def load_metadata_from_configuration - Split.configuration.experiment_for(@name)[:metadata] - end - - def load_metadata_from_redis - meta = redis.get(metadata_key) - JSON.parse(meta) unless meta.nil? - end - - def load_alternatives_from_configuration - alts = Split.configuration.experiment_for(@name)[:alternatives] - raise ArgumentError, "Experiment configuration is missing :alternatives array" unless alts - if alts.is_a?(Hash) - alts.keys - else - alts.flatten - end - end - - def load_alternatives_from_redis - alternatives = redis.lrange(@name, 0, -1) - alternatives.map do |alt| - alt = begin - JSON.parse(alt) - rescue - alt - end - Split::Alternative.new(alt, @name) - end - end - private def redis Split.redis diff --git a/lib/split/experiment_storage.rb b/lib/split/experiment_storage.rb new file mode 100644 index 00000000..a65ef17f --- /dev/null +++ b/lib/split/experiment_storage.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +module Split + class ExperimentStorage + class BaseStorage + attr_accessor :name + + def initialize(name) + @name = name + end + + def load + @data ||= load! + end + + def load! + experiment_config = load_experiment + alternatives = load_alternatives + metadata = load_metadata + goals = load_goals + + { + resettable: experiment_config[:resettable], + algorithm: experiment_config[:algorithm], + alternatives: alternatives, + goals: goals, + metadata: metadata + } + end + + def exists? + raise "implement" + end + + def load_alternatives + raise "implement" + end + + def load_metadata + raise "implement" + end + + def load_goals + raise "implement" + end + + def load_experiment + raise "implement" + end + end + + class ConfigStorage < BaseStorage + def exists? + !!Split.configuration.experiment_for(@name) + end + + def load_alternatives + alts = Split.configuration.experiment_for(@name)[:alternatives] + raise ArgumentError, "Experiment configuration is missing :alternatives array" unless alts + if alts.is_a?(Hash) + alts.keys + else + alts.flatten + end + end + + def load_metadata + Split.configuration.experiment_for(@name)[:metadata] + end + + def load_goals + Split::GoalsCollection.new(@name).load_from_configuration + end + + def load_experiment + Split.configuration.experiment_for(@name) + end + end + + class RedisStorage < BaseStorage + def exists? + redis.exists?(@name) + end + + def load_alternatives + alternatives = redis.lrange(@name, 0, -1) + alternatives.map do |alt| + alt = begin + JSON.parse(alt) + rescue + alt + end + Split::Alternative.new(alt, @name) + end + end + + def load_metadata + meta = redis.get(metadata_key) + JSON.parse(meta) unless meta.nil? + end + + def load_goals + Split::GoalsCollection.new(@name).load_from_redis + end + + def load_experiment + redis.hgetall(experiment_config_key).transform_keys(&:to_sym) + end + + def experiment_config_key + "experiment_configurations/#{@name}" + end + + def metadata_key + "#{name}:metadata" + end + + private + def redis + Split.redis + end + end + end +end From 687ca63152b27e3bb29f22f1d0d64c84fbc51092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Luis=20Leal=20Cardoso=20Junior?= Date: Fri, 2 Jan 2026 16:37:07 -0300 Subject: [PATCH 2/9] Use RedisStorage to validate if the experiment is new --- lib/split/experiment.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/split/experiment.rb b/lib/split/experiment.rb index 73c5303c..e221801c 100644 --- a/lib/split/experiment.rb +++ b/lib/split/experiment.rb @@ -97,7 +97,7 @@ def validate! end def new_record? - ExperimentCatalog.find(name).nil? + !@redis_storage.exists? end def ==(obj) From a4b88ef2a5b7e7c5a2dc52fb770e5539a3423b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Luis=20Leal=20Cardoso=20Junior?= Date: Fri, 2 Jan 2026 16:37:32 -0300 Subject: [PATCH 3/9] Validate current data with previous stored data from redis --- lib/split/experiment.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/split/experiment.rb b/lib/split/experiment.rb index e221801c..7ac1a518 100644 --- a/lib/split/experiment.rb +++ b/lib/split/experiment.rb @@ -447,11 +447,11 @@ def remove_experiment_configuration end def experiment_configuration_has_changed? - existing_experiment = Experiment.find(@name) + stored_data = @redis_storage.load - existing_experiment.alternatives.map(&:to_s) != @alternatives.map(&:to_s) || - existing_experiment.goals != @goals || - existing_experiment.metadata != @metadata + stored_data[:alternatives].map(&:to_s) != @alternatives.map(&:to_s) || + stored_data[:goals] != @goals || + stored_data[:metadata] != @metadata end def goals_collection From 396b9c305fe10f483313595c051485c07ebb1f54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Luis=20Leal=20Cardoso=20Junior?= Date: Fri, 2 Jan 2026 16:40:04 -0300 Subject: [PATCH 4/9] Avoid persisting resettable/algorithm if they did not change --- lib/split/experiment.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/split/experiment.rb b/lib/split/experiment.rb index 7ac1a518..a22a3ce5 100644 --- a/lib/split/experiment.rb +++ b/lib/split/experiment.rb @@ -83,8 +83,12 @@ def save persist_experiment_configuration end - redis.hmset(experiment_config_key, :resettable, resettable.to_s, - :algorithm, algorithm.to_s) + stored_data = @redis_storage.load + if stored_data[:resettable] != resettable.to_s || + stored_data[:algorithm] != algorithm.to_s + redis.hmset(experiment_config_key, :resettable, resettable.to_s, + :algorithm, algorithm.to_s) + end self end From e8df7bf621a3b9bd04e9f69d3e357d5402f800e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Luis=20Leal=20Cardoso=20Junior?= Date: Fri, 2 Jan 2026 16:47:47 -0300 Subject: [PATCH 5/9] Avoid fully loading the experiment if its not needed --- lib/split/user.rb | 2 +- spec/user_spec.rb | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/lib/split/user.rb b/lib/split/user.rb index 9ba7881e..7d9b90c1 100644 --- a/lib/split/user.rb +++ b/lib/split/user.rb @@ -16,7 +16,7 @@ def initialize(context, adapter = nil) def cleanup_old_experiments! return if @cleaned_up keys_without_finished(user.keys).each do |key| - experiment = ExperimentCatalog.find key_without_version(key) + experiment = Experiment.new key_without_version(key) if experiment.nil? || experiment.has_winner? || experiment.start_time.nil? user.delete key user.delete Experiment.finished_key(key) diff --git a/spec/user_spec.rb b/spec/user_spec.rb index acb51e55..23f5e5c6 100644 --- a/spec/user_spec.rb +++ b/spec/user_spec.rb @@ -48,16 +48,17 @@ end it "removes key if experiment has a winner" do - allow(Split::ExperimentCatalog).to receive(:find).with("link_color").and_return(experiment) - allow(experiment).to receive(:start_time).and_return(Date.today) - allow(experiment).to receive(:has_winner?).and_return(true) + experiment = Split::ExperimentCatalog.find_or_create("link_color", "red", "blue") + experiment.start + experiment.winner = "red" + + expect(experiment.has_winner?).to be_truthy @subject.cleanup_old_experiments! expect(@subject.keys).to be_empty end it "removes key if experiment has not started yet" do - allow(Split::ExperimentCatalog).to receive(:find).with("link_color").and_return(experiment) - allow(experiment).to receive(:has_winner?).and_return(false) + expect(Split::ExperimentCatalog.find("link_color")).to be_nil @subject.cleanup_old_experiments! expect(@subject.keys).to be_empty end @@ -66,11 +67,12 @@ let(:user_keys) { { "link_color" => "blue", "link_color:finished" => true } } it "does not remove finished key for experiment without a winner" do - allow(Split::ExperimentCatalog).to receive(:find).with("link_color").and_return(experiment) - allow(Split::ExperimentCatalog).to receive(:find).with("link_color:finished").and_return(nil) - allow(experiment).to receive(:start_time).and_return(Date.today) - allow(experiment).to receive(:has_winner?).and_return(false) + experiment = Split::ExperimentCatalog.find_or_create("link_color", "red", "blue") + experiment.start + + expect(experiment.has_winner?).to be_falsey @subject.cleanup_old_experiments! + expect(@subject.keys).to include("link_color") expect(@subject.keys).to include("link_color:finished") end From 8c0c9f9052357ba34e4e8a32b72d2052bacc0cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Luis=20Leal=20Cardoso=20Junior?= Date: Fri, 2 Jan 2026 16:55:26 -0300 Subject: [PATCH 6/9] Memoize Experiment#start_time over the period of this instance --- lib/split/experiment.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/split/experiment.rb b/lib/split/experiment.rb index a22a3ce5..d91552da 100644 --- a/lib/split/experiment.rb +++ b/lib/split/experiment.rb @@ -175,7 +175,7 @@ def start end def start_time - Split.cache(:experiment_start_times, @name) do + @start_time ||= Split.cache(:experiment_start_times, @name) do t = redis.hget(:experiment_start_times, @name) if t # Check if stored time is an integer From ca2c4021af860845832abd008e448b3c36d5ccc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Luis=20Leal=20Cardoso=20Junior?= Date: Fri, 2 Jan 2026 16:55:49 -0300 Subject: [PATCH 7/9] Avoid hset on the storage if the value did not change --- lib/split/persistence/redis_adapter.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/split/persistence/redis_adapter.rb b/lib/split/persistence/redis_adapter.rb index c69f3fe4..2093bdba 100644 --- a/lib/split/persistence/redis_adapter.rb +++ b/lib/split/persistence/redis_adapter.rb @@ -27,7 +27,7 @@ def [](field) end def []=(field, value) - Split.redis.hset(redis_key, field, value.to_s) + Split.redis.hset(redis_key, field, value.to_s) if self[field] != value.to_s expire_seconds = self.class.config[:expire_seconds] Split.redis.expire(redis_key, expire_seconds) if expire_seconds end From c9b1c56c843aa2b9abecf466d2b3934e4ce94397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Luis=20Leal=20Cardoso=20Junior?= Date: Fri, 2 Jan 2026 19:18:53 -0300 Subject: [PATCH 8/9] Simplify load_alternatives from config --- lib/split/experiment_storage.rb | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/split/experiment_storage.rb b/lib/split/experiment_storage.rb index a65ef17f..d073adfc 100644 --- a/lib/split/experiment_storage.rb +++ b/lib/split/experiment_storage.rb @@ -57,11 +57,9 @@ def exists? def load_alternatives alts = Split.configuration.experiment_for(@name)[:alternatives] raise ArgumentError, "Experiment configuration is missing :alternatives array" unless alts - if alts.is_a?(Hash) - alts.keys - else - alts.flatten - end + + alts = alts.keys if alts.is_a?(Hash) + alts.flatten end def load_metadata From eb464874607e50da91ad95a1a52cf734d48dac6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Luis=20Leal=20Cardoso=20Junior?= Date: Fri, 2 Jan 2026 19:19:07 -0300 Subject: [PATCH 9/9] Add specs for ExperimentStorage --- spec/experiment_storage_spec.rb | 59 +++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 spec/experiment_storage_spec.rb diff --git a/spec/experiment_storage_spec.rb b/spec/experiment_storage_spec.rb new file mode 100644 index 00000000..2350f91e --- /dev/null +++ b/spec/experiment_storage_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe Split::ExperimentStorage do + let(:experiment) do + { + resettable: "false", + algorithm: "Split::Algorithms::WeightedSample", + alternatives: [ "foo", "bar" ], + goals: ["purchase", "refund"], + metadata: { + "foo" => { "text" => "Something bad" }, + "bar" => { "text" => "Something good" } + } + } + end + before do + Split.configuration.experiments = { + my_exp: experiment + } + end + + context "ConfigStorage" do + let(:config_store) { Split::ExperimentStorage::ConfigStorage.new("my_exp") } + + it "loads an experiment from the configuration" do + stored_data = config_store.load + expect(stored_data).to match(experiment) + end + + it "checks if an experiment exists on the configuration" do + expect(config_store.exists?).to be_truthy + expect(Split::ExperimentStorage::ConfigStorage.new("whatever").exists?).to be_falsy + end + + it "memoizes data from the configuration by default" do + expect(config_store).to receive(:load!).once.and_call_original + config_store.load + config_store.load + end + end + + context "from Redis" do + before { Split::ExperimentCatalog.find_or_create(:my_exp) } + let(:config_store) { Split::ExperimentStorage::RedisStorage.new("my_exp") } + + it "loads an experiment from the configuration" do + stored_data = config_store.load + stored_data[:alternatives].map! { |alternative| alternative.name } + expect(stored_data).to match(experiment) + end + + it "checks if an experiment exists on the configuration" do + expect(config_store.exists?).to be_truthy + expect(Split::ExperimentStorage::ConfigStorage.new("whatever").exists?).to be_falsy + end + end +end