rollout 2.6.2 → 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 +5 -5
- data/.github/workflows/release.yml +54 -28
- data/.github/workflows/test.yml +148 -6
- data/.gitignore +2 -0
- data/README.md +105 -25
- data/Rakefile +25 -2
- data/docs/upgrading-to-v3.md +110 -0
- data/lib/rollout/feature.rb +28 -29
- data/lib/rollout/feature_state.rb +44 -0
- data/lib/rollout/logging.rb +32 -76
- data/lib/rollout/version.rb +1 -1
- data/lib/rollout.rb +65 -52
- 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 +155 -586
- data/spec/spec_helper.rb +83 -10
- data/spec/support/backend_contract.rb +95 -0
- data/spec/support/history_contract.rb +174 -0
- data/spec/support/rollout_backend_integration.rb +77 -0
- metadata +14 -29
- data/.circleci/config.yml +0 -119
data/spec/spec_helper.rb
CHANGED
|
@@ -5,23 +5,96 @@ 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
|
+
@global_events = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def fetch_feature(name)
|
|
18
|
+
state = @features[name.to_s]
|
|
19
|
+
return Rollout::FeatureState.new(name: name, percentage: 0) unless state
|
|
20
|
+
|
|
21
|
+
state.deep_clone
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def fetch_features(names)
|
|
25
|
+
names.map { |name| fetch_feature(name) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def feature_names
|
|
29
|
+
@features.keys
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def feature_exists?(name)
|
|
33
|
+
@features.key?(name.to_s)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def save_feature(state)
|
|
37
|
+
@features[state.name] = state.deep_clone
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def delete_feature(name)
|
|
41
|
+
@features.delete(name.to_s)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def clear_features
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def mutate_feature(name)
|
|
48
|
+
mutation = yield fetch_feature(name)
|
|
49
|
+
save_feature(mutation.fetch(:state))
|
|
50
|
+
event = mutation[:event]
|
|
51
|
+
if event
|
|
52
|
+
@events[name.to_s] << event
|
|
53
|
+
@global_events << event if mutation[:global]
|
|
54
|
+
end
|
|
55
|
+
mutation
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def feature_events(name, limit: nil)
|
|
59
|
+
limited_events(@events[name.to_s], limit)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def global_events(limit: nil)
|
|
63
|
+
limited_events(@global_events, limit)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def feature_updated_at(_name)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def delete_feature_events(name)
|
|
70
|
+
@events.delete(name.to_s)
|
|
71
|
+
end
|
|
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
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def limited_events(events, limit)
|
|
86
|
+
return events if limit.nil?
|
|
87
|
+
raise ArgumentError, "limit must be an Integer" unless limit.is_a?(Integer)
|
|
88
|
+
raise ArgumentError, "limit must be >= 0" if limit < 0
|
|
89
|
+
|
|
90
|
+
events.last(limit)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
16
93
|
|
|
17
94
|
RSpec.configure do |config|
|
|
18
95
|
config.example_status_persistence_file_path = '.rspec_status'
|
|
19
96
|
|
|
20
|
-
# config.disable_monkey_patching!
|
|
21
|
-
|
|
22
97
|
config.expect_with :rspec do |c|
|
|
23
98
|
c.syntax = :expect
|
|
24
99
|
end
|
|
25
|
-
|
|
26
|
-
config.before { $redis.flushdb }
|
|
27
100
|
end
|
|
@@ -0,0 +1,95 @@
|
|
|
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 "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
|
+
|
|
48
|
+
it "does not share nested data with a fetched state" do
|
|
49
|
+
backend.save_feature(
|
|
50
|
+
Rollout::FeatureState.new(name: :chat, percentage: 0, data: { "labels" => ["a"] }),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
backend.fetch_feature(:chat).data["labels"] << "b"
|
|
54
|
+
|
|
55
|
+
expect(backend.fetch_feature(:chat).data).to eq("labels" => ["a"])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "does not share nested data with a saved state" do
|
|
59
|
+
state = Rollout::FeatureState.new(name: :chat, percentage: 0, data: { "labels" => ["a"] })
|
|
60
|
+
backend.save_feature(state)
|
|
61
|
+
|
|
62
|
+
state.data["labels"] << "b"
|
|
63
|
+
|
|
64
|
+
expect(backend.fetch_feature(:chat).data).to eq("labels" => ["a"])
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "returns the same canonical metadata after save_feature" do
|
|
68
|
+
state = Rollout::FeatureState.new(name: :chat, percentage: 0, data: json_metadata)
|
|
69
|
+
backend.save_feature(state)
|
|
70
|
+
|
|
71
|
+
expect(backend.fetch_feature(:chat)).to eq state
|
|
72
|
+
expect(state.data["label"]).to eq "beta"
|
|
73
|
+
expect(state.data["labels"]).to eq ["a", { "nested" => "ok" }]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "tracks existence and names after save and delete" do
|
|
77
|
+
backend.save_feature(Rollout::FeatureState.new(name: :chat, percentage: 25))
|
|
78
|
+
|
|
79
|
+
expect(backend.feature_exists?(:chat)).to be_truthy
|
|
80
|
+
expect(backend.feature_names).to eq ["chat"]
|
|
81
|
+
|
|
82
|
+
backend.delete_feature(:chat)
|
|
83
|
+
|
|
84
|
+
expect(backend.feature_exists?(:chat)).to be_falsey
|
|
85
|
+
expect(backend.fetch_feature(:chat)).to eq empty_state(:chat)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "does not persist when mutate_feature's block raises" do
|
|
89
|
+
expect {
|
|
90
|
+
backend.mutate_feature(:chat) { raise "nope" }
|
|
91
|
+
}.to raise_error("nope")
|
|
92
|
+
|
|
93
|
+
expect(backend.feature_exists?(:chat)).to be_falsey
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -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: 2.
|
|
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:
|
|
11
|
+
date: 2026-10-01 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,22 @@ 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
|
|
157
|
+
- spec/support/history_contract.rb
|
|
158
|
+
- spec/support/rollout_backend_integration.rb
|
|
173
159
|
homepage: https://github.com/FetLife/rollout
|
|
174
160
|
licenses:
|
|
175
161
|
- MIT
|
|
@@ -189,9 +175,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
189
175
|
- !ruby/object:Gem::Version
|
|
190
176
|
version: '0'
|
|
191
177
|
requirements: []
|
|
192
|
-
|
|
193
|
-
rubygems_version: 2.5.1
|
|
178
|
+
rubygems_version: 3.5.22
|
|
194
179
|
signing_key:
|
|
195
180
|
specification_version: 4
|
|
196
|
-
summary: Feature flippers
|
|
181
|
+
summary: Feature flippers.
|
|
197
182
|
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
|