rollout 2.4.3 → 2.6.1

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.
data/lib/rollout.rb CHANGED
@@ -1,141 +1,30 @@
1
- require "rollout/version"
2
- require "zlib"
3
- require "set"
4
- require "json"
1
+ # frozen_string_literal: true
5
2
 
6
- class Rollout
7
- RAND_BASE = (2**32 - 1) / 100.0
8
-
9
- class Feature
10
- attr_accessor :groups, :users, :percentage, :data
11
- attr_reader :name, :options
12
-
13
- def initialize(name, string = nil, opts = {})
14
- @options = opts
15
- @name = name
16
-
17
- if string
18
- raw_percentage,raw_users,raw_groups,raw_data = string.split('|', 4)
19
- @percentage = raw_percentage.to_f
20
- @users = users_from_string(raw_users)
21
- @groups = groups_from_string(raw_groups)
22
- @data = raw_data.nil? || raw_data.strip.empty? ? {} : JSON.parse(raw_data)
23
- else
24
- clear
25
- end
26
- end
27
-
28
- def serialize
29
- "#{@percentage}|#{@users.to_a.join(",")}|#{@groups.to_a.join(",")}|#{serialize_data}"
30
- end
31
-
32
- def add_user(user)
33
- id = user_id(user)
34
- @users << id unless @users.include?(id)
35
- end
36
-
37
- def remove_user(user)
38
- @users.delete(user_id(user))
39
- end
40
-
41
- def add_group(group)
42
- @groups << group.to_sym unless @groups.include?(group.to_sym)
43
- end
44
-
45
- def remove_group(group)
46
- @groups.delete(group.to_sym)
47
- end
48
-
49
- def clear
50
- @groups = groups_from_string("")
51
- @users = users_from_string("")
52
- @percentage = 0
53
- @data = {}
54
- end
55
-
56
- def active?(rollout, user)
57
- if user
58
- id = user_id(user)
59
- user_in_percentage?(id) ||
60
- user_in_active_users?(id) ||
61
- user_in_active_group?(user, rollout)
62
- else
63
- @percentage == 100
64
- end
65
- end
66
-
67
- def user_in_active_users?(user)
68
- @users.include?(user_id(user))
69
- end
70
-
71
- def to_hash
72
- {
73
- percentage: @percentage,
74
- groups: @groups,
75
- users: @users
76
- }
77
- end
78
-
79
- private
80
- def user_id(user)
81
- if user.is_a?(Integer) || user.is_a?(String)
82
- user.to_s
83
- else
84
- user.send(id_user_by).to_s
85
- end
86
- end
87
-
88
- def id_user_by
89
- @options[:id_user_by] || :id
90
- end
91
-
92
- def user_in_percentage?(user)
93
- Zlib.crc32(user_id_for_percentage(user)) < RAND_BASE * @percentage
94
- end
95
-
96
- def user_id_for_percentage(user)
97
- if @options[:randomize_percentage]
98
- user_id(user).to_s + @name.to_s
99
- else
100
- user_id(user)
101
- end
102
- end
103
-
104
- def user_in_active_group?(user, rollout)
105
- @groups.any? do |g|
106
- rollout.active_in_group?(g, user)
107
- end
108
- end
109
-
110
- def serialize_data
111
- return "" unless @data.is_a? Hash
3
+ require 'rollout/feature'
4
+ require 'rollout/logging'
5
+ require 'rollout/version'
6
+ require 'zlib'
7
+ require 'set'
8
+ require 'json'
9
+ require 'observer'
112
10
 
113
- @data.to_json
114
- end
11
+ class Rollout
12
+ include Observable
115
13
 
116
- def users_from_string(raw_users)
117
- users = (raw_users || "").split(",").map(&:to_s)
118
- if @options[:use_sets]
119
- users.to_set
120
- else
121
- users
122
- end
123
- end
14
+ RAND_BASE = (2**32 - 1) / 100.0
124
15
 
125
- def groups_from_string(raw_groups)
126
- groups = (raw_groups || "").split(",").map(&:to_sym)
127
- if @options[:use_sets]
128
- groups.to_set
129
- else
130
- groups
131
- end
132
- end
133
- end
16
+ attr_reader :options, :storage
134
17
 
135
18
  def initialize(storage, opts = {})
136
19
  @storage = storage
137
20
  @options = opts
138
- @groups = { all: lambda { |user| true } }
21
+ @groups = { all: ->(_user) { true } }
22
+
23
+ extend(Logging) if opts[:logging]
24
+ end
25
+
26
+ def groups
27
+ @groups.keys
139
28
  end
