rollout 2.4.3 → 2.5.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/.circleci/config.yml +95 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +11 -0
- data/.travis.yml +5 -5
- data/Gemfile +3 -1
- data/README.md +18 -6
- data/Rakefile +5 -7
- data/lib/rollout.rb +64 -156
- data/lib/rollout/feature.rb +131 -0
- data/lib/rollout/logging.rb +198 -0
- data/lib/rollout/version.rb +3 -1
- data/rollout.gemspec +27 -23
- data/spec/rollout/logging_spec.rb +143 -0
- data/spec/rollout_spec.rb +15 -6
- data/spec/spec_helper.rb +21 -12
- metadata +42 -45
- data/.document +0 -5
- data/Appraisals +0 -7
- data/Gemfile.lock +0 -56
- data/gemfiles/redis_3.gemfile +0 -13
- data/gemfiles/redis_4.gemfile +0 -13
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 37e56080ba30fb08aaa7e718e22fae3cab0745e84a91537e4e5520c2843a63a2
|
4
|
+
data.tar.gz: 2b2f8c5a6e7a3ddd8275d818b4a168e7573204c2e259e323ca7a2ea9996062d9
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a31905463a16ca7413566460c9a693ab5b9063e74547c05aafe2cf40d5e8f8d83ecb9e85316dd48c3e6b5e5b63311ac222343b485228a695ff5b72f8b0d82a2c
|
7
|
+
data.tar.gz: 33fc542bdfe0def2b13a1b3697ba59c1e5f83e813a7c67740d94bf0a9725e17003fdc3fecfa923874883730dde589e0906b5d49ab19ff49c36943e259c28f134
|
@@ -0,0 +1,95 @@
|
|
1
|
+
version: 2.1
|
2
|
+
|
3
|
+
workflows:
|
4
|
+
main:
|
5
|
+
jobs:
|
6
|
+
- ruby27
|
7
|
+
- ruby26
|
8
|
+
- ruby25
|
9
|
+
- ruby24
|
10
|
+
- ruby23
|
11
|
+
|
12
|
+
executors:
|
13
|
+
ruby27:
|
14
|
+
docker:
|
15
|
+
- image: circleci/ruby:2.7
|
16
|
+
- image: circleci/redis:alpine
|
17
|
+
ruby26:
|
18
|
+
docker:
|
19
|
+
- image: circleci/ruby:2.6
|
20
|
+
- image: circleci/redis:alpine
|
21
|
+
ruby25:
|
22
|
+
docker:
|
23
|
+
- image: circleci/ruby:2.5
|
24
|
+
- image: circleci/redis:alpine
|
25
|
+
ruby24:
|
26
|
+
docker:
|
27
|
+
- image: circleci/ruby:2.4
|
28
|
+
- image: circleci/redis:alpine
|
29
|
+
ruby23:
|
30
|
+
docker:
|
31
|
+
- image: circleci/ruby:2.3
|
32
|
+
- image: circleci/redis:alpine
|
33
|
+
|
34
|
+
commands:
|
35
|
+
test:
|
36
|
+
steps:
|
37
|
+
- restore_cache:
|
38
|
+
keys:
|
39
|
+
- bundler-{{ checksum "Gemfile.lock" }}
|
40
|
+
|
41
|
+
- run:
|
42
|
+
name: Bundle Install
|
43
|
+
command: bundle check --path vendor/bundle || bundle install
|
44
|
+
|
45
|
+
- save_cache:
|
46
|
+
key: bundler-{{ checksum "Gemfile.lock" }}
|
47
|
+
paths:
|
48
|
+
- vendor/bundle
|
49
|
+
|
50
|
+
- run:
|
51
|
+
name: Run rspec
|
52
|
+
command: |
|
53
|
+
bundle exec rspec --format documentation --format RspecJunitFormatter --out test_results/rspec.xml
|
54
|
+
|
55
|
+
jobs:
|
56
|
+
ruby27:
|
57
|
+
executor: ruby27
|
58
|
+
steps:
|
59
|
+
- checkout
|
60
|
+
- test
|
61
|
+
|
62
|
+
- run:
|
63
|
+
name: Report Test Coverage
|
64
|
+
command: |
|
65
|
+
wget https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 -O cc-test-reporter
|
66
|
+
chmod +x cc-test-reporter
|
67
|
+
./cc-test-reporter format-coverage -t simplecov -o coverage/codeclimate.json coverage/.resultset.json
|
68
|
+
./cc-test-reporter upload-coverage -i coverage/codeclimate.json
|
69
|
+
|
70
|
+
- store_test_results:
|
71
|
+
path: test_results
|
72
|
+
|
73
|
+
ruby26:
|
74
|
+
executor: ruby26
|
75
|
+
steps:
|
76
|
+
- checkout
|
77
|
+
- test
|
78
|
+
|
79
|
+
ruby25:
|
80
|
+
executor: ruby25
|
81
|
+
steps:
|
82
|
+
- checkout
|
83
|
+
- test
|
84
|
+
|
85
|
+
ruby24:
|
86
|
+
executor: ruby24
|
87
|
+
steps:
|
88
|
+
- checkout
|
89
|
+
- test
|
90
|
+
|
91
|
+
ruby23:
|
92
|
+
executor: ruby23
|
93
|
+
steps:
|
94
|
+
- checkout
|
95
|
+
- test
|
data/.gitignore
CHANGED
data/.rubocop.yml
ADDED
data/.travis.yml
CHANGED
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -2,10 +2,10 @@
|
|
2
2
|
|
3
3
|
Fast feature flags based on Redis.
|
4
4
|
|
5
|
-
[](https://badge.fury.io/rb/rollout)
|
6
|
+
[](https://circleci.com/gh/fetlife/rollout)
|
7
|
+
[](https://codeclimate.com/github/FetLife/rollout)
|
8
|
+
[](https://codeclimate.com/github/FetLife/rollout/coverage)
|
9
9
|
|
10
10
|
## Install it
|
11
11
|
|
@@ -20,10 +20,18 @@ Initialize a rollout object. I assign it to a global var.
|
|
20
20
|
```ruby
|
21
21
|
require 'redis'
|
22
22
|
|
23
|
-
$redis
|
23
|
+
$redis = Redis.new
|
24
24
|
$rollout = Rollout.new($redis)
|
25
25
|
```
|
26
26
|
|
27
|
+
or even simpler
|
28
|
+
|
29
|
+
```ruby
|
30
|
+
require 'redis'
|
31
|
+
$rollout = Rollout.new(Redis.current) # Will use REDIS_URL env var or default redis url
|
32
|
+
```
|
33
|
+
|
34
|
+
|
27
35
|
Update data specific to a feature:
|
28
36
|
|
29
37
|
```ruby
|
@@ -71,6 +79,9 @@ Deactivate groups like this:
|
|
71
79
|
$rollout.deactivate_group(:chat, :all)
|
72
80
|
```
|
73
81
|
|
82
|
+
Groups need to be defined every time your app starts. The logic is not persisted
|
83
|
+
anywhere.
|
84
|
+
|
74
85
|
## Specific Users
|
75
86
|
|
76
87
|
You might want to let a specific user into a beta test or something. If that
|
@@ -98,7 +109,7 @@ $rollout.activate_percentage(:chat, 20)
|
|
98
109
|
The algorithm for determining which users get let in is this:
|
99
110
|
|
100
111
|
```ruby
|
101
|
-
CRC32(user.id)
|
112
|
+
CRC32(user.id) < (2**32 - 1) / 100.0 * percentage
|
102
113
|
```
|
103
114
|
|
104
115
|
So, for 20%, users 0, 1, 10, 11, 20, 21, etc would be allowed in. Those users
|
@@ -176,6 +187,7 @@ This example would use the "development:feature:chat:groups" key.
|
|
176
187
|
* Python: https://github.com/asenchi/proclaim
|
177
188
|
* PHP: https://github.com/opensoft/rollout
|
178
189
|
* Clojure: https://github.com/yeller/shoutout
|
190
|
+
* Perl: https://metacpan.org/pod/Toggle
|
179
191
|
|
180
192
|
|
181
193
|
## Contributors
|
data/Rakefile
CHANGED
@@ -1,9 +1,7 @@
|
|
1
|
-
|
2
|
-
require "rspec/core/rake_task"
|
1
|
+
# frozen_string_literal: true
|
3
2
|
|
4
|
-
|
3
|
+
require 'rspec/core/rake_task'
|
5
4
|
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
end
|
5
|
+
RSpec::Core::RakeTask.new(:spec)
|
6
|
+
|
7
|
+
task default: :spec
|
data/lib/rollout.rb
CHANGED
@@ -1,141 +1,30 @@
|
|
1
|
-
|
2
|
-
require "zlib"
|
3
|
-
require "set"
|
4
|
-
require "json"
|
1
|
+
# frozen_string_literal: true
|
5
2
|
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
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
|
3
|
+
require 'rollout/feature'
|
4
|
+
require 'rollout/logging'
|
5
|
+
require 'rollout/version'
|
6
|
+
require 'zlib'
|
7
|
+
require 'set'
|
8
|
+
require 'json'
|
9
|
+
require 'observer'
|
95
10
|
|
96
|
-
|
97
|
-
|
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
|
112
|
-
|
113
|
-
@data.to_json
|
114
|
-
end
|
11
|
+
class Rollout
|
12
|
+
include Observable
|
115
13
|
|
116
|
-
|
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
|
-
|
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:
|
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)
|
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) ||
|
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
|
|
@@ -242,7 +133,7 @@ class Rollout
|
|
242
133
|
|
243
134
|
def active_in_group?(group, user)
|
244
135
|
f = @groups[group.to_sym]
|
245
|
-
f
|
136
|
+
f&.call(user)
|
246
137
|
end
|
247
138
|
|
248
139
|
def get(feature)
|
@@ -263,29 +154,31 @@ class Rollout
|
|
263
154
|
end
|
264
155
|
|
265
156
|
def multi_get(*features)
|
266
|
-
|
157
|
+
return [] if features.empty?
|
158
|
+
|
159
|
+
feature_keys = features.map { |feature| key(feature) }
|
267
160
|
@storage.mget(*feature_keys).map.with_index { |string, index| Feature.new(features[index], string, @options) }
|
268
161
|
end
|
269
162
|
|
270
163
|
def features
|
271
|
-
(@storage.get(features_key) ||
|
164
|
+
(@storage.get(features_key) || '').split(',').map(&:to_sym)
|
272
165
|
end
|
273
166
|
|
274
167
|
def feature_states(user = nil)
|
275
|
-
features.each_with_object({}) do |f, hash|
|
276
|
-
hash[f] = active?(
|
168
|
+
multi_get(*features).each_with_object({}) do |f, hash|
|
169
|
+
hash[f.name] = f.active?(self, user)
|
277
170
|
end
|
278
171
|
end
|
279
172
|
|
280
173
|
def active_features(user = nil)
|
281
|
-
features.select do |f|
|
282
|
-
active?(
|
283
|
-
end
|
174
|
+
multi_get(*features).select do |f|
|
175
|
+
f.active?(self, user)
|
176
|
+
end.map(&:name)
|
284
177
|
end
|
285
178
|
|
286
179
|
def clear!
|
287
180
|
features.each do |feature|
|
288
|
-
with_feature(feature
|
181
|
+
with_feature(feature, &:clear)
|
289
182
|
@storage.del(key(feature))
|
290
183
|
end
|
291
184
|
|
@@ -293,7 +186,28 @@ class Rollout
|
|
293
186
|
end
|
294
187
|
|
295
188
|
def exists?(feature)
|
296
|
-
|
189
|
+
# since redis-rb v4.2, `#exists?` replaces `#exists` which now returns integer value instead of boolean
|
190
|
+
# https://github.com/redis/redis-rb/pull/918
|
191
|
+
if @storage.respond_to?(:exists?)
|
192
|
+
@storage.exists?(key(feature))
|
193
|
+
else
|
194
|
+
@storage.exists(key(feature))
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
def with_feature(feature)
|
199
|
+
f = get(feature)
|
200
|
+
|
201
|
+
if count_observers > 0
|
202
|
+
before = Marshal.load(Marshal.dump(f))
|
203
|
+
yield(f)
|
204
|
+
save(f)
|
205
|
+
changed
|
206
|
+
notify_observers(:update, before, f)
|
207
|
+
else
|
208
|
+
yield(f)
|
209
|
+
save(f)
|
210
|
+
end
|
297
211
|
end
|
298
212
|
|
299
213
|
private
|
@@ -303,17 +217,11 @@ class Rollout
|
|
303
217
|
end
|
304
218
|
|
305
219
|
def features_key
|
306
|
-
|
307
|
-
end
|
308
|
-
|
309
|
-
def with_feature(feature)
|
310
|
-
f = get(feature)
|
311
|
-
yield(f)
|
312
|
-
save(f)
|
220
|
+
'feature:__features__'
|
313
221
|
end
|
314
222
|
|
315
223
|
def save(feature)
|
316
224
|
@storage.set(key(feature.name), feature.serialize)
|
317
|
-
@storage.set(features_key, (features | [feature.name.to_sym]).join(
|
225
|
+
@storage.set(features_key, (features | [feature.name.to_sym]).join(','))
|
318
226
|
end
|
319
227
|
end
|