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.
@@ -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"
@@ -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.each(&:join)
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
- [setter, sibling].each(&:join)
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.join
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.join
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.join
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.2.0
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: 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