retriable 4.1.1 → 5.0.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/AGENTS.md +15 -0
- data/CHANGELOG.md +95 -1
- data/Gemfile +3 -1
- data/README.md +82 -46
- data/benchmark/config_publication.rb +79 -0
- data/docs/adr/0001-copy-on-write-config-publication.md +124 -0
- data/docs/agents/domain.md +38 -0
- data/docs/agents/issue-tracker.md +45 -0
- data/docs/agents/triage-labels.md +17 -0
- data/docs/migration.md +84 -0
- data/lib/retriable/config.rb +129 -6
- data/lib/retriable/core_ext/kernel.rb +2 -0
- data/lib/retriable/exponential_backoff.rb +13 -5
- data/lib/retriable/version.rb +1 -1
- data/lib/retriable.rb +107 -17
- data/sig/retriable.rbs +2 -2
- data/spec/config_spec.rb +178 -0
- data/spec/retriable_spec.rb +339 -16
- metadata +9 -2
data/spec/config_spec.rb
CHANGED
|
@@ -180,4 +180,182 @@ describe Retriable::Config do
|
|
|
180
180
|
end
|
|
181
181
|
end
|
|
182
182
|
end
|
|
183
|
+
|
|
184
|
+
context "context structure validation" do
|
|
185
|
+
it "rejects a context whose options contain a nested :contexts key" do
|
|
186
|
+
expect { described_class.new(contexts: { api: { contexts: {} } }) }
|
|
187
|
+
.to raise_error(ArgumentError, /contexts is not a valid option/)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it "rejects a context with an unknown option key" do
|
|
191
|
+
expect { described_class.new(contexts: { api: { does_not_exist: 1 } }) }
|
|
192
|
+
.to raise_error(ArgumentError, /does_not_exist is not a valid option/)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it "validates context structure even when intervals is provided" do
|
|
196
|
+
expect { described_class.new(intervals: [0.1], contexts: { api: { contexts: {} } }) }
|
|
197
|
+
.to raise_error(ArgumentError, /contexts is not a valid option/)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it "accepts a non-Hash context value (treated as empty options)" do
|
|
201
|
+
expect { described_class.new(contexts: { broken: nil }) }.not_to raise_error
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
it "accepts nil contexts" do
|
|
205
|
+
expect { described_class.new(contexts: nil) }.not_to raise_error
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it "accepts a valid context" do
|
|
209
|
+
expect { described_class.new(contexts: { api: { tries: 3, base_interval: 1.0 } }) }.not_to raise_error
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
context "#dup (copy-on-write isolation)" do
|
|
214
|
+
it "deep-copies contexts so mutating the copy leaves the original intact" do
|
|
215
|
+
original = described_class.new(contexts: { sql: { tries: 1 } })
|
|
216
|
+
copy = original.dup
|
|
217
|
+
|
|
218
|
+
copy.contexts[:http] = { tries: 2 }
|
|
219
|
+
copy.contexts[:sql][:tries] = 99
|
|
220
|
+
|
|
221
|
+
expect(original.contexts).to eq(sql: { tries: 1 })
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
it "deep-copies on and intervals collections" do
|
|
225
|
+
original = described_class.new(on: [StandardError], intervals: [1, 2])
|
|
226
|
+
copy = original.dup
|
|
227
|
+
|
|
228
|
+
copy.on << ArgumentError
|
|
229
|
+
copy.intervals << 3
|
|
230
|
+
|
|
231
|
+
expect(original.on).to eq([StandardError])
|
|
232
|
+
expect(original.intervals).to eq([1, 2])
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
it "preserves a non-collection on value (Exception class) without duping it" do
|
|
236
|
+
original = described_class.new(on: StandardError)
|
|
237
|
+
expect(original.dup.on).to be(StandardError)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
it "deep-copies a Hash on value so the copy is a distinct hash" do
|
|
241
|
+
original = described_class.new(on: { StandardError => /boom/ })
|
|
242
|
+
copy = original.dup
|
|
243
|
+
|
|
244
|
+
copy.on[ArgumentError] = /other/
|
|
245
|
+
|
|
246
|
+
expect(original.on).to eq(StandardError => /boom/)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it "deep-copies mutable values nested inside a context's options" do
|
|
250
|
+
original = described_class.new(contexts: { api: { intervals: [1, 2] } })
|
|
251
|
+
copy = original.dup
|
|
252
|
+
|
|
253
|
+
copy.contexts[:api][:intervals] << 3
|
|
254
|
+
|
|
255
|
+
expect(original.contexts[:api][:intervals]).to eq([1, 2])
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it "deep-copies the collection values of a Hash on" do
|
|
259
|
+
original = described_class.new(on: { StandardError => [/boom/] })
|
|
260
|
+
copy = original.dup
|
|
261
|
+
|
|
262
|
+
copy.on[StandardError] << /bang/
|
|
263
|
+
|
|
264
|
+
expect(original.on[StandardError]).to eq([/boom/])
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
it "preserves the container class of a Hash subclass" do
|
|
268
|
+
subclass = Class.new(Hash)
|
|
269
|
+
contexts = subclass.new
|
|
270
|
+
contexts[:api] = { tries: 1 }
|
|
271
|
+
|
|
272
|
+
expect(described_class.new(contexts: contexts).dup.contexts).to be_a(subclass)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
it "preserves a contexts default_proc so absent keys still resolve" do
|
|
276
|
+
contexts = Hash.new { |hash, key| hash[key] = { tries: 7 } }
|
|
277
|
+
copy = described_class.new(contexts: contexts).dup
|
|
278
|
+
|
|
279
|
+
expect(copy.contexts[:never_set]).to eq(tries: 7)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
it "deep-copies a mutable Hash default" do
|
|
283
|
+
fallback = []
|
|
284
|
+
contexts = Hash.new(fallback)
|
|
285
|
+
copy = described_class.new(contexts: contexts).dup
|
|
286
|
+
|
|
287
|
+
copy.contexts.default << :copy_only
|
|
288
|
+
|
|
289
|
+
expect(copy.contexts.default).not_to equal(fallback)
|
|
290
|
+
expect(fallback).to be_empty
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
it "preserves a self-referential Hash default" do
|
|
294
|
+
contexts = {}
|
|
295
|
+
contexts.default = contexts
|
|
296
|
+
copy = described_class.new(contexts: contexts).dup
|
|
297
|
+
|
|
298
|
+
expect(copy.contexts.default).to equal(copy.contexts)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
it "unfreezes copied containers so a configure block can mutate them" do
|
|
302
|
+
original = described_class.new(contexts: { api: { tries: 1 } }.freeze)
|
|
303
|
+
|
|
304
|
+
expect { original.dup.contexts[:added] = { tries: 2 } }.not_to raise_error
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
it "terminates on a self-referential contexts structure" do
|
|
308
|
+
original = described_class.new
|
|
309
|
+
original.contexts[:api] = { tries: 1 }
|
|
310
|
+
original.contexts[:api][:cycle] = original.contexts
|
|
311
|
+
|
|
312
|
+
copy = original.dup
|
|
313
|
+
|
|
314
|
+
expect(copy.contexts).not_to equal(original.contexts)
|
|
315
|
+
expect(copy.contexts[:api][:cycle]).to equal(copy.contexts)
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
context "#freeze (published snapshot immutability)" do
|
|
320
|
+
it "rejects mutation of the config itself" do
|
|
321
|
+
config = described_class.new.freeze
|
|
322
|
+
|
|
323
|
+
expect { config.tries = 99 }.to raise_error(FrozenError)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
it "rejects mutation one level down, inside contexts" do
|
|
327
|
+
config = described_class.new(contexts: { api: { tries: 1 } }).freeze
|
|
328
|
+
|
|
329
|
+
expect { config.contexts[:api][:tries] = 99 }.to raise_error(FrozenError)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
it "rejects mutation of the on collection" do
|
|
333
|
+
config = described_class.new(on: [StandardError]).freeze
|
|
334
|
+
|
|
335
|
+
expect { config.on << ArgumentError }.to raise_error(FrozenError)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
it "freezes a mutable Hash default" do
|
|
339
|
+
fallback = []
|
|
340
|
+
contexts = Hash.new(fallback)
|
|
341
|
+
contexts[:api] = { tries: 1 }
|
|
342
|
+
config = described_class.new(contexts: contexts).freeze
|
|
343
|
+
|
|
344
|
+
expect(config.contexts.default).to be_frozen
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
it "leaves leaves such as procs untouched" do
|
|
348
|
+
handler = ->(_exception) { true }
|
|
349
|
+
described_class.new(retry_if: handler).freeze
|
|
350
|
+
|
|
351
|
+
expect(handler).not_to be_frozen
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
it "is idempotent" do
|
|
355
|
+
config = described_class.new.freeze
|
|
356
|
+
|
|
357
|
+
expect { config.freeze }.not_to raise_error
|
|
358
|
+
expect(config.freeze).to equal(config)
|
|
359
|
+
end
|
|
360
|
+
end
|
|
183
361
|
end
|
data/spec/retriable_spec.rb
CHANGED
|
@@ -8,7 +8,10 @@ describe Retriable do
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
before(:each) do
|
|
11
|
-
|
|
11
|
+
# Reset to a pristine published snapshot, matching what load-time init
|
|
12
|
+
# publishes. Resetting to nil instead would force #config to carry a
|
|
13
|
+
# nil-guard that only the suite can reach.
|
|
14
|
+
described_class.instance_variable_set(:@config, Retriable::Config.new.freeze)
|
|
12
15
|
Thread.current.thread_variable_set(Retriable::OVERRIDE_THREAD_KEY, nil)
|
|
13
16
|
described_class.configure { |c| c.sleep_disabled = true }
|
|
14
17
|
@tries = 0
|
|
@@ -25,6 +28,31 @@ describe Retriable do
|
|
|
25
28
|
raise exception_class, "#{exception_class} occurred"
|
|
26
29
|
end
|
|
27
30
|
|
|
31
|
+
# Wall clock appears in the concurrency specs only as a deadlock backstop,
|
|
32
|
+
# never as a timing assertion. Every worker they start finishes in
|
|
33
|
+
# milliseconds, so this budget is approached only when a regression leaves a
|
|
34
|
+
# thread genuinely stuck.
|
|
35
|
+
let(:deadlock_backstop_seconds) { 10 }
|
|
36
|
+
|
|
37
|
+
# Joins each thread within the backstop, failing the example instead of hanging
|
|
38
|
+
# the suite when one never finishes. A bare Thread#join turns a serialization
|
|
39
|
+
# regression into a run that never terminates, which CI reports as a timeout
|
|
40
|
+
# with no failing example to point at.
|
|
41
|
+
#
|
|
42
|
+
# The kill matters for the examples that follow: Ruby releases a Mutex held by
|
|
43
|
+
# a killed thread, so one stuck worker cannot wedge every later `configure`.
|
|
44
|
+
# Kill is asynchronous, so wait briefly for it to land before failing —
|
|
45
|
+
# otherwise the mutex is still held when the next example starts.
|
|
46
|
+
def join_without_deadlock(*threads)
|
|
47
|
+
threads.flatten.compact.each do |thread|
|
|
48
|
+
next if thread.join(deadlock_backstop_seconds)
|
|
49
|
+
|
|
50
|
+
thread.kill
|
|
51
|
+
thread.join(1)
|
|
52
|
+
raise "a worker thread did not finish within #{deadlock_backstop_seconds}s; suspect a deadlock"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
28
56
|
context "global scope extension" do
|
|
29
57
|
it "cannot be called in the global scope without requiring the core_ext/kernel" do
|
|
30
58
|
script = "require 'retriable'; begin; retriable {}; rescue NoMethodError; exit 0; end; exit 1"
|
|
@@ -52,9 +80,9 @@ describe Retriable do
|
|
|
52
80
|
|
|
53
81
|
# These two specs lock in the anonymous block forwarding (`&`) semantics
|
|
54
82
|
# across both delegation layers: Kernel#retriable_with_context ->
|
|
55
|
-
# Retriable.with_context. If the `&` is dropped at either layer, the
|
|
56
|
-
#
|
|
57
|
-
#
|
|
83
|
+
# Retriable.with_context. If the `&` is dropped at either layer, the block
|
|
84
|
+
# is not forwarded and the `block_given?` guard in with_context raises
|
|
85
|
+
# ArgumentError instead of running the block.
|
|
58
86
|
it "forwards a block through Kernel#retriable_with_context" do
|
|
59
87
|
require_relative "../lib/retriable/core_ext/kernel"
|
|
60
88
|
Retriable.configure { |c| c.contexts[:sql] = { tries: 1 } }
|
|
@@ -64,13 +92,23 @@ describe Retriable do
|
|
|
64
92
|
expect(@tries).to eq(1)
|
|
65
93
|
end
|
|
66
94
|
|
|
67
|
-
it "
|
|
95
|
+
it "raises an ArgumentError when Kernel#retriable_with_context is called without a block" do
|
|
68
96
|
require_relative "../lib/retriable/core_ext/kernel"
|
|
69
97
|
Retriable.configure { |c| c.contexts[:sql] = { tries: 1 } }
|
|
70
98
|
|
|
71
|
-
expect
|
|
99
|
+
expect { retriable_with_context(:sql) }
|
|
100
|
+
.to raise_error(ArgumentError, /with_context requires a block/)
|
|
72
101
|
expect(@tries).to eq(0)
|
|
73
102
|
end
|
|
103
|
+
|
|
104
|
+
it "is not callable with an explicit receiver" do
|
|
105
|
+
require_relative "../lib/retriable/core_ext/kernel"
|
|
106
|
+
|
|
107
|
+
expect { "foo".retriable { increment_tries } }
|
|
108
|
+
.to raise_error(NoMethodError, /private method/)
|
|
109
|
+
expect { "foo".retriable_with_context(:sql) { increment_tries } }
|
|
110
|
+
.to raise_error(NoMethodError, /private method/)
|
|
111
|
+
end
|
|
74
112
|
end
|
|
75
113
|
|
|
76
114
|
context "#retriable" do
|
|
@@ -758,6 +796,242 @@ describe Retriable do
|
|
|
758
796
|
it "raises NoMethodError on invalid configuration" do
|
|
759
797
|
expect { described_class.configure { |c| c.does_not_exist = 123 } }.to raise_error(NoMethodError)
|
|
760
798
|
end
|
|
799
|
+
|
|
800
|
+
it "returns the configure block's result" do
|
|
801
|
+
result = described_class.configure do |c|
|
|
802
|
+
c.tries = 7
|
|
803
|
+
:configured
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
expect(result).to eq(:configured)
|
|
807
|
+
expect(described_class.config.tries).to eq(7)
|
|
808
|
+
end
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
context "#configure thread safety (copy-on-write)" do
|
|
812
|
+
it "eagerly initializes @config at load time, before any configure/config call" do
|
|
813
|
+
script = "require 'retriable'; " \
|
|
814
|
+
"exit(Retriable.instance_variable_get(:@config).is_a?(Retriable::Config) ? 0 : 1)"
|
|
815
|
+
expect(system(RbConfig.ruby, "-Ilib", "-e", script)).to be(true)
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
it "publishes a new Config object on configure instead of mutating in place" do
|
|
819
|
+
before = described_class.config
|
|
820
|
+
described_class.configure { |c| c.tries = 7 }
|
|
821
|
+
after = described_class.config
|
|
822
|
+
|
|
823
|
+
expect(after).not_to equal(before)
|
|
824
|
+
expect(after.tries).to eq(7)
|
|
825
|
+
expect(before.tries).not_to eq(7)
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
it "keeps an already-captured snapshot stable across later configures" do
|
|
829
|
+
described_class.configure { |c| c.contexts[:sql] = { tries: 1 } }
|
|
830
|
+
snapshot = described_class.config
|
|
831
|
+
|
|
832
|
+
described_class.configure { |c| c.contexts[:http] = { tries: 2 } }
|
|
833
|
+
described_class.configure { |c| c.contexts[:sql][:tries] = 99 }
|
|
834
|
+
|
|
835
|
+
expect(snapshot.contexts).to eq(sql: { tries: 1 })
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
it "publishes a frozen snapshot, so direct mutation fails loudly" do
|
|
839
|
+
described_class.configure { |c| c.contexts[:api] = { tries: 1 } }
|
|
840
|
+
|
|
841
|
+
expect { described_class.config.tries = 99 }.to raise_error(FrozenError)
|
|
842
|
+
expect { described_class.config.contexts[:api][:tries] = 99 }.to raise_error(FrozenError)
|
|
843
|
+
expect(described_class.config.contexts[:api]).to eq(tries: 1)
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
it "still hands the configure block a mutable candidate" do
|
|
847
|
+
expect do
|
|
848
|
+
described_class.configure do |c|
|
|
849
|
+
c.contexts[:api] = { tries: 1 }
|
|
850
|
+
c.contexts[:api][:tries] = 2
|
|
851
|
+
c.on = [StandardError]
|
|
852
|
+
c.on << ArgumentError
|
|
853
|
+
end
|
|
854
|
+
end.not_to raise_error
|
|
855
|
+
|
|
856
|
+
expect(described_class.config.contexts[:api]).to eq(tries: 2)
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
it "does not freeze collections the caller still owns" do
|
|
860
|
+
caller_owned = [StandardError]
|
|
861
|
+
|
|
862
|
+
described_class.configure { |c| c.on = caller_owned }
|
|
863
|
+
|
|
864
|
+
expect(caller_owned).not_to be_frozen
|
|
865
|
+
expect(described_class.config.on).to be_frozen
|
|
866
|
+
expect(described_class.config.on).not_to equal(caller_owned)
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
it "owns and freezes a copy of a mutable Hash default" do
|
|
870
|
+
fallback = []
|
|
871
|
+
contexts = Hash.new(fallback)
|
|
872
|
+
contexts[:api] = { tries: 1 }
|
|
873
|
+
|
|
874
|
+
described_class.configure { |c| c.contexts = contexts }
|
|
875
|
+
published_default = described_class.config.contexts.default
|
|
876
|
+
|
|
877
|
+
expect(published_default).not_to equal(fallback)
|
|
878
|
+
expect(published_default).to be_frozen
|
|
879
|
+
expect(fallback).not_to be_frozen
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
it "does not let a published default leak mutations into the caller's object" do
|
|
883
|
+
fallback = []
|
|
884
|
+
described_class.configure { |c| c.contexts = Hash.new(fallback) }
|
|
885
|
+
|
|
886
|
+
expect { described_class.config.contexts[:absent] << :leaked }.to raise_error(FrozenError)
|
|
887
|
+
expect(fallback).to be_empty
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
it "does not drop updates when configured concurrently from many threads" do
|
|
891
|
+
keys = (0...50).map { |i| :"ctx_#{i}" }
|
|
892
|
+
release = Queue.new
|
|
893
|
+
|
|
894
|
+
threads = keys.map do |key|
|
|
895
|
+
Thread.new do
|
|
896
|
+
release.pop
|
|
897
|
+
described_class.configure { |c| c.contexts[key] = { tries: 1 } }
|
|
898
|
+
end
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
keys.size.times { release << true }
|
|
902
|
+
join_without_deadlock(threads)
|
|
903
|
+
|
|
904
|
+
expect(described_class.config.contexts.keys).to match_array(keys)
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
it "lets nested configure calls join the outer transaction" do
|
|
908
|
+
inner_candidate = nil
|
|
909
|
+
|
|
910
|
+
result = described_class.configure do |outer|
|
|
911
|
+
outer.tries = 7
|
|
912
|
+
nested_result = described_class.configure do |inner|
|
|
913
|
+
inner_candidate = inner
|
|
914
|
+
inner.base_interval = 1
|
|
915
|
+
:nested_result
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
expect(nested_result).to eq(:nested_result)
|
|
919
|
+
expect(inner_candidate).to equal(outer)
|
|
920
|
+
:outer_result
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
expect(result).to eq(:outer_result)
|
|
924
|
+
expect(described_class.config.tries).to eq(7)
|
|
925
|
+
expect(described_class.config.base_interval).to eq(1)
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
it "rolls back nested changes when the outer configure raises" do
|
|
929
|
+
described_class.configure { |c| c.tries = 4 }
|
|
930
|
+
|
|
931
|
+
expect do
|
|
932
|
+
described_class.configure do |outer|
|
|
933
|
+
outer.tries = 7
|
|
934
|
+
described_class.configure { |inner| inner.base_interval = 1 }
|
|
935
|
+
raise "outer failed"
|
|
936
|
+
end
|
|
937
|
+
end.to raise_error(RuntimeError, "outer failed")
|
|
938
|
+
|
|
939
|
+
expect(described_class.config.tries).to eq(4)
|
|
940
|
+
expect(described_class.config.base_interval).to eq(0.5)
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
it "lets configure calls from a fiber join the outer transaction" do
|
|
944
|
+
described_class.configure do |outer|
|
|
945
|
+
fiber_candidate = Fiber.new do
|
|
946
|
+
described_class.configure do |inner|
|
|
947
|
+
inner.tries = 9
|
|
948
|
+
inner
|
|
949
|
+
end
|
|
950
|
+
end.resume
|
|
951
|
+
|
|
952
|
+
expect(fiber_candidate).to equal(outer)
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
expect(described_class.config.tries).to eq(9)
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
it "exposes the in-progress config to the configuring thread" do
|
|
959
|
+
described_class.configure do |c|
|
|
960
|
+
c.tries = 7
|
|
961
|
+
expect(described_class.config.tries).to eq(7)
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
expect(described_class.config.tries).to eq(7)
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
it "uses the in-progress config for retriable" do
|
|
968
|
+
attempts = 0
|
|
969
|
+
|
|
970
|
+
described_class.configure do |c|
|
|
971
|
+
c.tries = 1
|
|
972
|
+
c.sleep_disabled = true
|
|
973
|
+
|
|
974
|
+
expect do
|
|
975
|
+
described_class.retriable do
|
|
976
|
+
attempts += 1
|
|
977
|
+
raise StandardError
|
|
978
|
+
end
|
|
979
|
+
end.to raise_error(StandardError)
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
expect(attempts).to eq(1)
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
it "uses candidate-only contexts for with_context" do
|
|
986
|
+
described_class.configure do |c|
|
|
987
|
+
c.contexts[:candidate] = { tries: 1 }
|
|
988
|
+
|
|
989
|
+
expect(described_class.with_context(:candidate) { :found }).to eq(:found)
|
|
990
|
+
end
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
it "shares the in-progress config with fibers in the configuring thread" do
|
|
994
|
+
described_class.configure do |c|
|
|
995
|
+
c.tries = 7
|
|
996
|
+
|
|
997
|
+
expect(Fiber.new { described_class.config.tries }.resume).to eq(7)
|
|
998
|
+
end
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
it "does not block readers while configure is in progress" do
|
|
1002
|
+
published_tries = described_class.config.tries
|
|
1003
|
+
published_base_interval = described_class.config.base_interval
|
|
1004
|
+
configuring = Queue.new
|
|
1005
|
+
release = Queue.new
|
|
1006
|
+
writer = Thread.new do
|
|
1007
|
+
described_class.configure do |c|
|
|
1008
|
+
c.tries = published_tries + 1
|
|
1009
|
+
described_class.configure { |inner| inner.base_interval = published_base_interval + 1 }
|
|
1010
|
+
configuring << true
|
|
1011
|
+
release.pop
|
|
1012
|
+
end
|
|
1013
|
+
end
|
|
1014
|
+
reader = nil
|
|
1015
|
+
|
|
1016
|
+
begin
|
|
1017
|
+
configuring.pop
|
|
1018
|
+
reader = Thread.new do
|
|
1019
|
+
snapshot = described_class.config
|
|
1020
|
+
[snapshot.tries, snapshot.base_interval]
|
|
1021
|
+
end
|
|
1022
|
+
# The timeout is a deadlock backstop, not the assertion. A reader that
|
|
1023
|
+
# is not blocked returns immediately, so this budget is only ever
|
|
1024
|
+
# approached if #config starts waiting on the in-progress #configure.
|
|
1025
|
+
expect(reader.join(deadlock_backstop_seconds)).to be(reader),
|
|
1026
|
+
"reader blocked while configure was in progress"
|
|
1027
|
+
# Thread#value re-raises anything the reader raised, so a broken reader
|
|
1028
|
+
# fails loudly instead of hanging on an empty queue.
|
|
1029
|
+
expect(reader.value).to eq([published_tries, published_base_interval])
|
|
1030
|
+
ensure
|
|
1031
|
+
release << true
|
|
1032
|
+
join_without_deadlock(writer, reader)
|
|
1033
|
+
end
|
|
1034
|
+
end
|
|
761
1035
|
end
|
|
762
1036
|
|
|
763
1037
|
context "#retriable tries/intervals precedence" do
|
|
@@ -1110,7 +1384,7 @@ describe Retriable do
|
|
|
1110
1384
|
|
|
1111
1385
|
2.times { ready.pop }
|
|
1112
1386
|
2.times { proceed << true }
|
|
1113
|
-
threads
|
|
1387
|
+
join_without_deadlock(threads)
|
|
1114
1388
|
|
|
1115
1389
|
expect(results).to eq(1 => 1, 2 => 2)
|
|
1116
1390
|
end
|
|
@@ -1141,7 +1415,7 @@ describe Retriable do
|
|
|
1141
1415
|
sibling_done << true
|
|
1142
1416
|
end
|
|
1143
1417
|
|
|
1144
|
-
|
|
1418
|
+
join_without_deadlock(setter, sibling)
|
|
1145
1419
|
expect(sibling_tries).to eq(3)
|
|
1146
1420
|
end
|
|
1147
1421
|
|
|
@@ -1149,7 +1423,7 @@ describe Retriable do
|
|
|
1149
1423
|
child_tries = nil
|
|
1150
1424
|
|
|
1151
1425
|
described_class.with_override(tries: 1) do
|
|
1152
|
-
Thread.new do
|
|
1426
|
+
child = Thread.new do
|
|
1153
1427
|
tries = 0
|
|
1154
1428
|
begin
|
|
1155
1429
|
described_class.retriable(tries: 3) do
|
|
@@ -1159,7 +1433,8 @@ describe Retriable do
|
|
|
1159
1433
|
rescue StandardError
|
|
1160
1434
|
child_tries = tries
|
|
1161
1435
|
end
|
|
1162
|
-
end
|
|
1436
|
+
end
|
|
1437
|
+
join_without_deadlock(child)
|
|
1163
1438
|
end
|
|
1164
1439
|
|
|
1165
1440
|
expect(child_tries).to eq(3)
|
|
@@ -1168,7 +1443,7 @@ describe Retriable do
|
|
|
1168
1443
|
it "shares the active override with fibers in the same thread" do
|
|
1169
1444
|
fiber_tries = nil
|
|
1170
1445
|
|
|
1171
|
-
Thread.new do
|
|
1446
|
+
worker = Thread.new do
|
|
1172
1447
|
described_class.with_override(tries: 1) do
|
|
1173
1448
|
Fiber.new do
|
|
1174
1449
|
tries = 0
|
|
@@ -1182,7 +1457,8 @@ describe Retriable do
|
|
|
1182
1457
|
end
|
|
1183
1458
|
end.resume
|
|
1184
1459
|
end
|
|
1185
|
-
end
|
|
1460
|
+
end
|
|
1461
|
+
join_without_deadlock(worker)
|
|
1186
1462
|
|
|
1187
1463
|
expect(fiber_tries).to eq(1)
|
|
1188
1464
|
end
|
|
@@ -1191,7 +1467,7 @@ describe Retriable do
|
|
|
1191
1467
|
other_thread_tries = nil
|
|
1192
1468
|
|
|
1193
1469
|
described_class.with_override(tries: 1) do
|
|
1194
|
-
Thread.new do
|
|
1470
|
+
other = Thread.new do
|
|
1195
1471
|
tries = 0
|
|
1196
1472
|
begin
|
|
1197
1473
|
described_class.retriable(tries: 3) do
|
|
@@ -1201,7 +1477,8 @@ describe Retriable do
|
|
|
1201
1477
|
rescue StandardError
|
|
1202
1478
|
other_thread_tries = tries
|
|
1203
1479
|
end
|
|
1204
|
-
end
|
|
1480
|
+
end
|
|
1481
|
+
join_without_deadlock(other)
|
|
1205
1482
|
end
|
|
1206
1483
|
|
|
1207
1484
|
expect(other_thread_tries).to eq(3)
|
|
@@ -1248,8 +1525,15 @@ describe Retriable do
|
|
|
1248
1525
|
expect(@tries).to eq(1)
|
|
1249
1526
|
end
|
|
1250
1527
|
|
|
1251
|
-
it "
|
|
1252
|
-
expect
|
|
1528
|
+
it "raises an ArgumentError when called without a block" do
|
|
1529
|
+
expect { described_class.with_context(:sql) }
|
|
1530
|
+
.to raise_error(ArgumentError, /with_context requires a block/)
|
|
1531
|
+
expect(@tries).to eq(0)
|
|
1532
|
+
end
|
|
1533
|
+
|
|
1534
|
+
it "checks for a block before looking up the context" do
|
|
1535
|
+
expect { described_class.with_context(:missing) }
|
|
1536
|
+
.to raise_error(ArgumentError, /with_context requires a block/)
|
|
1253
1537
|
expect(@tries).to eq(0)
|
|
1254
1538
|
end
|
|
1255
1539
|
|
|
@@ -1290,6 +1574,13 @@ describe Retriable do
|
|
|
1290
1574
|
expect(@tries).to eq(1)
|
|
1291
1575
|
end
|
|
1292
1576
|
|
|
1577
|
+
it "surfaces an invalid context on any retriable call before that context is used" do
|
|
1578
|
+
described_class.configure { |c| c.contexts[:unused] = { contexts: {} } }
|
|
1579
|
+
|
|
1580
|
+
expect { described_class.retriable { :ok } }
|
|
1581
|
+
.to raise_error(ArgumentError, /contexts is not a valid option/)
|
|
1582
|
+
end
|
|
1583
|
+
|
|
1293
1584
|
it "invokes on_give_up configured on a context" do
|
|
1294
1585
|
callback_called = false
|
|
1295
1586
|
described_class.configure do |c|
|
|
@@ -1301,5 +1592,37 @@ describe Retriable do
|
|
|
1301
1592
|
|
|
1302
1593
|
expect(callback_called).to be(true)
|
|
1303
1594
|
end
|
|
1595
|
+
|
|
1596
|
+
it "resolves the context against a single config snapshot" do
|
|
1597
|
+
with_ctx = Retriable::Config.new(sleep_disabled: true, contexts: { api: { tries: 1 } })
|
|
1598
|
+
without_ctx = Retriable::Config.new(sleep_disabled: true)
|
|
1599
|
+
|
|
1600
|
+
# Simulate a concurrent #configure publishing a new config between
|
|
1601
|
+
# with_context's existence check and its option resolution: the first
|
|
1602
|
+
# config read sees the context, later reads do not. with_context must
|
|
1603
|
+
# read config once so the context options are never silently dropped.
|
|
1604
|
+
allow(described_class).to receive(:config).and_return(with_ctx, without_ctx)
|
|
1605
|
+
|
|
1606
|
+
expect { described_class.with_context(:api) { increment_tries_with_exception } }
|
|
1607
|
+
.to raise_error(StandardError)
|
|
1608
|
+
|
|
1609
|
+
expect(@tries).to eq(1)
|
|
1610
|
+
end
|
|
1611
|
+
|
|
1612
|
+
it "resolves global options against the same snapshot used for the context" do
|
|
1613
|
+
special_error = Class.new(StandardError)
|
|
1614
|
+
with_ctx = Retriable::Config.new(sleep_disabled: true, on: [special_error], contexts: { api: { tries: 2 } })
|
|
1615
|
+
swapped = Retriable::Config.new(sleep_disabled: true, on: [ArgumentError])
|
|
1616
|
+
|
|
1617
|
+
# A concurrent #configure swaps the global config (here, the retriable
|
|
1618
|
+
# `on` list) after with_context has captured its snapshot. The whole
|
|
1619
|
+
# context execution must use the captured snapshot, not the swapped one.
|
|
1620
|
+
allow(described_class).to receive(:config).and_return(with_ctx, swapped)
|
|
1621
|
+
|
|
1622
|
+
expect { described_class.with_context(:api) { increment_tries_with_exception(special_error) } }
|
|
1623
|
+
.to raise_error(special_error)
|
|
1624
|
+
|
|
1625
|
+
expect(@tries).to eq(2)
|
|
1626
|
+
end
|
|
1304
1627
|
end
|
|
1305
1628
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: retriable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 5.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jack Chu
|
|
@@ -24,14 +24,21 @@ files:
|
|
|
24
24
|
- ".hound.yml"
|
|
25
25
|
- ".rspec"
|
|
26
26
|
- ".rubocop.yml"
|
|
27
|
+
- AGENTS.md
|
|
27
28
|
- CHANGELOG.md
|
|
28
29
|
- CODE_OF_CONDUCT.md
|
|
29
30
|
- Gemfile
|
|
30
31
|
- LICENSE
|
|
31
32
|
- README.md
|
|
32
33
|
- Rakefile
|
|
34
|
+
- benchmark/config_publication.rb
|
|
33
35
|
- bin/console
|
|
34
36
|
- bin/setup
|
|
37
|
+
- docs/adr/0001-copy-on-write-config-publication.md
|
|
38
|
+
- docs/agents/domain.md
|
|
39
|
+
- docs/agents/issue-tracker.md
|
|
40
|
+
- docs/agents/triage-labels.md
|
|
41
|
+
- docs/migration.md
|
|
35
42
|
- docs/testing.md
|
|
36
43
|
- lib/retriable.rb
|
|
37
44
|
- lib/retriable/config.rb
|
|
@@ -65,7 +72,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
65
72
|
- !ruby/object:Gem::Version
|
|
66
73
|
version: '0'
|
|
67
74
|
requirements: []
|
|
68
|
-
rubygems_version: 4.0.
|
|
75
|
+
rubygems_version: 4.0.16
|
|
69
76
|
specification_version: 4
|
|
70
77
|
summary: Retriable is a simple DSL to retry failed code blocks with randomized exponential
|
|
71
78
|
backoff
|