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
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
require "date"
|
|
3
|
+
|
|
4
|
+
RSpec.describe Rollout::FeatureState do
|
|
5
|
+
def build_state(overrides = {})
|
|
6
|
+
described_class.new(**{
|
|
7
|
+
name: :chat,
|
|
8
|
+
percentage: 25,
|
|
9
|
+
users: [123, "456"],
|
|
10
|
+
groups: [:employees, "supporters"],
|
|
11
|
+
data: { description: "New navigation", "updated_at" => 1 },
|
|
12
|
+
}.merge(overrides))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def feature_for(state, options: {}, rollout: Object.new)
|
|
16
|
+
Rollout::Feature.new(state: state, rollout: rollout, options: options)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def json_value(value)
|
|
20
|
+
JSON.parse({ "value" => value }.to_json)["value"]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "normalizes persistence types" do
|
|
24
|
+
state = build_state
|
|
25
|
+
|
|
26
|
+
expect(state.name).to eq "chat"
|
|
27
|
+
expect(state.percentage).to eq 25.0
|
|
28
|
+
expect(state.users).to eq %w[123 456]
|
|
29
|
+
expect(state.groups).to eq %w[employees supporters]
|
|
30
|
+
expect(state.data).to eq("description" => "New navigation", "updated_at" => 1)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "does not share collections with the caller" do
|
|
34
|
+
users = ["123"]
|
|
35
|
+
groups = ["employees"]
|
|
36
|
+
nested = ["a"]
|
|
37
|
+
data = { "labels" => nested }
|
|
38
|
+
state = build_state(users: users, groups: groups, data: data)
|
|
39
|
+
|
|
40
|
+
users << "456"
|
|
41
|
+
groups << "supporters"
|
|
42
|
+
nested << "b"
|
|
43
|
+
data["labels"] << "c"
|
|
44
|
+
|
|
45
|
+
expect(state.users).to eq %w[123]
|
|
46
|
+
expect(state.groups).to eq %w[employees]
|
|
47
|
+
expect(state.data).to eq("labels" => ["a"])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "canonicalizes nested JSON-compatible metadata" do
|
|
51
|
+
state = build_state(
|
|
52
|
+
data: {
|
|
53
|
+
"description" => "New navigation",
|
|
54
|
+
"updated_at" => 1,
|
|
55
|
+
"enabled" => true,
|
|
56
|
+
"ratio" => 0.5,
|
|
57
|
+
"owner" => nil,
|
|
58
|
+
"kind" => :beta,
|
|
59
|
+
"labels" => ["a", { "nested" => :ok }],
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
expect(state.data).to eq(
|
|
64
|
+
"description" => "New navigation",
|
|
65
|
+
"updated_at" => 1,
|
|
66
|
+
"enabled" => true,
|
|
67
|
+
"ratio" => 0.5,
|
|
68
|
+
"owner" => nil,
|
|
69
|
+
"kind" => "beta",
|
|
70
|
+
"labels" => ["a", { "nested" => "ok" }],
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "canonicalizes Time, Date, and custom JSON values" do
|
|
75
|
+
custom = Object.new
|
|
76
|
+
def custom.to_json(*)
|
|
77
|
+
'"widget"'
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
released_at = Time.utc(2026, 1, 1)
|
|
81
|
+
day = Date.new(2026, 1, 1)
|
|
82
|
+
state = build_state(
|
|
83
|
+
data: {
|
|
84
|
+
"released_at" => released_at,
|
|
85
|
+
"day" => day,
|
|
86
|
+
"item" => custom,
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
expect(state.data).to eq(
|
|
91
|
+
"released_at" => json_value(released_at),
|
|
92
|
+
"day" => json_value(day),
|
|
93
|
+
"item" => "widget",
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "canonicalizes BigDecimal through JSON" do
|
|
98
|
+
require "bigdecimal"
|
|
99
|
+
|
|
100
|
+
amount = BigDecimal("1.5")
|
|
101
|
+
state = build_state(data: { "amount" => amount })
|
|
102
|
+
|
|
103
|
+
expect(state.data).to eq("amount" => json_value(amount))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it "rejects metadata that cannot be serialized as JSON" do
|
|
107
|
+
cyclic = {}
|
|
108
|
+
cyclic["self"] = cyclic
|
|
109
|
+
|
|
110
|
+
expect {
|
|
111
|
+
build_state(data: cyclic)
|
|
112
|
+
}.to raise_error(JSON::JSONError)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "rejects non-hash metadata" do
|
|
116
|
+
expect {
|
|
117
|
+
build_state(data: "nope")
|
|
118
|
+
}.to raise_error(ArgumentError, "data must be a Hash")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "does not share nested data or strings with its clone" do
|
|
122
|
+
description = +"hello"
|
|
123
|
+
state = build_state(data: { "description" => description, "labels" => ["a"] })
|
|
124
|
+
clone = state.deep_clone
|
|
125
|
+
|
|
126
|
+
clone.users << "999"
|
|
127
|
+
clone.groups << "admins"
|
|
128
|
+
clone.data["labels"] << "b"
|
|
129
|
+
clone.data["description"] << "!"
|
|
130
|
+
description << "?"
|
|
131
|
+
|
|
132
|
+
expect(state.users).to eq %w[123 456]
|
|
133
|
+
expect(state.groups).to eq %w[employees supporters]
|
|
134
|
+
expect(state.data).to eq("description" => "hello", "labels" => ["a"])
|
|
135
|
+
expect(clone.data["description"]).to eq "hello!"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
describe "Feature conversion" do
|
|
139
|
+
it "round-trips evaluation and metadata" do
|
|
140
|
+
rollout = Object.new
|
|
141
|
+
def rollout.active_in_group?(group, user)
|
|
142
|
+
group == :employees && user.id == 1
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
state = build_state(percentage: 20, users: ["42"], groups: ["employees"], data: { "description" => "New navigation" })
|
|
146
|
+
feature = feature_for(state, rollout: rollout)
|
|
147
|
+
restored = feature_for(feature.to_feature_state, rollout: rollout)
|
|
148
|
+
|
|
149
|
+
expect(feature.to_feature_state.name).to eq "chat"
|
|
150
|
+
expect(feature.to_feature_state.percentage).to eq 20.0
|
|
151
|
+
expect(feature.to_feature_state.users).to eq %w[42]
|
|
152
|
+
expect(feature.to_feature_state.groups).to eq %w[employees]
|
|
153
|
+
expect(feature.to_feature_state.data).to eq("description" => "New navigation")
|
|
154
|
+
expect(restored.name).to eq :chat
|
|
155
|
+
|
|
156
|
+
expect(restored.active?(double(id: 1))).to eq feature.active?(double(id: 1))
|
|
157
|
+
expect(restored.active?(double(id: 42))).to eq feature.active?(double(id: 42))
|
|
158
|
+
expect(restored.active?(double(id: 2))).to eq feature.active?(double(id: 2))
|
|
159
|
+
expect(restored.to_hash).to eq feature.to_hash
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it "canonicalizes JSON-serializable metadata when converting a Feature" do
|
|
163
|
+
released_at = Time.utc(2026, 1, 1)
|
|
164
|
+
feature = feature_for(build_state)
|
|
165
|
+
feature.data["released_at"] = released_at
|
|
166
|
+
feature.data["kind"] = :beta
|
|
167
|
+
|
|
168
|
+
expect(feature.to_feature_state.data).to eq(
|
|
169
|
+
"description" => "New navigation",
|
|
170
|
+
"updated_at" => 1,
|
|
171
|
+
"released_at" => json_value(released_at),
|
|
172
|
+
"kind" => "beta",
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it "does not share nested data with the source feature" do
|
|
177
|
+
feature = feature_for(build_state(data: { "labels" => ["a"] }))
|
|
178
|
+
state = feature.to_feature_state
|
|
179
|
+
|
|
180
|
+
feature.data["labels"] << "b"
|
|
181
|
+
|
|
182
|
+
expect(state.data).to eq("labels" => ["a"])
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it "does not share nested data with a reconstructed feature" do
|
|
186
|
+
nested = ["a"]
|
|
187
|
+
state = build_state(data: { "labels" => nested })
|
|
188
|
+
feature = feature_for(state)
|
|
189
|
+
|
|
190
|
+
feature.data["labels"] << "b"
|
|
191
|
+
nested << "c"
|
|
192
|
+
state.data["labels"] << "d"
|
|
193
|
+
|
|
194
|
+
expect(feature.data).to eq("labels" => ["a", "b"])
|
|
195
|
+
expect(state.data).to eq("labels" => ["a", "d"])
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it "uses sets when use_sets is enabled" do
|
|
199
|
+
feature = feature_for(build_state, options: { use_sets: true })
|
|
200
|
+
|
|
201
|
+
expect(feature.users).to eq %w[123 456].to_set
|
|
202
|
+
expect(feature.groups).to eq %i[employees supporters].to_set
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
it "preserves randomized percentage evaluation through a round trip" do
|
|
206
|
+
options = { randomize_percentage: true }
|
|
207
|
+
feature = feature_for(
|
|
208
|
+
Rollout::FeatureState.new(name: :chat, percentage: 20),
|
|
209
|
+
options: options,
|
|
210
|
+
)
|
|
211
|
+
restored = feature_for(feature.to_feature_state, options: options)
|
|
212
|
+
|
|
213
|
+
expect(restored.active?(double(id: 1))).to eq true
|
|
214
|
+
expect(restored.active?(double(id: 2))).to eq false
|
|
215
|
+
expect(restored.active?(double(id: 1))).to eq feature.active?(double(id: 1))
|
|
216
|
+
expect(restored.active?(double(id: 2))).to eq feature.active?(double(id: 2))
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -1,143 +1,114 @@
|
|
|
1
|
-
require
|
|
2
|
-
|
|
3
|
-
RSpec.describe
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
expect(first_event.name).to eq 'update'
|
|
18
|
-
expect(first_event.data).to eq(before: { percentage: 0 }, after: { percentage: 50 })
|
|
19
|
-
|
|
20
|
-
rollout.activate_percentage(feature, 75)
|
|
21
|
-
|
|
22
|
-
second_event = rollout.logging.last_event(feature)
|
|
23
|
-
|
|
24
|
-
expect(second_event.name).to eq 'update'
|
|
25
|
-
expect(second_event.data).to eq(before: { percentage: 50 }, after: { percentage: 75 })
|
|
26
|
-
|
|
27
|
-
rollout.activate_group(feature, :hipsters)
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe "Rollout::Logging" do
|
|
4
|
+
def build_feature(percentage: 0, groups: [], users: [], data: {})
|
|
5
|
+
Rollout::Feature.new(
|
|
6
|
+
state: Rollout::FeatureState.new(
|
|
7
|
+
name: :foo,
|
|
8
|
+
percentage: percentage,
|
|
9
|
+
users: users,
|
|
10
|
+
groups: groups,
|
|
11
|
+
data: data,
|
|
12
|
+
),
|
|
13
|
+
rollout: Object.new,
|
|
14
|
+
options: {},
|
|
15
|
+
)
|
|
16
|
+
end
|
|
28
17
|
|
|
29
|
-
|
|
18
|
+
let(:logger) { Rollout::Logging::Logger.new(adapter: Object.new) }
|
|
30
19
|
|
|
31
|
-
|
|
32
|
-
|
|
20
|
+
it "does not respond to logging unless enabled" do
|
|
21
|
+
rollout = Rollout.new(adapter: Object.new)
|
|
33
22
|
|
|
34
|
-
expect(rollout
|
|
23
|
+
expect(rollout).not_to respond_to :logging
|
|
35
24
|
end
|
|
36
25
|
|
|
37
|
-
context 'logging data changes' do
|
|
38
|
-
it 'logs changes' do
|
|
39
|
-
expect(rollout.logging.last_event(feature)).to be_nil
|
|
40
|
-
|
|
41
|
-
rollout.set_feature_data(feature, description: "foo")
|
|
42
26
|
|
|
43
|
-
|
|
27
|
+
it "builds an event for percentage changes" do
|
|
28
|
+
event = logger.event_for(build_feature, build_feature(percentage: 50))
|
|
44
29
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
expect(event.data).to eq(before: { "data.description": nil }, after: { "data.description": "foo" })
|
|
48
|
-
end
|
|
30
|
+
expect(event.name).to eq :update
|
|
31
|
+
expect(event.data).to eq(before: { percentage: 0 }, after: { percentage: 50 })
|
|
49
32
|
end
|
|
50
33
|
|
|
51
|
-
|
|
52
|
-
|
|
34
|
+
it "builds an event for metadata changes" do
|
|
35
|
+
event = logger.event_for(
|
|
36
|
+
build_feature,
|
|
37
|
+
build_feature(data: { "description" => "foo" }),
|
|
38
|
+
)
|
|
53
39
|
|
|
54
|
-
|
|
55
|
-
expect(rollout).not_to respond_to :logging
|
|
56
|
-
end
|
|
40
|
+
expect(event.data).to eq(before: { "data.description" => nil }, after: { "data.description" => "foo" })
|
|
57
41
|
end
|
|
58
42
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
it 'logs changes' do
|
|
63
|
-
expect(rollout.logging.last_event(feature)).to be_nil
|
|
64
|
-
|
|
65
|
-
rollout.activate_percentage(feature, 25)
|
|
66
|
-
|
|
67
|
-
first_event = rollout.logging.last_event(feature)
|
|
68
|
-
|
|
69
|
-
expect(first_event.name).to eq 'update'
|
|
70
|
-
expect(first_event.data).to eq(before: { percentage: 0 }, after: { percentage: 25 })
|
|
43
|
+
it "does not build an event when nothing changes" do
|
|
44
|
+
feature = build_feature(percentage: 25)
|
|
71
45
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
second_event = rollout.logging.last_event(feature)
|
|
75
|
-
|
|
76
|
-
expect(second_event.name).to eq 'update'
|
|
77
|
-
expect(second_event.data).to eq(before: { percentage: 25 }, after: { percentage: 30 })
|
|
78
|
-
|
|
79
|
-
expect(rollout.logging.events(feature)).to eq [second_event]
|
|
80
|
-
end
|
|
46
|
+
expect(logger.event_for(feature, feature)).to be_nil
|
|
81
47
|
end
|
|
82
48
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
rollout.logging.with_context(actor: current_user.nickname) do
|
|
88
|
-
rollout.activate_percentage(feature, 25)
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
event = rollout.logging.last_event(feature)
|
|
92
|
-
|
|
93
|
-
expect(event.name).to eq 'update'
|
|
94
|
-
expect(event.data).to eq(before: { percentage: 0 }, after: { percentage: 25 })
|
|
95
|
-
expect(event.context).to eq(actor: current_user.nickname)
|
|
49
|
+
it "adds context to the event" do
|
|
50
|
+
event = nil
|
|
51
|
+
logger.with_context(actor: "lester") do
|
|
52
|
+
event = logger.event_for(build_feature, build_feature(percentage: 25))
|
|
96
53
|
end
|
|
54
|
+
|
|
55
|
+
expect(event.context).to eq(actor: "lester")
|
|
97
56
|
end
|
|
98
57
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
58
|
+
it "does not build an event when logging is disabled" do
|
|
59
|
+
event = logger.without do
|
|
60
|
+
logger.event_for(build_feature, build_feature(percentage: 25))
|
|
61
|
+
end
|
|
103
62
|
|
|
104
|
-
|
|
105
|
-
|
|
63
|
+
expect(event).to be_nil
|
|
64
|
+
end
|
|
106
65
|
|
|
107
|
-
|
|
66
|
+
it "restores nested without state" do
|
|
67
|
+
nested_enabled = nil
|
|
68
|
+
outer_enabled = nil
|
|
108
69
|
|
|
109
|
-
|
|
70
|
+
logger.without do
|
|
71
|
+
logger.without {}
|
|
72
|
+
nested_enabled = logger.logging_enabled?
|
|
73
|
+
end
|
|
74
|
+
outer_enabled = logger.logging_enabled?
|
|
110
75
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
76
|
+
expect(nested_enabled).to eq false
|
|
77
|
+
expect(outer_enabled).to eq true
|
|
78
|
+
end
|
|
114
79
|
|
|
115
|
-
|
|
80
|
+
it "forwards a history limit to the backend" do
|
|
81
|
+
backend = double("backend")
|
|
82
|
+
logger = Rollout::Logging::Logger.new(adapter: backend)
|
|
83
|
+
events = [Object.new]
|
|
116
84
|
|
|
117
|
-
|
|
85
|
+
expect(backend).to receive(:feature_events).with(:chat, limit: 2).and_return(events)
|
|
86
|
+
expect(logger.events(:chat, limit: 2)).to eq events
|
|
87
|
+
end
|
|
118
88
|
|
|
119
|
-
|
|
89
|
+
it "requests one event for last_event" do
|
|
90
|
+
backend = double("backend")
|
|
91
|
+
logger = Rollout::Logging::Logger.new(adapter: backend)
|
|
92
|
+
event = Object.new
|
|
120
93
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
94
|
+
expect(backend).to receive(:feature_events).with(:chat, limit: 1).and_return([event])
|
|
95
|
+
expect(logger.last_event(:chat)).to eq event
|
|
96
|
+
end
|
|
124
97
|
|
|
125
|
-
|
|
98
|
+
it "forwards a global history limit to the backend" do
|
|
99
|
+
backend = double("backend")
|
|
100
|
+
logger = Rollout::Logging::Logger.new(adapter: backend)
|
|
101
|
+
events = [Object.new]
|
|
126
102
|
|
|
127
|
-
|
|
128
|
-
|
|
103
|
+
expect(backend).to receive(:global_events).with(limit: 2).and_return(events)
|
|
104
|
+
expect(logger.global_events(limit: 2)).to eq events
|
|
129
105
|
end
|
|
130
106
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
rollout.activate_percentage(feature, 25)
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
event = rollout.logging.last_event(feature)
|
|
107
|
+
it "delegates history deletion" do
|
|
108
|
+
backend = double("backend")
|
|
109
|
+
logger = Rollout::Logging::Logger.new(adapter: backend)
|
|
138
110
|
|
|
139
|
-
|
|
140
|
-
|
|
111
|
+
expect(backend).to receive(:delete_feature_events).with(:chat)
|
|
112
|
+
logger.delete(:chat)
|
|
141
113
|
end
|
|
142
114
|
end
|
|
143
|
-
|