async_experiments 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 73d448d952cbbee2567c807bafd327fead90ac38
4
- data.tar.gz: 47f6da2a76c6f75c42ea8904c72421164bc77c3c
3
+ metadata.gz: feb9b5191b98c9363df96dbfaa10d2fb1fa73370
4
+ data.tar.gz: e2baeb8347ddf14935f9560ea2eef8ab9e015238
5
5
  SHA512:
6
- metadata.gz: c3d83b96b7143a1594c36d3d29b3ba77e6dd65409a35201dae68059cfdc8e2ffe4e03e28b6805d58251a82340984daee93eaf1ec40c03c820caaaf0e85b28ca2
7
- data.tar.gz: a8932415d200081e7a6176175cbd29372a5e91463cdc040e5d72a5424939af9e10176ef149efa891868af3e4c84cc009ffe06fd5bf99f5ebdf55ee24b47c0100
6
+ metadata.gz: 1261f8e02a56a3147f148d478d5c0476d7a1cd0da47560f6f6524c6ca4c2ba583730e2a106da484655b963619feb7385ff5e854de4f34ef54796c5e9ccd7d845
7
+ data.tar.gz: 6eb95fa38740c975d5f05cd32cc84e81c1d41a4a9b61549528c7ec7486fb0a94c59a6cda93767b2a83b29c0b4abd1f25bf604a6b72b6e5800e105ac9236cc286
data/README.md CHANGED
@@ -1,10 +1,15 @@
1
1
  # Asynchronous Experiments
2
2
 
3
- Similar to GitHub Scientist, but uses Sidekiq (and its Redis connection pool)
4
- to run control and candidate branches of experiments in parallel, storing the
5
- output for later review.
3
+ This tool is used to understand the implications of replacing a section of code
4
+ (known as control) with a different piece of code (the candidate) in terms of
5
+ interchangeable outputs and effects on performance. It reports differences in
6
+ output to redis and differences in duration comparisons to statsd.
6
7
 
