flipper 1.3.4 → 1.4.2

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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -6
  3. data/.github/workflows/examples.yml +5 -4
  4. data/.github/workflows/release.yml +54 -0
  5. data/.superset/config.json +4 -0
  6. data/CLAUDE.md +93 -0
  7. data/README.md +4 -3
  8. data/examples/cloud/poll_interval/README.md +111 -0
  9. data/examples/cloud/poll_interval/client.rb +108 -0
  10. data/examples/cloud/poll_interval/server.rb +98 -0
  11. data/examples/expressions.rb +35 -11
  12. data/lib/flipper/adapter.rb +17 -1
  13. data/lib/flipper/adapters/actor_limit.rb +27 -1
  14. data/lib/flipper/adapters/cache_base.rb +21 -3
  15. data/lib/flipper/adapters/dual_write.rb +6 -2
  16. data/lib/flipper/adapters/failover.rb +9 -3
  17. data/lib/flipper/adapters/failsafe.rb +2 -2
  18. data/lib/flipper/adapters/http/client.rb +15 -4
  19. data/lib/flipper/adapters/http.rb +37 -2
  20. data/lib/flipper/adapters/instrumented.rb +2 -2
  21. data/lib/flipper/adapters/memoizable.rb +3 -3
  22. data/lib/flipper/adapters/memory.rb +1 -1
  23. data/lib/flipper/adapters/pstore.rb +1 -1
  24. data/lib/flipper/adapters/strict.rb +30 -0
  25. data/lib/flipper/adapters/sync/feature_synchronizer.rb +5 -1
  26. data/lib/flipper/adapters/sync/synchronizer.rb +13 -5
  27. data/lib/flipper/adapters/sync.rb +7 -3
  28. data/lib/flipper/cli.rb +51 -0
  29. data/lib/flipper/cloud/configuration.rb +3 -2
  30. data/lib/flipper/cloud/dsl.rb +2 -2
  31. data/lib/flipper/cloud/middleware.rb +1 -1
  32. data/lib/flipper/cloud/migrate.rb +71 -0
  33. data/lib/flipper/cloud/telemetry.rb +1 -1
  34. data/lib/flipper/cloud.rb +1 -0
  35. data/lib/flipper/dsl.rb +1 -1
  36. data/lib/flipper/expressions/feature_enabled.rb +34 -0
  37. data/lib/flipper/expressions/time.rb +8 -1
  38. data/lib/flipper/gates/expression.rb +2 -2
  39. data/lib/flipper/instrumentation/log_subscriber.rb +1 -1
  40. data/lib/flipper/poller.rb +49 -8
  41. data/lib/flipper/version.rb +1 -1
  42. data/lib/flipper.rb +17 -1
  43. data/spec/flipper/adapter_spec.rb +20 -0
  44. data/spec/flipper/adapters/actor_limit_spec.rb +55 -0
  45. data/spec/flipper/adapters/dual_write_spec.rb +13 -0
  46. data/spec/flipper/adapters/failover_spec.rb +12 -0
  47. data/spec/flipper/adapters/http_spec.rb +240 -0
  48. data/spec/flipper/adapters/strict_spec.rb +62 -4
  49. data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +12 -0
  50. data/spec/flipper/adapters/sync/synchronizer_spec.rb +87 -0
  51. data/spec/flipper/adapters/sync_spec.rb +13 -0
  52. data/spec/flipper/cli_spec.rb +51 -0
  53. data/spec/flipper/cloud/middleware_spec.rb +1 -1
  54. data/spec/flipper/cloud/migrate_spec.rb +160 -0
  55. data/spec/flipper/cloud/telemetry_spec.rb +1 -1
  56. data/spec/flipper/engine_spec.rb +2 -2
  57. data/spec/flipper/expressions/time_spec.rb +16 -0
  58. data/spec/flipper/gates/expression_spec.rb +82 -0
  59. data/spec/flipper/middleware/memoizer_spec.rb +37 -6
  60. data/spec/flipper/poller_spec.rb +347 -4
  61. data/spec/flipper_integration_spec.rb +133 -0
  62. data/spec/flipper_spec.rb +5 -0
  63. data/spec/spec_helper.rb +7 -0
  64. metadata +18 -112
  65. data/lib/flipper/expressions/duration.rb +0 -28
  66. data/spec/flipper/expressions/duration_spec.rb +0 -43
@@ -1,19 +1,23 @@
1
1
  require "flipper/poller"
2
+ require "flipper/adapters/http"
2
3
 
