rollout 3.1.0 → 3.2.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 +4 -4
- data/.github/workflows/release.yml +3 -1
- data/.github/workflows/test.yml +104 -2
- data/README.md +24 -7
- data/Rakefile +10 -0
- data/docs/upgrading-to-v3.md +12 -3
- data/lib/rollout/logging.rb +12 -0
- data/lib/rollout/version.rb +1 -1
- data/lib/rollout.rb +9 -2
- data/rollout.gemspec +1 -1
- data/spec/rollout_spec.rb +51 -0
- data/spec/spec_helper.rb +16 -2
- data/spec/support/backend_contract.rb +7 -0
- data/spec/support/history_contract.rb +174 -0
- data/spec/support/rollout_backend_integration.rb +77 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz: '
|
|
3
|
+
metadata.gz: cadea20a7d731c5538e5d6b6b19b34075843230fd5c6d75ffc4c269ce667a63f
|
|
4
|
+
data.tar.gz: '06483d373ff87c4e0537ab73d57be5bba089d43a4abacd87168bdf338cb4ca5e'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 515d4d646e3c4c2c2fee20d77088a9dd22fe5730dd605764abfbc0e189591f7eaaf076830940358500e05eed30f456b9389f8843bad1df283d01b795eae6e935
|
|
7
|
+
data.tar.gz: c41503b0e1acff3c3c99de9b08d7c06167f2d4a2f7c7fc493230c11c70f633dcae0bc4c5a7028d04e11b01dfd7edfb6eaaf6d33255c559cd4b93fda206e10427
|
|
@@ -5,6 +5,7 @@ on:
|
|
|
5
5
|
tags:
|
|
6
6
|
- 'rollout/v*'
|
|
7
7
|
- 'rollout-redis-adapter/v*'
|
|
8
|
+
- 'rollout-active_record-adapter/v*'
|
|
8
9
|
|
|
9
10
|
permissions:
|
|
10
11
|
contents: write
|
|
@@ -32,6 +33,7 @@ jobs:
|
|
|
32
33
|
case "$GITHUB_REF_NAME" in
|
|
33
34
|
rollout/v*) name=rollout; directory=. ;;
|
|
34
35
|
rollout-redis-adapter/v*) name=rollout-redis-adapter; directory=rollout-redis-adapter ;;
|
|
36
|
+
rollout-active_record-adapter/v*) name=rollout-active_record-adapter; directory=rollout-active_record-adapter ;;
|
|
35
37
|
*) echo "Unexpected tag $GITHUB_REF_NAME" >&2; exit 1 ;;
|
|
36
38
|
esac
|
|
37
39
|
version="${GITHUB_REF_NAME##*/v}"
|
|
@@ -60,7 +62,7 @@ jobs:
|
|
|
60
62
|
runs-on: ubuntu-latest
|
|
61
63
|
steps:
|
|
62
64
|
- name: Create GitHub Release
|
|
63
|
-
uses: softprops/action-gh-release@
|
|
65
|
+
uses: softprops/action-gh-release@v3
|
|
64
66
|
with:
|
|
65
67
|
tag_name: ${{ github.ref }}
|
|
66
68
|
name: ${{ needs.publish.outputs.name }} ${{ needs.publish.outputs.version }}
|
data/.github/workflows/test.yml
CHANGED
|
@@ -28,7 +28,7 @@ jobs:
|
|
|
28
28
|
bundle exec rspec spec --format progress --format RspecJunitFormatter --out test_results/rspec.xml
|
|
29
29
|
- name: Upload test results
|
|
30
30
|
if: always()
|
|
31
|
-
uses: actions/upload-artifact@
|
|
31
|
+
uses: actions/upload-artifact@v6
|
|
32
32
|
with:
|
|
33
33
|
name: core-test-results-${{ matrix.ruby-version }}
|
|
34
34
|
path: test_results/rspec.xml
|
|
@@ -65,8 +65,110 @@ jobs:
|
|
|
65
65
|
bundle exec rspec --format progress --format RspecJunitFormatter --out test_results/rspec.xml
|
|
66
66
|
- name: Upload test results
|
|
67
67
|
if: always()
|
|
68
|
-
uses: actions/upload-artifact@
|
|
68
|
+
uses: actions/upload-artifact@v6
|
|
69
69
|
with:
|
|
70
70
|
name: redis-test-results-${{ matrix.ruby-version }}
|
|
71
71
|
path: rollout-redis-adapter/test_results/rspec.xml
|
|
72
72
|
retention-days: 30
|
|
73
|
+
|
|
74
|
+
active_record:
|
|
75
|
+
runs-on: ubuntu-latest
|
|
76
|
+
strategy:
|
|
77
|
+
fail-fast: false
|
|
78
|
+
matrix:
|
|
79
|
+
include:
|
|
80
|
+
- ruby-version: '3.3'
|
|
81
|
+
activerecord: '~> 8.1.0'
|
|
82
|
+
db: sqlite3
|
|
83
|
+
- ruby-version: '3.3'
|
|
84
|
+
activerecord: '~> 8.1.0'
|
|
85
|
+
db: postgresql
|
|
86
|
+
- ruby-version: '3.3'
|
|
87
|
+
activerecord: '~> 8.1.0'
|
|
88
|
+
db: mysql2
|
|
89
|
+
- ruby-version: '3.3'
|
|
90
|
+
activerecord: '~> 7.1.0'
|
|
91
|
+
db: postgresql
|
|
92
|
+
- ruby-version: '3.3'
|
|
93
|
+
activerecord: '~> 7.1.0'
|
|
94
|
+
db: mysql2
|
|
95
|
+
- ruby-version: '3.2'
|
|
96
|
+
activerecord: '~> 8.0.0'
|
|
97
|
+
db: sqlite3
|
|
98
|
+
- ruby-version: '3.1'
|
|
99
|
+
activerecord: '~> 7.2.0'
|
|
100
|
+
db: sqlite3
|
|
101
|
+
- ruby-version: '2.7'
|
|
102
|
+
activerecord: '~> 7.1.0'
|
|
103
|
+
db: sqlite3
|
|
104
|
+
env:
|
|
105
|
+
ACTIVERECORD_VERSION: ${{ matrix.activerecord }}
|
|
106
|
+
ROLLOUT_AR_ADAPTER: ${{ matrix.db }}
|
|
107
|
+
POSTGRES_HOST: localhost
|
|
108
|
+
POSTGRES_USER: postgres
|
|
109
|
+
POSTGRES_PASSWORD: postgres
|
|
110
|
+
POSTGRES_DB: rollout_test
|
|
111
|
+
MYSQL_HOST: 127.0.0.1
|
|
112
|
+
MYSQL_USER: root
|
|
113
|
+
MYSQL_PASSWORD: root
|
|
114
|
+
MYSQL_DATABASE: rollout_test
|
|
115
|
+
REDIS_HOST: 127.0.0.1
|
|
116
|
+
services:
|
|
117
|
+
redis:
|
|
118
|
+
image: redis:7-alpine
|
|
119
|
+
ports:
|
|
120
|
+
- 6379:6379
|
|
121
|
+
options: >-
|
|
122
|
+
--health-cmd "redis-cli ping"
|
|
123
|
+
--health-interval 10s
|
|
124
|
+
--health-timeout 5s
|
|
125
|
+
--health-retries 5
|
|
126
|
+
postgres:
|
|
127
|
+
image: postgres:16
|
|
128
|
+
env:
|
|
129
|
+
POSTGRES_USER: postgres
|
|
130
|
+
POSTGRES_PASSWORD: postgres
|
|
131
|
+
POSTGRES_DB: rollout_test
|
|
132
|
+
ports:
|
|
133
|
+
- 5432:5432
|
|
134
|
+
options: >-
|
|
135
|
+
--health-cmd "pg_isready -U postgres"
|
|
136
|
+
--health-interval 10s
|
|
137
|
+
--health-timeout 5s
|
|
138
|
+
--health-retries 5
|
|
139
|
+
mysql:
|
|
140
|
+
image: mysql:8.0
|
|
141
|
+
env:
|
|
142
|
+
MYSQL_ROOT_PASSWORD: root
|
|
143
|
+
MYSQL_DATABASE: rollout_test
|
|
144
|
+
ports:
|
|
145
|
+
- 3306:3306
|
|
146
|
+
options: >-
|
|
147
|
+
--health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -proot"
|
|
148
|
+
--health-interval 10s
|
|
149
|
+
--health-timeout 5s
|
|
150
|
+
--health-retries 5
|
|
151
|
+
steps:
|
|
152
|
+
- name: Checkout code
|
|
153
|
+
uses: actions/checkout@v5
|
|
154
|
+
- name: Install database client libraries
|
|
155
|
+
run: sudo apt-get update && sudo apt-get install -y libpq-dev default-libmysqlclient-dev
|
|
156
|
+
- name: Setup Ruby
|
|
157
|
+
uses: ruby/setup-ruby@v1
|
|
158
|
+
with:
|
|
159
|
+
ruby-version: ${{ matrix.ruby-version }}
|
|
160
|
+
working-directory: rollout-active_record-adapter
|
|
161
|
+
bundler-cache: true
|
|
162
|
+
cache-version: ar-${{ matrix.ruby-version }}-${{ matrix.activerecord }}-${{ matrix.db }}
|
|
163
|
+
- name: Run Active Record adapter tests
|
|
164
|
+
working-directory: rollout-active_record-adapter
|
|
165
|
+
run: |
|
|
166
|
+
mkdir -p test_results
|
|
167
|
+
bundle exec rspec --format progress --format RspecJunitFormatter --out test_results/rspec.xml
|
|
168
|
+
- name: Upload test results
|
|
169
|
+
if: always()
|
|
170
|
+
uses: actions/upload-artifact@v5
|
|
171
|
+
with:
|
|
172
|
+
name: active-record-test-results-${{ matrix.ruby-version }}-${{ matrix.db }}-${{ strategy.job-index }}
|
|
173
|
+
path: rollout-active_record-adapter/test_results/rspec.xml
|
|
174
|
+
retention-days: 30
|
data/README.md
CHANGED
|
@@ -22,6 +22,11 @@ gem "rollout", "~> 3.1"
|
|
|
22
22
|
gem "rollout-redis-adapter", "~> 0.1"
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
Active Record applications can use `rollout-active_record-adapter` instead of
|
|
26
|
+
`rollout-redis-adapter`. See the [Active Record adapter README](rollout-active_record-adapter/README.md).
|
|
27
|
+
To copy current feature state from Redis, see
|
|
28
|
+
[Migrate from Redis](rollout-active_record-adapter/README.md#migrate-from-redis).
|
|
29
|
+
|
|
25
30
|
## How it works
|
|
26
31
|
|
|
27
32
|
Initialize a rollout object. I assign it to a global var.
|
|
@@ -29,7 +34,7 @@ Initialize a rollout object. I assign it to a global var.
|
|
|
29
34
|
```ruby
|
|
30
35
|
require "redis"
|
|
31
36
|
require "rollout"
|
|
32
|
-
require "rollout
|
|
37
|
+
require "rollout-redis-adapter"
|
|
33
38
|
|
|
34
39
|
$redis = Redis.new
|
|
35
40
|
$rollout = Rollout.new(adapter: Rollout::Adapters::Redis.new($redis))
|
|
@@ -196,7 +201,7 @@ gem "redis-namespace"
|
|
|
196
201
|
require "redis"
|
|
197
202
|
require "redis/namespace"
|
|
198
203
|
require "rollout"
|
|
199
|
-
require "rollout
|
|
204
|
+
require "rollout-redis-adapter"
|
|
200
205
|
|
|
201
206
|
$ns = Redis::Namespace.new(Rails.env, redis: $redis)
|
|
202
207
|
$rollout = Rollout.new(adapter: Rollout::Adapters::Redis.new($ns))
|
|
@@ -232,11 +237,12 @@ Rollout 3-compatible UI release before upgrading production.
|
|
|
232
237
|
|
|
233
238
|
## Testing
|
|
234
239
|
|
|
235
|
-
Install dependencies first. Core and the
|
|
240
|
+
Install dependencies first. Core and the adapter gems have separate Gemfiles:
|
|
236
241
|
|
|
237
242
|
```bash
|
|
238
243
|
bundle install
|
|
239
244
|
bundle install --gemfile=rollout-redis-adapter/Gemfile
|
|
245
|
+
bundle install --gemfile=rollout-active_record-adapter/Gemfile
|
|
240
246
|
```
|
|
241
247
|
|
|
242
248
|
Core tests do not need Redis:
|
|
@@ -261,6 +267,15 @@ bundle exec rake spec:redis
|
|
|
261
267
|
Optional connection settings: `REDIS_HOST`, `REDIS_PORT`, `REDIS_DB`. `REDIS_DB`
|
|
262
268
|
overrides the default database 7 that is flushed before each example.
|
|
263
269
|
|
|
270
|
+
Active Record adapter tests default to SQLite. PostgreSQL and MySQL are also
|
|
271
|
+
supported:
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
bundle exec rake spec:active_record
|
|
275
|
+
ROLLOUT_AR_ADAPTER=postgresql bundle exec rake spec:active_record
|
|
276
|
+
ROLLOUT_AR_ADAPTER=mysql2 bundle exec rake spec:active_record
|
|
277
|
+
```
|
|
278
|
+
|
|
264
279
|
## Releasing
|
|
265
280
|
|
|
266
281
|
Each gem has its own version and tag.
|
|
@@ -268,10 +283,12 @@ Each gem has its own version and tag.
|
|
|
268
283
|
- Configure a RubyGems trusted publisher for the gem you are releasing. Use
|
|
269
284
|
repository owner `fetlife`, repository `rollout`, workflow filename
|
|
270
285
|
`release.yml`, and no GitHub environment.
|
|
271
|
-
- Update and commit the version in `lib/rollout/version.rb
|
|
272
|
-
`rollout-redis-adapter/rollout-redis-adapter.gemspec
|
|
273
|
-
|
|
274
|
-
|
|
286
|
+
- Update and commit the version in `lib/rollout/version.rb`,
|
|
287
|
+
`rollout-redis-adapter/rollout-redis-adapter.gemspec`, or
|
|
288
|
+
`rollout-active_record-adapter/rollout-active_record-adapter.gemspec`.
|
|
289
|
+
- Tag the release commit with `rollout/vX.Y.Z`,
|
|
290
|
+
`rollout-redis-adapter/vX.Y.Z`, or
|
|
291
|
+
`rollout-active_record-adapter/vX.Y.Z`, matching the gem version.
|
|
275
292
|
- Push the tag with `git push origin <tag>`. CI publishes the selected gem and
|
|
276
293
|
creates its GitHub release.
|
|
277
294
|
|
data/Rakefile
CHANGED
|
@@ -16,6 +16,16 @@ namespace :spec do
|
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
|
+
|
|
20
|
+
desc "Run Active Record adapter tests"
|
|
21
|
+
task :active_record do
|
|
22
|
+
gemfile = File.expand_path("rollout-active_record-adapter/Gemfile", __dir__)
|
|
23
|
+
Dir.chdir("rollout-active_record-adapter") do
|
|
24
|
+
Bundler.with_unbundled_env do
|
|
25
|
+
sh({ "BUNDLE_GEMFILE" => gemfile }, "bundle exec rspec")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
19
29
|
end
|
|
20
30
|
|
|
21
31
|
task default: :spec
|
data/docs/upgrading-to-v3.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Rollout 3 keeps feature evaluation in the `rollout` gem and moves Redis
|
|
4
4
|
persistence to `rollout-redis-adapter`. Existing Redis keys stay in place. This is
|
|
5
|
-
not
|
|
5
|
+
not a storage migration onto Active Record. For the Active Record adapter
|
|
6
|
+
and Redis cutover, see
|
|
7
|
+
[rollout-active_record-adapter/README.md](../rollout-active_record-adapter/README.md).
|
|
6
8
|
|
|
7
9
|
## 1. Update dependencies
|
|
8
10
|
|
|
@@ -39,7 +41,7 @@ $rollout = Rollout.new(
|
|
|
39
41
|
# After
|
|
40
42
|
require "redis"
|
|
41
43
|
require "rollout"
|
|
42
|
-
require "rollout
|
|
44
|
+
require "rollout-redis-adapter"
|
|
43
45
|
|
|
44
46
|
$redis = Redis.new
|
|
45
47
|
$rollout = Rollout.new(
|
|
@@ -90,11 +92,18 @@ history.
|
|
|
90
92
|
|
|
91
93
|
| Operation | Feature state | Per-feature history | Global history |
|
|
92
94
|
| --- | --- | --- | --- |
|
|
93
|
-
| `delete`, logging enabled | Removed | Removed | Retained |
|
|
95
|
+
| `delete`, logging enabled | Removed | Removed | Retained; emits a global `delete` event when `global: true` and the adapter supports event-aware deletion |
|
|
94
96
|
| `delete`, logging disabled | Removed | Retained | Retained |
|
|
95
97
|
| `logging.delete` | Unchanged | Removed | Retained |
|
|
96
98
|
| `clear!` | Removed | Retained | Retained |
|
|
97
99
|
|
|
100
|
+
The deletion event includes the feature name, timestamp, and current logging
|
|
101
|
+
context (for example, an actor supplied with `logging.with_context`). Built-in
|
|
102
|
+
adapters write it only when the feature exists and global logging is enabled;
|
|
103
|
+
it is subject to `history_length` like other global events. Older or custom
|
|
104
|
+
adapters without `delete_feature_with_history` retain the previous deletion
|
|
105
|
+
behavior and do not record a deletion event.
|
|
106
|
+
|
|
98
107
|
`clear!` still resets each feature first, so logging-enabled instances record
|
|
99
108
|
a reset event before the state is deleted. History remains subject to
|
|
100
109
|
`history_length`. The Redis registry key is removed after clearing, including
|
data/lib/rollout/logging.rb
CHANGED
|
@@ -74,6 +74,18 @@ class Rollout
|
|
|
74
74
|
@adapter.delete_feature_events(feature_name)
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
+
def delete_event_for(feature_name)
|
|
78
|
+
return unless logging_enabled? && global
|
|
79
|
+
|
|
80
|
+
Event.new(
|
|
81
|
+
feature: feature_name.to_s,
|
|
82
|
+
name: :delete,
|
|
83
|
+
data: {},
|
|
84
|
+
context: current_context,
|
|
85
|
+
created_at: Time.now,
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
77
89
|
def event_for(before, after)
|
|
78
90
|
return unless logging_enabled?
|
|
79
91
|
|
data/lib/rollout/version.rb
CHANGED
data/lib/rollout.rb
CHANGED
|
@@ -38,9 +38,16 @@ class Rollout
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def delete(feature)
|
|
41
|
-
@adapter.delete_feature(feature)
|
|
41
|
+
return @adapter.delete_feature(feature) unless respond_to?(:logging)
|
|
42
42
|
|
|
43
|
-
if respond_to?(:
|
|
43
|
+
if @adapter.respond_to?(:delete_feature_with_history)
|
|
44
|
+
@adapter.delete_feature_with_history(
|
|
45
|
+
feature,
|
|
46
|
+
event: logging.delete_event_for(feature),
|
|
47
|
+
history_length: logging.history_length,
|
|
48
|
+
)
|
|
49
|
+
else
|
|
50
|
+
@adapter.delete_feature(feature)
|
|
44
51
|
logging.delete(feature)
|
|
45
52
|
end
|
|
46
53
|
end
|
data/rollout.gemspec
CHANGED
|
@@ -14,7 +14,7 @@ Gem::Specification.new do |spec|
|
|
|
14
14
|
spec.license = 'MIT'
|
|
15
15
|
|
|
16
16
|
spec.files = `git ls-files`.split("\n").reject do |file|
|
|
17
|
-
file.start_with?('rollout-redis-adapter/')
|
|
17
|
+
file.start_with?('rollout-redis-adapter/', 'rollout-active_record-adapter/')
|
|
18
18
|
end
|
|
19
19
|
spec.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
|
20
20
|
spec.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
|
data/spec/rollout_spec.rb
CHANGED
|
@@ -191,5 +191,56 @@ RSpec.describe Rollout do
|
|
|
191
191
|
|
|
192
192
|
expect(rollout.logging.events(:chat)).to eq []
|
|
193
193
|
end
|
|
194
|
+
|
|
195
|
+
it "records a global deletion event with the current context" do
|
|
196
|
+
backend = RolloutMemoryBackend.new
|
|
197
|
+
rollout = described_class.new(adapter: backend, logging: { global: true })
|
|
198
|
+
rollout.activate_percentage(:chat, 25)
|
|
199
|
+
|
|
200
|
+
rollout.logging.with_context(actor: "alice") { rollout.delete(:chat) }
|
|
201
|
+
|
|
202
|
+
event = rollout.logging.global_events.last
|
|
203
|
+
expect(event.name).to eq :delete
|
|
204
|
+
expect(event.feature).to eq "chat"
|
|
205
|
+
expect(event.data).to eq({})
|
|
206
|
+
expect(event.context).to eq(actor: "alice")
|
|
207
|
+
expect(event.created_at).to be_a(Time)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
it "does not record a deletion event for a missing feature" do
|
|
211
|
+
backend = RolloutMemoryBackend.new
|
|
212
|
+
rollout = described_class.new(adapter: backend, logging: { global: true })
|
|
213
|
+
|
|
214
|
+
rollout.delete(:chat)
|
|
215
|
+
|
|
216
|
+
expect(rollout.logging.global_events).to eq []
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it "does not record a deletion event when logging is suppressed" do
|
|
220
|
+
backend = RolloutMemoryBackend.new
|
|
221
|
+
rollout = described_class.new(adapter: backend, logging: { global: true })
|
|
222
|
+
rollout.activate_percentage(:chat, 25)
|
|
223
|
+
|
|
224
|
+
rollout.logging.without { rollout.delete(:chat) }
|
|
225
|
+
|
|
226
|
+
expect(rollout.exists?(:chat)).to eq false
|
|
227
|
+
expect(rollout.logging.events(:chat)).to eq []
|
|
228
|
+
expect(rollout.logging.global_events.map(&:name)).to eq [:update]
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
it "preserves deletion behavior for adapters without event-aware deletion" do
|
|
232
|
+
backend_class = Class.new(RolloutMemoryBackend) do
|
|
233
|
+
undef_method :delete_feature_with_history
|
|
234
|
+
end
|
|
235
|
+
backend = backend_class.new
|
|
236
|
+
rollout = described_class.new(adapter: backend, logging: { global: true })
|
|
237
|
+
rollout.activate_percentage(:chat, 25)
|
|
238
|
+
|
|
239
|
+
expect { rollout.delete(:chat) }.not_to raise_error
|
|
240
|
+
|
|
241
|
+
expect(rollout.exists?(:chat)).to eq false
|
|
242
|
+
expect(rollout.logging.events(:chat)).to eq []
|
|
243
|
+
expect(rollout.logging.global_events.map(&:name)).to eq [:update]
|
|
244
|
+
end
|
|
194
245
|
end
|
|
195
246
|
end
|
data/spec/spec_helper.rb
CHANGED
|
@@ -11,6 +11,7 @@ class RolloutMemoryBackend
|
|
|
11
11
|
def initialize
|
|
12
12
|
@features = {}
|
|
13
13
|
@events = Hash.new { |hash, key| hash[key] = [] }
|
|
14
|
+
@global_events = []
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def fetch_feature(name)
|
|
@@ -47,7 +48,10 @@ class RolloutMemoryBackend
|
|
|
47
48
|
mutation = yield fetch_feature(name)
|
|
48
49
|
save_feature(mutation.fetch(:state))
|
|
49
50
|
event = mutation[:event]
|
|
50
|
-
|
|
51
|
+
if event
|
|
52
|
+
@events[name.to_s] << event
|
|
53
|
+
@global_events << event if mutation[:global]
|
|
54
|
+
end
|
|
51
55
|
mutation
|
|
52
56
|
end
|
|
53
57
|
|
|
@@ -56,7 +60,7 @@ class RolloutMemoryBackend
|
|
|
56
60
|
end
|
|
57
61
|
|
|
58
62
|
def global_events(limit: nil)
|
|
59
|
-
limited_events(
|
|
63
|
+
limited_events(@global_events, limit)
|
|
60
64
|
end
|
|
61
65
|
|
|
62
66
|
def feature_updated_at(_name)
|
|
@@ -66,6 +70,16 @@ class RolloutMemoryBackend
|
|
|
66
70
|
@events.delete(name.to_s)
|
|
67
71
|
end
|
|
68
72
|
|
|
73
|
+
def delete_feature_with_history(name, event:, history_length:)
|
|
74
|
+
existed = feature_exists?(name)
|
|
75
|
+
delete_feature(name)
|
|
76
|
+
delete_feature_events(name)
|
|
77
|
+
if existed && event
|
|
78
|
+
@global_events << event
|
|
79
|
+
@global_events = @global_events.last(history_length)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
69
83
|
private
|
|
70
84
|
|
|
71
85
|
def limited_events(events, limit)
|
|
@@ -38,6 +38,13 @@ RSpec.shared_examples "a rollout feature backend" do
|
|
|
38
38
|
expect(backend.fetch_features([])).to eq []
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
it "round-trips a double-precision percentage" do
|
|
42
|
+
percentage = 33.333333333333336
|
|
43
|
+
backend.save_feature(Rollout::FeatureState.new(name: :chat, percentage: percentage))
|
|
44
|
+
|
|
45
|
+
expect(backend.fetch_feature(:chat).percentage).to eq percentage
|
|
46
|
+
end
|
|
47
|
+
|
|
41
48
|
it "does not share nested data with a fetched state" do
|
|
42
49
|
backend.save_feature(
|
|
43
50
|
Rollout::FeatureState.new(name: :chat, percentage: 0, data: { "labels" => ["a"] }),
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
RSpec.shared_examples "a rollout history backend" do
|
|
2
|
+
let(:rollout) { Rollout.new(adapter: backend, logging: logging) }
|
|
3
|
+
let(:logging) { true }
|
|
4
|
+
let(:feature) { :foo }
|
|
5
|
+
|
|
6
|
+
it "persists feature events oldest-to-newest" do
|
|
7
|
+
rollout.activate_percentage(feature, 50)
|
|
8
|
+
rollout.activate_percentage(feature, 75)
|
|
9
|
+
rollout.activate_group(feature, :hipsters)
|
|
10
|
+
|
|
11
|
+
events = rollout.logging.events(feature)
|
|
12
|
+
expect(events.map(&:name)).to eq %w[update update update]
|
|
13
|
+
expect(events.map { |event| event.data }).to eq [
|
|
14
|
+
{ before: { percentage: 0 }, after: { percentage: 50 } },
|
|
15
|
+
{ before: { percentage: 50 }, after: { percentage: 75 } },
|
|
16
|
+
{ before: { groups: [] }, after: { groups: ["hipsters"] } },
|
|
17
|
+
]
|
|
18
|
+
expect(rollout.logging.updated_at(feature)).to_not be_nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it "does not write a history event when a double-precision percentage is unchanged" do
|
|
22
|
+
percentage = 33.333333333333336
|
|
23
|
+
rollout.activate_percentage(feature, percentage)
|
|
24
|
+
|
|
25
|
+
expect do
|
|
26
|
+
rollout.activate_percentage(feature, percentage)
|
|
27
|
+
end.not_to change { rollout.logging.events(feature).count }
|
|
28
|
+
|
|
29
|
+
expect(rollout.get(feature).percentage).to eq percentage
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it "persists metadata changes and actor context" do
|
|
33
|
+
rollout.logging.with_context(actor: "alice") do
|
|
34
|
+
rollout.with_feature(feature) do |current|
|
|
35
|
+
current.percentage = 25.0
|
|
36
|
+
current.data.update(description: "New navigation")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
event = rollout.logging.last_event(feature)
|
|
41
|
+
expect(event.data).to eq(
|
|
42
|
+
before: { percentage: 0, "data.description": nil },
|
|
43
|
+
after: { percentage: 25.0, "data.description": "New navigation" },
|
|
44
|
+
)
|
|
45
|
+
expect(event.context).to eq(actor: "alice")
|
|
46
|
+
expect(rollout.logging.events(feature).count).to eq 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
context "history truncation" do
|
|
50
|
+
let(:logging) { { history_length: 1 } }
|
|
51
|
+
|
|
52
|
+
it "keeps only the configured number of events" do
|
|
53
|
+
rollout.activate_percentage(feature, 25)
|
|
54
|
+
rollout.activate_percentage(feature, 30)
|
|
55
|
+
|
|
56
|
+
expect(rollout.logging.events(feature).map { |event| event.data[:after][:percentage] }).to eq [30]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
context "global logs" do
|
|
61
|
+
let(:logging) { { global: true } }
|
|
62
|
+
|
|
63
|
+
it "logs changes across features oldest-to-newest" do
|
|
64
|
+
rollout.activate_percentage("foo", 25)
|
|
65
|
+
rollout.activate_percentage("bar", 30)
|
|
66
|
+
rollout.activate_percentage("baz", 40)
|
|
67
|
+
|
|
68
|
+
expect(rollout.logging.global_events.map(&:feature)).to eq %w[foo bar baz]
|
|
69
|
+
expect(rollout.logging.global_events(limit: 2).map(&:feature)).to eq %w[bar baz]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "removes feature history on delete" do
|
|
74
|
+
rollout.activate_percentage(feature, 25)
|
|
75
|
+
rollout.delete(feature)
|
|
76
|
+
|
|
77
|
+
expect(rollout.logging.events(feature)).to eq []
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it "records a global-only delete event for an existing feature" do
|
|
81
|
+
logged_rollout = Rollout.new(adapter: backend, logging: { global: true, history_length: 2 })
|
|
82
|
+
logged_rollout.activate_percentage(feature, 25)
|
|
83
|
+
logged_rollout.logging.with_context(actor: "alice") { logged_rollout.delete(feature) }
|
|
84
|
+
|
|
85
|
+
event = logged_rollout.logging.global_events.last
|
|
86
|
+
expect(event.name).to eq "delete"
|
|
87
|
+
expect(event.feature).to eq feature.to_s
|
|
88
|
+
expect(event.data).to eq({})
|
|
89
|
+
expect(event.context).to eq(actor: "alice")
|
|
90
|
+
expect(logged_rollout.logging.events(feature)).to eq []
|
|
91
|
+
expect(logged_rollout.logging.updated_at(feature)).to be_nil
|
|
92
|
+
expect(backend.feature_names.map(&:to_s)).not_to include(feature.to_s)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "does not record deletions of missing features and honors global history length" do
|
|
96
|
+
logged_rollout = Rollout.new(adapter: backend, logging: { global: true, history_length: 1 })
|
|
97
|
+
logged_rollout.activate_percentage(feature, 25)
|
|
98
|
+
logged_rollout.delete(feature)
|
|
99
|
+
expect(logged_rollout.logging.global_events.map(&:name)).to eq ["delete"]
|
|
100
|
+
|
|
101
|
+
logged_rollout.delete(feature)
|
|
102
|
+
|
|
103
|
+
expect(logged_rollout.logging.global_events.map(&:name)).to eq ["delete"]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it "does not add a delete event when global history is disabled" do
|
|
107
|
+
logged_rollout = Rollout.new(adapter: backend, logging: { global: false })
|
|
108
|
+
logged_rollout.activate_percentage(feature, 25)
|
|
109
|
+
|
|
110
|
+
logged_rollout.delete(feature)
|
|
111
|
+
|
|
112
|
+
expect(logged_rollout.logging.global_events).to eq []
|
|
113
|
+
expect(logged_rollout.logging.events(feature)).to eq []
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "does not retain a delete event when history length is zero" do
|
|
117
|
+
logged_rollout = Rollout.new(adapter: backend, logging: { global: true, history_length: 0 })
|
|
118
|
+
logged_rollout.activate_percentage(feature, 25)
|
|
119
|
+
|
|
120
|
+
logged_rollout.delete(feature)
|
|
121
|
+
|
|
122
|
+
expect(logged_rollout.logging.global_events).to eq []
|
|
123
|
+
expect(logged_rollout.logging.events(feature)).to eq []
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "keeps feature history on clear!" do
|
|
127
|
+
rollout.activate_percentage(feature, 25)
|
|
128
|
+
rollout.clear!
|
|
129
|
+
|
|
130
|
+
expect(rollout.features).to eq []
|
|
131
|
+
expect(rollout.logging.events(feature).map { |event| event.data[:after][:percentage] }).to eq [25, 0]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it "keeps feature history when deleting without logging" do
|
|
135
|
+
rollout.activate_percentage(feature, 25)
|
|
136
|
+
|
|
137
|
+
Rollout.new(adapter: backend).delete(feature)
|
|
138
|
+
|
|
139
|
+
expect(rollout.exists?(feature)).to be_falsey
|
|
140
|
+
expect(rollout.logging.events(feature)).not_to eq []
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it "logging.delete removes feature history only" do
|
|
144
|
+
rollout.activate_percentage(feature, 25)
|
|
145
|
+
rollout.logging.delete(feature)
|
|
146
|
+
|
|
147
|
+
expect(rollout.logging.events(feature)).to eq []
|
|
148
|
+
expect(rollout.logging.updated_at(feature)).to be_nil
|
|
149
|
+
expect(rollout.get(feature).percentage).to eq 25
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
it "adapter delete_feature preserves history" do
|
|
153
|
+
rollout.activate_percentage(feature, 25)
|
|
154
|
+
rollout.adapter.delete_feature(feature)
|
|
155
|
+
|
|
156
|
+
expect(rollout.exists?(feature)).to be_falsey
|
|
157
|
+
expect(rollout.logging.events(feature)).not_to eq []
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it "returns the newest events oldest-to-newest when limited" do
|
|
161
|
+
rollout.activate_percentage(feature, 25)
|
|
162
|
+
rollout.activate_percentage(feature, 50)
|
|
163
|
+
rollout.activate_percentage(feature, 75)
|
|
164
|
+
|
|
165
|
+
expect(rollout.logging.events(feature, limit: 2).map { |event| event.data[:after][:percentage] }).to eq [50, 75]
|
|
166
|
+
expect(rollout.logging.events(feature, limit: 0)).to eq []
|
|
167
|
+
expect(rollout.logging.last_event(feature).data[:after][:percentage]).to eq 75
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it "rejects an invalid history limit" do
|
|
171
|
+
expect { rollout.logging.events(feature, limit: -1) }.to raise_error(ArgumentError)
|
|
172
|
+
expect { rollout.adapter.feature_events(feature, limit: 1.5) }.to raise_error(ArgumentError)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
RSpec.shared_examples "a rollout backend integration" do
|
|
2
|
+
let(:rollout) { Rollout.new(adapter: backend) }
|
|
3
|
+
|
|
4
|
+
it "persists combined feature state" do
|
|
5
|
+
rollout.with_feature(:chat) do |feature|
|
|
6
|
+
feature.percentage = 10.5
|
|
7
|
+
feature.groups = [:employees]
|
|
8
|
+
feature.users = ["123"]
|
|
9
|
+
feature.data.update(description: "New navigation", label: :beta)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
feature = rollout.get(:chat)
|
|
13
|
+
expect(feature.percentage).to eq 10.5
|
|
14
|
+
expect(feature.groups).to eq [:employees]
|
|
15
|
+
expect(feature.users).to eq %w[123]
|
|
16
|
+
expect(feature.data).to eq("description" => "New navigation", "label" => "beta")
|
|
17
|
+
expect(rollout.exists?(:chat)).to eq true
|
|
18
|
+
expect(rollout.features).to eq [:chat]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it "persists metadata that contains pipes" do
|
|
22
|
+
rollout.activate_user(:chat, 8)
|
|
23
|
+
rollout.set_feature_data(:chat, "|call||text|" => "a|bunch|of|stuff")
|
|
24
|
+
|
|
25
|
+
expect(rollout.get(:chat).data).to include("|call||text|" => "a|bunch|of|stuff")
|
|
26
|
+
expect(rollout.get(:chat).users).to eq %w[8]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "clears users, groups, percentage, and data on deactivate" do
|
|
30
|
+
rollout.activate_user(:chat, 42)
|
|
31
|
+
rollout.activate_group(:chat, :employees)
|
|
32
|
+
rollout.activate_percentage(:chat, 50)
|
|
33
|
+
rollout.set_feature_data(:chat, description: "foo")
|
|
34
|
+
|
|
35
|
+
rollout.deactivate(:chat)
|
|
36
|
+
|
|
37
|
+
expect(rollout.features).to eq [:chat]
|
|
38
|
+
expect(rollout.get(:chat).to_hash).to eq(
|
|
39
|
+
percentage: 0,
|
|
40
|
+
users: [],
|
|
41
|
+
groups: [],
|
|
42
|
+
data: {},
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "keeps users, groups, and data on deactivate_percentage" do
|
|
47
|
+
rollout.activate_user(:chat, 42)
|
|
48
|
+
rollout.activate_group(:chat, :employees)
|
|
49
|
+
rollout.activate_percentage(:chat, 50)
|
|
50
|
+
rollout.set_feature_data(:chat, description: "foo")
|
|
51
|
+
|
|
52
|
+
rollout.deactivate_percentage(:chat)
|
|
53
|
+
|
|
54
|
+
expect(rollout.get(:chat).percentage).to eq 0
|
|
55
|
+
expect(rollout.get(:chat).users).to eq %w[42]
|
|
56
|
+
expect(rollout.get(:chat).groups).to eq [:employees]
|
|
57
|
+
expect(rollout.get(:chat).data).to eq("description" => "foo")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it "removes the feature on delete" do
|
|
61
|
+
rollout.activate(:chat)
|
|
62
|
+
rollout.delete(:chat)
|
|
63
|
+
|
|
64
|
+
expect(rollout.features).to eq []
|
|
65
|
+
expect(rollout.exists?(:chat)).to eq false
|
|
66
|
+
expect(rollout.get(:chat).percentage).to eq 0
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "clears persisted features" do
|
|
70
|
+
rollout.activate(:signup)
|
|
71
|
+
rollout.activate(:chat)
|
|
72
|
+
rollout.clear!
|
|
73
|
+
|
|
74
|
+
expect(rollout.features).to eq []
|
|
75
|
+
expect(rollout.exists?(:chat)).to eq false
|
|
76
|
+
end
|
|
77
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rollout
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- James Golick
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: observer
|
|
@@ -154,6 +154,8 @@ files:
|
|
|
154
154
|
- spec/rollout_spec.rb
|
|
155
155
|
- spec/spec_helper.rb
|
|
156
156
|
- spec/support/backend_contract.rb
|
|
157
|
+
- spec/support/history_contract.rb
|
|
158
|
+
- spec/support/rollout_backend_integration.rb
|
|
157
159
|
homepage: https://github.com/FetLife/rollout
|
|
158
160
|
licenses:
|
|
159
161
|
- MIT
|