7
- Provides helpers to assist with rendering the output from the comparison.
8
+ It is similar to [GitHub Scientist](https://github.com/github/scientist), but
9
+ uses Sidekiq (and its Redis connection pool) to run control and candidate branches
10
+ of experiments in parallel, storing the output for later review.
11
+
12
+ It provides helpers to assist with rendering the output from the comparison.
8
13
 
9
14
  ## IMPORTANT NOTE ABOUT SIDEKIQ
10
15
 
@@ -18,7 +23,23 @@ The gem also assumes access to statsd for reporting purposes.
18
23
 
19
24
  ## Technical documentation
20
25
 
21
- Example usage:
26
+ Example usage: [experiments-framework](https://github.com/alphagov/publishing-api/tree/experiments-framework)
27
+ branch of the [Publishing API](https://github.com/alphagov/publishing-api)
28
+
29
+ ### Evaluate a piece of code for replacing
30
+
31
+ - Identify the code you want to consider replacing, your control
32
+ - Include `AsyncExperiments::ExperimentControl` into the class that contains
33
+ your control code
34
+ - `experiment_control` is passed the user defined name of the experiment,
35
+ details of a candidate worker it can initialise and a block of the control
36
+ code
37
+ - `experiment_control` will return the results of the control code and the
38
+ code can proceed as before
39
+ - By default the results of the experiment will be stored in redis for 24 hours
40
+ this can be altered by including `results_expiry: {number of seconds}` in
41
+ the hash of `experiment_control` arguments.
42
+
22
43
  ```
23
44
  require "async_experiments/experiment_control"
24
45
 
@@ -52,6 +73,14 @@ class ContentItemsController < ApplicationController
52
73
  end
53
74
  ```
54
75
 
76
+ ### Run your replacement code asynchronously from a Sidekiq worker
77
+
78
+ - A candidate worker is created, which will be created automatically based on
79
+ the arguments passed to `experiment_control`.
80
+ - The worker receives the arguments defined in the args attribute of the
81
+ candidate, with an extra argument that is the name of the experiment.
82
+ - The name of the experiment and a block of the candidate code is passed to
83
+ `experiment_candidate` which will monitor the duration and the response.
55
84
  ```
56
85
  require "async_experiments/candidate_worker"
57
86
 
@@ -66,6 +95,11 @@ class LinkablesCandidate < AsyncExperiments::CandidateWorker
66
95
  end
67
96
  ```
68
97
 
98
+ ### Access the instances where the response of candidate and control didn't match
99
+
100
+ - The static method `get_experiment_data` can be called on `AsyncExperiments`
101
+ to load an array of the cases where the responses didn't match
102
+
69
103
  ```
70
104
  class DebugController < ApplicationController
71
105
  skip_before_action :require_signin_permission!
@@ -88,22 +122,22 @@ end
88
122
  <% @mismatched_responses.each_with_index do |mismatch, i| %>
89
123
  <li>
90
124
  <ul>
91
- <li><a href="#missing-#{i}">Missing</a></li>
92
- <li><a href="#extra-#{i}">Extra</a></li>
93
- <li><a href="#changed-#{i}">Changed</a></li>
125
+ <li><a href="#missing-<%= i %>">Missing</a></li>
126
+ <li><a href="#extra-<%= i %>">Extra</a></li>
127
+ <li><a href="#changed-<%= i %>">Changed</a></li>
94
128
  </ul>
95
129
 
96
- <h3 id="missing-#{i}">Missing from candidate</h3>
130
+ <h3 id="missing-<%= i %>">Missing from candidate</h3>
97
131
  <% mismatch[:missing].each do |entry| %>
98
132
  <pre><%= PP.pp(entry, "") %></pre>
99
133
  <% end %>
100
134
 
101
- <h3 id="extra-#{i}">Extra in candidate</h3>
135
+ <h3 id="extra-<%= i %>">Extra in candidate</h3>
102
136
  <% mismatch[:extra].each do |entry| %>
103
137
  <pre><%= PP.pp(entry, "") %></pre>
104
138
  <% end %>
105
139
 
106
- <h3 id="changed-#{i}">Changed in candidate</h3>
140
+ <h3 id="changed-<%= i %>">Changed in candidate</h3>
107
141
  <% mismatch[:changed].each do |entry| %>
108
142
  <pre><%= PP.pp(entry, "") %></pre>
109
143
  <% end %>
@@ -112,12 +146,22 @@ end
112
146
  </ul>
113
147
  ```
114
148
 
149
+ ### Make statsd available
150
+
151
+ - For a rails app this would be done in `config/initialisers`
152
+
115
153
  ```
116
154
  statsd_client = Statsd.new("localhost")
117
155
  statsd_client.namespace = "govuk.app.publishing-api"
118
156
  AsyncExperiments.statsd = statsd_client
119
157
  ```
120
158
 
159
+ ### Example implementation
160
+
161
+ The [experiments-framework](https://github.com/alphagov/publishing-api/tree/experiments-framework)
162
+ branch of GOV.UK [Publishing API](https://github.com/alphagov/publishing-api)
163
+ contains an implementation of this gem.
164
+
121
165
  ## Licence
122
166
 
123
167
  [MIT License](LICENCE)
@@ -1,6 +1,8 @@
1
1
  require "json"
2
2
  require "securerandom"
3
- require "async_experiments/experiment_result_worker"
3
+ require "async_experiments/experiment_result_candidate_worker"
4
+ require "async_experiments/experiment_result_control_worker"
5
+
4
6
 
5
7
  module AsyncExperiments
6
8
  def self.statsd
@@ -12,20 +14,15 @@ module AsyncExperiments
12
14
  end
13
15
 
14
16
  def self.get_experiment_data(experiment_name)
15
- mismatched_responses = Sidekiq.redis { |redis|
16
- redis.lrange("experiments:#{experiment_name}:mismatches", 0, -1)
17
- }
17
+ key_pattern = "experiments:#{experiment_name}:mismatches:*"
18
+ mismatched_responses = redis_scan_and_retrieve(key_pattern).map do |json|
19
+ JSON.parse(json)
20
+ end
18
21
 
19
- mismatched_responses.map { |json|
20
- parsed = JSON.parse(json)
22
+ mismatched_responses.map do |parsed|
23
+ missing, other = parsed.partition { |(operator)| operator == "-" }
21
24
 
22
- missing, other = parsed.partition {|(operator, _, _)|
23
- operator == "-"
24
- }
25
-
26
- extra, changed = other.partition {|(operator, _, _)|
27
- operator == "+"
28
- }
25
+ extra, changed = other.partition { |(operator)| operator == "+" }
29
26
 
30
27
  missing_entries, extra_entries = self.fix_ordering_issues(
31
28
  missing.map(&:last),
@@ -37,7 +34,21 @@ module AsyncExperiments
37
34
  extra: extra_entries,
38
35
  changed: changed.map(&:last),
39
36
  }
40
- }
37
+ end
38
+ end
39
+
40
+ def self.get_experiment_exceptions(experiment_name)
41
+ redis_scan_and_retrieve("experiments:#{experiment_name}:exceptions:*")
42
+ end
43
+
44
+ def self.redis_scan_and_retrieve(key_pattern)
45
+ Sidekiq.redis do |redis|
46
+ enumerator = redis.scan_each(
47
+ match: key_pattern
48
+ )
49
+ retrieve = -> (key) { redis.get(key) }
50
+ enumerator.map(&retrieve).compact
51
+ end
41
52
  end
42
53
 
43
54
  def self.fix_ordering_issues(missing_entries, extra_entries)
@@ -1,4 +1,4 @@
1
- require "async_experiments/experiment_result_worker"
1
+ require "async_experiments/experiment_result_candidate_worker"
2
2
  require "async_experiments/experiment_error_worker"
3
3
 
4
4
  module AsyncExperiments
@@ -13,7 +13,7 @@ module AsyncExperiments
13
13
  start_time = Time.now
14
14
  run_output = yield
15
15
  duration = (Time.now - start_time).to_f
16
- ExperimentResultWorker.perform_async(experiment[:name], experiment[:id], run_output, duration, :candidate)
16
+ ExperimentResultCandidateWorker.perform_async(experiment[:name], experiment[:id], run_output, duration, experiment[:candidate_expiry])
17
17
 
18
18
  run_output
19
19
  rescue StandardError => exception
@@ -22,7 +22,7 @@ module AsyncExperiments
22
22
  else
23
23
  backtrace = exception.backtrace
24
24
  backtrace.unshift(exception.inspect)
25
- ExperimentErrorWorker.perform_async(experiment[:name], backtrace.join("\n"))
25
+ ExperimentErrorWorker.perform_async(experiment[:name], backtrace.join("\n"), experiment[:results_expiry])
26
26
  end
27
27
  end
28
28
  end
@@ -1,8 +1,10 @@
1
- require "async_experiments/experiment_result_worker"
1
+ require "async_experiments/experiment_result_control_worker"
2
2
 
3
3
  module AsyncExperiments
4
4
  module ExperimentControl
5
- def experiment_control(name, candidate:)
5
+ def experiment_control(
6
+ name, candidate:, candidate_expiry: 60, results_expiry: 24 * 60 * 60
7
+ )
6
8
  start_time = Time.now
7
9
  run_output = yield
8
10
  duration = (Time.now - start_time).to_f
@@ -13,12 +15,14 @@ module AsyncExperiments
13
15
  run_output = run_output.to_a
14
16
  end
15
17
 
16
- ExperimentResultWorker.perform_async(name, id, run_output, duration, :control)
18
+ ExperimentResultControlWorker.perform_in(1, name, id, run_output, duration, results_expiry)
17
19
 
18
20
  candidate_worker = candidate.fetch(:worker)
19
21
  candidate_worker.perform_async(*candidate.fetch(:args),
20
22
  name: name,
21
23
  id: id,
24
+ candidate_expiry: candidate_expiry,
25
+ results_expiry: results_expiry,
22
26
  )
23
27
 
24
28
  run_output
@@ -1,13 +1,18 @@
1
+ require "digest/sha2"
2
+
1
3
  module AsyncExperiments
2
4
  class ExperimentErrorWorker
3
5
  include Sidekiq::Worker
4
6
 
5
7
  sidekiq_options queue: :experiments
6
8
 
7
- def perform(experiment_name, exception_string)
9
+ def perform(experiment_name, exception_string, expiry)
8
10
  Sidekiq.redis do |redis|
9
11
  AsyncExperiments.statsd.increment("experiments.#{experiment_name}.exceptions")
10
- redis.rpush("experiments:#{experiment_name}:exceptions", exception_string)
12
+ hash = Digest::SHA2.base64digest(exception_string)
13
+ redis_key = "experiments:#{experiment_name}:exceptions:#{hash}"
14
+ redis.set(redis_key, exception_string) unless redis.exists(redis_key)
15
+ redis.expire(redis_key, expiry)
11
16
  end
12
17
  end
13
18
  end
@@ -1,5 +1,6 @@
1
1
  require "json"
2
2
  require "hashdiff"
3
+ require "digest/sha2"
3
4
  require "async_experiments/util"
4
5
 
5
6
  module AsyncExperiments
@@ -13,7 +14,7 @@ module AsyncExperiments
13
14
  @run_output = run_output
14
15
  @duration = duration
15
16
 
16
- if Util.blank?(run_output) || Util.blank?(duration)
17
+ if Util.blank?(duration)
17
18
  redis_data = data_from_redis
18
19
 
19
20
  if redis_data
@@ -25,29 +26,23 @@ module AsyncExperiments
25
26
 
26
27
  attr_reader :key, :run_output, :duration
27
28
 
28
- def store_run_output
29
- redis.set("experiments:#{key}:#{type}", {
29
+ def store_run_output(expiry)
30
+ redis_key = "experiments:#{key}:#{type}"
31
+ redis.set(redis_key, {
30
32
  run_output: run_output,
31
33
  duration: duration,
32
34
  }.to_json)
35
+ redis.expire(redis_key, expiry)
33
36
  end
34
37
 
35
- def process_run_output(candidate)
38
+ def process_run_output(candidate, expiry)
36
39
  variation = HashDiff.diff(sort(self.run_output), sort(candidate.run_output))
37
- report_data(variation, candidate)
40
+ report_data(variation, candidate, expiry)
38
41
  redis.del("experiments:#{key}:candidate")
39
42
  end
40
43
 
41
- def control?
42
- type == :control
43
- end
44
-
45
- def candidate?
46
- type == :candidate
47
- end
48
-
49
44
  def available?
50
- Util.present?(run_output) && Util.present?(duration)
45
+ Util.present?(duration)
51
46
  end
52
47
 
53
48
  protected
@@ -64,16 +59,24 @@ module AsyncExperiments
64
59
  end
65
60
  end
66
61
 
67
- def report_data(variation, candidate)
62
+ def report_data(variation, candidate, expiry)
68
63
  statsd.timing("experiments.#{name}.control", self.duration)
69
64
  statsd.timing("experiments.#{name}.candidate", candidate.duration)
70
65
 
71
66
  if variation != []
72
67
  statsd.increment("experiments.#{name}.mismatches")
73
- redis.rpush("experiments:#{name}:mismatches", JSON.dump(variation))
68
+ store_mismatch(variation, expiry)
74
69
  end
75
70
  end
76
71
 
72
+ def store_mismatch(mismatch, expiry)
73
+ json = JSON.dump(mismatch)
74
+ hash = Digest::SHA2.base64digest(json)
75
+ redis_key = "experiments:#{name}:mismatches:#{hash}"
76
+ redis.set(redis_key, json) unless redis.exists(redis_key)
77
+ redis.expire(redis_key, expiry)
78
+ end
79
+
77
80
  def sort(object)
78
81
  case object
79
82
  when Array
@@ -0,0 +1,24 @@
1
+ require "async_experiments/experiment_result"
2
+
3
+ module AsyncExperiments
4
+ class ExperimentResultCandidateWorker
5
+ include Sidekiq::Worker
6
+
7
+ sidekiq_options queue: :experiments
8
+
9
+ LOCK_TIMEOUT = 60
10
+
11
+ def perform(name, id, run_output, duration, expiry)
12
+ Sidekiq.redis do |redis|
13
+ result = ExperimentResult.new(name, id, :candidate, redis, statsd, run_output, duration)
14
+ result.store_run_output(expiry)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def statsd
21
+ AsyncExperiments.statsd
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,29 @@
1
+ require "async_experiments/experiment_result"
2
+
3
+ module AsyncExperiments
4
+ class ExperimentResultControlWorker
5
+ include Sidekiq::Worker
6
+
7
+ sidekiq_options queue: :experiments
8
+
9
+ LOCK_TIMEOUT = 60
10
+
11
+ def perform(name, id, run_output, duration, expiry, allowed_attempts = 5, attempt = 1)
12
+ Sidekiq.redis do |redis|
13
+ result = ExperimentResult.new(name, id, :control, redis, statsd, run_output, duration)
14
+ candidate = ExperimentResult.new(name, id, :candidate, redis, statsd)
15
+ if candidate.available?
16
+ result.process_run_output(candidate, expiry)
17
+ elsif allowed_attempts > attempt
18
+ self.class.perform_in(5, name, id, run_output, duration, expiry, allowed_attempts, attempt + 1)
19
+ end
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def statsd
26
+ AsyncExperiments.statsd
27
+ end
28
+ end
29
+ end
@@ -1,3 +1,3 @@
1
1
  module AsyncExperiments
2
- VERSION = "0.0.1"
2
+ VERSION = "0.1.0"
3
3
  end
@@ -3,29 +3,36 @@ require "json"
3
3
  require "async_experiments"
4
4
 
5
5
  RSpec.describe AsyncExperiments do
6
+ let(:redis) do
7
+ double(
8
+ :redis,
9
+ scan_each: nil,
10
+ get: nil,
11
+ )
12
+ end
13
+
14
+ before do
15
+ allow(Sidekiq).to receive(:redis).and_yield(redis)
16
+ end
17
+
6
18
  describe ".get_experiment_data(experiment_name)" do
7
19
  let(:name) { "some_experiment" }
8
20
 
9
- let(:experiment_results) {
10
- [
11
- JSON.dump([
12
- ["-", "[1]", {same_key: 1, different_key: 2}],
13
- ["+", "[2]", {same_key: 1, different_key: 3}],
14
- ["+", "[3]", "Extra element"],
15
- ["-", "[4]", "Missing element"],
16
- ["~", "[5]", "Changed element"],
17
- ["-", "[3]", {moved_complex_object: 1}],
18
- ["+", "[6]", {moved_complex_object: 1}],
19
- ])
20
- ]
21
- }
22
-
23
- let(:redis) { double(:redis) }
21
+ let(:experiment_result) do
22
+ JSON.dump([
23
+ ["-", "[1]", { same_key: 1, different_key: 2 }],
24
+ ["+", "[2]", { same_key: 1, different_key: 3 }],
25
+ ["+", "[3]", "Extra element"],
26
+ ["-", "[4]", "Missing element"],
27
+ ["~", "[5]", "Changed element"],
28
+ ["-", "[3]", { moved_complex_object: 1 }],
29
+ ["+", "[6]", { moved_complex_object: 1 }],
30
+ ])
31
+ end
24
32
 
25
33
  before do
26
- allow(Sidekiq).to receive(:redis).and_yield(redis)
27
- allow(redis).to receive(:lrange).with("experiments:#{name}:mismatches", 0, -1)
28
- .and_return(experiment_results)
34
+ allow(redis).to receive(:scan_each).and_return([1])
35
+ allow(redis).to receive(:get).and_return(experiment_result)
29
36
  end
30
37
 
31
38
  it "partitions and resorts experiment results for useful output" do
@@ -33,11 +40,11 @@ RSpec.describe AsyncExperiments do
33
40
 
34
41
  expect(results).to eq([
35
42
  missing: [
36
- {"same_key" => 1, "different_key" => 2},
43
+ { "same_key" => 1, "different_key" => 2 },
37
44
  "Missing element",
38
45
  ],
39
46
  extra: [
40
- {"same_key" => 1, "different_key" => 3},
47
+ { "same_key" => 1, "different_key" => 3 },
41
48
  "Extra element",
42
49
  ],
43
50
  changed: [
@@ -46,4 +53,58 @@ RSpec.describe AsyncExperiments do
46
53
  ])
47
54
  end
48
55
  end
56
+
57
+ describe ".get_experiment_exceptions(experiment_name)" do
58
+ let(:name) { "some_experiment" }
59
+
60
+ let(:errors) do
61
+ ["error 1", "error 2"]
62
+ end
63
+
64
+ before do
65
+ allow(redis).to receive(:scan_each).and_return([1, 2])
66
+ allow(redis).to receive(:get).with(1).and_return(errors[0])
67
+ allow(redis).to receive(:get).with(2).and_return(errors[1])
68
+ end
69
+
70
+ it "returns a list of exceptions" do
71
+ results = described_class.get_experiment_exceptions(name)
72
+
73
+ expect(results).to eq(errors)
74
+ end
75
+ end
76
+
77
+ describe ".redis_scan_and_retrieve" do
78
+ subject { described_class.redis_scan_and_retrieve(key_pattern) }
79
+ let(:key_pattern) { "pattern" }
80
+
81
+ context "no items" do
82
+ before { allow(redis).to receive(:scan_each).and_return([]) }
83
+ it { is_expected.to eq([]) }
84
+ end
85
+
86
+ context "all items exist" do
87
+ let(:items) { ["item 1", "item 2"] }
88
+
89
+ before do
90
+ allow(redis).to receive(:scan_each).and_return([1, 2])
91
+ allow(redis).to receive(:get).with(1).and_return(items[0])
92
+ allow(redis).to receive(:get).with(2).and_return(items[1])
93
+ end
94
+
95
+ it { is_expected.to eq(items) }
96
+ end
97
+
98
+ context "some items are not available" do
99
+ let(:item) { "item" }
100
+
101
+ before do
102
+ allow(redis).to receive(:scan_each).and_return([1, 2])
103
+ allow(redis).to receive(:get).with(1).and_return(item)
104
+ allow(redis).to receive(:get).with(2).and_return(nil)
105
+ end
106
+
107
+ it { is_expected.to eq([item]) }
108
+ end
109
+ end
49
110
  end