rollout 2.6.2 → 3.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 +5 -5
- data/.github/workflows/release.yml +51 -27
- data/.github/workflows/test.yml +46 -6
- data/.gitignore +2 -0
- data/README.md +87 -24
- data/Rakefile +15 -2
- data/docs/upgrading-to-v3.md +101 -0
- data/lib/rollout/feature.rb +28 -29
- data/lib/rollout/feature_state.rb +44 -0
- data/lib/rollout/logging.rb +20 -76
- data/lib/rollout/version.rb +1 -1
- data/lib/rollout.rb +56 -50
- data/rollout.gemspec +6 -5
- data/spec/rollout/feature_spec.rb +70 -39
- data/spec/rollout/feature_state_spec.rb +219 -0
- data/spec/rollout/logging_spec.rb +81 -110
- data/spec/rollout/memory_backend_contract_spec.rb +8 -0
- data/spec/rollout_spec.rb +120 -602
- data/spec/spec_helper.rb +69 -10
- data/spec/support/backend_contract.rb +88 -0
- metadata +12 -29
- data/.circleci/config.yml +0 -119
data/lib/rollout/logging.rb
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
class Rollout
|
|
2
4
|
module Logging
|
|
3
5
|
def self.extended(rollout)
|
|
4
6
|
options = rollout.options[:logging]
|
|
5
7
|
options = options.is_a?(Hash) ? options.dup : {}
|
|
6
|
-
options[:storage] ||= rollout.storage
|
|
7
|
-
|
|
8
|
-
logger = Logger.new(**options)
|
|
9
8
|
|
|
10
|
-
rollout.
|
|
9
|
+
logger = Logger.new(adapter: rollout.adapter, **options)
|
|
11
10
|
rollout.define_singleton_method(:logging) do
|
|
12
11
|
logger
|
|
13
12
|
end
|
|
@@ -16,12 +15,6 @@ class Rollout
|
|
|
16
15
|
class Event
|
|
17
16
|
attr_reader :feature, :name, :data, :context, :created_at
|
|
18
17
|
|
|
19
|
-
def self.from_raw(value, score)
|
|
20
|
-
hash = JSON.parse(value, symbolize_names: true)
|
|
21
|
-
|
|
22
|
-
new(**hash.merge(created_at: Time.at(-score.to_f / 1_000_000)))
|
|
23
|
-
end
|
|
24
|
-
|
|
25
18
|
def initialize(feature: nil, name:, data:, context: {}, created_at:)
|
|
26
19
|
@feature = feature
|
|
27
20
|
@name = name
|
|
@@ -53,45 +46,37 @@ class Rollout
|
|
|
53
46
|
end
|
|
54
47
|
|
|
55
48
|
class Logger
|
|
56
|
-
|
|
49
|
+
attr_reader :history_length, :global, :adapter
|
|
50
|
+
|
|
51
|
+
def initialize(adapter:, history_length: 50, global: false)
|
|
52
|
+
@adapter = adapter
|
|
57
53
|
@history_length = history_length
|
|
58
|
-
@storage = storage
|
|
59
54
|
@global = global
|
|
60
55
|
end
|
|
61
56
|
|
|
62
57
|
def updated_at(feature_name)
|
|
63
|
-
|
|
64
|
-
_, score = @storage.zrange(storage_key, 0, 0, with_scores: true).first
|
|
65
|
-
Time.at(-score.to_f / 1_000_000) if score
|
|
58
|
+
@adapter.feature_updated_at(feature_name)
|
|
66
59
|
end
|
|
67
60
|
|
|
68
61
|
def last_event(feature_name)
|
|
69
|
-
|
|
70
|
-
value = @storage.zrange(storage_key, 0, 0, with_scores: true).first
|
|
71
|
-
Event.from_raw(*value) if value
|
|
62
|
+
events(feature_name, limit: 1).last
|
|
72
63
|
end
|
|
73
64
|
|
|
74
|
-
def events(feature_name)
|
|
75
|
-
|
|
76
|
-
@storage
|
|
77
|
-
.zrange(storage_key, 0, -1, with_scores: true)
|
|
78
|
-
.map { |v| Event.from_raw(*v) }
|
|
79
|
-
.reverse
|
|
65
|
+
def events(feature_name, limit: nil)
|
|
66
|
+
@adapter.feature_events(feature_name, limit: limit)
|
|
80
67
|
end
|
|
81
68
|
|
|
82
|
-
def global_events
|
|
83
|
-
@
|
|
84
|
-
.zrange(global_events_storage_key, 0, -1, with_scores: true)
|
|
85
|
-
.map { |v| Event.from_raw(*v) }
|
|
86
|
-
.reverse
|
|
69
|
+
def global_events(limit: nil)
|
|
70
|
+
@adapter.global_events(limit: limit)
|
|
87
71
|
end
|
|
88
72
|
|
|
89
73
|
def delete(feature_name)
|
|
90
|
-
|
|
91
|
-
@storage.del(storage_key)
|
|
74
|
+
@adapter.delete_feature_events(feature_name)
|
|
92
75
|
end
|
|
93
76
|
|
|
94
|
-
def
|
|
77
|
+
def event_for(before, after)
|
|
78
|
+
return unless logging_enabled?
|
|
79
|
+
|
|
95
80
|
before_hash = before.to_hash
|
|
96
81
|
before_hash.delete(:data).each do |k, v|
|
|
97
82
|
before_hash["data.#{k}"] = v
|
|
@@ -116,41 +101,13 @@ class Rollout
|
|
|
116
101
|
|
|
117
102
|
return if changed_count == 0
|
|
118
103
|
|
|
119
|
-
|
|
104
|
+
Event.new(
|
|
120
105
|
feature: after.name,
|
|
121
106
|
name: :update,
|
|
122
107
|
data: change,
|
|
123
108
|
context: current_context,
|
|
124
109
|
created_at: Time.now,
|
|
125
110
|
)
|
|
126
|
-
|
|
127
|
-
storage_key = events_storage_key(after.name)
|
|
128
|
-
|
|
129
|
-
@storage.zadd(storage_key, -event.timestamp, event.serialize)
|
|
130
|
-
@storage.zremrangebyrank(storage_key, @history_length, -1)
|
|
131
|
-
|
|
132
|
-
if @global
|
|
133
|
-
@storage.zadd(global_events_storage_key, -event.timestamp, event.serialize)
|
|
134
|
-
@storage.zremrangebyrank(global_events_storage_key, @history_length, -1)
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def log(event, *args)
|
|
139
|
-
return unless logging_enabled?
|
|
140
|
-
|
|
141
|
-
unless respond_to?(event)
|
|
142
|
-
raise ArgumentError, "Invalid log event: #{event}"
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
expected_arity = method(event).arity
|
|
146
|
-
unless args.count == expected_arity
|
|
147
|
-
raise(
|
|
148
|
-
ArgumentError,
|
|
149
|
-
"Invalid number of arguments for event '#{event}': expected #{expected_arity} but got #{args.count}",
|
|
150
|
-
)
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
public_send(event, *args)
|
|
154
111
|
end
|
|
155
112
|
|
|
156
113
|
CONTEXT_THREAD_KEY = :rollout_logging_context
|
|
@@ -171,29 +128,16 @@ class Rollout
|
|
|
171
128
|
end
|
|
172
129
|
|
|
173
130
|
def without
|
|
131
|
+
previous = Thread.current[WITHOUT_THREAD_KEY]
|
|
174
132
|
Thread.current[WITHOUT_THREAD_KEY] = true
|
|
175
133
|
yield
|
|
176
134
|
ensure
|
|
177
|
-
Thread.current[WITHOUT_THREAD_KEY] =
|
|
135
|
+
Thread.current[WITHOUT_THREAD_KEY] = previous
|
|
178
136
|
end
|
|
179
137
|
|
|
180
138
|
def logging_enabled?
|
|
181
139
|
!Thread.current[WITHOUT_THREAD_KEY]
|
|
182
140
|
end
|
|
183
|
-
|
|
184
|
-
private
|
|
185
|
-
|
|
186
|
-
def global_events_storage_key
|
|
187
|
-
"feature:_global_:logging:events"
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
def events_storage_key(feature_name)
|
|
191
|
-
"feature:#{feature_name}:logging:events"
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
def current_timestamp
|
|
195
|
-
(Time.now.to_f * 1_000_000).to_i
|
|
196
|
-
end
|
|
197
141
|
end
|
|
198
142
|
end
|
|
199
143
|
end
|
data/lib/rollout/version.rb
CHANGED
data/lib/rollout.rb
CHANGED
|
@@ -13,14 +13,14 @@ class Rollout
|
|
|
13
13
|
|
|
14
14
|
RAND_BASE = (2**32 - 1) / 100.0
|
|
15
15
|
|
|
16
|
-
attr_reader :options, :
|
|
16
|
+
attr_reader :options, :adapter
|
|
17
17
|
|
|
18
|
-
def initialize(
|
|
19
|
-
@
|
|
20
|
-
@options =
|
|
18
|
+
def initialize(adapter:, **options)
|
|
19
|
+
@adapter = adapter
|
|
20
|
+
@options = options
|
|
21
21
|
@groups = { all: ->(_user) { true } }
|
|
22
22
|
|
|
23
|
-
extend(Logging) if
|
|
23
|
+
extend(Logging) if options[:logging]
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def groups
|
|
@@ -38,10 +38,7 @@ class Rollout
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def delete(feature)
|
|
41
|
-
|
|
42
|
-
features.delete(feature.to_s)
|
|
43
|
-
@storage.set(features_key, features.join(','))
|
|
44
|
-
@storage.del(key(feature))
|
|
41
|
+
@adapter.delete_feature(feature)
|
|
45
42
|
|
|
46
43
|
if respond_to?(:logging)
|
|
47
44
|
logging.delete(feature)
|
|
@@ -137,8 +134,12 @@ class Rollout
|
|
|
137
134
|
end
|
|
138
135
|
|
|
139
136
|
def get(feature)
|
|
140
|
-
|
|
141
|
-
|
|
137
|
+
Feature.new(
|
|
138
|
+
state: @adapter.fetch_feature(feature),
|
|
139
|
+
rollout: self,
|
|
140
|
+
options: @options,
|
|
141
|
+
name: feature,
|
|
142
|
+
)
|
|
142
143
|
end
|
|
143
144
|
|
|
144
145
|
def set_feature_data(feature, data)
|
|
@@ -156,18 +157,13 @@ class Rollout
|
|
|
156
157
|
def multi_get(*features)
|
|
157
158
|
return [] if features.empty?
|
|
158
159
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
.mget(*feature_keys)
|
|
163
|
-
.map
|
|
164
|
-
.with_index do |string, index|
|
|
165
|
-
Feature.new(features[index], state: string, rollout: self, options: @options)
|
|
166
|
-
end
|
|
160
|
+
@adapter.fetch_features(features).zip(features).map do |state, name|
|
|
161
|
+
Feature.new(state: state, rollout: self, options: @options, name: name)
|
|
162
|
+
end
|
|
167
163
|
end
|
|
168
164
|
|
|
169
165
|
def features
|
|
170
|
-
|
|
166
|
+
@adapter.feature_names.map(&:to_sym)
|
|
171
167
|
end
|
|
172
168
|
|
|
173
169
|
def feature_states(user = nil)
|
|
@@ -185,49 +181,59 @@ class Rollout
|
|
|
185
181
|
def clear!
|
|
186
182
|
features.each do |feature|
|
|
187
183
|
with_feature(feature, &:clear)
|
|
188
|
-
@
|
|
184
|
+
@adapter.delete_feature(feature)
|
|
189
185
|
end
|
|
190
|
-
|
|
191
|
-
@storage.del(features_key)
|
|
186
|
+
@adapter.clear_features
|
|
192
187
|
end
|
|
193
188
|
|
|
194
189
|
def exists?(feature)
|
|
195
|
-
|
|
196
|
-
# https://github.com/redis/redis-rb/pull/918
|
|
197
|
-
if @storage.respond_to?(:exists?)
|
|
198
|
-
@storage.exists?(key(feature))
|
|
199
|
-
else
|
|
200
|
-
@storage.exists(key(feature))
|
|
201
|
-
end
|
|
190
|
+
@adapter.feature_exists?(feature)
|
|
202
191
|
end
|
|
203
192
|
|
|
204
193
|
def with_feature(feature)
|
|
205
|
-
|
|
194
|
+
mutated = nil
|
|
195
|
+
before = nil
|
|
196
|
+
capture_logging = logging_capture?
|
|
197
|
+
notify = count_observers > 0
|
|
198
|
+
snapshot = notify || capture_logging
|
|
199
|
+
|
|
200
|
+
@adapter.mutate_feature(feature) do |current_state|
|
|
201
|
+
mutated = Feature.new(
|
|
202
|
+
state: current_state,
|
|
203
|
+
rollout: self,
|
|
204
|
+
options: @options,
|
|
205
|
+
name: feature,
|
|
206
|
+
)
|
|
207
|
+
before = mutated.deep_clone if snapshot
|
|
208
|
+
yield mutated
|
|
209
|
+
|
|
210
|
+
state = mutated.to_feature_state
|
|
211
|
+
mutated = Feature.new(
|
|
212
|
+
state: state,
|
|
213
|
+
rollout: self,
|
|
214
|
+
options: @options,
|
|
215
|
+
name: feature,
|
|
216
|
+
)
|
|
217
|
+
event = capture_logging ? logging.event_for(before, mutated) : nil
|
|
218
|
+
result = { state: state, event: event }
|
|
219
|
+
if event
|
|
220
|
+
result[:history_length] = logging.history_length
|
|
221
|
+
result[:global] = logging.global
|
|
222
|
+
end
|
|
223
|
+
result
|
|
224
|
+
end
|
|
206
225
|
|
|
207
|
-
if
|
|
208
|
-
before = f.deep_clone
|
|
209
|
-
yield(f)
|
|
210
|
-
save(f)
|
|
226
|
+
if notify
|
|
211
227
|
changed
|
|
212
|
-
notify_observers(:update, before,
|
|
213
|
-
else
|
|
214
|
-
yield(f)
|
|
215
|
-
save(f)
|
|
228
|
+
notify_observers(:update, before, mutated)
|
|
216
229
|
end
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
private
|
|
220
230
|
|
|
221
|
-
|
|
222
|
-
"feature:#{name}"
|
|
231
|
+
mutated
|
|
223
232
|
end
|
|
224
233
|
|
|
225
|
-
|
|
226
|
-
'feature:__features__'
|
|
227
|
-
end
|
|
234
|
+
private
|
|
228
235
|
|
|
229
|
-
def
|
|
230
|
-
|
|
231
|
-
@storage.set(features_key, (features | [feature.name.to_sym]).join(','))
|
|
236
|
+
def logging_capture?
|
|
237
|
+
respond_to?(:logging) && logging.logging_enabled?
|
|
232
238
|
end
|
|
233
239
|
end
|
data/rollout.gemspec
CHANGED
|
@@ -8,12 +8,14 @@ Gem::Specification.new do |spec|
|
|
|
8
8
|
spec.version = Rollout::VERSION
|
|
9
9
|
spec.authors = ['James Golick']
|
|
10
10
|
spec.email = ['jamesgolick@gmail.com']
|
|
11
|
-
spec.description = 'Feature flippers
|
|
12
|
-
spec.summary = 'Feature flippers
|
|
11
|
+
spec.description = 'Feature flippers.'
|
|
12
|
+
spec.summary = 'Feature flippers.'
|
|
13
13
|
spec.homepage = 'https://github.com/FetLife/rollout'
|
|
14
14
|
spec.license = 'MIT'
|
|
15
15
|
|
|
16
|
-
spec.files
|
|
16
|
+
spec.files = `git ls-files`.split("\n").reject do |file|
|
|
17
|
+
file.start_with?('rollout-redis-adapter/')
|
|
18
|
+
end
|
|
17
19
|
spec.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
|
18
20
|
spec.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
|
|
19
21
|
spec.require_paths = ['lib']
|
|
@@ -21,10 +23,9 @@ Gem::Specification.new do |spec|
|
|
|
21
23
|
spec.required_ruby_version = '>= 2.3'
|
|
22
24
|
|
|
23
25
|
spec.add_dependency 'observer'
|
|
24
|
-
spec.add_dependency 'redis', '>= 4.0', '< 6'
|
|
25
26
|
|
|
26
27
|
spec.add_development_dependency 'rake'
|
|
27
|
-
spec.add_development_dependency 'bundler', '>= 1
|
|
28
|
+
spec.add_development_dependency 'bundler', '>= 2.1'
|
|
28
29
|
spec.add_development_dependency 'pry'
|
|
29
30
|
spec.add_development_dependency 'rspec', '~> 3.13'
|
|
30
31
|
spec.add_development_dependency 'rspec_junit_formatter', '~> 0.6'
|
|
@@ -1,54 +1,85 @@
|
|
|
1
1
|
require "spec_helper"
|
|
2
2
|
|
|
3
3
|
describe "Rollout::Feature" do
|
|
4
|
-
|
|
4
|
+
def build_feature(name: :chat, percentage: 0, users: [], groups: [], data: {}, options: {}, rollout: nil)
|
|
5
|
+
rollout ||= Object.new
|
|
6
|
+
Rollout::Feature.new(
|
|
7
|
+
state: Rollout::FeatureState.new(
|
|
8
|
+
name: name,
|
|
9
|
+
percentage: percentage,
|
|
10
|
+
users: users,
|
|
11
|
+
groups: groups,
|
|
12
|
+
data: data,
|
|
13
|
+
),
|
|
14
|
+
rollout: rollout,
|
|
15
|
+
options: options,
|
|
16
|
+
)
|
|
17
|
+
end
|
|
5
18
|
|
|
6
19
|
describe "#add_user" do
|
|
7
20
|
it "ids a user using id_user_by" do
|
|
8
|
-
user
|
|
9
|
-
feature =
|
|
21
|
+
user = double("User", email: "test@test.com")
|
|
22
|
+
feature = build_feature(options: { id_user_by: :email })
|
|
10
23
|
feature.add_user(user)
|
|
11
24
|
expect(user).to have_received :email
|
|
12
25
|
end
|
|
13
26
|
end
|
|
14
27
|
|
|
15
28
|
describe "#initialize" do
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
feature = Rollout::Feature.new(:chat, rollout: rollout)
|
|
19
|
-
expect(feature.groups).to be_empty
|
|
20
|
-
expect(feature.users).to be_empty
|
|
21
|
-
expect(feature.percentage).to eq 0
|
|
22
|
-
expect(feature.data).to eq({})
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
it 'clears feature attributes when string is nil' do
|
|
26
|
-
feature = Rollout::Feature.new(:chat, state: nil, rollout: rollout)
|
|
27
|
-
expect(feature.groups).to be_empty
|
|
28
|
-
expect(feature.users).to be_empty
|
|
29
|
-
expect(feature.percentage).to eq 0
|
|
30
|
-
expect(feature.data).to eq({})
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
it 'clears feature attributes when string is empty string' do
|
|
34
|
-
feature = Rollout::Feature.new(:chat, state: "", rollout: rollout)
|
|
35
|
-
expect(feature.groups).to be_empty
|
|
36
|
-
expect(feature.users).to be_empty
|
|
37
|
-
expect(feature.percentage).to eq 0
|
|
38
|
-
expect(feature.data).to eq({})
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
describe "when there is no data" do
|
|
42
|
-
it 'sets @data to empty hash' do
|
|
43
|
-
feature = Rollout::Feature.new(:chat, state: "0||", rollout: rollout)
|
|
44
|
-
expect(feature.data).to eq({})
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
it 'sets @data to empty hash' do
|
|
48
|
-
feature = Rollout::Feature.new(:chat, state: "||| ", rollout: rollout)
|
|
49
|
-
expect(feature.data).to eq({})
|
|
50
|
-
end
|
|
51
|
-
end
|
|
29
|
+
it "uses the state's name" do
|
|
30
|
+
expect(build_feature(name: :video).name).to eq :video
|
|
52
31
|
end
|
|
32
|
+
|
|
33
|
+
it "preserves an explicit public name" do
|
|
34
|
+
feature = Rollout::Feature.new(
|
|
35
|
+
state: Rollout::FeatureState.new(name: :chat, percentage: 0),
|
|
36
|
+
rollout: Object.new,
|
|
37
|
+
name: "chat",
|
|
38
|
+
)
|
|
39
|
+
feature.percentage = 50
|
|
40
|
+
feature.clear
|
|
41
|
+
|
|
42
|
+
expect(feature.name).to eq "chat"
|
|
43
|
+
expect(feature.to_feature_state.name).to eq "chat"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "clears feature attributes for an empty state" do
|
|
47
|
+
feature = build_feature
|
|
48
|
+
|
|
49
|
+
expect(feature.groups).to be_empty
|
|
50
|
+
expect(feature.users).to be_empty
|
|
51
|
+
expect(feature.percentage).to eq 0
|
|
52
|
+
expect(feature.data).to eq({})
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
describe "percentage assignment" do
|
|
57
|
+
it "keeps the same users across features when randomize_percentage is off" do
|
|
58
|
+
chat = build_feature(name: :chat, percentage: 20)
|
|
59
|
+
beta = build_feature(name: :beta, percentage: 20)
|
|
60
|
+
|
|
61
|
+
expect(chat.active?(double(id: 2))).to eq true
|
|
62
|
+
expect(chat.active?(double(id: 6))).to eq true
|
|
63
|
+
expect(chat.active?(double(id: 1))).to eq false
|
|
64
|
+
expect(beta.active?(double(id: 2))).to eq true
|
|
65
|
+
expect(beta.active?(double(id: 1))).to eq false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "changes assignment by feature name when randomize_percentage is on" do
|
|
69
|
+
chat = build_feature(name: :chat, percentage: 20, options: { randomize_percentage: true })
|
|
70
|
+
beta = build_feature(name: :beta, percentage: 20, options: { randomize_percentage: true })
|
|
71
|
+
|
|
72
|
+
expect(chat.active?(double(id: 1))).to eq true
|
|
73
|
+
expect(beta.active?(double(id: 1))).to eq false
|
|
74
|
+
expect(chat.active?(double(id: 5))).to eq false
|
|
75
|
+
expect(beta.active?(double(id: 5))).to eq true
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it "does not expose assign_state" do
|
|
80
|
+
feature = build_feature
|
|
81
|
+
|
|
82
|
+
expect(feature).not_to respond_to(:assign_state)
|
|
83
|
+
expect(feature.private_methods).to include(:assign_state)
|
|
53
84
|
end
|
|
54
85
|
end
|