140
29
 
141
30
  def activate(feature)
@@ -145,16 +34,18 @@ class Rollout
145
34
  end
146
35
 
147
36
  def deactivate(feature)
148
- with_feature(feature) do |f|
149
- f.clear
150
- end
37
+ with_feature(feature, &:clear)
151
38
  end
152
39
 
153
40
  def delete(feature)
154
- features = (@storage.get(features_key) || "").split(",")
41
+ features = (@storage.get(features_key) || '').split(',')
155
42
  features.delete(feature.to_s)
156
- @storage.set(features_key, features.join(","))
43
+ @storage.set(features_key, features.join(','))
157
44
  @storage.del(key(feature))
45
+
46
+ if respond_to?(:logging)
47
+ logging.delete(feature)
48
+ end
158
49
  end
159
50
 
160
51
  def set(feature, desired_state)
@@ -193,20 +84,20 @@ class Rollout
193
84
 
194
85
  def activate_users(feature, users)
195
86
  with_feature(feature) do |f|
196
- users.each{|user| f.add_user(user)}
87
+ users.each { |user| f.add_user(user) }
197
88
  end
198
89
  end
199
90
 
200
91
  def deactivate_users(feature, users)
201
92
  with_feature(feature) do |f|
202
- users.each{|user| f.remove_user(user)}
93
+ users.each { |user| f.remove_user(user) }
203
94
  end
204
95
  end
205
96
 
206
97
  def set_users(feature, users)
207
98
  with_feature(feature) do |f|
208
99
  f.users = []
209
- users.each{|user| f.add_user(user)}
100
+ users.each { |user| f.add_user(user) }
210
101
  end
211
102
  end
212
103
 
@@ -216,7 +107,7 @@ class Rollout
216
107
 
217
108
  def active?(feature, user = nil)
218
109
  feature = get(feature)
219
- feature.active?(self, user)
110
+ feature.active?(user)
220
111
  end
221
112
 
222
113
  def user_in_active_users?(feature, user = nil)
@@ -242,12 +133,12 @@ class Rollout
242
133
 
243
134
  def active_in_group?(group, user)
244
135
  f = @groups[group.to_sym]
245
- f && f.call(user)
136
+ f&.call(user)
246
137
  end
247
138
 
248
139
  def get(feature)
249
140
  string = @storage.get(key(feature))
250
- Feature.new(feature, string, @options)
141
+ Feature.new(feature, state: string, rollout: self, options: @options)
251
142
  end
252
143
 
253
144
  def set_feature_data(feature, data)
@@ -263,29 +154,37 @@ class Rollout
263
154
  end
264
155
 
265
156
  def multi_get(*features)
266
- feature_keys = features.map{ |feature| key(feature) }
267
- @storage.mget(*feature_keys).map.with_index { |string, index| Feature.new(features[index], string, @options) }
157
+ return [] if features.empty?
158
+
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
268
167
  end
269
168
 
270
169
  def features
271
- (@storage.get(features_key) || "").split(",").map(&:to_sym)
170
+ (@storage.get(features_key) || '').split(',').map(&:to_sym)
272
171
  end
273
172
 
274
173
  def feature_states(user = nil)
275
- features.each_with_object({}) do |f, hash|
276
- hash[f] = active?(f, user)
174
+ multi_get(*features).each_with_object({}) do |f, hash|
175
+ hash[f.name] = f.active?(user)
277
176
  end
278
177
  end
279
178
 
280
179
  def active_features(user = nil)
281
- features.select do |f|
282
- active?(f, user)
283
- end
180
+ multi_get(*features).select do |f|
181
+ f.active?(user)
182
+ end.map(&:name)
284
183
  end
285
184
 
286
185
  def clear!
287
186
  features.each do |feature|
288
- with_feature(feature) { |f| f.clear }
187
+ with_feature(feature, &:clear)
289
188
  @storage.del(key(feature))
290
189
  end
291
190
 
@@ -293,7 +192,28 @@ class Rollout
293
192
  end
294
193
 
295
194
  def exists?(feature)
