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 +4 -4
- data/README.md +55 -11
- data/lib/async_experiments.rb +25 -14
- data/lib/async_experiments/candidate_worker.rb +3 -3
- data/lib/async_experiments/experiment_control.rb +7 -3
- data/lib/async_experiments/experiment_error_worker.rb +7 -2
- data/lib/async_experiments/experiment_result.rb +19 -16
- data/lib/async_experiments/experiment_result_candidate_worker.rb +24 -0
- data/lib/async_experiments/experiment_result_control_worker.rb +29 -0
- data/lib/async_experiments/version.rb +1 -1
- data/spec/async_experiments_spec.rb +81 -20
- data/spec/candidate_worker_spec.rb +21 -24
- data/spec/examples.txt +48 -37
- data/spec/experiment_control_spec.rb +8 -6
- data/spec/experiment_error_worker_spec.rb +27 -6
- data/spec/experiment_result_candidate_worker_spec.rb +50 -0
- data/spec/experiment_result_control_worker_spec.rb +96 -0
- data/spec/experiment_result_spec.rb +103 -85
- metadata +8 -5
- data/lib/async_experiments/experiment_result_worker.rb +0 -36
- data/spec/experiment_result_worker_spec.rb +0 -106
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: feb9b5191b98c9363df96dbfaa10d2fb1fa73370
|
4
|
+
data.tar.gz: e2baeb8347ddf14935f9560ea2eef8ab9e015238
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1261f8e02a56a3147f148d478d5c0476d7a1cd0da47560f6f6524c6ca4c2ba583730e2a106da484655b963619feb7385ff5e854de4f34ef54796c5e9ccd7d845
|
7
|
+
data.tar.gz: 6eb95fa38740c975d5f05cd32cc84e81c1d41a4a9b61549528c7ec7486fb0a94c59a6cda93767b2a83b29c0b4abd1f25bf604a6b72b6e5800e105ac9236cc286
|
data/README.md
CHANGED
@@ -1,10 +1,15 @@
|
|
1
1
|
# Asynchronous Experiments
|
2
2
|
|
3
|
-
|
4
|
-
|
5
|
-
|
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
|
-
|
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
|
92
|
-
<li><a href="#extra
|
93
|
-
<li><a href="#changed
|
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
|
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
|
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
|
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)
|
data/lib/async_experiments.rb
CHANGED
@@ -1,6 +1,8 @@
|
|
1
1
|
require "json"
|
2
2
|
require "securerandom"
|
3
|
-
require "async_experiments/
|
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
|
-
|
16
|
-
|
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
|
20
|
-
|
22
|
+
mismatched_responses.map do |parsed|
|
23
|
+
missing, other = parsed.partition { |(operator)| operator == "-" }
|
21
24
|
|
22
|
-
|
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/
|
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
|
-
|
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/
|
1
|
+
require "async_experiments/experiment_result_control_worker"
|
2
2
|
|
3
3
|
module AsyncExperiments
|
4
4
|
module ExperimentControl
|
5
|
-
def experiment_control(
|
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
|
-
|
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
|
-
|
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?(
|
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
|
-
|
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?(
|
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
|
-
|
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
|
@@ -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(:
|
10
|
-
[
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
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(
|
27
|
-
allow(redis).to receive(:
|
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
|