rollout-redis 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1d7d811416500c11c77ca8c8490d301c0ce266db5dc6e7967e07c25e435be454
4
+ data.tar.gz: c8e76ea2823845c3c3ef2d1929e4e1e75d0f19688ac99b5e4a6e903dae3a57ec
5
+ SHA512:
6
+ metadata.gz: e03a813c99336b16cdee142457f04e56a3cb12b8292f95034a583cd79ae12e97b9586db54cb3070d297bf762259acdb3a9026496124a1a6a40dbcf880fd4433d
7
+ data.tar.gz: 632ad6cf617ba379149383ad21e1033d5c6c3c12f9f8de58b1c1465d1440e957760f7ac4c15c011886dd9b188c73f2387b16d7cc144f4ece16446183f8dfcc21
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+ All changes to `rollout-redis` will be documented in this file.
3
+
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+
8
+ ## [0.1.0] - 2023-10-23
9
+
10
+ - Initial version
data/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # rollout-redis 🚀
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/rollout-redis.svg)](https://badge.fury.io/rb/rollout-redis)
4
+
5
+ Fast and easy feature flags based on Redis.
6
+
7
+ Based on the discontinued [rollout](https://github.com/fetlife/rollout) project, removing some capabilities, including some new features and supporting latest Redis versions.
8
+
9
+ Topics covered in this README:
10
+
11
+ - [Install it](#install-it)
12
+ - [Quick Start](#quick-start-💨)
13
+ - [Advanced features](#advanced-features-🦾)
14
+ - [Gradual activation based on percentages](#gradual-activation-based-on-percentages)
15
+ - [Caching Feature Flags](#caching-feature-flags)
16
+ - [Auto-deactivating flags](#auto-deactivating-flags)
17
+ - [Migrating from rollout gem](#migrating-from-rollout-gem-🚨)
18
+ - [Changelog](#changelog)
19
+ - [Contributing](#contributing)
20
+
21
+ ## Install it
22
+
23
+ ```bash
24
+ gem install rollout-redis
25
+ ```
26
+
27
+ ## Quick Start 💨
28
+
29
+ Instantiate the `Rollout` class sending a `Redis` instance as a parameter.
30
+
31
+ ```ruby
32
+ require 'redis'
33
+ require 'rollout'
34
+
35
+ @redis ||= Redis.new(
36
+ host: ENV.fetch('REDIS_HOST'),
37
+ port: ENV.fetch('REDIS_PORT')
38
+ )
39
+ @rollout ||= Rollout.new(@redis)
40
+ ```
41
+
42
+ Now you can activate Feature Flags:
43
+
44
+ ```ruby
45
+ @rollout.activate('FEATURE_FLAG_NAME') # => true/false
46
+ ```
47
+
48
+ Verify if a feature is currently enabled:
49
+
50
+ ```ruby
51
+ if @rollout.active?('FEATURE_FLAG_NAME')
52
+ # your new code here...
53
+ end
54
+ ```
55
+
56
+ An alternative to the if check, is to wrap your code under the `with_feature` method. The wrapped code will be performed only if the feature flag is active:
57
+
58
+ ```ruby
59
+ @rollout.with_feature('FEATURE_FLAG_NAME') do
60
+ # your new code here...
61
+ end
62
+ ```
63
+
64
+ If there is an issue, you have the option to disable a feature:
65
+
66
+ ```ruby
67
+ @rollout.deactivate('FEATURE_FLAG_NAME')
68
+ ```
69
+
70
+ ## Advanced features 🦾
71
+
72
+ ### Gradual activation based on percentages
73
+
74
+ When introducing a new feature, it's a recommended practice to gradually enable it for a specific portion of your target audience to evaluate its impact. To achieve this, you can utilize the `activate` method, as shown below:
75
+
76
+ ```ruby
77
+ @rollout.activate('FEATURE_FLAG_NAME', 20)
78
+ ```
79
+
80
+ Now, to know if a feature flags is enabled, you need to provide a determinator (in this example, we're using the user email):
81
+
82
+ ```ruby
83
+ if @rollout.active?('FEATURE_FLAG_NAME', user_email)
84
+ # your new code here...
85
+ end
86
+ ```
87
+
88
+ The gradual activation also works wrapping your code within the `with_feature` method, you just need to provde the determinator you want to use.
89
+
90
+ ```ruby
91
+ @rollout.with_feature('FEATURE_FLAG_NAME', user_email) do
92
+ # your new code here...
93
+ end
94
+ ```
95
+
96
+ It's important to note that if you use the `active?` method without specifying a determinator to determine whether this subset of the audience should see the new feature, it will always return `false` since the activation percentage is less than 100%. See:
97
+
98
+ ```ruby
99
+ @rollout.activate('FEATURE_FLAG_NAME', 20)
100
+ @rollout.active?('FEATURE_FLAG_NAME') # => false
101
+ ```
102
+
103
+ ### Caching Feature Flags
104
+
105
+ The Rollout gem is tightly integrated with Redis for feature flag status management. Consequently, occasional connectivity issues between your application and the Redis storage may arise.
106
+
107
+ To prevent potential application degradation when the Redis storage is unavailable, you can enable feature flag status caching during the gem's instantiation:
108
+
109
+ ```ruby
110
+ @rollout ||= Rollout.new(redis).with_cache
111
+ ```
112
+
113
+ Additionally, you can specify extra parameters to configure the duration (in seconds) for which the feature flag status is stored in the cache. By default, this duration is set to 300 seconds (5 minutes):
114
+
115
+ ```ruby
116
+ @rollout ||= Rollout.new(redis)
117
+ .with_cache(expires_in: 300)
118
+ ```
119
+
120
+ In the case that you need to clear the cache at any point, you can make use of the `clean_cache` method:
121
+
122
+ ```ruby
123
+ @rollout.clean_cache
124
+ ```
125
+
126
+ ### Auto-deactivating flags
127
+
128
+ If you want to allow the gem to deactivate your feature flag automatically when a threshold of erros is reached, you can enable the degrade feature using the `with_degrade` method.
129
+
130
+ ```ruby
131
+ @rollout ||= Rollout.new(redis)
132
+ .with_cache
133
+ .with_degrade(sample: 5000, min: 100, threshold: 0.1)
134
+ ```
135
+
136
+ So now, instead of using the `active?` method, you need to wrap your new code under the `with_feature` method.
137
+
138
+ ```ruby
139
+ @rollout.with_feature('FEATURE_FLAG_NAME') do
140
+ # your new feature code here...
141
+ end
142
+ ```
143
+
144
+ When any unexpected error appears during the wrapped code execution, the Rollout gem will take it into account for automatically deactivating the feature flag if the threshold of errors is reached. All the managed or captured errors inside the wrapped code will not be taken into consideration.
145
+
146
+ ## Migrating from rollout gem 🚨
147
+
148
+ If you are currently using the unmaintained [rollout](https://github.com/fetlife/rollout) gem, you should consider checking this [migration guide](https://github.com/jcagarcia/rollout-redis/blob/main/MIGRATING_FROM_ROLLOUT_GEM.md) for start using the new `rollout-redis` gem.
149
+
150
+ ## Changelog
151
+
152
+ If you're interested in seeing the changes and bug fixes between each version of `rollout-redis`, read the [Changelog](https://github.com/jcagarcia/rollout-redis/blob/main/CHANGELOG.md).
153
+
154
+ ## Contributing
155
+
156
+ We welcome and appreciate contributions from the open-source community. Before you get started, please take a moment to review the guidelines below.
157
+
158
+ ### How to Contribute
159
+
160
+ 1. Fork the repository.
161
+ 2. Clone the repository to your local machine.
162
+ 3. Create a new branch for your contribution.
163
+ 4. Make your changes and ensure they meet project standards.
164
+ 5. Commit your changes with clear messages.
165
+ 6. Push your branch to your GitHub repository.
166
+ 7. Open a pull request in our repository.
167
+ 8. Participate in code review and address feedback.
168
+ 9. Once approved, your changes will be merged.
169
+
170
+ ### Development
171
+
172
+ This project is dockerized. Once you clone the repository, you can use the `Make` commands to build the project.
173
+
174
+ ```shell
175
+ make build
176
+ ```
177
+
178
+ You can pass the tests running:
179
+
180
+ ```shell
181
+ make test
182
+ ```
183
+
184
+ ### Issue Tracker
185
+
186
+ Open issues on the GitHub issue tracker with clear information.
187
+
188
+ ### Contributors
189
+
190
+ * Juan Carlos García - Creator - https://github.com/jcagarcia
191
+
192
+ The `rollout-redis` gem is based on the discontinued [rollout](https://github.com/fetlife/rollout) project, created by [James Golick](https://github.com/jamesgolick)
193
+
data/Rakefile ADDED
@@ -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,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ class Rollout
6
+ class Feature
7
+ attr_accessor :percentage
8
+ attr_reader :name, :data
9
+
10
+ RAND_BASE = (2**32 - 1) / 100.0
11
+
12
+ def initialize(name, data={})
13
+ @name = name
14
+ @data = data
15
+ @percentage = @data[:percentage]
16
+ end
17
+
18
+ def active?(determinator=nil)
19
+ if determinator
20
+ determinator_in_percentage?(determinator)
21
+ else
22
+ @percentage == 100
23
+ end
24
+ end
25
+
26
+ def add_request
27
+ if @data[:requests]
28
+ @data[:requests] = @data[:requests] + 1
29
+ else
30
+ @data[:requests] = 1
31
+ end
32
+ end
33
+
34
+ def add_error
35
+ if @data[:errors]
36
+ @data[:errors] = @data[:errors] + 1
37
+ else
38
+ @data[:errors] = 1
39
+ end
40
+ end
41
+
42
+ def requests
43
+ @data[:requests] || 0
44
+ end
45
+
46
+ def errors
47
+ @data[:errors] || 0
48
+ end
49
+
50
+ private
51
+
52
+ def determinator_in_percentage?(determinator)
53
+ Zlib.crc32(determinator) < RAND_BASE * @percentage
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rollout
4
+ VERSION = '0.1.0'
5
+ end
data/lib/rollout.rb ADDED
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rollout/feature'
4
+ require 'rollout/version'
5
+ require 'redis'
6
+ require 'json'
7
+
8
+ class Rollout
9
+
10
+ class Error < StandardError; end
11
+
12
+ attr_reader :storage
13
+
14
+ def initialize(storage)
15
+ @storage = storage
16
+ @cache_enabled = false
17
+ @degrade_enabled = false
18
+ end
19
+
20
+ def with_cache(expires_in: 300)
21
+ @cache_enabled = true
22
+ @cache_time = expires_in
23
+ @cache = {}
24
+
25
+ self
26
+ end
27
+
28
+ def with_degrade(min: 100, threshold: 0.1)
29
+ @degrade_enabled = true
30
+ @degrade_min = min
31
+ @degrade_threshold = threshold
32
+
33
+ self
34
+ end
35
+
36
+ def activate(feature_name, percentage=100)
37
+ data = { percentage: percentage }
38
+ feature = Feature.new(feature_name, data)
39
+ @cache[feature_name] = {
40
+ feature: feature,
41
+ timestamp: Time.now.to_i
42
+ } if @cache_enabled
43
+ save(feature) == "OK"
44
+ end
45
+
46
+ def activate_percentage(feature_name, percentage)
47
+ activate(feature_name, percentage)
48
+ end
49
+
50
+ def deactivate(feature_name)
51
+ del(feature_name)
52
+ end
53
+
54
+ def active?(feature_name, determinator = nil)
55
+ feature = get(feature_name, determinator)
56
+ return false unless feature
57
+
58
+ active = feature.active?(determinator)
59
+
60
+ if active && @degrade_enabled
61
+ feature.add_request
62
+ save(feature)
63
+ end
64
+
65
+ active
66
+ end
67
+
68
+ def with_feature_flag(feature_name, determinator = nil, &block)
69
+ yield if active?(feature_name, determinator)
70
+ rescue => e
71
+ feature = get(feature_name, determinator)
72
+ if @degrade_enabled && feature
73
+ feature.add_error
74
+ save(feature)
75
+
76
+ deactivate(feature_name) if degraded?(feature)
77
+ end
78
+ raise e
79
+ end
80
+
81
+ def clean_cache
82
+ return unless @cache_enabled
83
+
84
+ @cache = {}
85
+ end
86
+
87
+ def migrate_from_rollout_format
88
+ keys = @storage.keys('feature:*')
89
+
90
+ keys.each do |old_key|
91
+ new_key = old_key.gsub('feature:', 'feature-rollout-redis:')
92
+ old_data = @storage.get(old_key)
93
+
94
+ if old_data
95
+ percentage = old_data.split('|')[0].to_i
96
+
97
+ new_data = {
98
+ percentage: percentage,
99
+ requests: 0,
100
+ errors: 0
101
+ }.to_json
102
+
103
+ @storage.set(new_key, new_data)
104
+
105
+ puts "Migrated key: #{old_key} to #{new_key} with data #{new_data}"
106
+ end
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ def get(feature_name, determinator = nil)
113
+ feature = from_redis(feature_name)
114
+ return unless feature
115
+
116
+ @cache[feature_name] = {
117
+ feature: feature,
118
+ timestamp: Time.now.to_i
119
+ } if @cache_enabled
120
+
121
+ feature
122
+ rescue ::Redis::BaseError => e
123
+ cached_feature = from_cache(feature_name)
124
+ raise Rollout::Error.new(e) unless cached_feature
125
+
126
+ cached_feature
127
+ end
128
+
129
+ def save(feature)
130
+ @storage.set(key(feature.name), feature.data.to_json)
131
+ end
132
+
133
+ def del(feature_name)
134
+ @storage.del(key(feature_name)) == 1
135
+ end
136
+
137
+ def from_cache(feature_name)
138
+ return nil unless @cache_enabled
139
+
140
+ cached = @cache[feature_name]
141
+
142
+ if expired?(cached[:timestamp])
143
+ @cache.delete(feature_name)
144
+ return nil
145
+ end
146
+
147
+ cached[:feature]
148
+ end
149
+
150
+ def from_redis(feature_name)
151
+ data = @storage.get(key(feature_name))
152
+ return unless data
153
+ Feature.new(feature_name, JSON.parse(data, symbolize_names: true))
154
+ end
155
+
156
+ def expired?(timestamp)
157
+ Time.now.to_i - timestamp > @cache_time
158
+ end
159
+
160
+ def degraded?(feature)
161
+ return false if !@degrade_enabled
162
+ return false if feature.requests < @degrade_min
163
+
164
+ feature.errors > @degrade_threshold * feature.requests
165
+ end
166
+
167
+ def key(name)
168
+ "feature-rollout-redis:#{name}"
169
+ end
170
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.push File.expand_path('lib', __dir__)
4
+ require 'rollout/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'rollout-redis'
8
+ spec.version = Rollout::VERSION
9
+ spec.authors = ['Juan Carlos García']
10
+ spec.email = ['jugade92@gmail.com']
11
+ spec.description = 'Fast and easy feature flags based on the latest Redis versions.'
12
+ spec.summary = 'Fast and easy feature flags based on the latest Redis versions.'
13
+ spec.homepage = 'https://github.com/jcagarcia/rollout-redis'
14
+ spec.license = 'MIT'
15
+
16
+ files = Dir["lib/**/*.rb", "lib/**/tasks/*.rake"]
17
+ rootfiles = ["CHANGELOG.md", "rollout-redis.gemspec", "Rakefile", "README.md"]
18
+
19
+ spec.files = rootfiles + files
20
+ spec.executables = []
21
+ spec.require_paths = ["lib"]
22
+
23
+ spec.required_ruby_version = '>= 2.3'
24
+
25
+ spec.add_runtime_dependency 'redis', '>= 4.0', '<= 5'
26
+
27
+ spec.add_development_dependency 'bundler', '>= 2.4'
28
+ spec.add_development_dependency 'rspec', '~> 3.12'
29
+ spec.add_development_dependency 'mock_redis', '~> 0.37'
30
+ end
metadata ADDED
@@ -0,0 +1,112 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rollout-redis
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Juan Carlos García
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-10-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: redis
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '4.0'
20
+ - - "<="
21
+ - !ruby/object:Gem::Version
22
+ version: '5'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '4.0'
30
+ - - "<="
31
+ - !ruby/object:Gem::Version
32
+ version: '5'
33
+ - !ruby/object:Gem::Dependency
34
+ name: bundler
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.4'
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '2.4'
47
+ - !ruby/object:Gem::Dependency
48
+ name: rspec
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.12'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.12'
61
+ - !ruby/object:Gem::Dependency
62
+ name: mock_redis
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.37'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.37'
75
+ description: Fast and easy feature flags based on the latest Redis versions.
76
+ email:
77
+ - jugade92@gmail.com
78
+ executables: []
79
+ extensions: []
80
+ extra_rdoc_files: []
81
+ files:
82
+ - CHANGELOG.md
83
+ - README.md
84
+ - Rakefile
85
+ - lib/rollout.rb
86
+ - lib/rollout/feature.rb
87
+ - lib/rollout/version.rb
88
+ - rollout-redis.gemspec
89
+ homepage: https://github.com/jcagarcia/rollout-redis
90
+ licenses:
91
+ - MIT
92
+ metadata: {}
93
+ post_install_message:
94
+ rdoc_options: []
95
+ require_paths:
96
+ - lib
97
+ required_ruby_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '2.3'
102
+ required_rubygems_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ requirements: []
108
+ rubygems_version: 3.4.10
109
+ signing_key:
110
+ specification_version: 4
111
+ summary: Fast and easy feature flags based on the latest Redis versions.
112
+ test_files: []