rollout 2.6.1 → 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 +4 -4
- data/.github/workflows/release.yml +70 -0
- data/.github/workflows/test.yml +72 -0
- data/.gitignore +4 -0
- data/.ruby-version +1 -0
- data/README.md +91 -20
- data/Rakefile +58 -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 +18 -31
- 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
|
-
autorequire:
|
|
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,36 +122,43 @@ 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
|
-
- ".
|
|
132
|
+
- ".github/workflows/release.yml"
|
|
133
|
+
- ".github/workflows/test.yml"
|
|
153
134
|
- ".gitignore"
|
|
154
135
|
- ".rspec"
|
|
155
136
|
- ".rubocop.yml"
|
|
137
|
+
- ".ruby-version"
|
|
156
138
|
- ".travis.yml"
|
|
157
139
|
- Gemfile
|
|
158
140
|
- LICENSE
|
|
159
141
|
- README.md
|
|
160
142
|
- Rakefile
|
|
143
|
+
- docs/upgrading-to-v3.md
|
|
161
144
|
- lib/rollout.rb
|
|
162
145
|
- lib/rollout/feature.rb
|
|
146
|
+
- lib/rollout/feature_state.rb
|
|
163
147
|
- lib/rollout/logging.rb
|
|
164
148
|
- lib/rollout/version.rb
|
|
165
149
|
- rollout.gemspec
|
|
166
150
|
- spec/rollout/feature_spec.rb
|
|
151
|
+
- spec/rollout/feature_state_spec.rb
|
|
167
152
|
- spec/rollout/logging_spec.rb
|
|
153
|
+
- spec/rollout/memory_backend_contract_spec.rb
|
|
168
154
|
- spec/rollout_spec.rb
|
|
169
155
|
- spec/spec_helper.rb
|
|
156
|
+
- spec/support/backend_contract.rb
|
|
170
157
|
homepage: https://github.com/FetLife/rollout
|
|
171
158
|
licenses:
|
|
172
159
|
- MIT
|
|
173
160
|
metadata: {}
|
|
174
|
-
post_install_message:
|
|
161
|
+
post_install_message:
|
|
175
162
|
rdoc_options: []
|
|
176
163
|
require_paths:
|
|
177
164
|
- lib
|
|
@@ -186,8 +173,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
186
173
|
- !ruby/object:Gem::Version
|
|
187
174
|
version: '0'
|
|
188
175
|
requirements: []
|
|
189
|
-
rubygems_version: 3.5.
|
|
190
|
-
signing_key:
|
|
176
|
+
rubygems_version: 3.5.22
|
|
177
|
+
signing_key:
|
|
191
178
|
specification_version: 4
|
|
192
|
-
summary: Feature flippers
|
|
179
|
+
summary: Feature flippers.
|
|
193
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
|