async_experiments 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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