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.
@@ -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.add_observer(logger, :log)
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
- def initialize(storage: nil, history_length: 50, global: false)
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
- storage_key = events_storage_key(feature_name)
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
- storage_key = events_storage_key(feature_name)
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
- storage_key = events_storage_key(feature_name)
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
- @storage
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
- storage_key = events_storage_key(feature_name)
91
- @storage.del(storage_key)
74
+ @adapter.delete_feature_events(feature_name)
92
75
  end
93
76
 
94
- def update(before, after)
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
- event = Event.new(
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] = nil
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Rollout
4
- VERSION = '2.6.2'
4
+ VERSION = '3.1.0'
5
5
  end
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, :storage
16
+ attr_reader :options, :adapter
17
17
 
18
- def initialize(storage, opts = {})
19
- @storage = storage
20
- @options = opts
18
+ def initialize(adapter:, **options)
19
+ @adapter = adapter
20
+ @options = options
21
21
  @groups = { all: ->(_user) { true } }
22
22
 
23
- extend(Logging) if opts[:logging]
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
- features = (@storage.get(features_key) || '').split(',')
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
- string = @storage.get(key(feature))
141
- Feature.new(feature, state: string, rollout: self, options: @options)
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
- feature_keys = features.map { |feature| key(feature) }
160
-
161
- @storage
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
- (@storage.get(features_key) || '').split(',').map(&:to_sym)
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
- @storage.del(key(feature))
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
- # since redis-rb v4.2, `#exists?` replaces `#exists` which now returns integer value instead of boolean
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
- f = get(feature)
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 count_observers > 0
208
- before = f.deep_clone
209
- yield(f)
210
- save(f)
226
+ if notify
211
227
  changed
212
- notify_observers(:update, before, f)
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
- def key(name)
222
- "feature:#{name}"
231
+ mutated
223
232
  end
224
233
 
225
- def features_key
226
- 'feature:__features__'
227
- end
234
+ private
228
235
 
229
- def save(feature)
230
- @storage.set(key(feature.name), feature.serialize)
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 with redis.'
12
- spec.summary = 'Feature flippers with redis.'
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 = `git ls-files`.split("\n")
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.17'
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
- let(:rollout) { Rollout.new($redis) }
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 = double("User", email: "test@test.com")
9
- feature = Rollout::Feature.new(:chat, state: nil, rollout: rollout, options: { id_user_by: :email })
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
- describe "when string does not exist" do
17
- it 'clears feature attributes when string is not given' do
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