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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: ae73aef78e5898a7936f6853788580ace070953f
4
- data.tar.gz: 216f839eb30e2dbd26b33c5a5f224c285e73dc66
2
+ SHA256:
3
+ metadata.gz: 37e56080ba30fb08aaa7e718e22fae3cab0745e84a91537e4e5520c2843a63a2
4
+ data.tar.gz: 2b2f8c5a6e7a3ddd8275d818b4a168e7573204c2e259e323ca7a2ea9996062d9
5
5
  SHA512:
6
- metadata.gz: 6da7d61689d2048e82ef624ac93bc38abf9b141fc53e1787bafcd937d84eccf663ad54141040eeabaaeab5580700600fbc5b8b1bc5a319447ce53fbb1c7a12b9
7
- data.tar.gz: 8c2d609455ea50da07b7229736db71e83856c4701d1b8c296472f4e6fb3583e748257a04840599a9a833114bcc9e9443f85172a0bbfdb15212beb8079aa8e629
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
@@ -19,5 +19,6 @@ rdoc
19
19
  pkg
20
20
  *.gem
21
21
  gemfiles/*.lock
22
+ .rspec_status
22
23
 
23
24
  ## PROJECT::SPECIFIC
@@ -0,0 +1,11 @@
1
+ AllCops:
2
+ Exclude:
3
+ - 'spec/**/*'
4
+
5
+ Metrics/LineLength:
6
+ Max: 120
7
+ Metrics/MethodLength:
8
+ Max: 20
9
+
10
+ Style/TrailingCommaInArguments:
11
+ EnforcedStyleForMultiline: comma
@@ -4,13 +4,13 @@ sudo: false
4
4
  services:
5
5
  - redis-server
6
6
  rvm:
7
- - 2.4.1
8
- - 2.3.1
7
+ - 2.6
8
+ - 2.5
9
+ - 2.4
10
+ - 2.3
9
11
  - 2.2
10
12
  - 2.1
11
- - 2.0.0
12
- - 1.9.3
13
- - jruby-19mode
13
+ - 2.0
14
14
  env:
15
15
  - USE_REAL_REDIS=true
16
16
  gemfile:
data/Gemfile CHANGED
@@ -1,3 +1,5 @@
1
- source "https://rubygems.org"
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
2
4
 
3
5
  gemspec
data/README.md CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  Fast feature flags based on Redis.
4
4
 
5
- [![Build Status](https://travis-ci.org/fetlife/rollout.svg?branch=master)](https://travis-ci.org/fetlife/rollout)
6
- [![Code Climate](https://codeclimate.com/github/FetLife/rollout/badges/gpa.svg)](https://codeclimate.com/github/fetlife/rollout)
7
- [![Test Coverage](https://codeclimate.com/github/FetLife/rollout/badges/coverage.svg)](https://codeclimate.com/github/fetlife/rollout/coverage)
8
- [![Dependency Status](https://gemnasium.com/FetLife/rollout.svg)](https://gemnasium.com/fetlife/rollout)
5
+ [![Gem Version](https://badge.fury.io/rb/rollout.svg)](https://badge.fury.io/rb/rollout)
6
+ [![CircleCI](https://circleci.com/gh/fetlife/rollout.svg?style=svg)](https://circleci.com/gh/fetlife/rollout)
7
+ [![Code Climate](https://codeclimate.com/github/FetLife/rollout/badges/gpa.svg)](https://codeclimate.com/github/FetLife/rollout)
8
+ [![Test Coverage](https://codeclimate.com/github/FetLife/rollout/badges/coverage.svg)](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 = Redis.new
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) % 100_000 < percentage * 1_000
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
- begin
2
- require "rspec/core/rake_task"
1
+ # frozen_string_literal: true
3
2
 
4
- RSpec::Core::RakeTask.new(:spec)
3
+ require 'rspec/core/rake_task'
5
4
 
6
- task default: :spec
7
- rescue LoadError
8
- # no rspec available
9
- end
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ task default: :spec
@@ -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
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
- 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
- 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,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 && f.call(user)
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
- feature_keys = features.map{ |feature| key(feature) }
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) || "").split(",").map(&:to_sym)
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?(f, user)
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?(f, user)
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) { |f| f.clear }
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
- @storage.exists(key(feature))
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
- "feature:__features__"
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