rollout 2.6.2 → 3.2.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,28 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'rollout/feature_state'
4
+
3
5
  class Rollout
4
6
  class Feature
5
7
  attr_accessor :groups, :users, :percentage, :data
6
8
  attr_reader :name, :options
7
9
 
8
- def initialize(name, rollout:, state: nil, options: {})
9
- @name = name
10
+ def initialize(state:, rollout:, options: {}, name: nil)
10
11
  @rollout = rollout
11
12
  @options = options
12
-
13
- if state
14
- raw_percentage, raw_users, raw_groups, raw_data = state.split('|', 4)
15
- @percentage = raw_percentage.to_f
16
- @users = users_from_string(raw_users)
17
- @groups = groups_from_string(raw_groups)
18
- @data = raw_data.nil? || raw_data.strip.empty? ? {} : JSON.parse(raw_data)
19
- else
20
- clear
21
- end
13
+ @name = name.nil? ? state.name.to_sym : name
14
+ assign_state(state)
22
15
  end
23
16
 
24
- def serialize
25
- "#{@percentage}|#{@users.to_a.join(',')}|#{@groups.to_a.join(',')}|#{serialize_data}"
17
+ def to_feature_state
18
+ FeatureState.new(
19
+ name: @name,
20
+ percentage: @percentage,
21
+ users: Array(@users),
22
+ groups: Array(@groups),
23
+ data: @data,
24
+ )
26
25
  end
27
26
 
28
27
  def add_user(user)
@@ -43,10 +42,7 @@ class Rollout
43
42
  end
44
43
 
45
44
  def clear
46
- @groups = groups_from_string('')
47
- @users = users_from_string('')
48
- @percentage = 0
49
- @data = {}
45
+ assign_state(FeatureState.new(name: @name, percentage: 0))
50
46
  end
51
47
 
52
48
  def active?(user)
@@ -77,12 +73,21 @@ class Rollout
77
73
  c = self.clone
78
74
  c.instance_variable_set('@rollout', nil)
79
75
  c = Marshal.load(Marshal.dump(c))
80
- c.instance_variable_set('@rollot', @rollout)
76
+ c.instance_variable_set('@rollout', @rollout)
81
77
  c
82
78
  end
83
79
 
84
80
  private
85
81
 
82
+ def assign_state(state)
83
+ state = state.deep_clone
84
+
85
+ @percentage = state.percentage
86
+ @users = users_from_array(state.users)
87
+ @groups = groups_from_array(state.groups)
88
+ @data = state.data
89
+ end
90
+
86
91
  def user_id(user)
87
92
  if user.is_a?(Integer) || user.is_a?(String)
88
93
  user.to_s
@@ -113,14 +118,8 @@ class Rollout
113
118
  end
114
119
  end
115
120
 
116
- def serialize_data
117
- return '' unless @data.is_a? Hash
118
-
119
- @data.to_json
120
- end
121
-
122
- def users_from_string(raw_users)
123
- users = (raw_users || '').split(',').map(&:to_s)
121
+ def users_from_array(users)
122
+ users = Array(users).map(&:to_s)
124
123
  if @options[:use_sets]
125
124
  users.to_set
126
125
  else
@@ -128,8 +127,8 @@ class Rollout
128
127
  end
129
128
  end
130
129
 
131
- def groups_from_string(raw_groups)
132
- groups = (raw_groups || '').split(',').map(&:to_sym)
130
+ def groups_from_array(groups)
131
+ groups = Array(groups).map(&:to_sym)
133
132
  if @options[:use_sets]
134
133
  groups.to_set
135
134
  else
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ class Rollout
6
+ class FeatureState
7
+ attr_reader :name, :percentage, :users, :groups, :data
8
+
9
+ def initialize(name:, percentage:, users: [], groups: [], data: {})
10
+ raise ArgumentError, "data must be a Hash" unless data.is_a?(Hash)
11
+
12
+ @name = name.to_s.dup
13
+ @percentage = percentage.to_f
14
+ @users = Array(users).map { |user| user.to_s.dup }
15
+ @groups = Array(groups).map { |group| group.to_s.dup }
16
+ @data = canonicalize_data(data)
17
+ end
18
+
19
+ def ==(other)
20
+ other.is_a?(FeatureState) &&
21
+ name == other.name &&
22
+ percentage == other.percentage &&
23
+ users == other.users &&
24
+ groups == other.groups &&
25
+ data == other.data
26
+ end
27
+
28
+ def deep_clone
29
+ self.class.new(
30
+ name: name,
31
+ percentage: percentage,
32
+ users: users,
33
+ groups: groups,
34
+ data: data,
35
+ )
36
+ end
37
+
38
+ private
39
+
40
+ def canonicalize_data(data)
41
+ JSON.parse(data.to_json)
42
+ end
43
+ end
44
+ end
@@ -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,49 @@ 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 delete_event_for(feature_name)
78
+ return unless logging_enabled? && global
79
+
80
+ Event.new(
81
+ feature: feature_name.to_s,
82
+ name: :delete,
83
+ data: {},
84
+ context: current_context,
85
+ created_at: Time.now,
86
+ )
87
+ end
88
+
89
+ def event_for(before, after)
90
+ return unless logging_enabled?
91
+
95
92
  before_hash = before.to_hash
