Skip to content

Commit 8f67693

Browse files
authored
Merge pull request #754 from splitrb/reduce-more-redis-roundtrips
Reduce Redis roundtrips on the ab_test path
2 parents 19f2fa6 + 92490bc commit 8f67693

5 files changed

Lines changed: 144 additions & 9 deletions

File tree

lib/split/experiment.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ def validate!
102102
end
103103

104104
def new_record?
105-
!@redis_storage.exists?
105+
@redis_storage.new_record?
106106
end
107107

108108
def ==(obj)
@@ -462,13 +462,16 @@ def persist_experiment_configuration
462462
else
463463
delete_metadata
464464
end
465+
466+
@redis_storage.reload
465467
end
466468

467469
def remove_experiment_configuration
468470
@alternatives.each(&:delete)
469471
goals_collection.delete
470472
delete_metadata
471473
redis.del(@name)
474+
@redis_storage.reload
472475
end
473476

474477
def experiment_configuration_has_changed?

lib/split/experiment_storage.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ def load
1313
@data ||= load!
1414
end
1515

16+
def reload
17+
@data = nil
18+
end
19+
20+
def new_record?
21+
load[:alternatives].empty?
22+
end
23+
1624
def load!
1725
experiment_config = load_experiment
1826
alternatives = load_alternatives

lib/split/user.rb

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,25 @@ def initialize(context, adapter = nil)
1515

1616
def cleanup_old_experiments!
1717
return if @cleaned_up
18-
keys_without_finished(user.keys).each do |key|
19-
experiment = Experiment.new key_without_version(key)
20-
if experiment.nil? || experiment.has_winner? || experiment.start_time.nil?
21-
user.delete key
22-
user.delete Experiment.finished_key(key)
18+
keys = keys_without_finished(user.keys)
19+
names = keys.map { |key| key_without_version(key) }
20+
21+
unless names.empty?
22+
winners, start_times = Split.redis.pipelined do |pipe|
23+
pipe.hmget(:experiment_winner, *names)
24+
pipe.hmget(:experiment_start_times, *names)
25+
end
26+
27+
keys.each_with_index do |key, index|
28+
has_winner = !winners[index].nil?
29+
not_started = start_times[index].nil?
30+
if has_winner || not_started
31+
user.delete key
32+
user.delete Experiment.finished_key(key)
33+
end
2334
end
2435
end
36+
2537
@cleaned_up = true
2638
end
2739

@@ -43,10 +55,17 @@ def cleanup_old_versions!(experiment)
4355
end
4456

4557
def active_experiments
58+
experiments_by_key = keys_without_finished(user.keys).each_with_object({}) do |key, memo|
59+
memo[key] = Metric.possible_experiments(key_without_version(key))
60+
end
61+
62+
names = experiments_by_key.values.flatten.map(&:name).uniq
63+
winners = names.empty? ? {} : names.zip(Split.redis.hmget(:experiment_winner, *names)).to_h
64+
4665
experiment_pairs = {}
47-
keys_without_finished(user.keys).each do |key|
48-
Metric.possible_experiments(key_without_version(key)).each do |experiment|
49-
if !experiment.has_winner?
66+
experiments_by_key.each do |key, experiments|
67+
experiments.each do |experiment|
68+
unless winners[experiment.name]
5069
experiment_pairs[key_without_version(key)] = user[key]
5170
end
5271
end

spec/support/redis_call_counter.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# frozen_string_literal: true
2+
3+
require "redis-client"
4+
5+
module SplitRedisCallInstrumentation
6+
KEY = :split_redis_call_count
7+
8+
def call(command, config)
9+
SplitRedisCallInstrumentation.increment
10+
super
11+
end
12+
13+
def call_pipelined(commands, config)
14+
SplitRedisCallInstrumentation.increment
15+
super
16+
end
17+
18+
class << self
19+
def count
20+
previous = Thread.current[KEY]
21+
Thread.current[KEY] = 0
22+
yield
23+
Thread.current[KEY]
24+
ensure
25+
Thread.current[KEY] = previous
26+
end
27+
28+
def increment
29+
count = Thread.current[KEY]
30+
Thread.current[KEY] = count + 1 if count
31+
end
32+
end
33+
end
34+
35+
RedisClient.register(SplitRedisCallInstrumentation)
36+
37+
RSpec::Matchers.define :make_redis_calls do |expected|
38+
supports_block_expectations
39+
40+
match do |block|
41+
@actual = SplitRedisCallInstrumentation.count(&block)
42+
values_match?(expected, @actual)
43+
end
44+
45+
failure_message do
46+
"expected block to make #{description_of(expected)} Redis roundtrip(s), but made #{@actual}"
47+
end
48+
49+
failure_message_when_negated do
50+
"expected block not to make #{description_of(expected)} Redis roundtrip(s), but it did (#{@actual})"
51+
end
52+
53+
description do
54+
"make #{description_of(expected)} Redis roundtrip(s)"
55+
end
56+
end

spec/user_spec.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,55 @@
8888
@subject.cleanup_old_experiments!
8989
end
9090
end
91+
92+
context "with many experiments" do
93+
let(:user_keys) do
94+
{
95+
"with_winner" => "red",
96+
"not_started" => "red",
97+
"active" => "red"
98+
}
99+
end
100+
101+
before do
102+
with_winner = Split::ExperimentCatalog.find_or_create("with_winner", "red", "blue")
103+
with_winner.start
104+
with_winner.winner = "red"
105+
106+
Split::ExperimentCatalog.find_or_create("active", "red", "blue").start
107+
end
108+
109+
it "keeps active experiments while dropping finished/not-started ones" do
110+
@subject.cleanup_old_experiments!
111+
112+
expect(@subject.keys).to eq(["active"])
113+
end
114+
115+
it "batches the winner/start-time lookups into a single roundtrip" do
116+
expect { @subject.cleanup_old_experiments! }.to make_redis_calls(1)
117+
end
118+
end
119+
end
120+
121+
context "#active_experiments" do
122+
let(:user_keys) { { "with_winner" => "red", "active" => "red" } }
123+
124+
before do
125+
with_winner = Split::ExperimentCatalog.find_or_create("with_winner", "red", "blue")
126+
with_winner.start
127+
with_winner.winner = "red"
128+
129+
Split::ExperimentCatalog.find_or_create("active", "red", "blue").start
130+
end
131+
132+
it "excludes experiments that already have a winner" do
133+
expect(@subject.active_experiments).to eq("active" => "red")
134+
end
135+
136+
it "fetches every experiment's winner in a single call" do
137+
expect(Split.redis).to receive(:hmget).with(:experiment_winner, any_args).once.and_call_original
138+
@subject.active_experiments
139+
end
91140
end
92141

93142
context "allows user to be loaded from adapter" do

0 commit comments

Comments
 (0)