retriable 4.2.0 → 5.0.1

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.
@@ -8,7 +8,10 @@ describe Retriable do
8
8
  end
9
9
 
10
10
  before(:each) do
11
- described_class.instance_variable_set(:@config, nil)
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"
@@ -105,6 +133,19 @@ describe Retriable do
105
133
  expect(@tries).to eq(3)
106
134
  end
107
135
 
136
+ it "re-raises StopIteration after the configured tries" do
137
+ failure = StopIteration.new("end of input")
138
+
139
+ expect do
140
+ described_class.retriable(tries: 2) do
141
+ increment_tries
142
+ raise failure
143
+ end
144
+ end.to(raise_error { |error| expect(error).to equal(failure) })
145
+
146
+ expect(@tries).to eq(2)
147
+ end
148
+
108
149
  it "makes only 1 try when exception raised is not descendent of StandardError" do
109
150
  expect do
110
151
  described_class.retriable { increment_tries_with_exception(NonStandardError) }
@@ -768,6 +809,242 @@ describe Retriable do
768
809
  it "raises NoMethodError on invalid configuration" do
769
810
  expect { described_class.configure { |c| c.does_not_exist = 123 } }.to raise_error(NoMethodError)
770
811
  end
812
+
813
+ it "returns the configure block's result" do
814
+ result = described_class.configure do |c|
815
+ c.tries = 7
816
+ :configured
817
+ end
818
+
819
+ expect(result).to eq(:configured)
820
+ expect(described_class.config.tries).to eq(7)
821
+ end
822
+ end
823
+
824
+ context "#configure thread safety (copy-on-write)" do
825
+ it "eagerly initializes @config at load time, before any configure/config call" do
826
+ script = "require 'retriable'; " \
827
+ "exit(Retriable.instance_variable_get(:@config).is_a?(Retriable::Config) ? 0 : 1)"
828
+ expect(system(RbConfig.ruby, "-Ilib", "-e", script)).to be(true)
829
+ end
830
+
831
+ it "publishes a new Config object on configure instead of mutating in place" do
832
+ before = described_class.config
833
+ described_class.configure { |c| c.tries = 7 }
834
+ after = described_class.config
835
+
836
+ expect(after).not_to equal(before)
837
+ expect(after.tries).to eq(7)
838
+ expect(before.tries).not_to eq(7)
839
+ end
840
+
841
+ it "keeps an already-captured snapshot stable across later configures" do
842
+ described_class.configure { |c| c.contexts[:sql] = { tries: 1 } }
843
+ snapshot = described_class.config
844
+
845
+ described_class.configure { |c| c.contexts[:http] = { tries: 2 } }
846
+ described_class.configure { |c| c.contexts[:sql][:tries] = 99 }
847
+
848
+ expect(snapshot.contexts).to eq(sql: { tries: 1 })
849
+ end
850
+
851
+ it "publishes a frozen snapshot, so direct mutation fails loudly" do
852
+ described_class.configure { |c| c.contexts[:api] = { tries: 1 } }
853
+
854
+ expect { described_class.config.tries = 99 }.to raise_error(FrozenError)
855
+ expect { described_class.config.contexts[:api][:tries] = 99 }.to raise_error(FrozenError)
856
+ expect(described_class.config.contexts[:api]).to eq(tries: 1)
857
+ end
858
+
859
+ it "still hands the configure block a mutable candidate" do
860
+ expect do
861
+ described_class.configure do |c|
862
+ c.contexts[:api] = { tries: 1 }
863
+ c.contexts[:api][:tries] = 2
864
+ c.on = [StandardError]
865
+ c.on << ArgumentError
866
+ end
867
+ end.not_to raise_error
868
+
869
+ expect(described_class.config.contexts[:api]).to eq(tries: 2)
870
+ end
871
+
872
+ it "does not freeze collections the caller still owns" do
873
+ caller_owned = [StandardError]
874
+
875
+ described_class.configure { |c| c.on = caller_owned }
876
+
877
+ expect(caller_owned).not_to be_frozen
878
+ expect(described_class.config.on).to be_frozen
879
+ expect(described_class.config.on).not_to equal(caller_owned)
880
+ end
881
+
882
+ it "owns and freezes a copy of a mutable Hash default" do
883
+ fallback = []
884
+ contexts = Hash.new(fallback)
885
+ contexts[:api] = { tries: 1 }
886
+
887
+ described_class.configure { |c| c.contexts = contexts }
888
+ published_default = described_class.config.contexts.default
889
+
890
+ expect(published_default).not_to equal(fallback)
891
+ expect(published_default).to be_frozen
892
+ expect(fallback).not_to be_frozen
893
+ end
894
+
895
+ it "does not let a published default leak mutations into the caller's object" do
896
+ fallback = []
897
+ described_class.configure { |c| c.contexts = Hash.new(fallback) }
898
+
899
+ expect { described_class.config.contexts[:absent] << :leaked }.to raise_error(FrozenError)
900
+ expect(fallback).to be_empty
901
+ end
902
+
903
+ it "does not drop updates when configured concurrently from many threads" do
904
+ keys = (0...50).map { |i| :"ctx_#{i}" }
905
+ release = Queue.new
906
+
907
+ threads = keys.map do |key|
908
+ Thread.new do
909
+ release.pop
910
+ described_class.configure { |c| c.contexts[key] = { tries: 1 } }
911
+ end
912
+ end
913
+
914
+ keys.size.times { release << true }
915
+ join_without_deadlock(threads)
916
+
917
+ expect(described_class.config.contexts.keys).to match_array(keys)
918
+ end
919
+
920
+ it "lets nested configure calls join the outer transaction" do
921
+ inner_candidate = nil
922
+
923
+ result = described_class.configure do |outer|
924
+ outer.tries = 7
925
+ nested_result = described_class.configure do |inner|
926
+ inner_candidate = inner
927
+ inner.base_interval = 1
928
+ :nested_result
929
+ end
930
+
931
+ expect(nested_result).to eq(:nested_result)
932
+ expect(inner_candidate).to equal(outer)
933
+ :outer_result
934
+ end
935
+
936
+ expect(result).to eq(:outer_result)
937
+ expect(described_class.config.tries).to eq(7)
938
+ expect(described_class.config.base_interval).to eq(1)
939
+ end
940
+
941
+ it "rolls back nested changes when the outer configure raises" do
942
+ described_class.configure { |c| c.tries = 4 }
943
+
944
+ expect do
945
+ described_class.configure do |outer|
946
+ outer.tries = 7
947
+ described_class.configure { |inner| inner.base_interval = 1 }
948
+ raise "outer failed"
949
+ end
950
+ end.to raise_error(RuntimeError, "outer failed")
951
+
952
+ expect(described_class.config.tries).to eq(4)
953
+ expect(described_class.config.base_interval).to eq(0.5)
954
+ end
955
+
956
+ it "lets configure calls from a fiber join the outer transaction" do
957
+ described_class.configure do |outer|
958
+ fiber_candidate = Fiber.new do
959
+ described_class.configure do |inner|
960
+ inner.tries = 9
961
+ inner
962
+ end
963
+ end.resume
964
+
965
+ expect(fiber_candidate).to equal(outer)
966
+ end
967
+
968
+ expect(described_class.config.tries).to eq(9)
969
+ end
970
+
971
+ it "exposes the in-progress config to the configuring thread" do
972
+ described_class.configure do |c|
973
+ c.tries = 7
974
+ expect(described_class.config.tries).to eq(7)
975
+ end
976
+
977
+ expect(described_class.config.tries).to eq(7)
978
+ end
979
+
980
+ it "uses the in-progress config for retriable" do
981
+ attempts = 0
982
+
983
+ described_class.configure do |c|
984
+ c.tries = 1
985
+ c.sleep_disabled = true
986
+
987
+ expect do
988
+ described_class.retriable do
989
+ attempts += 1
990
+ raise StandardError
991
+ end
992
+ end.to raise_error(StandardError)
993
+ end
994
+
995
+ expect(attempts).to eq(1)
996
+ end
997
+
998
+ it "uses candidate-only contexts for with_context" do
999
+ described_class.configure do |c|
1000
+ c.contexts[:candidate] = { tries: 1 }
1001
+
1002
+ expect(described_class.with_context(:candidate) { :found }).to eq(:found)
1003
+ end
1004
+ end
1005
+
1006
+ it "shares the in-progress config with fibers in the configuring thread" do
1007
+ described_class.configure do |c|
1008
+ c.tries = 7
1009
+
1010
+ expect(Fiber.new { described_class.config.tries }.resume).to eq(7)
1011
+ end
1012
+ end
1013
+
1014
+ it "does not block readers while configure is in progress" do
1015
+ published_tries = described_class.config.tries
1016
+ published_base_interval = described_class.config.base_interval
1017
+ configuring = Queue.new
1018
+ release = Queue.new
1019
+ writer = Thread.new do
1020
+ described_class.configure do |c|
1021
+ c.tries = published_tries + 1
1022
+ described_class.configure { |inner| inner.base_interval = published_base_interval + 1 }
1023
+ configuring << true
1024
+ release.pop
1025
+ end
1026
+ end
1027
+ reader = nil
1028
+
1029
+ begin
1030
+ configuring.pop
1031
+ reader = Thread.new do
1032
+ snapshot = described_class.config
1033
+ [snapshot.tries, snapshot.base_interval]
1034
+ end
1035
+ # The timeout is a deadlock backstop, not the assertion. A reader that
1036
+ # is not blocked returns immediately, so this budget is only ever
1037
+ # approached if #config starts waiting on the in-progress #configure.
1038
+ expect(reader.join(deadlock_backstop_seconds)).to be(reader),
1039
+ "reader blocked while configure was in progress"
1040
+ # Thread#value re-raises anything the reader raised, so a broken reader
1041
+ # fails loudly instead of hanging on an empty queue.
1042
+ expect(reader.value).to eq([published_tries, published_base_interval])
1043
+ ensure
1044
+ release << true
1045
+ join_without_deadlock(writer, reader)
1046
+ end
1047
+ end
771
1048
  end