3
4
  RSpec.describe Flipper::Poller do
4
- let(:remote_adapter) { Flipper::Adapters::Memory.new }
5
- let(:remote) { Flipper.new(remote_adapter) }
5
+ let(:url) { "http://app.com/flipper" }
6
+ let(:remote_adapter) { Flipper::Adapters::Http.new(url: url) }
6
7
  let(:local) { Flipper.new(subject.adapter) }
7
8
 
8
9
  subject do
9
10
  described_class.new(
10
11
  remote_adapter: remote_adapter,
11
12
  start_automatically: false,
12
- interval: Float::INFINITY
13
+ interval: 3600 # 1 hour
13
14
  )
14
15
  end
15
16
 
16
17
  before do
18
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
19
+ .to_return(status: 200, body: JSON.generate(features: []))
20
+
17
21
  allow(subject).to receive(:loop).and_yield # Make loop just call once
18
22
  allow(subject).to receive(:sleep) # Disable sleep
19
23
  allow(Thread).to receive(:new).and_yield # Disable separate thread
@@ -28,12 +32,320 @@ RSpec.describe Flipper::Poller do
28
32
 
29
33
  describe "#sync" do
30
34
  it "syncs remote adapter to local adapter" do
31
- remote.enable :polling
35
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
36
+ .to_return(status: 200, body: JSON.generate(
37
+ features: [
38
+ {
39
+ key: "polling",
40
+ gates: [
41
+ { key: "boolean", value: true }
42
+ ]
43
+ }
44
+ ]
45
+ ))
32
46
 
33
47
  expect(local.enabled?(:polling)).to be(false)
34
48
  subject.sync
35
49
  expect(local.enabled?(:polling)).to be(true)
36
50
  end
