rollout 2.4.2 → 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 +70 -155
- 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 +25 -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
|
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
|
3
|
+
require 'rollout/feature'
|
4
|
+
require 'rollout/logging'
|
5
|
+
require 'rollout/version'
|
6
|
+
require 'zlib'
|
7
|
+
require 'set'
|
8
|
+
require 'json'
|
9
|
+
require 'observer'
|
103
10
|
|
104
|
-
|
105
|
-
|
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,13 +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) }
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def set_users(feature, users)
|
98
|
+
with_feature(feature) do |f|
|
99
|
+
f.users = []
|
100
|
+
users.each { |user| f.add_user(user) }
|
203
101
|
end
|
204
102
|
end
|
205
103
|
|
@@ -235,7 +133,7 @@ class Rollout
|
|
235
133
|
|
236
134
|
def active_in_group?(group, user)
|
237
135
|
f = @groups[group.to_sym]
|
238
|
-
f
|
136
|
+
f&.call(user)
|
239
137
|
end
|
240
138
|
|
241
139
|
def get(feature)
|
@@ -256,29 +154,31 @@ class Rollout
|
|
256
154
|
end
|
257
155
|
|
258
156
|
def multi_get(*features)
|
259
|
-
|
157
|
+
return [] if features.empty?
|
158
|
+
|
159
|
+
feature_keys = features.map { |feature| key(feature) }
|
260
160
|
@storage.mget(*feature_keys).map.with_index { |string, index| Feature.new(features[index], string, @options) }
|
261
161
|
end
|
262
162
|
|
263
163
|
def features
|
264
|
-
(@storage.get(features_key) ||
|
164
|
+
(@storage.get(features_key) || '').split(',').map(&:to_sym)
|
265
165
|
end
|
266
166
|
|
267
167
|
def feature_states(user = nil)
|
268
|
-
features.each_with_object({}) do |f, hash|
|
269
|
-
hash[f] = active?(
|
168
|
+
multi_get(*features).each_with_object({}) do |f, hash|
|
169
|
+
hash[f.name] = f.active?(self, user)
|
270
170
|
end
|
271
171
|
end
|
272
172
|
|
273
173
|
def active_features(user = nil)
|
274
|
-
features.select do |f|
|
275
|
-
active?(
|
276
|
-
end
|
174
|
+
multi_get(*features).select do |f|
|
175
|
+
f.active?(self, user)
|
176
|
+
end.map(&:name)
|
277
177
|
end
|
278
178
|
|
279
179
|
def clear!
|
280
180
|
features.each do |feature|
|
281
|
-
with_feature(feature
|
181
|
+
with_feature(feature, &:clear)
|
282
182
|
@storage.del(key(feature))
|
283
183
|
end
|
284
184
|
|
@@ -286,7 +186,28 @@ class Rollout
|
|
286
186
|
end
|
287
187
|
|
288
188
|
def exists?(feature)
|
289
|
-
|
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
|
290
211
|
end
|
291
212
|
|
292
213
|
private
|
@@ -296,17 +217,11 @@ class Rollout
|
|
296
217
|
end
|
297
218
|
|
298
219
|
def features_key
|
299
|
-
|
300
|
-
end
|
301
|
-
|
302
|
-
def with_feature(feature)
|
303
|
-
f = get(feature)
|
304
|
-
yield(f)
|
305
|
-
save(f)
|
220
|
+
'feature:__features__'
|
306
221
|
end
|
307
222
|
|
308
223
|
def save(feature)
|
309
224
|
@storage.set(key(feature.name), feature.serialize)
|
310
|
-
@storage.set(features_key, (features | [feature.name.to_sym]).join(
|
225
|
+
@storage.set(features_key, (features | [feature.name.to_sym]).join(','))
|
311
226
|
end
|
312
227
|
end
|