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 +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
|