51
+
52
+ context "when poll-shutdown header is present" do
53
+ before do
54
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
55
+ .to_return(
56
+ status: 200,
57
+ body: JSON.generate(
58
+ features: [
59
+ {
60
+ key: "polling",
61
+ gates: [
62
+ { key: "boolean", value: true }
63
+ ]
64
+ }
65
+ ]
66
+ ),
67
+ headers: { "poll-shutdown" => "true" }
68
+ )
69
+ end
70
+
71
+ it "stops the poller when poll-shutdown header is true" do
72
+ expect(subject).to receive(:stop).and_call_original
73
+ subject.sync
74
+ end
75
+
76
+ it "prevents poller from restarting after shutdown" do
77
+ subject.sync # This should trigger shutdown
78
+
79
+ # Try to start again - should be a no-op
80
+ expect(Thread).not_to receive(:new)
81
+ subject.start
82
+ end
83
+
84
+ it "instruments the shutdown_requested event" do
85
+ instrumenter = subject.instance_variable_get(:@instrumenter)
86
+
87
+ expect(instrumenter).to receive(:instrument).with(
88
+ "poller.#{Flipper::InstrumentationNamespace}",
89
+ { operation: :poll }
90
+ ).and_call_original
91
+
92
+ expect(instrumenter).to receive(:instrument).with(
93
+ "poller.#{Flipper::InstrumentationNamespace}",
94
+ { operation: :shutdown_requested }
95
+ ).and_call_original
96
+
97
+ expect(instrumenter).to receive(:instrument).with(
98
+ "poller.#{Flipper::InstrumentationNamespace}",
99
+ { operation: :stop }
100
+ )
101
+
102
+ subject.sync
103
+ end
104
+ end
105
+
106
+ context "when poll-shutdown header is present on error response" do
107
+ before do
108
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
109
+ .to_return(
110
+ status: 404,
111
+ body: JSON.generate({ error: "Not found" }),
112
+ headers: { "poll-shutdown" => "true" }
113
+ )
114
+ end
115
+
116
+ it "stops polling even when sync fails with error response" do
117
+ # sync will raise an error, but should still check shutdown header
118
+ expect { subject.sync }.to raise_error(Flipper::Adapters::Http::Error)
119
+
120
+ # Verify shutdown was triggered
121
+ expect(Thread).not_to receive(:new)
122
+ subject.start
123
+ end
124
+ end
125
+
126
+ context "when poll-shutdown header is false" do
127
+ before do
128
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
129
+ .to_return(
130
+ status: 200,
131
+ body: JSON.generate(
132
+ features: [
133
+ {
134
+ key: "polling",
135
+ gates: [
136
+ { key: "boolean", value: true }
137
+ ]
138
+ }
139
+ ]
140
+ ),
141
+ headers: { "poll-shutdown" => "false" }
142
+ )
143
+ end
144
+
145
+ it "does not stop the poller" do
146
+ expect(subject).not_to receive(:stop)
147
+ subject.sync
148
+ end
149
+ end
150
+
151
+ context "when poll-shutdown header is missing" do
152
+ before do
153
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
154
+ .to_return(
155
+ status: 200,
156
+ body: JSON.generate(
157
+ features: [
158
+ {
159
+ key: "polling",
160
+ gates: [
161
+ { key: "boolean", value: true }
162
+ ]
163
+ }
164
+ ]
165
+ )
166
+ )
167
+ end
168
+
169
+ it "does not stop the poller" do
170
+ expect(subject).not_to receive(:stop)
171
+ subject.sync
172
+ end
173
+ end
174
+
175
+ context "when poll-interval header is lower than initial interval" do
176
+ before do
177
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
178
+ .to_return(
179
+ status: 200,
180
+ body: JSON.generate(
181
+ features: [
182
+ {
183
+ key: "polling",
184
+ gates: [
185
+ { key: "boolean", value: true }
186
+ ]
187
+ }
188
+ ]
189
+ ),
190
+ headers: { "poll-interval" => "30" }
191
+ )
192
+ end
193
+
194
+ it "uses the initial interval as minimum" do
195
+ expect(subject.interval).to eq(3600.0)
196
+ subject.sync
197
+ expect(subject.interval).to eq(3600.0) # Keeps 3600 because it's the initial interval
198
+ end
199
+ end
200
+
201
+ context "when poll-interval header is below minimum" do
202
+ subject do
203
+ described_class.new(
204
+ remote_adapter: remote_adapter,
205
+ start_automatically: false,
206
+ interval: 10 # Set initial to minimum
207
+ )
208
+ end
209
+
210
+ before do
211
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
212
+ .to_return(
213
+ status: 200,
214
+ body: JSON.generate(
215
+ features: [
216
+ {
217
+ key: "polling",
218
+ gates: [
219
+ { key: "boolean", value: true }
220
+ ]
221
+ }
222
+ ]
223
+ ),
224
+ headers: { "poll-interval" => "5" }
225
+ )
226
+ end
227
+
228
+ it "enforces minimum poll interval" do
229
+ expect(subject.interval).to eq(10.0)
230
+ subject.sync
231
+ # Header says 5, minimum is 10, initial is 10, so max(5->10, 10) = 10
232
+ expect(subject.interval).to eq(Flipper::Poller::MINIMUM_POLL_INTERVAL)
233
+ end
234
+ end
235
+
236
+ context "when poll-interval header is higher than initial interval" do
237
+ subject do
238
+ described_class.new(
239
+ remote_adapter: remote_adapter,
240
+ start_automatically: false,
241
+ interval: 20
242
+ )
243
+ end
244
+
245
+ before do
246
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
247
+ .to_return(
248
+ status: 200,
249
+ body: JSON.generate(
250
+ features: [
251
+ {
252
+ key: "polling",
253
+ gates: [
254
+ { key: "boolean", value: true }
255
+ ]
256
+ }
257
+ ]
258
+ ),
259
+ headers: { "poll-interval" => "60" }
260
+ )
261
+ end
262
+
263
+ it "updates to the higher interval from header" do
264
+ expect(subject.interval).to eq(20.0)
265
+ subject.sync
266
+ expect(subject.interval).to eq(60.0) # Uses 60 because it's higher than initial 20
267
+ end
268
+ end
269
+
270
+ context "when poll-interval header can decrease back to initial interval" do
271
+ subject do
272
+ described_class.new(
273
+ remote_adapter: remote_adapter,
274
+ start_automatically: false,
275
+ interval: 10
276
+ )
277
+ end
278
+
279
+ before do
280
+ # First sync increases interval to 60
281
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
282
+ .to_return(
283
+ status: 200,
284
+ body: JSON.generate(
285
+ features: [
286
+ {
287
+ key: "polling",
288
+ gates: [
289
+ { key: "boolean", value: true }
290
+ ]
291
+ }
292
+ ]
293
+ ),
294
+ headers: { "poll-interval" => "60" }
295
+ ).times(1).then
296
+ .to_return(
297
+ status: 200,
298
+ body: JSON.generate(
299
+ features: [
300
+ {
301
+ key: "polling",
302
+ gates: [
303
+ { key: "boolean", value: true }
304
+ ]
305
+ }
306
+ ]
307
+ ),
308
+ headers: { "poll-interval" => "10" }
309
+ )
310
+ end
311
+
312
+ it "allows interval to go back down to initial after being increased" do
313
+ expect(subject.interval).to eq(10.0)
314
+
315
+ # First sync: header says 60, initial is 10, so use 60
316
+ subject.sync
317
+ expect(subject.interval).to eq(60.0)
318
+
319
+ # Second sync: header says 10, initial is 10, so use 10
320
+ subject.sync
321
+ expect(subject.interval).to eq(10.0)
322
+ end
323
+ end
324
+
325
+ context "when poll-interval header is missing" do
326
+ before do
327
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
328
+ .to_return(
329
+ status: 200,
330
+ body: JSON.generate(
331
+ features: [
332
+ {
333
+ key: "polling",
334
+ gates: [
335
+ { key: "boolean", value: true }
336
+ ]
337
+ }
338
+ ]
339
+ )
340
+ )
341
+ end
342
+
343
+ it "does not change the interval" do
344
+ original_interval = subject.interval
345
+ subject.sync
346
+ expect(subject.interval).to eq(original_interval)
347
+ end
348
+ end
37
349
  end
