Skip to content

Commit cca970a

Browse files
authored
Merge pull request #742 from splitrb/refactor-experiment-load
Refactor Experiment Storage In order to reduce the amount of Redis Calls made during a single experiment
2 parents 39817b8 + eb46487 commit cca970a

6 files changed

Lines changed: 217 additions & 73 deletions

File tree

lib/split/experiment.rb

Lines changed: 23 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require "split/experiment_storage"
4+
35
module Split
46
class Experiment
57
attr_accessor :name
@@ -26,6 +28,9 @@ def initialize(name, options = {})
2628

2729
@name = name.to_s
2830

31+
@config_storage = ExperimentStorage::ConfigStorage.new(name)
32+
@redis_storage = ExperimentStorage::RedisStorage.new(name)
33+
2934
extract_alternatives_from_options(options)
3035
end
3136

@@ -50,23 +55,16 @@ def extract_alternatives_from_options(options)
5055

5156
if alts.length == 1
5257
if alts[0].is_a? Hash
53-
alts = alts[0].map { |k, v| { k => v } }
54-
end
55-
end
56-
57-
if alts.empty?
58-
exp_config = Split.configuration.experiment_for(name)
59-
if exp_config
60-
alts = load_alternatives_from_configuration
61-
options[:goals] = Split::GoalsCollection.new(@name).load_from_configuration
62-
options[:metadata] = load_metadata_from_configuration
63-
options[:resettable] = exp_config[:resettable]
64-
options[:algorithm] = exp_config[:algorithm]
58+
alts[0].map! { |k, v| { k => v } }
6559
end
6660
end
6761

6862
options[:alternatives] = alts
6963

64+
if alts.empty? && @config_storage.exists?
65+
options.merge!(@config_storage.load)
66+
end
67+
7068
set_alternatives_and_options(options)
7169

7270
# calculate probability that each alternative is the winner
@@ -85,8 +83,12 @@ def save
8583
persist_experiment_configuration
8684
end
8785

88-
redis.hmset(experiment_config_key, :resettable, resettable.to_s,
89-
:algorithm, algorithm.to_s)
86+
stored_data = @redis_storage.load
87+
if stored_data[:resettable] != resettable.to_s ||
88+
stored_data[:algorithm] != algorithm.to_s
89+
redis.hmset(experiment_config_key, :resettable, resettable.to_s,
90+
:algorithm, algorithm.to_s)
91+
end
9092
self
9193
end
9294

@@ -99,7 +101,7 @@ def validate!
99101
end
100102

101103
def new_record?
102-
ExperimentCatalog.find(name).nil?
104+
!@redis_storage.exists?
103105
end
104106

105107
def ==(obj)
@@ -173,7 +175,7 @@ def start
173175
end
174176

175177
def start_time
176-
Split.cache(:experiment_start_times, @name) do
178+
@start_time ||= Split.cache(:experiment_start_times, @name) do
177179
t = redis.hget(:experiment_start_times, @name)
178180
if t
179181
# Check if stored time is an integer
@@ -257,17 +259,7 @@ def delete_metadata
257259
end
258260

259261
def load_from_redis
260-
exp_config = redis.hgetall(experiment_config_key)
261-
262-
options = {
263-
resettable: exp_config["resettable"],
264-
algorithm: exp_config["algorithm"],
265-
alternatives: load_alternatives_from_redis,
266-
goals: Split::GoalsCollection.new(@name).load_from_redis,
267-
metadata: load_metadata_from_redis
268-
}
269-
270-
set_alternatives_and_options(options)
262+
set_alternatives_and_options(@redis_storage.load)
271263
end
272264

273265
def can_calculate_winning_alternatives?
@@ -430,37 +422,6 @@ def experiment_config_key
430422
"experiment_configurations/#{@name}"
431423
end
432424

433-
def load_metadata_from_configuration
434-
Split.configuration.experiment_for(@name)[:metadata]
435-
end
436-
437-
def load_metadata_from_redis
438-
meta = redis.get(metadata_key)
439-
JSON.parse(meta) unless meta.nil?
440-
end
441-
442-
def load_alternatives_from_configuration
443-
alts = Split.configuration.experiment_for(@name)[:alternatives]
444-
raise ArgumentError, "Experiment configuration is missing :alternatives array" unless alts
445-
if alts.is_a?(Hash)
446-
alts.keys
447-
else
448-
alts.flatten
449-
end
450-
end
451-
452-
def load_alternatives_from_redis
453-
alternatives = redis.lrange(@name, 0, -1)
454-
alternatives.map do |alt|
455-
alt = begin
456-
JSON.parse(alt)
457-
rescue
458-
alt
459-
end
460-
Split::Alternative.new(alt, @name)
461-
end
462-
end
463-
464425
private
465426
def redis
466427
Split.redis
@@ -490,11 +451,11 @@ def remove_experiment_configuration
490451
end
491452

