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
data/spec/spec_helper.rb
CHANGED
|
@@ -5,23 +5,82 @@ require 'simplecov'
|
|
|
5
5
|
SimpleCov.start
|
|
6
6
|
|
|
7
7
|
require 'bundler/setup'
|
|
8
|
-
require 'redis'
|
|
9
8
|
require 'rollout'
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
class RolloutMemoryBackend
|
|
11
|
+
def initialize
|
|
12
|
+
@features = {}
|
|
13
|
+
@events = Hash.new { |hash, key| hash[key] = [] }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def fetch_feature(name)
|
|
17
|
+
state = @features[name.to_s]
|
|
18
|
+
return Rollout::FeatureState.new(name: name, percentage: 0) unless state
|
|
19
|
+
|
|
20
|
+
state.deep_clone
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def fetch_features(names)
|
|
24
|
+
names.map { |name| fetch_feature(name) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def feature_names
|
|
28
|
+
@features.keys
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def feature_exists?(name)
|
|
32
|
+
@features.key?(name.to_s)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def save_feature(state)
|
|
36
|
+
@features[state.name] = state.deep_clone
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def delete_feature(name)
|
|
40
|
+
@features.delete(name.to_s)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def clear_features
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def mutate_feature(name)
|
|
47
|
+
mutation = yield fetch_feature(name)
|
|
48
|
+
save_feature(mutation.fetch(:state))
|
|
49
|
+
event = mutation[:event]
|
|
50
|
+
@events[name.to_s] << event if event
|
|
51
|
+
mutation
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def feature_events(name, limit: nil)
|
|
55
|
+
limited_events(@events[name.to_s], limit)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def global_events(limit: nil)
|
|
59
|
+
limited_events([], limit)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def feature_updated_at(_name)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def delete_feature_events(name)
|
|
66
|
+
@events.delete(name.to_s)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def limited_events(events, limit)
|
|
72
|
+
return events if limit.nil?
|
|
73
|
+
raise ArgumentError, "limit must be an Integer" unless limit.is_a?(Integer)
|
|
74
|
+
raise ArgumentError, "limit must be >= 0" if limit < 0
|
|
75
|
+
|
|
76
|
+
events.last(limit)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
16
79
|
|
|
17
80
|
RSpec.configure do |config|
|
|
18
81
|
config.example_status_persistence_file_path = '.rspec_status'
|
|
19
82
|
|
|
20
|
-
# config.disable_monkey_patching!
|
|
21
|
-
|
|
22
83
|
config.expect_with :rspec do |c|
|
|
23
84
|
c.syntax = :expect
|
|
24
85
|
end
|
|
25
|
-
|
|
26
|
-
config.before { $redis.flushdb }
|
|
27
86
|
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
require "date"
|
|
2
|
+
|
|
3
|
+
RSpec.shared_examples "a rollout feature backend" do
|
|
4
|
+
def empty_state(name)
|
|
5
|
+
Rollout::FeatureState.new(name: name, percentage: 0)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def json_metadata
|
|
9
|
+
{
|
|
10
|
+
"description" => "New navigation",
|
|
11
|
+
"updated_at" => 1,
|
|
12
|
+
"enabled" => true,
|
|
13
|
+
"ratio" => 0.5,
|
|
14
|
+
"owner" => nil,
|
|
15
|
+
"label" => :beta,
|
|
16
|
+
"released_at" => Time.utc(2026, 1, 1),
|
|
17
|
+
"day" => Date.new(2026, 1, 1),
|
|
18
|
+
"labels" => ["a", { "nested" => :ok }],
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "returns an empty state for a missing feature" do
|
|
23
|
+
state = backend.fetch_feature(:chat)
|
|
24
|
+
|
|
25
|
+
expect(state).to eq empty_state(:chat)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "preserves fetch_features order and duplicates" do
|
|
29
|
+
backend.save_feature(Rollout::FeatureState.new(name: :chat, percentage: 25))
|
|
30
|
+
|
|
31
|
+
states = backend.fetch_features([:chat, :missing, :chat])
|
|
32
|
+
|
|
33
|
+
expect(states.map(&:name)).to eq %w[chat missing chat]
|
|
34
|
+
expect(states.map(&:percentage)).to eq [25.0, 0.0, 25.0]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it "returns no features for an empty fetch_features request" do
|
|
38
|
+
expect(backend.fetch_features([])).to eq []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "does not share nested data with a fetched state" do
|
|
42
|
+
backend.save_feature(
|
|
43
|
+
Rollout::FeatureState.new(name: :chat, percentage: 0, data: { "labels" => ["a"] }),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
backend.fetch_feature(:chat).data["labels"] << "b"
|
|
47
|
+
|
|
48
|
+
expect(backend.fetch_feature(:chat).data).to eq("labels" => ["a"])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it "does not share nested data with a saved state" do
|
|
52
|
+
state = Rollout::FeatureState.new(name: :chat, percentage: 0, data: { "labels" => ["a"] })
|
|
53
|
+
backend.save_feature(state)
|
|
54
|
+
|
|
55
|
+
state.data["labels"] << "b"
|
|
56
|
+
|
|
57
|
+
expect(backend.fetch_feature(:chat).data).to eq("labels" => ["a"])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it "returns the same canonical metadata after save_feature" do
|
|
61
|
+
state = Rollout::FeatureState.new(name: :chat, percentage: 0, data: json_metadata)
|
|
62
|
+
backend.save_feature(state)
|
|
63
|
+
|
|
64
|
+
expect(backend.fetch_feature(:chat)).to eq state
|
|
65
|
+
expect(state.data["label"]).to eq "beta"
|
|
66
|
+
expect(state.data["labels"]).to eq ["a", { "nested" => "ok" }]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "tracks existence and names after save and delete" do
|
|
70
|
+
backend.save_feature(Rollout::FeatureState.new(name: :chat, percentage: 25))
|
|
71
|
+
|
|
72
|
+
expect(backend.feature_exists?(:chat)).to be_truthy
|
|
73
|
+
expect(backend.feature_names).to eq ["chat"]
|
|
74
|
+
|
|
75
|
+
backend.delete_feature(:chat)
|
|
76
|
+
|
|
77
|
+
expect(backend.feature_exists?(:chat)).to be_falsey
|
|
78
|
+
expect(backend.fetch_feature(:chat)).to eq empty_state(:chat)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "does not persist when mutate_feature's block raises" do
|
|
82
|
+
expect {
|
|
83
|
+
backend.mutate_feature(:chat) { raise "nope" }
|
|
84
|
+
}.to raise_error("nope")
|
|
85
|
+
|
|
86
|
+
expect(backend.feature_exists?(:chat)).to be_falsey
|
|
87
|
+
end
|
|
88
|
+
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:
|
|
4
|
+
version: 3.1.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:
|
|
11
|
+
date: 2026-09-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: observer
|
|
@@ -24,26 +24,6 @@ dependencies:
|
|
|
24
24
|
- - ">="
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
26
|
version: '0'
|
|
27
|
-
- !ruby/object:Gem::Dependency
|
|
28
|
-
name: redis
|
|
29
|
-
requirement: !ruby/object:Gem::Requirement
|
|
30
|
-
requirements:
|
|
31
|
-
- - ">="
|
|
32
|
-
- !ruby/object:Gem::Version
|
|
33
|
-
version: '4.0'
|
|
34
|
-
- - "<"
|
|
35
|
-
- !ruby/object:Gem::Version
|
|
36
|
-
version: '6'
|
|
37
|
-
type: :runtime
|
|
38
|
-
prerelease: false
|
|
39
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
40
|
-
requirements:
|
|
41
|
-
- - ">="
|
|
42
|
-
- !ruby/object:Gem::Version
|
|
43
|
-
version: '4.0'
|
|
44
|
-
- - "<"
|
|
45
|
-
- !ruby/object:Gem::Version
|
|
46
|
-
version: '6'
|
|
47
27
|
- !ruby/object:Gem::Dependency
|
|
48
28
|
name: rake
|
|
49
29
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -64,14 +44,14 @@ dependencies:
|
|
|
64
44
|
requirements:
|
|
65
45
|
- - ">="
|
|
66
46
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: '1
|
|
47
|
+
version: '2.1'
|
|
68
48
|
type: :development
|
|
69
49
|
prerelease: false
|
|
70
50
|
version_requirements: !ruby/object:Gem::Requirement
|
|
71
51
|
requirements:
|
|
72
52
|
- - ">="
|
|
73
53
|
- !ruby/object:Gem::Version
|
|
74
|
-
version: '1
|
|
54
|
+
version: '2.1'
|
|
75
55
|
- !ruby/object:Gem::Dependency
|
|
76
56
|
name: pry
|
|
77
57
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -142,14 +122,13 @@ dependencies:
|
|
|
142
122
|
- - '='
|
|
143
123
|
- !ruby/object:Gem::Version
|
|
144
124
|
version: '0.17'
|
|
145
|
-
description: Feature flippers
|
|
125
|
+
description: Feature flippers.
|
|
146
126
|
email:
|
|
147
127
|
- jamesgolick@gmail.com
|
|
148
128
|
executables: []
|
|
149
129
|
extensions: []
|
|
150
130
|
extra_rdoc_files: []
|
|
151
131
|
files:
|
|
152
|
-
- ".circleci/config.yml"
|
|
153
132
|
- ".github/workflows/release.yml"
|
|
154
133
|
- ".github/workflows/test.yml"
|
|
155
134
|
- ".gitignore"
|
|
@@ -161,15 +140,20 @@ files:
|
|
|
161
140
|
- LICENSE
|
|
162
141
|
- README.md
|
|
163
142
|
- Rakefile
|
|
143
|
+
- docs/upgrading-to-v3.md
|
|
164
144
|
- lib/rollout.rb
|
|
165
145
|
- lib/rollout/feature.rb
|
|
146
|
+
- lib/rollout/feature_state.rb
|
|
166
147
|
- lib/rollout/logging.rb
|
|
167
148
|
- lib/rollout/version.rb
|
|
168
149
|
- rollout.gemspec
|
|
169
150
|
- spec/rollout/feature_spec.rb
|
|
151
|
+
- spec/rollout/feature_state_spec.rb
|
|
170
152
|
- spec/rollout/logging_spec.rb
|
|
153
|
+
- spec/rollout/memory_backend_contract_spec.rb
|
|
171
154
|
- spec/rollout_spec.rb
|
|
172
155
|
- spec/spec_helper.rb
|
|
156
|
+
- spec/support/backend_contract.rb
|
|
173
157
|
homepage: https://github.com/FetLife/rollout
|
|
174
158
|
licenses:
|
|
175
159
|
- MIT
|
|
@@ -189,9 +173,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
189
173
|
- !ruby/object:Gem::Version
|
|
190
174
|
version: '0'
|
|
191
175
|
requirements: []
|
|
192
|
-
|
|
193
|
-
rubygems_version: 2.5.1
|
|
176
|
+
rubygems_version: 3.5.22
|
|
194
177
|
signing_key:
|
|
195
178
|
specification_version: 4
|
|
196
|
-
summary: Feature flippers
|
|
179
|
+
summary: Feature flippers.
|
|
197
180
|
test_files: []
|
data/.circleci/config.yml
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
version: 2.1
|
|
2
|
-
|
|
3
|
-
workflows:
|
|
4
|
-
main:
|
|
5
|
-
jobs:
|
|
6
|
-
- ruby33
|
|
7
|
-
- ruby31
|
|
8
|
-
- ruby32
|
|
9
|
-
- ruby30
|
|
10
|
-
- ruby27
|
|
11
|
-
- ruby26
|
|
12
|
-
- ruby25
|
|
13
|
-
- ruby24
|
|
14
|
-
|
|
15
|
-
executors:
|
|
16
|
-
ruby33:
|
|
17
|
-
docker:
|
|
18
|
-
- image: cimg/ruby:3.3
|
|
19
|
-
- image: cimg/redis:7.2
|
|
20
|
-
ruby32:
|
|
21
|
-
docker:
|
|
22
|
-
- image: cimg/ruby:3.2
|
|
23
|
-
- image: cimg/redis:7.2
|
|
24
|
-
ruby31:
|
|
25
|
-
docker:
|
|
26
|
-
- image: cimg/ruby:3.1
|
|
27
|
-
- image: cimg/redis:7.2
|
|
28
|
-
ruby30:
|
|
29
|
-
docker:
|
|
30
|
-
- image: cimg/ruby:3.0
|
|
31
|
-
- image: cimg/redis:7.2
|
|
32
|
-
ruby27:
|
|
33
|
-
docker:
|
|
34
|
-
- image: cimg/ruby:2.7
|
|
35
|
-
- image: cimg/redis:7.2
|
|
36
|
-
ruby26:
|
|
37
|
-
docker:
|
|
38
|
-
- image: cimg/ruby:2.7
|
|
39
|
-
- image: cimg/redis:7.2
|
|
40
|
-
ruby25:
|
|
41
|
-
docker:
|
|
42
|
-
- image: cimg/ruby:2.7
|
|
43
|
-
- image: cimg/redis:7.2
|
|
44
|
-
ruby24:
|
|
45
|
-
docker:
|
|
46
|
-
- image: cimg/ruby:2.4
|
|
47
|
-
- image: cimg/redis:7.2
|
|
48
|
-
|
|
49
|
-
commands:
|
|
50
|
-
test:
|
|
51
|
-
steps:
|
|
52
|
-
- run:
|
|
53
|
-
name: Bundle Install
|
|
54
|
-
command: bundle check --path vendor/bundle || bundle install
|
|
55
|
-
|
|
56
|
-
- run:
|
|
57
|
-
name: Run rspec
|
|
58
|
-
command: |
|
|
59
|
-
bundle exec rspec --format documentation --format RspecJunitFormatter --out test_results/rspec.xml
|
|
60
|
-
|
|
61
|
-
jobs:
|
|
62
|
-
ruby33:
|
|
63
|
-
executor: ruby33
|
|
64
|
-
steps:
|
|
65
|
-
- checkout
|
|
66
|
-
- test
|
|
67
|
-
|
|
68
|
-
- run:
|
|
69
|
-
name: Report Test Coverage
|
|
70
|
-
command: |
|
|
71
|
-
wget https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 -O cc-test-reporter
|
|
72
|
-
chmod +x cc-test-reporter
|
|
73
|
-
./cc-test-reporter format-coverage -t simplecov -o coverage/codeclimate.json coverage/.resultset.json
|
|
74
|
-
./cc-test-reporter upload-coverage -i coverage/codeclimate.json
|
|
75
|
-
|
|
76
|
-
- store_test_results:
|
|
77
|
-
path: test_results
|
|
78
|
-
|
|
79
|
-
ruby32:
|
|
80
|
-
executor: ruby30
|
|
81
|
-
steps:
|
|
82
|
-
- checkout
|
|
83
|
-
- test
|
|
84
|
-
|
|
85
|
-
ruby31:
|
|
86
|
-
executor: ruby30
|
|
87
|
-
steps:
|
|
88
|
-
- checkout
|
|
89
|
-
- test
|
|
90
|
-
|
|
91
|
-
ruby30:
|
|
92
|
-
executor: ruby30
|
|
93
|
-
steps:
|
|
94
|
-
- checkout
|
|
95
|
-
- test
|
|
96
|
-
|
|
97
|
-
ruby27:
|
|
98
|
-
executor: ruby27
|
|
99
|
-
steps:
|
|
100
|
-
- checkout
|
|
101
|
-
- test
|
|
102
|
-
|
|
103
|
-
ruby26:
|
|
104
|
-
executor: ruby27
|
|
105
|
-
steps:
|
|
106
|
-
- checkout
|
|
107
|
-
- test
|
|
108
|
-
|
|
109
|
-
ruby25:
|
|
110
|
-
executor: ruby27
|
|
111
|
-
steps:
|
|
112
|
-
- checkout
|
|
113
|
-
- test
|
|
114
|
-
|
|
115
|
-
ruby24:
|
|
116
|
-
executor: ruby24
|
|
117
|
-
steps:
|
|
118
|
-
- checkout
|
|
119
|
-
- test
|