38
350
 
39
351
  describe "#start" do
@@ -43,5 +355,36 @@ RSpec.describe Flipper::Poller do
43
355
  expect(subject).to receive(:sync)
44
356
  subject.start
45
357
  end
358
+
359
+ context "after shutdown_requested" do
360
+ before do
361
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
362
+ .to_return(
363
+ status: 200,
364
+ body: JSON.generate(features: []),
365
+ headers: { "poll-shutdown" => "true" }
366
+ )
367
+ end
368
+
369
+ it "does not start when shutdown was requested" do
370
+ subject.sync # This triggers shutdown
371
+
372
+ expect(Thread).not_to receive(:new)
373
+ subject.start
374
+ end
375
+
376
+ it "allows starting after a fork" do
377
+ subject.sync # This triggers shutdown
378
+
379
+ # Simulate fork by changing PID
380
+ allow(Process).to receive(:pid).and_return(subject.instance_variable_get(:@pid) + 1)
381
+
382
+ # After fork, start should work again
383
+ expect(Thread).to receive(:new).and_yield
384
+ expect(subject).to receive(:loop).and_yield
385
+ expect(subject).to receive(:sync)
386
+ subject.start
387
+ end
388
+ end
46
389
  end
47
390
  end
@@ -600,6 +600,139 @@ RSpec.describe Flipper do
600
600
  end
601
601
  end
602
602
 