492453
def experiment_configuration_has_changed?
493-
existing_experiment = Experiment.find(@name)
454+
stored_data = @redis_storage.load
494455

495-
existing_experiment.alternatives.map(&:to_s) != @alternatives.map(&:to_s) ||
496-
existing_experiment.goals != @goals ||
497-
existing_experiment.metadata != @metadata
456+
stored_data[:alternatives].map(&:to_s) != @alternatives.map(&:to_s) ||
457+
stored_data[:goals] != @goals ||
458+
stored_data[:metadata] != @metadata
498459
end
499460

500461
def goals_collection

lib/split/experiment_storage.rb

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# frozen_string_literal: true
2+
3+
module Split
4+
class ExperimentStorage
5+
class BaseStorage
6+
attr_accessor :name
7+
8+
def initialize(name)
9+
@name = name
10+
end
11+
12+
def load
13+
@data ||= load!
14+
end
15+
16+
def load!
17+
experiment_config = load_experiment
18+
alternatives = load_alternatives
19+
metadata = load_metadata
20+
goals = load_goals
21+
22+
{
23+
resettable: experiment_config[:resettable],
24+
algorithm: experiment_config[:algorithm],
25+
alternatives: alternatives,
26+
goals: goals,
27+
metadata: metadata
28+
}
29+
end
30+
31+
def exists?
32+
raise "implement"
33+
end
34+
35+
def load_alternatives
36+
raise "implement"
37+
end
38+
39+
def load_metadata
40+
raise "implement"
41+
end
42+
43+
def load_goals
44+
raise "implement"
45+
end
46+
47+
def load_experiment
48+
raise "implement"
49+
end
50+
end
51+
52+
class ConfigStorage < BaseStorage
53+
def exists?
54+
!!Split.configuration.experiment_for(@name)
55+
end
56+
57+
def load_alternatives
58+
alts = Split.configuration.experiment_for(@name)[:alternatives]
59+
raise ArgumentError, "Experiment configuration is missing :alternatives array" unless alts
60+
61+
alts = alts.keys if alts.is_a?(Hash)
62+
alts.flatten
63+
end
64+
65+
def load_metadata
66+
Split.configuration.experiment_for(@name)[:metadata]
67+
end
68+
69+
def load_goals
70+
Split::GoalsCollection.new(@name).load_from_configuration
71+
end
72+
73+
def load_experiment
74+
Split.configuration.experiment_for(@name)
75+
end
76+
end
77+
78+
class RedisStorage < BaseStorage
79+
def exists?
80+
redis.exists?(@name)
81+
end
82+
83+
def load_alternatives
84+
alternatives = redis.lrange(@name, 0, -1)
85+
alternatives.map do |alt|
86+
alt = begin
87+
JSON.parse(alt)
88+
rescue
89+
alt
90+
end
91+
Split::Alternative.new(alt, @name)
92+
end
93+
end
94+
95+
def load_metadata
96+
meta = redis.get(metadata_key)
97+
JSON.parse(meta) unless meta.nil?
98+
end
99+
100+
def load_goals
101+
Split::GoalsCollection.new(@name).load_from_redis
102+
end
103+
104+
def load_experiment
105+
redis.hgetall(experiment_config_key).transform_keys(&:to_sym)
106+
end
107+
108+
def experiment_config_key
109+
"experiment_configurations/#{@name}"
110+
end
111+
112+
def metadata_key
113+
"#{name}:metadata"
114+
end
115+
116+
private
117+
def redis
118+
Split.redis
119+
end
120+
end
121+
end
122+
end

lib/split/persistence/redis_adapter.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def [](field)
2727
end
2828

2929
def []=(field, value)
30-
Split.redis.hset(redis_key, field, value.to_s)
30+
Split.redis.hset(redis_key, field, value.to_s) if self[field] != value.to_s
3131
expire_seconds = self.class.config[:expire_seconds]
3232
Split.redis.expire(redis_key, expire_seconds) if expire_seconds
3333
end