296
- @storage.exists(key(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
202
+ end
203
+
204
+ def with_feature(feature)
205
+ f = get(feature)
206
+
207
+ if count_observers > 0
208
+ before = f.deep_clone
209
+ yield(f)
210
+ save(f)
211
+ changed
212
+ notify_observers(:update, before, f)
213
+ else
214
+ yield(f)
215
+ save(f)
216
+ end
297
217
  end
298
218
 
299
219
  private
@@ -303,17 +223,11 @@ class Rollout
303
223
  end
304
224
 
305
225
  def features_key
306
- "feature:__features__"
307
- end
308
-
309
- def with_feature(feature)
310
- f = get(feature)
311
- yield(f)
312
- save(f)
226
+ 'feature:__features__'
313
227
  end
314
228
 
315
229
  def save(feature)
316
230
  @storage.set(key(feature.name), feature.serialize)
317
- @storage.set(features_key, (features | [feature.name.to_sym]).join(","))
231
+ @storage.set(features_key, (features | [feature.name.to_sym]).join(','))
318
232
  end
319
233
  end
data/rollout.gemspec CHANGED
@@ -1,27 +1,33 @@
1
- # -*- encoding: utf-8 -*-
2
- $:.push File.expand_path("../lib", __FILE__)
3
- require "rollout/version"
1
+ # frozen_string_literal: true
4
2
 
5
- Gem::Specification.new do |s|
6
- s.name = "rollout"
7
- s.version = Rollout::VERSION
8
- s.authors = ["James Golick"]
9
- s.email = ["jamesgolick@gmail.com"]
10
- s.description = "Feature flippers with redis."
11
- s.summary = "Feature flippers with redis."
12
- s.homepage = "https://github.com/FetLife/rollout"
13
- s.license = "MIT"
3
+ $LOAD_PATH.push File.expand_path('lib', __dir__)
4
+ require 'rollout/version'
14
5
 
15
- s.files = `git ls-files`.split("\n")
16
- s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
17
- s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
18
- s.require_paths = ["lib"]
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'rollout'
8
+ spec.version = Rollout::VERSION
9
+ spec.authors = ['James Golick']
10
+ spec.email = ['jamesgolick@gmail.com']
11
+ spec.description = 'Feature flippers with redis.'
12
+ spec.summary = 'Feature flippers with redis.'
13
+ spec.homepage = 'https://github.com/FetLife/rollout'
14
+ spec.license = 'MIT'
19
15
 
20
- s.add_development_dependency "rspec"
21
- s.add_development_dependency "appraisal"
22
- s.add_development_dependency "bundler", ">= 1.0.0"
23
- s.add_development_dependency "redis"
24
- s.add_development_dependency "fakeredis"
25
- s.add_development_dependency "simplecov"
26
- s.add_development_dependency "codeclimate-test-reporter"
16
+ spec.files = `git ls-files`.split("\n")
17
+ spec.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
18
+ spec.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
19
+ spec.require_paths = ['lib']
20
+
21
+ spec.required_ruby_version = '>= 2.3'
22
+
23
+ spec.add_dependency 'observer'
24
+ spec.add_dependency 'redis', '>= 4.0', '< 6'
25
+
26
+ spec.add_development_dependency 'rake'
27
+ spec.add_development_dependency 'bundler', '>= 1.17'
28
+ spec.add_development_dependency 'pry'
29
+ spec.add_development_dependency 'rspec', '~> 3.13'
30
+ spec.add_development_dependency 'rspec_junit_formatter', '~> 0.6'
31
+ spec.add_development_dependency 'rubocop', '~> 0.71'
32
+ spec.add_development_dependency 'simplecov', '0.17'
27
33
  end
@@ -0,0 +1,54 @@
1
+ require "spec_helper"
2
+
3
+ describe "Rollout::Feature" do
4
+ let(:rollout) { Rollout.new($redis) }
5
+
6
+ describe "#add_user" do
7
+ 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 })
10
+ feature.add_user(user)
11
+ expect(user).to have_received :email
12
+ end
13
+ end
14
+
15
+ 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
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,143 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe 'Rollout::Logging' do
4
+ let(:rollout) { Rollout.new($redis, logging: logging) }
5
+ let(:logging) { true }
6
+ let(:feature) { :foo }
7
+
8
+ it 'logs changes' do
9
+ expect(rollout.logging.last_event(feature)).to be_nil
10
+
11
+ rollout.activate_percentage(feature, 50)
12
+
13
+ expect(rollout.logging.updated_at(feature)).to_not be_nil
14
+
15
+ first_event = rollout.logging.last_event(feature)
16
+
17
+ expect(first_event.name).to eq 'update'
18
+ expect(first_event.data).to eq(before: { percentage: 0 }, after: { percentage: 50 })
19
+
20
+ rollout.activate_percentage(feature, 75)
21
+
22
+ second_event = rollout.logging.last_event(feature)
23
+
24
+ expect(second_event.name).to eq 'update'
25
+ expect(second_event.data).to eq(before: { percentage: 50 }, after: { percentage: 75 })
26
+
27
+ rollout.activate_group(feature, :hipsters)
28
+
29
+ third_event = rollout.logging.last_event(feature)
30
+
31
+ expect(third_event.name).to eq 'update'
32
+ expect(third_event.data).to eq(before: { groups: [] }, after: { groups: ['hipsters'] })
33
+
34
+ expect(rollout.logging.events(feature)).to eq [first_event, second_event, third_event]
35
+ end
36
+
37
+ context 'logging data changes' do
38
+ it 'logs changes' do
39
+ expect(rollout.logging.last_event(feature)).to be_nil
40
+
41
+ rollout.set_feature_data(feature, description: "foo")
42
+
43
+ event = rollout.logging.last_event(feature)
44
+
45
+ expect(event).not_to be_nil
46
+ expect(event.name).to eq 'update'
47
+ expect(event.data).to eq(before: { "data.description": nil }, after: { "data.description": "foo" })
48
+ end
49
+ end
50
+
51
+ context 'no logging' do
52
+ let(:logging) { nil }
53
+
54
+ it 'doesnt even respond to logging' do
55
+ expect(rollout).not_to respond_to :logging
56
+ end
57
+ end
58
+
59
+ context 'history truncation' do
60
+ let(:logging) { { history_length: 1 } }
61
+
62
+ it 'logs changes' do
63
+ expect(rollout.logging.last_event(feature)).to be_nil
64
+
65
+ rollout.activate_percentage(feature, 25)
66
+
67
+ first_event = rollout.logging.last_event(feature)
68
+
69
+ expect(first_event.name).to eq 'update'
70
+ expect(first_event.data).to eq(before: { percentage: 0 }, after: { percentage: 25 })
71
+
72
+ rollout.activate_percentage(feature, 30)
73
+
74
+ second_event = rollout.logging.last_event(feature)
75
+
76
+ expect(second_event.name).to eq 'update'
77
+ expect(second_event.data).to eq(before: { percentage: 25 }, after: { percentage: 30 })
78
+
79
+ expect(rollout.logging.events(feature)).to eq [second_event]
80
+ end
81
+ end
82
+
83
+ context 'with context' do
84
+ let(:current_user) { double(nickname: 'lester') }
85
+
86
+ it 'adds context to the event' do
87
+ rollout.logging.with_context(actor: current_user.nickname) do
88
+ rollout.activate_percentage(feature, 25)
89
+ end
90
+
91
+ event = rollout.logging.last_event(feature)
92
+
93
+ expect(event.name).to eq 'update'
94
+ expect(event.data).to eq(before: { percentage: 0 }, after: { percentage: 25 })
95
+ expect(event.context).to eq(actor: current_user.nickname)
96
+ end
97
+ end
98
+
99
+ context 'global logs' do
100
+ let(:logging) { { global: true } }
101
+ let(:feature_foo) { 'foo' }
102
+ let(:feature_bar) { 'bar' }
103
+
104
+ it 'logs changes' do
105
+ expect(rollout.logging.last_event(feature_foo)).to be_nil
106
+
107
+ rollout.activate_percentage(feature_foo, 25)
108
+
109
+ event_foo = rollout.logging.last_event(feature_foo)
110
+
111
+ expect(event_foo.feature).to eq feature_foo
112
+ expect(event_foo.name).to eq 'update'
113
+ expect(event_foo.data).to eq(before: { percentage: 0 }, after: { percentage: 25 })
114
+
115
+ expect(rollout.logging.events(feature_foo)).to eq [event_foo]
116
+
117
+ rollout.activate_percentage(feature_bar, 30)
118
+
119
+ event_bar = rollout.logging.last_event(feature_bar)
120
+
121
+ expect(event_bar.feature).to eq feature_bar
122
+ expect(event_bar.name).to eq 'update'
123
+ expect(event_bar.data).to eq(before: { percentage: 0 }, after: { percentage: 30 })
124
+
125
+ expect(rollout.logging.events(feature_bar)).to eq [event_bar]
126
+
127
+ expect(rollout.logging.global_events).to eq [event_foo, event_bar]
128
+ end
129
+ end
130
+
131
+ context 'no logging for block' do
132
+ it 'doesnt log' do
133
+ rollout.logging.without do
134
+ rollout.activate_percentage(feature, 25)
135
+ end
136
+
137
+ event = rollout.logging.last_event(feature)
138
+
139
+ expect(event).to be_nil
140
+ end
141
+ end
142
+ end
143
+