retriable 4.2.0 → 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 +54 -1
- data/Gemfile +2 -0
- data/README.md +70 -44
- 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 +104 -0
- data/lib/retriable/version.rb +1 -1
- data/lib/retriable.rb +102 -15
- data/sig/retriable.rbs +1 -1
- data/spec/config_spec.rb +149 -0
- data/spec/retriable_spec.rb +308 -9
- metadata +9 -2
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"
|
|
@@ -768,6 +796,242 @@ describe Retriable do
|
|
|
768
796
|
it "raises NoMethodError on invalid configuration" do
|
|
769
797
|
expect { described_class.configure { |c| c.does_not_exist = 123 } }.to raise_error(NoMethodError)
|
|
770
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
|
|
771
1035
|
end
|
|
772
1036
|
|
|
773
1037
|
context "#retriable tries/intervals precedence" do
|
|
@@ -1120,7 +1384,7 @@ describe Retriable do
|
|
|
1120
1384
|
|
|
1121
1385
|
2.times { ready.pop }
|
|
1122
1386
|
2.times { proceed << true }
|
|
1123
|
-
threads
|
|
1387
|
+
join_without_deadlock(threads)
|
|
1124
1388
|
|
|
1125
1389
|
expect(results).to eq(1 => 1, 2 => 2)
|
|
1126
1390
|
end
|
|
@@ -1151,7 +1415,7 @@ describe Retriable do
|
|
|
1151
1415
|
sibling_done << true
|
|
1152
1416
|
end
|
|
1153
1417
|
|
|
1154
|
-
|
|
1418
|
+
join_without_deadlock(setter, sibling)
|
|
1155
1419
|
expect(sibling_tries).to eq(3)
|
|
1156
1420
|
end
|
|
1157
1421
|
|
|
@@ -1159,7 +1423,7 @@ describe Retriable do
|
|
|
1159
1423
|
child_tries = nil
|
|
1160
1424
|
|
|
1161
1425
|
described_class.with_override(tries: 1) do
|
|
1162
|
-
Thread.new do
|
|
1426
|
+
child = Thread.new do
|
|
1163
1427
|
tries = 0
|
|
1164
1428
|
begin
|
|
1165
1429
|
described_class.retriable(tries: 3) do
|
|
@@ -1169,7 +1433,8 @@ describe Retriable do
|
|
|
1169
1433
|
rescue StandardError
|
|
1170
1434
|
child_tries = tries
|
|
1171
1435
|
end
|
|
1172
|
-
end
|
|
1436
|
+
end
|
|
1437
|
+
join_without_deadlock(child)
|
|
1173
1438
|
end
|
|
1174
1439
|
|
|
1175
1440
|
expect(child_tries).to eq(3)
|
|
@@ -1178,7 +1443,7 @@ describe Retriable do
|
|
|
1178
1443
|
it "shares the active override with fibers in the same thread" do
|
|
1179
1444
|
fiber_tries = nil
|
|
1180
1445
|
|
|
1181
|
-
Thread.new do
|
|
1446
|
+
worker = Thread.new do
|
|
1182
1447
|
described_class.with_override(tries: 1) do
|
|
1183
1448
|
Fiber.new do
|
|
1184
1449
|
tries = 0
|
|
@@ -1192,7 +1457,8 @@ describe Retriable do
|
|
|
1192
1457
|
end
|
|
1193
1458
|
end.resume
|
|
1194
1459
|
end
|
|
1195
|
-
end
|
|
1460
|
+
end
|
|
1461
|
+
join_without_deadlock(worker)
|
|
1196
1462
|
|
|
1197
1463
|
expect(fiber_tries).to eq(1)
|
|
1198
1464
|
end
|
|
@@ -1201,7 +1467,7 @@ describe Retriable do
|
|
|
1201
1467
|
other_thread_tries = nil
|
|
1202
1468
|
|
|
1203
1469
|
described_class.with_override(tries: 1) do
|
|
1204
|
-
Thread.new do
|
|
1470
|
+
other = Thread.new do
|
|
1205
1471
|
tries = 0
|
|
1206
1472
|
begin
|
|
1207
1473
|
described_class.retriable(tries: 3) do
|
|
@@ -1211,7 +1477,8 @@ describe Retriable do
|
|
|
1211
1477
|
rescue StandardError
|
|
1212
1478
|
other_thread_tries = tries
|
|
1213
1479
|
end
|
|
1214
|
-
end
|
|
1480
|
+
end
|
|
1481
|
+
join_without_deadlock(other)
|
|
1215
1482
|
end
|
|
1216
1483
|
|
|
1217
1484
|
expect(other_thread_tries).to eq(3)
|
|
@@ -1325,5 +1592,37 @@ describe Retriable do
|
|
|
1325
1592
|
|
|
1326
1593
|
expect(callback_called).to be(true)
|
|
1327
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
|
|
1328
1627
|
end
|
|
1329
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:
|
|
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
|