lib/split/user.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def initialize(context, adapter = nil)
1616
def cleanup_old_experiments!
1717
return if @cleaned_up
1818
keys_without_finished(user.keys).each do |key|
19-
experiment = ExperimentCatalog.find key_without_version(key)
19+
experiment = Experiment.new key_without_version(key)
2020
if experiment.nil? || experiment.has_winner? || experiment.start_time.nil?
2121
user.delete key
2222
user.delete Experiment.finished_key(key)

spec/experiment_storage_spec.rb

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
describe Split::ExperimentStorage do
6+
let(:experiment) do
7+
{
8+
resettable: "false",
9+
algorithm: "Split::Algorithms::WeightedSample",
10+
alternatives: [ "foo", "bar" ],
11+
goals: ["purchase", "refund"],
12+
metadata: {
13+
"foo" => { "text" => "Something bad" },
14+
"bar" => { "text" => "Something good" }
15+
}
16+
}
17+
end
18+
before do
19+
Split.configuration.experiments = {
20+
my_exp: experiment
21+
}
22+
end
23+
24+
context "ConfigStorage" do
25+
let(:config_store) { Split::ExperimentStorage::ConfigStorage.new("my_exp") }
26+
27+
it "loads an experiment from the configuration" do
28+
stored_data = config_store.load
29+
expect(stored_data).to match(experiment)
30+
end
31+
32+
it "checks if an experiment exists on the configuration" do
33+
expect(config_store.exists?).to be_truthy
34+
expect(Split::ExperimentStorage::ConfigStorage.new("whatever").exists?).to be_falsy
35+
end
36+
37+
it "memoizes data from the configuration by default" do
38+
expect(config_store).to receive(:load!).once.and_call_original
39+
config_store.load
40+
config_store.load
41+
end
42+
end
43+
44+
context "from Redis" do
45+
before { Split::ExperimentCatalog.find_or_create(:my_exp) }
46+
let(:config_store) { Split::ExperimentStorage::RedisStorage.new("my_exp") }
47+
48+
it "loads an experiment from the configuration" do
49+
stored_data = config_store.load
50+
stored_data[:alternatives].map! { |alternative| alternative.name }
51+
expect(stored_data).to match(experiment)
52+
end
53+
54+
it "checks if an experiment exists on the configuration" do
55+
expect(config_store.exists?).to be_truthy
56+
expect(Split::ExperimentStorage::ConfigStorage.new("whatever").exists?).to be_falsy
57+
end
58+
end
59+
end

spec/user_spec.rb

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,17 @@
4848
end
4949

5050
it "removes key if experiment has a winner" do
51-
allow(Split::ExperimentCatalog).to receive(:find).with("link_color").and_return(experiment)
52-
allow(experiment).to receive(:start_time).and_return(Date.today)
53-
allow(experiment).to receive(:has_winner?).and_return(true)
51+
experiment = Split::ExperimentCatalog.find_or_create("link_color", "red", "blue")
52+
experiment.start
53+
experiment.winner = "red"
54+
55+
expect(experiment.has_winner?).to be_truthy
5456
@subject.cleanup_old_experiments!
5557
expect(@subject.keys).to be_empty
5658
end
5759

5860
it "removes key if experiment has not started yet" do
59-
allow(Split::ExperimentCatalog).to receive(:find).with("link_color").and_return(experiment)
60-
allow(experiment).to receive(:has_winner?).and_return(false)
61+
expect(Split::ExperimentCatalog.find("link_color")).to be_nil
6162
@subject.cleanup_old_experiments!
6263
expect(@subject.keys).to be_empty
6364
end
@@ -66,11 +67,12 @@
6667
let(:user_keys) { { "link_color" => "blue", "link_color:finished" => true } }
6768

6869
it "does not remove finished key for experiment without a winner" do
69-
allow(Split::ExperimentCatalog).to receive(:find).with("link_color").and_return(experiment)
70-
allow(Split::ExperimentCatalog).to receive(:find).with("link_color:finished").and_return(nil)
71-
allow(experiment).to receive(:start_time).and_return(Date.today)
72-
allow(experiment).to receive(:has_winner?).and_return(false)
70+
experiment = Split::ExperimentCatalog.find_or_create("link_color", "red", "blue")
71+
experiment.start
72+
73+
expect(experiment.has_winner?).to be_falsey
7374
@subject.cleanup_old_experiments!
75+
7476
expect(@subject.keys).to include("link_color")
7577
expect(@subject.keys).to include("link_color:finished")
7678
end

0 commit comments

Comments
 (0)