rollout 2.6.2 → 3.1.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/.github/workflows/release.yml +51 -27
- data/.github/workflows/test.yml +46 -6
- data/.gitignore +2 -0
- data/README.md +87 -24
- data/Rakefile +15 -2
- data/docs/upgrading-to-v3.md +101 -0
- data/lib/rollout/feature.rb +28 -29
- data/lib/rollout/feature_state.rb +44 -0
- data/lib/rollout/logging.rb +20 -76
- data/lib/rollout/version.rb +1 -1
- data/lib/rollout.rb +56 -50
- data/rollout.gemspec +6 -5
- data/spec/rollout/feature_spec.rb +70 -39
- data/spec/rollout/feature_state_spec.rb +219 -0
- data/spec/rollout/logging_spec.rb +81 -110
- data/spec/rollout/memory_backend_contract_spec.rb +8 -0
- data/spec/rollout_spec.rb +120 -602
- data/spec/spec_helper.rb +69 -10
- data/spec/support/backend_contract.rb +88 -0
- metadata +12 -29
- data/.circleci/config.yml +0 -119
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f825c30d598b5b2b194b97b0371bbd1ceda8f341458a64b1a601941ee11580eb
|
|
4
|
+
data.tar.gz: '038f689c1fe1f5eb3aba0e4eaadcc80da24f320b863a4fffecf6ca3fe4355ba4'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2dd85351ad0860f8a2bd86b8b018fad5ed6175751f3018fcc20cad2e96fed7df38de74fc04205207a6de627b168fa0dd04575bf534aa461ed6d4d9b74d36d013
|
|
7
|
+
data.tar.gz: ab692a6ae7d929d52df99b7d9de6f0ca41a018c72f5cddc05b3fa37051418ca476b7b89af862c77353e8a82646d0adfa8832a04a8c96b3941a6d865da69626d0
|
|
@@ -3,44 +3,68 @@ name: Release
|
|
|
3
3
|
on:
|
|
4
4
|
push:
|
|
5
5
|
tags:
|
|
6
|
-
- 'v*'
|
|
6
|
+
- 'rollout/v*'
|
|
7
|
+
- 'rollout-redis-adapter/v*'
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: write
|
|
11
|
+
id-token: write
|
|
7
12
|
|
|
8
13
|
jobs:
|
|
9
|
-
|
|
14
|
+
publish:
|
|
10
15
|
runs-on: ubuntu-latest
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
- 6379:6379
|
|
16
|
-
options: >-
|
|
17
|
-
--health-cmd "redis-cli ping"
|
|
18
|
-
--health-interval 10s
|
|
19
|
-
--health-timeout 5s
|
|
20
|
-
--health-retries 5
|
|
16
|
+
outputs:
|
|
17
|
+
name: ${{ steps.package.outputs.name }}
|
|
18
|
+
version: ${{ steps.package.outputs.version }}
|
|
19
|
+
previous_tag: ${{ steps.package.outputs.previous_tag }}
|
|
21
20
|
steps:
|
|
22
21
|
- name: Checkout code
|
|
23
|
-
uses: actions/checkout@
|
|
22
|
+
uses: actions/checkout@v5
|
|
23
|
+
with:
|
|
24
|
+
fetch-depth: 0
|
|
24
25
|
- name: Setup Ruby
|
|
25
26
|
uses: ruby/setup-ruby@v1
|
|
26
27
|
with:
|
|
27
|
-
|
|
28
|
-
- name:
|
|
29
|
-
|
|
28
|
+
ruby-version: '3.3'
|
|
29
|
+
- name: Select package
|
|
30
|
+
id: package
|
|
31
|
+
run: |
|
|
32
|
+
case "$GITHUB_REF_NAME" in
|
|
33
|
+
rollout/v*) name=rollout; directory=. ;;
|
|
34
|
+
rollout-redis-adapter/v*) name=rollout-redis-adapter; directory=rollout-redis-adapter ;;
|
|
35
|
+
*) echo "Unexpected tag $GITHUB_REF_NAME" >&2; exit 1 ;;
|
|
36
|
+
esac
|
|
37
|
+
version="${GITHUB_REF_NAME##*/v}"
|
|
38
|
+
spec_version=$(ruby -e 'puts Gem::Specification.load(ARGV[0]).version' "$directory/$name.gemspec")
|
|
39
|
+
if [ "$spec_version" != "$version" ]; then
|
|
40
|
+
echo "Tag version $version does not match gemspec $spec_version" >&2
|
|
41
|
+
exit 1
|
|
42
|
+
fi
|
|
43
|
+
previous_tag=$(git tag --list "$name/v*" --sort=-version:refname | awk -v current="$GITHUB_REF_NAME" '$0 != current { print; exit }')
|
|
44
|
+
previous_tag=${previous_tag:-v2.6.2}
|
|
45
|
+
echo "name=$name" >> "$GITHUB_OUTPUT"
|
|
46
|
+
echo "version=$version" >> "$GITHUB_OUTPUT"
|
|
47
|
+
echo "directory=$directory" >> "$GITHUB_OUTPUT"
|
|
48
|
+
echo "previous_tag=$previous_tag" >> "$GITHUB_OUTPUT"
|
|
49
|
+
- name: Build selected gem
|
|
50
|
+
working-directory: ${{ steps.package.outputs.directory }}
|
|
51
|
+
run: gem build "${{ steps.package.outputs.name }}.gemspec"
|
|
52
|
+
- name: Configure RubyGems credentials
|
|
53
|
+
uses: rubygems/configure-rubygems-credentials@v2.1.0
|
|
54
|
+
- name: Publish selected gem
|
|
55
|
+
working-directory: ${{ steps.package.outputs.directory }}
|
|
56
|
+
run: gem push "${{ steps.package.outputs.name }}-${{ steps.package.outputs.version }}.gem"
|
|
57
|
+
|
|
58
|
+
github-release:
|
|
59
|
+
needs: publish
|
|
60
|
+
runs-on: ubuntu-latest
|
|
61
|
+
steps:
|
|
30
62
|
- name: Create GitHub Release
|
|
31
63
|
uses: softprops/action-gh-release@v2
|
|
32
64
|
with:
|
|
33
65
|
tag_name: ${{ github.ref }}
|
|
34
|
-
name: ${{
|
|
66
|
+
name: ${{ needs.publish.outputs.name }} ${{ needs.publish.outputs.version }}
|
|
35
67
|
generate_release_notes: true
|
|
68
|
+
previous_tag: ${{ needs.publish.outputs.previous_tag }}
|
|
36
69
|
draft: false
|
|
37
|
-
prerelease: false
|
|
38
|
-
- name: Set up RubyGems credentials
|
|
39
|
-
env:
|
|
40
|
-
RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
|
|
41
|
-
run: |
|
|
42
|
-
mkdir -p ~/.gem
|
|
43
|
-
echo ":rubygems_api_key: $RUBYGEMS_API_KEY" > ~/.gem/credentials
|
|
44
|
-
chmod 0600 ~/.gem/credentials
|
|
45
|
-
- name: Release to RubyGems
|
|
46
|
-
run: bundle exec rake release
|
|
70
|
+
prerelease: false
|
data/.github/workflows/test.yml
CHANGED
|
@@ -4,13 +4,41 @@ on:
|
|
|
4
4
|
push:
|
|
5
5
|
branches:
|
|
6
6
|
- master
|
|
7
|
+
- v3
|
|
7
8
|
pull_request:
|
|
8
|
-
|
|
9
|
-
- master
|
|
9
|
+
|
|
10
10
|
|
|
11
11
|
jobs:
|
|
12
|
-
|
|
12
|
+
core:
|
|
13
13
|
runs-on: ubuntu-latest
|
|
14
|
+
strategy:
|
|
15
|
+
matrix:
|
|
16
|
+
ruby-version: ['3.3', '3.2', '3.1', '3.0', '2.7', '2.6', '2.5', '2.4']
|
|
17
|
+
steps:
|
|
18
|
+
- name: Checkout code
|
|
19
|
+
uses: actions/checkout@v5
|
|
20
|
+
- name: Setup Ruby
|
|
21
|
+
uses: ruby/setup-ruby@v1
|
|
22
|
+
with:
|
|
23
|
+
ruby-version: ${{ matrix.ruby-version }}
|
|
24
|
+
bundler-cache: true
|
|
25
|
+
- name: Run core tests
|
|
26
|
+
run: |
|
|
27
|
+
mkdir -p test_results
|
|
28
|
+
bundle exec rspec spec --format progress --format RspecJunitFormatter --out test_results/rspec.xml
|
|
29
|
+
- name: Upload test results
|
|
30
|
+
if: always()
|
|
31
|
+
uses: actions/upload-artifact@v5
|
|
32
|
+
with:
|
|
33
|
+
name: core-test-results-${{ matrix.ruby-version }}
|
|
34
|
+
path: test_results/rspec.xml
|
|
35
|
+
retention-days: 30
|
|
36
|
+
|
|
37
|
+
redis:
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
strategy:
|
|
40
|
+
matrix:
|
|
41
|
+
ruby-version: ['3.3', '3.2', '3.1', '3.0', '2.7', '2.6', '2.5', '2.4']
|
|
14
42
|
services:
|
|
15
43
|
redis:
|
|
16
44
|
image: redis:7-alpine
|
|
@@ -23,10 +51,22 @@ jobs:
|
|
|
23
51
|
--health-retries 5
|
|
24
52
|
steps:
|
|
25
53
|
- name: Checkout code
|
|
26
|
-
uses: actions/checkout@
|
|
54
|
+
uses: actions/checkout@v5
|
|
27
55
|
- name: Setup Ruby
|
|
28
56
|
uses: ruby/setup-ruby@v1
|
|
29
57
|
with:
|
|
58
|
+
ruby-version: ${{ matrix.ruby-version }}
|
|
59
|
+
working-directory: rollout-redis-adapter
|
|
30
60
|
bundler-cache: true
|
|
31
|
-
- name: Run tests
|
|
32
|
-
|
|
61
|
+
- name: Run Redis adapter tests
|
|
62
|
+
working-directory: rollout-redis-adapter
|
|
63
|
+
run: |
|
|
64
|
+
mkdir -p test_results
|
|
65
|
+
bundle exec rspec --format progress --format RspecJunitFormatter --out test_results/rspec.xml
|
|
66
|
+
- name: Upload test results
|
|
67
|
+
if: always()
|
|
68
|
+
uses: actions/upload-artifact@v5
|
|
69
|
+
with:
|
|
70
|
+
name: redis-test-results-${{ matrix.ruby-version }}
|
|
71
|
+
path: rollout-redis-adapter/test_results/rspec.xml
|
|
72
|
+
retention-days: 30
|
data/.gitignore
CHANGED
data/README.md
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
# rollout
|
|
2
2
|
|
|
3
|
-
Fast feature flags
|
|
3
|
+
Fast feature flags.
|
|
4
|
+
|
|
5
|
+
Upgrading from Rollout 2? Follow the [Rollout 3 upgrade guide](docs/upgrading-to-v3.md)
|
|
6
|
+
before updating your dependencies.
|
|
4
7
|
|
|
5
8
|
[](https://badge.fury.io/rb/rollout)
|
|
6
|
-
[](https://github.com/fetlife/rollout/actions/workflows/test.yml)
|
|
7
10
|
[](https://codeclimate.com/github/FetLife/rollout)
|
|
8
11
|
[](https://codeclimate.com/github/FetLife/rollout/coverage)
|
|
9
12
|
|
|
10
13
|
## Install it
|
|
11
14
|
|
|
12
15
|
```bash
|
|
13
|
-
gem install rollout
|
|
16
|
+
gem install rollout -v '~> 3.1'
|
|
17
|
+
gem install rollout-redis-adapter -v '~> 0.1'
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
gem "rollout", "~> 3.1"
|
|
22
|
+
gem "rollout-redis-adapter", "~> 0.1"
|
|
14
23
|
```
|
|
15
24
|
|
|
16
25
|
## How it works
|
|
@@ -18,17 +27,12 @@ gem install rollout
|
|
|
18
27
|
Initialize a rollout object. I assign it to a global var.
|
|
19
28
|
|
|
20
29
|
```ruby
|
|
21
|
-
require
|
|
30
|
+
require "redis"
|
|
31
|
+
require "rollout"
|
|
32
|
+
require "rollout/adapters/redis"
|
|
22
33
|
|
|
23
34
|
$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) # Will use REDIS_URL env var or default redis url
|
|
35
|
+
$rollout = Rollout.new(adapter: Rollout::Adapters::Redis.new($redis))
|
|
32
36
|
```
|
|
33
37
|
|
|
34
38
|
|
|
@@ -109,11 +113,12 @@ $rollout.activate_percentage(:chat, 20)
|
|
|
109
113
|
The algorithm for determining which users get let in is this:
|
|
110
114
|
|
|
111
115
|
```ruby
|
|
112
|
-
|
|
116
|
+
Zlib.crc32(user.id.to_s) < (2**32 - 1) / 100.0 * percentage
|
|
113
117
|
```
|
|
114
118
|
|
|
115
|
-
|
|
116
|
-
|
|
119
|
+
The result is deterministic: the same user is always in or out at a given
|
|
120
|
+
percentage, and users already included remain included as the percentage
|
|
121
|
+
increases.
|
|
117
122
|
|
|
118
123
|
Deactivate all percentages like this:
|
|
119
124
|
|
|
@@ -128,7 +133,10 @@ In some cases you might want to have a feature activated for a random set of
|
|
|
128
133
|
users. It can come specially handy when using Rollout for split tests.
|
|
129
134
|
|
|
130
135
|
```ruby
|
|
131
|
-
$rollout = Rollout.new(
|
|
136
|
+
$rollout = Rollout.new(
|
|
137
|
+
adapter: Rollout::Adapters::Redis.new($redis),
|
|
138
|
+
randomize_percentage: true,
|
|
139
|
+
)
|
|
132
140
|
```
|
|
133
141
|
|
|
134
142
|
When on `randomize_percentage` will make sure that 50% of users for feature A
|
|
@@ -166,8 +174,9 @@ failure detection code.
|
|
|
166
174
|
You can inspect the state of your feature using:
|
|
167
175
|
|
|
168
176
|
```ruby
|
|
169
|
-
|
|
170
|
-
|
|
177
|
+
feature = $rollout.get(:chat)
|
|
178
|
+
feature.to_hash
|
|
179
|
+
# => { percentage: 5.0, groups: [:caretakers], users: ["1"], data: {} }
|
|
171
180
|
```
|
|
172
181
|
|
|
173
182
|
## Namespacing
|
|
@@ -180,18 +189,32 @@ environments by using the
|
|
|
180
189
|
[redis-namespace](https://github.com/resque/redis-namespace) gem.
|
|
181
190
|
|
|
182
191
|
```ruby
|
|
192
|
+
gem "redis-namespace"
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
require "redis"
|
|
197
|
+
require "redis/namespace"
|
|
198
|
+
require "rollout"
|
|
199
|
+
require "rollout/adapters/redis"
|
|
200
|
+
|
|
183
201
|
$ns = Redis::Namespace.new(Rails.env, redis: $redis)
|
|
184
|
-
$rollout = Rollout.new($ns)
|
|
202
|
+
$rollout = Rollout.new(adapter: Rollout::Adapters::Redis.new($ns))
|
|
185
203
|
$rollout.activate_group(:chat, :all)
|
|
186
204
|
```
|
|
187
205
|
|
|
188
|
-
This example
|
|
206
|
+
This example stores the chat feature at `development:feature:chat` when
|
|
207
|
+
`Rails.env` is `"development"`.
|
|
189
208
|
|
|
190
209
|
## Frontend / UI
|
|
191
210
|
|
|
192
211
|
* [rollout-ui](https://github.com/fetlife/rollout-ui)
|
|
193
212
|
* [Rollout-Dashboard](https://github.com/fiverr/rollout_dashboard/)
|
|
194
213
|
|
|
214
|
+
These integrations may not yet support Rollout 3. Use a version compatible with
|
|
215
|
+
the Rollout release you install. If you depend on rollout-ui, wait for a
|
|
216
|
+
Rollout 3-compatible UI release before upgrading production.
|
|
217
|
+
|
|
195
218
|
## Implementations in other languages
|
|
196
219
|
|
|
197
220
|
* Python: https://github.com/asenchi/proclaim
|
|
@@ -207,13 +230,53 @@ This example would use the "development:feature:chat:groups" key.
|
|
|
207
230
|
* Eric Rafaloff - Maintainer - https://github.com/EricR
|
|
208
231
|
|
|
209
232
|
|
|
233
|
+
## Testing
|
|
234
|
+
|
|
235
|
+
Install dependencies first. Core and the Redis adapter have separate Gemfiles:
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
bundle install
|
|
239
|
+
bundle install --gemfile=rollout-redis-adapter/Gemfile
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Core tests do not need Redis:
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
bundle exec rake spec
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Redis adapter tests flush database 7 by default. Use a disposable instance,
|
|
249
|
+
not a shared or production Redis. Start Redis in a separate terminal:
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
docker run --rm -p 6379:6379 redis:7-alpine
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Then run:
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
bundle exec rake spec:redis
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Optional connection settings: `REDIS_HOST`, `REDIS_PORT`, `REDIS_DB`. `REDIS_DB`
|
|
262
|
+
overrides the default database 7 that is flushed before each example.
|
|
263
|
+
|
|
210
264
|
## Releasing
|
|
211
265
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
266
|
+
Each gem has its own version and tag.
|
|
267
|
+
|
|
268
|
+
- Configure a RubyGems trusted publisher for the gem you are releasing. Use
|
|
269
|
+
repository owner `fetlife`, repository `rollout`, workflow filename
|
|
270
|
+
`release.yml`, and no GitHub environment.
|
|
271
|
+
- Update and commit the version in `lib/rollout/version.rb` or
|
|
272
|
+
`rollout-redis-adapter/rollout-redis-adapter.gemspec`.
|
|
273
|
+
- Tag the release commit with `rollout/vX.Y.Z` or
|
|
274
|
+
`rollout-redis-adapter/vX.Y.Z`, matching the gem version.
|
|
275
|
+
- Push the tag with `git push origin <tag>`. CI publishes the selected gem and
|
|
276
|
+
creates its GitHub release.
|
|
215
277
|
|
|
216
|
-
|
|
278
|
+
Use package-prefixed tags, not `vX.Y.Z`. Publish core first when the adapter
|
|
279
|
+
depends on a new core version.
|
|
217
280
|
|
|
218
281
|
## Copyright
|
|
219
282
|
|
data/Rakefile
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "bundler/gem_tasks"
|
|
4
3
|
require "rspec/core/rake_task"
|
|
5
4
|
|
|
6
|
-
RSpec::Core::RakeTask.new(:spec)
|
|
5
|
+
RSpec::Core::RakeTask.new(:spec) do |task|
|
|
6
|
+
task.pattern = "spec/**/*_spec.rb"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
namespace :spec do
|
|
10
|
+
desc "Run Redis adapter tests"
|
|
11
|
+
task :redis do
|
|
12
|
+
gemfile = File.expand_path("rollout-redis-adapter/Gemfile", __dir__)
|
|
13
|
+
Dir.chdir("rollout-redis-adapter") do
|
|
14
|
+
Bundler.with_unbundled_env do
|
|
15
|
+
sh({ "BUNDLE_GEMFILE" => gemfile }, "bundle exec rspec")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
7
20
|
|
|
8
21
|
task default: :spec
|
|
9
22
|
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Upgrading to Rollout 3
|
|
2
|
+
|
|
3
|
+
Rollout 3 keeps feature evaluation in the `rollout` gem and moves Redis
|
|
4
|
+
persistence to `rollout-redis-adapter`. Existing Redis keys stay in place. This is
|
|
5
|
+
not the later Active Record / PostgreSQL migration.
|
|
6
|
+
|
|
7
|
+
## 1. Update dependencies
|
|
8
|
+
|
|
9
|
+
Add the adapter gem next to `rollout`:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "rollout", "~> 3.1"
|
|
13
|
+
gem "rollout-redis-adapter", "~> 0.1"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Use compatible releases of both gems. The Redis adapter is versioned
|
|
17
|
+
independently of core. Rollout `3.0.0` used `backend:` and
|
|
18
|
+
`Rollout::Redis::Backend`; `3.1.0` uses `adapter:` and
|
|
19
|
+
`Rollout::Adapters::Redis`. If you use
|
|
20
|
+
[rollout-ui](https://github.com/fetlife/rollout-ui), wait for a Rollout
|
|
21
|
+
3-compatible UI release before upgrading production.
|
|
22
|
+
|
|
23
|
+
## 2. Update requires and initialization
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
# Before
|
|
27
|
+
require "redis"
|
|
28
|
+
require "rollout"
|
|
29
|
+
|
|
30
|
+
$redis = Redis.new
|
|
31
|
+
$rollout = Rollout.new(
|
|
32
|
+
$redis,
|
|
33
|
+
randomize_percentage: true,
|
|
34
|
+
logging: { history_length: 100, global: true },
|
|
35
|
+
)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
# After
|
|
40
|
+
require "redis"
|
|
41
|
+
require "rollout"
|
|
42
|
+
require "rollout/adapters/redis"
|
|
43
|
+
|
|
44
|
+
$redis = Redis.new
|
|
45
|
+
$rollout = Rollout.new(
|
|
46
|
+
adapter: Rollout::Adapters::Redis.new($redis),
|
|
47
|
+
randomize_percentage: true,
|
|
48
|
+
logging: { history_length: 100, global: true },
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Pass the same Redis client, database, and namespace you already use. Repeat
|
|
53
|
+
this change at every initialization site, including jobs, scripts, and
|
|
54
|
+
consoles.
|
|
55
|
+
|
|
56
|
+
`redis-namespace` still works:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
$ns = Redis::Namespace.new(Rails.env, redis: $redis)
|
|
60
|
+
$rollout = Rollout.new(adapter: Rollout::Adapters::Redis.new($ns))
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 3. Breaking changes
|
|
64
|
+
|
|
65
|
+
| Area | Breaking change | Required action |
|
|
66
|
+
| --- | --- | --- |
|
|
67
|
+
| Storage configuration | `Rollout.new(redis, options)` now requires `adapter:`. | Wrap the existing client in `Rollout::Adapters::Redis.new(redis)` and pass options as keywords. |
|
|
68
|
+
| Storage access | `rollout.storage` is removed. `rollout.adapter` returns an adapter, not the Redis client. | Keep your own Redis client reference if application code needs direct access. |
|
|
69
|
+
| Logging storage | `logging: { storage: other_redis }` is no longer supported. The Redis backend stores features and history through the same client. | Applications using separate history storage cannot preserve that setup with the current adapter. Removing the option does not migrate existing history. |
|
|
70
|
+
| Custom logging | `Logger#log` and `Logger#update` are removed. Built-in logging is no longer an observer. | Use Rollout mutations and `logging.with_context` rather than calling those methods directly. |
|
|
71
|
+
| Feature construction | `Feature.new(name, rollout:, state: payload)` no longer accepts a name argument or raw Redis payload. | Prefer `rollout.get(name)`. Direct construction requires `Feature.new(state: feature_state, rollout: rollout, options: rollout.options)`. |
|
|
72
|
+
| Feature serialization | `feature.serialize` is removed. | Use `feature.to_feature_state` for a backend-neutral snapshot. Redis encoding belongs to `Rollout::Redis::Codec`. |
|
|
73
|
+
| Feature metadata | Metadata is canonicalized to JSON. Symbol keys and values become strings. Values such as `Time`, `Date`, and `BigDecimal` persist as their JSON representations, not as original Ruby objects. | No data migration is required. JSON-serializable writes continue to work. |
|
|
74
|
+
| History decoding | `Logging::Event.from_raw` is removed. | Decode persisted Redis members with `Rollout::Redis::Codec.decode_event(value, score)`. |
|
|
75
|
+
|
|
76
|
+
## 4. History and lifecycle
|
|
77
|
+
|
|
78
|
+
History reads can request a bound. `limit` is the newest N events, returned
|
|
79
|
+
oldest-to-newest. Omit `limit` to read the full retained history. `0` returns
|
|
80
|
+
no events.
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
rollout.logging.events(:chat, limit: 10)
|
|
84
|
+
rollout.logging.global_events(limit: 10)
|
|
85
|
+
rollout.logging.last_event(:chat)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`last_event` reads one persisted member. It does not load the complete
|
|
89
|
+
history.
|
|
90
|
+
|
|
91
|
+
| Operation | Feature state | Per-feature history | Global history |
|
|
92
|
+
| --- | --- | --- | --- |
|
|
93
|
+
| `delete`, logging enabled | Removed | Removed | Retained |
|
|
94
|
+
| `delete`, logging disabled | Removed | Retained | Retained |
|
|
95
|
+
| `logging.delete` | Unchanged | Removed | Retained |
|
|
96
|
+
| `clear!` | Removed | Retained | Retained |
|
|
97
|
+
|
|
98
|
+
`clear!` still resets each feature first, so logging-enabled instances record
|
|
99
|
+
a reset event before the state is deleted. History remains subject to
|
|
100
|
+
`history_length`. The Redis registry key is removed after clearing, including
|
|
101
|
+
when it was already empty.
|
data/lib/rollout/feature.rb
CHANGED
|
@@ -1,28 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'rollout/feature_state'
|
|
4
|
+
|
|
3
5
|
class Rollout
|
|
4
6
|
class Feature
|
|
5
7
|
attr_accessor :groups, :users, :percentage, :data
|
|
6
8
|
attr_reader :name, :options
|
|
7
9
|
|
|
8
|
-
def initialize(
|
|
9
|
-
@name = name
|
|
10
|
+
def initialize(state:, rollout:, options: {}, name: nil)
|
|
10
11
|
@rollout = rollout
|
|
11
12
|
@options = options
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
raw_percentage, raw_users, raw_groups, raw_data = state.split('|', 4)
|
|
15
|
-
@percentage = raw_percentage.to_f
|
|
16
|
-
@users = users_from_string(raw_users)
|
|
17
|
-
@groups = groups_from_string(raw_groups)
|
|
18
|
-
@data = raw_data.nil? || raw_data.strip.empty? ? {} : JSON.parse(raw_data)
|
|
19
|
-
else
|
|
20
|
-
clear
|
|
21
|
-
end
|
|
13
|
+
@name = name.nil? ? state.name.to_sym : name
|
|
14
|
+
assign_state(state)
|
|
22
15
|
end
|
|
23
16
|
|
|
24
|
-
def
|
|
25
|
-
|
|
17
|
+
def to_feature_state
|
|
18
|
+
FeatureState.new(
|
|
19
|
+
name: @name,
|
|
20
|
+
percentage: @percentage,
|
|
21
|
+
users: Array(@users),
|
|
22
|
+
groups: Array(@groups),
|
|
23
|
+
data: @data,
|
|
24
|
+
)
|
|
26
25
|
end
|
|
27
26
|
|
|
28
27
|
def add_user(user)
|
|
@@ -43,10 +42,7 @@ class Rollout
|
|
|
43
42
|
end
|
|
44
43
|
|
|
45
44
|
def clear
|
|
46
|
-
@
|
|
47
|
-
@users = users_from_string('')
|
|
48
|
-
@percentage = 0
|
|
49
|
-
@data = {}
|
|
45
|
+
assign_state(FeatureState.new(name: @name, percentage: 0))
|
|
50
46
|
end
|
|
51
47
|
|
|
52
48
|
def active?(user)
|
|
@@ -77,12 +73,21 @@ class Rollout
|
|
|
77
73
|
c = self.clone
|
|
78
74
|
c.instance_variable_set('@rollout', nil)
|
|
79
75
|
c = Marshal.load(Marshal.dump(c))
|
|
80
|
-
c.instance_variable_set('@
|
|
76
|
+
c.instance_variable_set('@rollout', @rollout)
|
|
81
77
|
c
|
|
82
78
|
end
|
|
83
79
|
|
|
84
80
|
private
|
|
85
81
|
|
|
82
|
+
def assign_state(state)
|
|
83
|
+
state = state.deep_clone
|
|
84
|
+
|
|
85
|
+
@percentage = state.percentage
|
|
86
|
+
@users = users_from_array(state.users)
|
|
87
|
+
@groups = groups_from_array(state.groups)
|
|
88
|
+
@data = state.data
|
|
89
|
+
end
|
|
90
|
+
|
|
86
91
|
def user_id(user)
|
|
87
92
|
if user.is_a?(Integer) || user.is_a?(String)
|
|
88
93
|
user.to_s
|
|
@@ -113,14 +118,8 @@ class Rollout
|
|
|
113
118
|
end
|
|
114
119
|
end
|
|
115
120
|
|
|
116
|
-
def
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
@data.to_json
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def users_from_string(raw_users)
|
|
123
|
-
users = (raw_users || '').split(',').map(&:to_s)
|
|
121
|
+
def users_from_array(users)
|
|
122
|
+
users = Array(users).map(&:to_s)
|
|
124
123
|
if @options[:use_sets]
|
|
125
124
|
users.to_set
|
|
126
125
|
else
|
|
@@ -128,8 +127,8 @@ class Rollout
|
|
|
128
127
|
end
|
|
129
128
|
end
|
|
130
129
|
|
|
131
|
-
def
|
|
132
|
-
groups = (
|
|
130
|
+
def groups_from_array(groups)
|
|
131
|
+
groups = Array(groups).map(&:to_sym)
|
|
133
132
|
if @options[:use_sets]
|
|
134
133
|
groups.to_set
|
|
135
134
|
else
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
class Rollout
|
|
6
|
+
class FeatureState
|
|
7
|
+
attr_reader :name, :percentage, :users, :groups, :data
|
|
8
|
+
|
|
9
|
+
def initialize(name:, percentage:, users: [], groups: [], data: {})
|
|
10
|
+
raise ArgumentError, "data must be a Hash" unless data.is_a?(Hash)
|
|
11
|
+
|
|
12
|
+
@name = name.to_s.dup
|
|
13
|
+
@percentage = percentage.to_f
|
|
14
|
+
@users = Array(users).map { |user| user.to_s.dup }
|
|
15
|
+
@groups = Array(groups).map { |group| group.to_s.dup }
|
|
16
|
+
@data = canonicalize_data(data)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def ==(other)
|
|
20
|
+
other.is_a?(FeatureState) &&
|
|
21
|
+
name == other.name &&
|
|
22
|
+
percentage == other.percentage &&
|
|
23
|
+
users == other.users &&
|
|
24
|
+
groups == other.groups &&
|
|
25
|
+
data == other.data
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def deep_clone
|
|
29
|
+
self.class.new(
|
|
30
|
+
name: name,
|
|
31
|
+
percentage: percentage,
|
|
32
|
+
users: users,
|
|
33
|
+
groups: groups,
|
|
34
|
+
data: data,
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def canonicalize_data(data)
|
|
41
|
+
JSON.parse(data.to_json)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|