rollout 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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 37e56080ba30fb08aaa7e718e22fae3cab0745e84a91537e4e5520c2843a63a2
4
+ data.tar.gz: 2b2f8c5a6e7a3ddd8275d818b4a168e7573204c2e259e323ca7a2ea9996062d9
5
+ SHA512:
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
@@ -0,0 +1,24 @@
1
+ ## MAC OS
2
+ .DS_Store
3
+
4
+ ## TEXTMATE
5
+ *.tmproj
6
+ tmtags
7
+
8
+ ## EMACS
9
+ *~
10
+ \#*
11
+ .\#*
12
+
13
+ ## VIM
14
+ *.swp
15
+
16
+ ## PROJECT::GENERAL
17
+ coverage
18
+ rdoc
19
+ pkg
20
+ *.gem
21
+ gemfiles/*.lock
22
+ .rspec_status
23
+
24
+ ## PROJECT::SPECIFIC
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
@@ -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
@@ -0,0 +1,20 @@
1
+ language: ruby
2
+ cache: bundler
3
+ sudo: false
4
+ services:
5
+ - redis-server
6
+ rvm:
7
+ - 2.6
8
+ - 2.5
9
+ - 2.4
10
+ - 2.3
11
+ - 2.2
12
+ - 2.1
13
+ - 2.0
14
+ env:
15
+ - USE_REAL_REDIS=true
16
+ gemfile:
17
+ - gemfiles/redis_3.gemfile
18
+ - gemfiles/redis_4.gemfile
19
+ script:
20
+ - bundle exec rspec
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2010-InfinityAndBeyond BitLove, Inc.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,201 @@
1
+ # rollout
2
+
3
+ Fast feature flags based on Redis.
4
+
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
+
10
+ ## Install it
11
+
12
+ ```bash
13
+ gem install rollout
14
+ ```
15
+
16
+ ## How it works
17
+
18
+ Initialize a rollout object. I assign it to a global var.
19
+
20
+ ```ruby
21
+ require 'redis'
22
+
23
+ $redis = Redis.new
24
+ $rollout = Rollout.new($redis)
25
+ ```
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
+
35
+ Update data specific to a feature:
36
+
37
+ ```ruby
38
+ $rollout.set_feature_data(:chat, description: 'foo', release_date: 'bar', whatever: 'baz')
39
+ ```
40
+
41
+ Check whether a feature is active for a particular user:
42
+
43
+ ```ruby
44
+ $rollout.active?(:chat, User.first) # => true/false
45
+ ```
46
+
47
+ Check whether a feature is active globally:
48
+
49
+ ```ruby
50
+ $rollout.active?(:chat)
51
+ ```
52
+
53
+ You can activate features using a number of different mechanisms.
54
+
55
+ ## Groups
56
+
57
+ Rollout ships with one group by default: "all", which does exactly what it
58
+ sounds like.
59
+
60
+ You can activate the all group for the chat feature like this:
61
+
62
+ ```ruby
63
+ $rollout.activate_group(:chat, :all)
64
+ ```
65
+
66
+ You might also want to define your own groups. We have one for our caretakers:
67
+
68
+ ```ruby
69
+ $rollout.define_group(:caretakers) do |user|
70
+ user.caretaker?
71
+ end
72
+ ```
73
+
74
+ You can activate multiple groups per feature.
75
+
76
+ Deactivate groups like this:
77
+
78
+ ```ruby
79
+ $rollout.deactivate_group(:chat, :all)
80
+ ```
81
+
82
+ Groups need to be defined every time your app starts. The logic is not persisted
83
+ anywhere.
84
+
85
+ ## Specific Users
86
+
87
+ You might want to let a specific user into a beta test or something. If that
88
+ user isn't part of an existing group, you can let them in specifically:
89
+
90
+ ```ruby
91
+ $rollout.activate_user(:chat, @user)
92
+ ```
93
+
94
+ Deactivate them like this:
95
+
96
+ ```ruby
97
+ $rollout.deactivate_user(:chat, @user)
98
+ ```
99
+
100
+ ## User Percentages
101
+
102
+ If you're rolling out a new feature, you might want to test the waters by
103
+ slowly enabling it for a percentage of your users.
104
+
105
+ ```ruby
106
+ $rollout.activate_percentage(:chat, 20)
107
+ ```
108
+
109
+ The algorithm for determining which users get let in is this:
110
+
111
+ ```ruby
112
+ CRC32(user.id) < (2**32 - 1) / 100.0 * percentage
113
+ ```
114
+
115
+ So, for 20%, users 0, 1, 10, 11, 20, 21, etc would be allowed in. Those users
116
+ would remain in as the percentage increases.
117
+
118
+ Deactivate all percentages like this:
119
+
120
+ ```ruby
121
+ $rollout.deactivate_percentage(:chat)
122
+ ```
123
+
124
+ _Note that activating a feature for 100% of users will also make it active
125
+ "globally". That is when calling Rollout#active? without a user object._
126
+
127
+ In some cases you might want to have a feature activated for a random set of
128
+ users. It can come specially handy when using Rollout for split tests.
129
+
130
+ ```ruby
131
+ $rollout = Rollout.new($redis, randomize_percentage: true)
132
+ ```
133
+
134
+ When on `randomize_percentage` will make sure that 50% of users for feature A
135
+ are selected independently from users for feature B.
136
+
137
+ ## Global actions
138
+
139
+ While groups can come in handy, the actual global setter for a feature does not require a group to be passed.
140
+
141
+ ```ruby
142
+ $rollout.activate(:chat)
143
+ ```
144
+
145
+ In that case you can check the global availability of a feature using the following
146
+
147
+ ```ruby
148
+ $rollout.active?(:chat)
149
+ ```
150
+
151
+ And if something is wrong you can set a feature off for everybody using
152
+
153
+ Deactivate everybody at once:
154
+
155
+ ```ruby
156
+ $rollout.deactivate(:chat)
157
+ ```
158
+
159
+ For many of our features, we keep track of error rates using redis, and
160
+ deactivate them automatically when a threshold is reached to prevent service
161
+ failures from cascading. See https://github.com/jamesgolick/degrade for the
162
+ failure detection code.
163
+
164
+ ## Namespacing
165
+
166
+ Rollout separates its keys from other keys in the data store using the
167
+ "feature" keyspace.
168
+
169
+ If you're using redis, you can namespace keys further to support multiple
170
+ environments by using the
171
+ [redis-namespace](https://github.com/resque/redis-namespace) gem.
172
+
173
+ ```ruby
174
+ $ns = Redis::Namespace.new(Rails.env, redis: $redis)
175
+ $rollout = Rollout.new($ns)
176
+ $rollout.activate_group(:chat, :all)
177
+ ```
178
+
179
+ This example would use the "development:feature:chat:groups" key.
180
+
181
+ ## Frontend / UI
182
+
183
+ * [Rollout-Dashboard](https://github.com/fiverr/rollout_dashboard/)
184
+
185
+ ## Implementations in other languages
186
+
187
+ * Python: https://github.com/asenchi/proclaim
188
+ * PHP: https://github.com/opensoft/rollout
189
+ * Clojure: https://github.com/yeller/shoutout
190
+ * Perl: https://metacpan.org/pod/Toggle
191
+
192
+
193
+ ## Contributors
194
+
195
+ * James Golick - Creator - https://github.com/jamesgolick
196
+ * Eric Rafaloff - Maintainer - https://github.com/EricR
197
+
198
+
199
+ ## Copyright
200
+
201
+ Copyright (c) 2010-InfinityAndBeyond BitLove, Inc. See LICENSE for details.
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core/rake_task'
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ task default: :spec
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rollout/feature'
4
+ require 'rollout/logging'
5
+ require 'rollout/version'
6
+ require 'zlib'
7
+ require 'set'
8
+ require 'json'
9
+ require 'observer'
10
+
11
+ class Rollout
12
+ include Observable
13
+
14
+ RAND_BASE = (2**32 - 1) / 100.0
15
+
16
+ attr_reader :options, :storage
17
+
18
+ def initialize(storage, opts = {})
19
+ @storage = storage
20
+ @options = opts
21
+ @groups = { all: ->(_user) { true } }
22
+
23
+ extend(Logging) if opts[:logging]
24
+ end
25
+
26
+ def groups
27
+ @groups.keys
28
+ end
29
+
30
+ def activate(feature)
31
+ with_feature(feature) do |f|
32
+ f.percentage = 100
33
+ end
34
+ end
35
+
36
+ def deactivate(feature)
37
+ with_feature(feature, &:clear)
38
+ end
39
+
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)
47
+ logging.delete(feature)
48
+ end
49
+ end
50
+
51
+ def set(feature, desired_state)
52
+ with_feature(feature) do |f|
53
+ if desired_state
54
+ f.percentage = 100
55
+ else
56
+ f.clear
57
+ end
58
+ end
59
+ end
60
+
61
+ def activate_group(feature, group)
62
+ with_feature(feature) do |f|
63
+ f.add_group(group)
64
+ end
65
+ end
66
+
67
+ def deactivate_group(feature, group)
68
+ with_feature(feature) do |f|
69
+ f.remove_group(group)
70
+ end
71
+ end
72
+
73
+ def activate_user(feature, user)
74
+ with_feature(feature) do |f|
75
+ f.add_user(user)
76
+ end
77
+ end
78
+
79
+ def deactivate_user(feature, user)
80
+ with_feature(feature) do |f|
81
+ f.remove_user(user)
82
+ end
83
+ end
84
+
85
+ def activate_users(feature, users)
86
+ with_feature(feature) do |f|
87
+ users.each { |user| f.add_user(user) }
88
+ end
89
+ end
90
+
91
+ def deactivate_users(feature, users)
92
+ with_feature(feature) do |f|
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) }
101
+ end
102
+ end
103
+
104
+ def define_group(group, &block)
105
+ @groups[group.to_sym] = block
106
+ end
107
+
108
+ def active?(feature, user = nil)
109
+ feature = get(feature)
110
+ feature.active?(self, user)
111
+ end
112
+
113
+ def user_in_active_users?(feature, user = nil)
114
+ feature = get(feature)
115
+ feature.user_in_active_users?(user)
116
+ end
117
+
118
+ def inactive?(feature, user = nil)
119
+ !active?(feature, user)
120
+ end
121
+
122
+ def activate_percentage(feature, percentage)
123
+ with_feature(feature) do |f|
124
+ f.percentage = percentage
125
+ end
126
+ end
127
+
128
+ def deactivate_percentage(feature)
129
+ with_feature(feature) do |f|
130
+ f.percentage = 0
131
+ end
132
+ end
133
+
134
+ def active_in_group?(group, user)
135
+ f = @groups[group.to_sym]
136
+ f&.call(user)
137
+ end
138
+
139
+ def get(feature)
140
+ string = @storage.get(key(feature))
141
+ Feature.new(feature, string, @options)
142
+ end
143
+
144
+ def set_feature_data(feature, data)
145
+ with_feature(feature) do |f|
146
+ f.data.merge!(data) if data.is_a? Hash
147
+ end
148
+ end
149
+
150
+ def clear_feature_data(feature)
151
+ with_feature(feature) do |f|
152
+ f.data = {}
153
+ end
154
+ end
155
+
156
+ def multi_get(*features)
157
+ return [] if features.empty?
158
+
159
+ feature_keys = features.map { |feature| key(feature) }
160
+ @storage.mget(*feature_keys).map.with_index { |string, index| Feature.new(features[index], string, @options) }
161
+ end
162
+
163
+ def features
164
+ (@storage.get(features_key) || '').split(',').map(&:to_sym)
165
+ end
166
+
167
+ def feature_states(user = nil)
168
+ multi_get(*features).each_with_object({}) do |f, hash|
169
+ hash[f.name] = f.active?(self, user)
170
+ end
171
+ end
172
+
173
+ def active_features(user = nil)
174
+ multi_get(*features).select do |f|
175
+ f.active?(self, user)
176
+ end.map(&:name)
177
+ end
178
+
179
+ def clear!
180
+ features.each do |feature|
181
+ with_feature(feature, &:clear)
182
+ @storage.del(key(feature))
183
+ end
184
+
185
+ @storage.del(features_key)
186
+ end
187
+
188
+ def exists?(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
211
+ end
212
+
213
+ private
214
+
215
+ def key(name)
216
+ "feature:#{name}"
217
+ end
218
+
219
+ def features_key
220
+ 'feature:__features__'
221
+ end
222
+
223
+ def save(feature)
224
+ @storage.set(key(feature.name), feature.serialize)
225
+ @storage.set(features_key, (features | [feature.name.to_sym]).join(','))
226
+ end
227
+ end