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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f825c30d598b5b2b194b97b0371bbd1ceda8f341458a64b1a601941ee11580eb
4
- data.tar.gz: '038f689c1fe1f5eb3aba0e4eaadcc80da24f320b863a4fffecf6ca3fe4355ba4'
3
+ metadata.gz: cadea20a7d731c5538e5d6b6b19b34075843230fd5c6d75ffc4c269ce667a63f
4
+ data.tar.gz: '06483d373ff87c4e0537ab73d57be5bba089d43a4abacd87168bdf338cb4ca5e'
5
5
  SHA512:
6
- metadata.gz: 2dd85351ad0860f8a2bd86b8b018fad5ed6175751f3018fcc20cad2e96fed7df38de74fc04205207a6de627b168fa0dd04575bf534aa461ed6d4d9b74d36d013
7
- data.tar.gz: ab692a6ae7d929d52df99b7d9de6f0ca41a018c72f5cddc05b3fa37051418ca476b7b89af862c77353e8a82646d0adfa8832a04a8c96b3941a6d865da69626d0
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@v2
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 }}
@@ -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@v5
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@v5
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/adapters/redis"
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/adapters/redis"
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 Redis adapter have separate Gemfiles:
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` 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.
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
@@ -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 the later Active Record / PostgreSQL migration.
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/adapters/redis"
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
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Rollout
4
- VERSION = '3.1.0'
4
+ VERSION = '3.2.0'
5
5
  end
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?(:logging)
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
- @events[name.to_s] << event if event
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([], limit)
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.1.0
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-09-14 00:00:00.000000000 Z
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