603
+ context "for FeatureEnabled" do
604
+ before do
605
+ @original_instance = Flipper.instance
606
+ Flipper.instance = flipper
607
+ end
608
+
609
+ after do
610
+ Flipper.instance = @original_instance
611
+ Thread.current[:flipper_evaluating_features] = nil
612
+ end
613
+
614
+ it "returns true when referenced feature is enabled" do
615
+ flipper.enable(:search_beta)
616
+ feature.enable Flipper.feature_enabled(:search_beta)
617
+
618
+ expect(feature.enabled?).to be(true)
619
+ end
620
+
621
+ it "returns false when referenced feature is disabled" do
622
+ feature.enable Flipper.feature_enabled(:search_beta)
623
+
624
+ expect(feature.enabled?).to be(false)
625
+ end
626
+
627
+ it "passes actor through to referenced feature" do
628
+ flipper.enable_actor(:search_beta, pitt)
629
+ feature.enable Flipper.feature_enabled(:search_beta)
630
+
631
+ expect(feature.enabled?(pitt)).to be(true)
632
+ expect(feature.enabled?(clooney)).to be(false)
633
+ end
634
+
635
+ it "returns false on circular dependency" do
636
+ feature.enable Flipper.feature_enabled(:other)
637
+ flipper[:other].enable Flipper.feature_enabled(:search)
638
+
639
+ expect(feature.enabled?).to be(false)
640
+ end
641
+
642
+ it "returns false on self-reference" do
643
+ feature.enable Flipper.feature_enabled(:search)
644
+
645
+ expect(feature.enabled?).to be(false)
646
+ end
647
+
648
+ it "cleans up thread-local state after evaluation" do
649
+ flipper.enable(:search_beta)
650
+ feature.enable Flipper.feature_enabled(:search_beta)
651
+
652
+ feature.enabled?
653
+ evaluating = Thread.current[:flipper_evaluating_features]
654
+ expect(evaluating.nil? || evaluating.empty?).to be(true)
655
+ end
656
+
657
+ it "cleans up thread-local state after self-reference" do
658
+ feature.enable Flipper.feature_enabled(:search)
659
+
660
+ feature.enabled?
661
+ evaluating = Thread.current[:flipper_evaluating_features]
662
+ expect(evaluating.nil? || evaluating.empty?).to be(true)
663
+ end
664
+
665
+ it "cleans up thread-local state after circular dependency" do
666
+ feature.enable Flipper.feature_enabled(:other)
667
+ flipper[:other].enable Flipper.feature_enabled(:search)
668
+
669
+ feature.enabled?
670
+ evaluating = Thread.current[:flipper_evaluating_features]
671
+ expect(evaluating.nil? || evaluating.empty?).to be(true)
672
+ end
673
+ end
674
+
675
+ context "for FeatureDisabled" do
676
+ before do
677
+ @original_instance = Flipper.instance
678
+ Flipper.instance = flipper
679
+ end
680
+
681
+ after do
682
+ Flipper.instance = @original_instance
683
+ Thread.current[:flipper_evaluating_features] = nil
684
+ end
685
+
686
+ it "returns true when referenced feature is disabled" do
687
+ feature.enable Flipper.feature_disabled(:old_checkout)
688
+
689
+ expect(feature.enabled?).to be(true)
690
+ end
691
+
692
+ it "returns false when referenced feature is enabled" do
693
+ flipper.enable(:old_checkout)
694
+ feature.enable Flipper.feature_disabled(:old_checkout)
695
+
696
+ expect(feature.enabled?).to be(false)
697
+ end
698
+ end
699
+
700
+ context "for FeatureEnabled composed with other expressions" do
701
+ before do
702
+ @original_instance = Flipper.instance
703
+ Flipper.instance = flipper
704
+ end
705
+
706
+ after do
707
+ Flipper.instance = @original_instance
708
+ Thread.current[:flipper_evaluating_features] = nil
709
+ end
710
+
711
+ it "works with Any" do
712
+ feature.enable Flipper.any(
713
+ Flipper.feature_enabled(:beta_program),
714
+ Flipper.property(:plan).eq("basic")
715
+ )
716
+
717
+ expect(feature.enabled?(basic_plan_actor)).to be(true)
718
+ expect(feature.enabled?(premium_plan_actor)).to be(false)
719
+
720
+ flipper.enable(:beta_program)
721
+ expect(feature.enabled?(premium_plan_actor)).to be(true)
722
+ end
723
+
724
+ it "works with All" do
725
+ flipper.enable(:basic_search)
726
+ feature.enable Flipper.all(
727
+ Flipper.feature_enabled(:basic_search),
728
+ Flipper.property(:plan).eq("basic")
729
+ )
730
+
731
+ expect(feature.enabled?(basic_plan_actor)).to be(true)
732
+ expect(feature.enabled?(premium_plan_actor)).to be(false)
733
+ end
734
+ end
735
+
603
736
  context "for All" do
604
737
  it "works" do
605
738
  true_actor = Flipper::Actor.new("User;1", {
data/spec/flipper_spec.rb CHANGED
@@ -218,6 +218,11 @@ RSpec.describe Flipper do
218
218
  expect(described_class.adapter).to eq(described_class.instance.adapter)
219
219
  end
220
220
 
221
+ it 'delegates adapter_stack to instance' do
222
+ expect(described_class.adapter_stack).to eq(described_class.instance.adapter_stack)
223
+ expect(described_class.adapter_stack).to eq("memoizable -> memory")
224
+ end
225
+
221
226
  it 'delegates memoize= to instance' do
222
227
  expect(described_class.adapter.memoizing?).to be(false)
223
228
  described_class.memoize = true
data/spec/spec_helper.rb CHANGED
@@ -39,6 +39,13 @@ RSpec.configure do |config|
39
39
  Flipper.configuration = nil
40
40
  end
41
41
 
42
+ config.after(:example) do
43
+ # Stop any pollers started during the test BEFORE WebMock clears stubs
44
+ # in its own after hook (RSpec runs after hooks in reverse registration
45
+ # order, so ours runs first since webmock/rspec was required before this).
46
+ Flipper::Poller.reset if defined?(Flipper::Poller)
47
+ end
48
+
42
49
  config.disable_monkey_patching!
43
50
 
44
51
  config.filter_run focus: true