772
1049
 
773
1050
  context "#retriable tries/intervals precedence" do
@@ -1120,7 +1397,7 @@ describe Retriable do
1120
1397
 
1121
1398
  2.times { ready.pop }
1122
1399
  2.times { proceed << true }
1123
- threads.each(&:join)
1400
+ join_without_deadlock(threads)
1124
1401
 
1125
1402
  expect(results).to eq(1 => 1, 2 => 2)
1126
1403
  end
@@ -1151,7 +1428,7 @@ describe Retriable do
1151
1428
  sibling_done << true
1152
1429
  end
1153
1430
 
1154
- [setter, sibling].each(&:join)
1431
+ join_without_deadlock(setter, sibling)
1155
1432
  expect(sibling_tries).to eq(3)
1156
1433
  end
1157
1434
 
@@ -1159,7 +1436,7 @@ describe Retriable do
1159
1436
  child_tries = nil
1160
1437
 
1161
1438
  described_class.with_override(tries: 1) do
1162
- Thread.new do
1439
+ child = Thread.new do
1163
1440
  tries = 0
1164
1441
  begin
1165
1442
  described_class.retriable(tries: 3) do
@@ -1169,7 +1446,8 @@ describe Retriable do
1169
1446
  rescue StandardError
1170
1447
  child_tries = tries