96
93
  before_hash.delete(:data).each do |k, v|
97
94
  before_hash["data.#{k}"] = v
@@ -116,41 +113,13 @@ class Rollout
116
113
 
117
114
  return if changed_count == 0
118
115
 
119
- event = Event.new(
116
+ Event.new(
120
117
  feature: after.name,
121
118
  name: :update,
122
119
  data: change,
123
120
  context: current_context,
124
121
  created_at: Time.now,
125
122
  )
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
123
  end
155
124
 
156
125
  CONTEXT_THREAD_KEY = :rollout_logging_context
@@ -171,29 +140,16 @@ class Rollout
171
140
  end
172
141
 
173
142
  def without
143
+ previous = Thread.current[WITHOUT_THREAD_KEY]
174
144
  Thread.current[WITHOUT_THREAD_KEY] = true
175
145
  yield
176
146
  ensure
177
- Thread.current[WITHOUT_THREAD_KEY] = nil
147
+ Thread.current[WITHOUT_THREAD_KEY] = previous
178
148
  end
179
149
 
180
150
  def logging_enabled?
181
151
  !Thread.current[WITHOUT_THREAD_KEY]
182
152
  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
153
  end
198
154
  end
199
155
  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.2.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,12 +38,16 @@ 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))
45
-
46
- if respond_to?(:logging)
41
+ return @adapter.delete_feature(feature) unless respond_to?(:logging)
42
+
43
+ if @adapter.respond_to?(:delete_feature_with_history)
44
+ @adapter.delete_feature_with_history(
45
+ feature,
46
+ event: logging.delete_event_for(feature),
47
+ history_length: logging.history_length,
48
+ )
49
+ else
50
+ @adapter.delete_feature(feature)
47
51
  logging.delete(feature)
48
52
  end
49
53
  end
@@ -137,8 +141,12 @@ class Rollout
137
141
  end
138
142
 
139
143
  def get(feature)
140
- string = @storage.get(key(feature))
141
- Feature.new(feature, state: string, rollout: self, options: @options)
144
+ Feature.new(
145
+ state: @adapter.fetch_feature(feature),
146
+ rollout: self,
147
+ options: @options,
148
+ name: feature,
149
+ )
142
150
  end
143
151
 
144
152
  def set_feature_data(feature, data)
@@ -156,18 +164,13 @@ class Rollout
156
164
  def multi_get(*features)
157
165
  return [] if features.empty?
158
166
 
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
167
+ @adapter.fetch_features(features).zip(features).map do |state, name|
168
+ Feature.new(state: state, rollout: self, options: @options, name: name)
169
+ end
167
170
  end
168
171
 
169
172
  def features
170
- (@storage.get(features_key) || '').split(',').map(&:to_sym)
173
+ @adapter.feature_names.map(&:to_sym)
171
174
  end
172
175
 
173
176
  def feature_states(user = nil)
@@ -185,49 +188,59 @@ class Rollout
185
188
  def clear!
186
189
  features.each do |feature|
187
190
  with_feature(feature, &:clear)
188
- @storage.del(key(feature))
191
+ @adapter.delete_feature(feature)
189
192
  end
190
-
191
- @storage.del(features_key)
193
+ @adapter.clear_features
192
194
  end
193
195
 
194
196
  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
197
+ @adapter.feature_exists?(feature)
202
198
  end
203
199
 
204
200
  def with_feature(feature)
205
- f = get(feature)
201
+ mutated = nil
202
+ before = nil
203
+ capture_logging = logging_capture?
204
+ notify = count_observers > 0
205
+ snapshot = notify || capture_logging
206
+
207
+ @adapter.mutate_feature(feature) do |current_state|
208
+ mutated = Feature.new(
209
+ state: current_state,
210
+ rollout: self,
211
+ options: @options,
212
+ name: feature,
213
+ )
214
+ before = mutated.deep_clone if snapshot
215
+ yield mutated
216
+
217
+ state = mutated.to_feature_state
218
+ mutated = Feature.new(
219
+ state: state,
220
+ rollout: self,
221
+ options: @options,
222
+ name: feature,
223
+ )
224
+ event = capture_logging ? logging.event_for(before, mutated) : nil
225
+ result = { state: state, event: event }
226
+ if event
227
+ result[:history_length] = logging.history_length
228
+ result[:global] = logging.global
229
+ end
230
+ result
231
+ end
206
232
 
207
- if count_observers > 0
208
- before = f.deep_clone
209
- yield(f)
210
- save(f)
233
+ if notify
211
234
  changed
212
- notify_observers(:update, before, f)
213
- else
214
- yield(f)
215
- save(f)
235
+ notify_observers(:update, before, mutated)
216
236
  end
217
- end
218
237
 
219
- private
220
-
221
- def key(name)
222
- "feature:#{name}"
238
+ mutated
223
239
  end
224
240
 
225
- def features_key
226
- 'feature:__features__'
227
- end
241
+ private
228
242
 
229
- def save(feature)
230
- @storage.set(key(feature.name), feature.serialize)
231
- @storage.set(features_key, (features | [feature.name.to_sym]).join(','))
243
+ def logging_capture?
244
+ respond_to?(:logging) && logging.logging_enabled?
232
245
  end
233
246
  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/', 'rollout-active_record-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