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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: fbabd7f8b0651b97e5ebe8a2bdc4b990c810e162
4
- data.tar.gz: 4b5f9ff84fbcbbbef81e05c885b67cf699309e47
2
+ SHA256:
3
+ metadata.gz: f825c30d598b5b2b194b97b0371bbd1ceda8f341458a64b1a601941ee11580eb
4
+ data.tar.gz: '038f689c1fe1f5eb3aba0e4eaadcc80da24f320b863a4fffecf6ca3fe4355ba4'
5
5
  SHA512:
6
- metadata.gz: 1a87eb9c45aef7a24868361e04f5bdb53fb55ab2949fb411a3f39198ddac4085b63d05a730f06820feaed797327772f4bf95fc783f8f5d5c55dcf24379c51d99
7
- data.tar.gz: 7c7e3b692bf11cbcebd99ad2a8d3e4db41a2b233ac5ce5ae0551c15f522e93d0f474e3fc5f868b3fedd76d1d8fb8751d178ac39ef3721b10e9a99b2499e3ec32
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
- release:
14
+ publish:
10
15
  runs-on: ubuntu-latest
11
- services:
12
- redis:
13
- image: redis:7-alpine
14
- ports:
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@v4
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
- bundler-cache: true
28
- - name: Run tests
29
- run: bundle exec rspec
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: ${{ github.ref_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
@@ -4,13 +4,41 @@ on:
4
4
  push:
5
5
  branches:
6
6
  - master
7
+ - v3
7
8
  pull_request:
8
- branches:
9
- - master
9
+
10
10
 
11
11
  jobs:
12
- test:
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@v4
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
- run: bundle exec rspec
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
@@ -18,7 +18,9 @@ coverage
18
18
  rdoc
19
19
  pkg
20
20
  *.gem
21
+ Gemfile.lock
21
22
  gemfiles/*.lock
23
+ test_results
22
24
  .rspec_status
23
25
  .bundle/config
24
26
  /vendor
data/README.md CHANGED
@@ -1,16 +1,25 @@
1
1
  # rollout
2
2
 
3
- Fast feature flags based on Redis.
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
  [![Gem Version](https://badge.fury.io/rb/rollout.svg)](https://badge.fury.io/rb/rollout)
6
- [![CircleCI](https://circleci.com/gh/fetlife/rollout.svg?style=svg)](https://circleci.com/gh/fetlife/rollout)
9
+ [![CI](https://github.com/fetlife/rollout/actions/workflows/test.yml/badge.svg)](https://github.com/fetlife/rollout/actions/workflows/test.yml)
7
10
  [![Code Climate](https://codeclimate.com/github/FetLife/rollout/badges/gpa.svg)](https://codeclimate.com/github/FetLife/rollout)
8
11
  [![Test Coverage](https://codeclimate.com/github/FetLife/rollout/badges/coverage.svg)](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 'redis'
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
- CRC32(user.id) < (2**32 - 1) / 100.0 * percentage
116
+ Zlib.crc32(user.id.to_s) < (2**32 - 1) / 100.0 * percentage
113
117
  ```
114
118
 
115
- So, for 20%, users 0, 1, 10, 11, 20, 21, etc would be allowed in. Those users
116
- would remain in as the percentage increases.
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($redis, randomize_percentage: true)
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
- >> $rollout.get(:chat)
170
- => #<Rollout::Feature:0x00007f99fa4ec528 @data={}, @groups=[:caretakers], @name=:chat, @options={}, @percentage=0.05, @users=["1"]>
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 would use the "development:feature:chat:groups" key.
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
- 1. Bump version: `rake version:patch` (or `minor`/`major`)
213
- 2. Commit and tag: `git commit -am "Bump version" && git tag v0.7.3`
214
- 3. Push: `git push origin master --tags`
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
- The GitHub Actions workflow will automatically publish to RubyGems when tags are pushed.
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.
@@ -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(name, rollout:, state: nil, options: {})
9
- @name = name
10
+ def initialize(state:, rollout:, options: {}, name: nil)
10
11
  @rollout = rollout
11
12
  @options = options
12
-
13
- if state
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 serialize
25
- "#{@percentage}|#{@users.to_a.join(',')}|#{@groups.to_a.join(',')}|#{serialize_data}"
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
- @groups = groups_from_string('')
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('@rollot', @rollout)
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 serialize_data
117
- return '' unless @data.is_a? Hash
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 groups_from_string(raw_groups)
132
- groups = (raw_groups || '').split(',').map(&:to_sym)
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