1171
1448
  end
1172
- end.join
1449
+ end
1450
+ join_without_deadlock(child)
1173
1451
  end
1174
1452
 
1175
1453
  expect(child_tries).to eq(3)
@@ -1178,7 +1456,7 @@ describe Retriable do
1178
1456
  it "shares the active override with fibers in the same thread" do
1179
1457
  fiber_tries = nil
1180
1458
 
1181
- Thread.new do
1459
+ worker = Thread.new do
1182
1460
  described_class.with_override(tries: 1) do
1183
1461
  Fiber.new do
1184
1462
  tries = 0
@@ -1192,7 +1470,8 @@ describe Retriable do
1192
1470
  end
1193
1471
  end.resume
1194
1472
  end
1195
- end.join
1473
+ end
1474
+ join_without_deadlock(worker)
1196
1475
 
1197
1476
  expect(fiber_tries).to eq(1)
1198
1477
  end
@@ -1201,7 +1480,7 @@ describe Retriable do
1201
1480
  other_thread_tries = nil
1202
1481
 
1203
1482
  described_class.with_override(tries: 1) do
1204
- Thread.new do
1483
+ other = Thread.new do
1205
1484
  tries = 0
1206
1485
  begin
1207
1486
  described_class.retriable(tries: 3) do
@@ -1211,7 +1490,8 @@ describe Retriable do
1211
1490
  rescue StandardError
1212
1491
  other_thread_tries = tries
1213
1492
  end
1214
- end.join
1493
+ end
1494
+ join_without_deadlock(other)
1215
1495
  end
1216
1496
 
1217
1497
  expect(other_thread_tries).to eq(3)
@@ -1325,5 +1605,37 @@ describe Retriable do
1325
1605
 
1326
1606
  expect(callback_called).to be(true)
1327
1607
  end
1608
+
1609
+ it "resolves the context against a single config snapshot" do
1610
+ with_ctx = Retriable::Config.new(sleep_disabled: true, contexts: { api: { tries: 1 } })
1611
+ without_ctx = Retriable::Config.new(sleep_disabled: true)
1612
+
1613
+ # Simulate a concurrent #configure publishing a new config between
1614
+ # with_context's existence check and its option resolution: the first
1615
+ # config read sees the context, later reads do not. with_context must
1616
+ # read config once so the context options are never silently dropped.
1617
+ allow(described_class).to receive(:config).and_return(with_ctx, without_ctx)
1618
+
1619
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }
1620
+ .to raise_error(StandardError)
1621
+
1622
+ expect(@tries).to eq(1)
1623
+ end
1624
+
1625
+ it "resolves global options against the same snapshot used for the context" do
1626
+ special_error = Class.new(StandardError)
1627
+ with_ctx = Retriable::Config.new(sleep_disabled: true, on: [special_error], contexts: { api: { tries: 2 } })
1628
+ swapped = Retriable::Config.new(sleep_disabled: true, on: [ArgumentError])
1629
+
1630
+ # A concurrent #configure swaps the global config (here, the retriable
1631
+ # `on` list) after with_context has captured its snapshot. The whole
1632
+ # context execution must use the captured snapshot, not the swapped one.
1633
+ allow(described_class).to receive(:config).and_return(with_ctx, swapped)
1634
+
1635
+ expect { described_class.with_context(:api) { increment_tries_with_exception(special_error) } }
1636
+ .to raise_error(special_error)
1637
+
1638
+ expect(@tries).to eq(2)
1639
+ end
1328
1640
  end
1329
1641
  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.2.0
4
+ version: 5.0.1
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: 3.6.9
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