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.
data/docs/migration.md ADDED
@@ -0,0 +1,84 @@
1
+ # Migrating Retriable
2
+
3
+ Upgrade guides for Retriable's breaking changes, newest first. See the
4
+ [CHANGELOG](../CHANGELOG.md) for the full history of every release.
5
+
6
+ - [4.x to 5.x](#4x-to-5x)
7
+ - [3.x to 4.0](#3x-to-40)
8
+
9
+ ## 4.x to 5.x
10
+
11
+ Retriable 5.0 makes configuration copy-on-write so that concurrent readers see
12
+ one complete configuration. As part of that change, `Retriable.config` returns a
13
+ deeply frozen snapshot. Code that mutates this snapshot directly now raises
14
+ `FrozenError`:
15
+
16
+ ```ruby
17
+ Retriable.config.sleep_disabled = true # => FrozenError
18
+ Retriable.config.contexts[:api] = {} # => FrozenError
19
+ ```
20
+
21
+ Move these writes into a `Retriable.configure` block:
22
+
23
+ ```ruby
24
+ Retriable.configure do |config|
25
+ config.sleep_disabled = true
26
+ config.contexts[:api] = {}
27
+ end
28
+ ```
29
+
30
+ Check test setup files such as `spec_helper` and `rails_helper`, where direct
31
+ configuration writes are common. Reading `Retriable.config` is unchanged.
32
+
33
+ ## 3.x to 4.0
34
+
35
+ ### Ruby version
36
+
37
+ Retriable 4.0 requires Ruby 3.2 or later. If your application still runs Ruby
38
+ 2.3.0 through 3.1.x, pin Retriable to the 3.8 release line in your Gemfile:
39
+
40
+ ```ruby
41
+ gem "retriable", "~> 3.8"
42
+ ```
43
+
44
+ ### `timeout:` option removed
45
+
46
+ Retriable 4.0 removes the `timeout:` option deprecated in 3.8.0. The option
47
+ called `Timeout.timeout`, which can interrupt code at any line and leave
48
+ non-interrupt-safe libraries in a corrupt state. [Issue #96](https://github.com/kamui/retriable/issues/96)
49
+ has the original bug report.
50
+
51
+ Replace code such as `Retriable.retriable(timeout: 5) { ... }` with one of the
52
+ following approaches.
53
+
54
+ 1. Prefer the library's own timeout setting, such as `Net::HTTP#read_timeout`,
55
+ Faraday's `request.timeout`, or a database statement timeout. These settings
56
+ avoid the arbitrary interruption caused by `Timeout.timeout`.
57
+
58
+ 2. If the library has no timeout setting, wrap the operation yourself:
59
+
60
+ ```ruby
61
+ require "timeout"
62
+
63
+ Retriable.retriable do
64
+ Timeout.timeout(5) do
65
+ # code here...
66
+ end
67
+ end
68
+ ```
69
+
70
+ This keeps the old behavior, including its risks. `Timeout.timeout` may
71
+ interrupt code while it holds a mutex, file handle, network socket, or other
72
+ internal state. Use it only when the library offers no safer timeout. For more
73
+ detail, read [why Ruby's `Timeout` is dangerous](https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/),
74
+ [Headius on `Thread#raise` and `Timeout`](http://blog.headius.com/2008/02/ruby-threadraise-threadkill-timeoutrb.html),
75
+ [In Ruby, don't use `Timeout`](https://adamhooper.medium.com/in-ruby-dont-use-timeout-77d9d4e5a001), or
76
+ [Timeout: Ruby's most dangerous API](https://www.mikeperham.com/2015/05/08/timeout-rubys-most-dangerous-api/).
77
+
78
+ `Timeout.timeout(5)` applies to each attempt, so every retry gets a new
79
+ five-second limit. Use `max_elapsed_time:` to cap the total time spent across
80
+ all attempts.
81
+
82
+ Passing `timeout:` to `Retriable.retriable` or `Retriable.with_override` now
83
+ raises `ArgumentError`. Setting `timeout` in `Retriable.configure` now raises
84
+ `NoMethodError` because the configuration attribute no longer exists.
@@ -21,6 +21,9 @@ module Retriable
21
21
  CONTEXT_ATTRIBUTES = (ATTRIBUTES - %i[contexts]).freeze
22
22
  private_constant :CONTEXT_ATTRIBUTES
23
23
 
24
+ OWNED_CONTAINER_ATTRIBUTES = %i[on intervals contexts].freeze
25
+ private_constant :OWNED_CONTAINER_ATTRIBUTES
26
+
24
27
  attr_accessor(*ATTRIBUTES)
25
28
 
26
29
  def initialize(opts = {})
@@ -72,6 +75,23 @@ module Retriable
72
75
  validate_backoff_options
73
76
  end
74
77
 
78
+ # Deep-freezes the containers this Config owns, then itself. Without the deep
79
+ # part a "frozen" Config stays mutable one level down
80
+ # (`config.contexts[:api][:tries] = 1`), which is precisely the corruption a
81
+ # published snapshot exists to rule out. Leaves — procs, exception classes,
82
+ # regexps, scalars — are shared by reference and left untouched.
83
+ #
84
+ # Retriable only ever freezes a #dup it produced itself, so this never
85
+ # freezes a container the caller still holds.
86
+ def freeze
87
+ return self if frozen?
88
+
89
+ OWNED_CONTAINER_ATTRIBUTES.each do |attribute|
90
+ deep_freeze(instance_variable_get(:"@#{attribute}"))
91
+ end
92
+ super
93
+ end
94
+
75
95
  private
76
96
 
77
97
  def validate_contexts
@@ -89,6 +109,90 @@ module Retriable
89
109
  end
90
110
  end
91
111
 
112
+ def initialize_copy(other)
113
+ super
114
+ OWNED_CONTAINER_ATTRIBUTES.each do |attribute|
115
+ instance_variable_set(:"@#{attribute}", deep_dup(other.public_send(attribute)))
116
+ end
117
+ end
118
+
119
+ # Recursively copies the mutable containers (Hash/Array/Set) so a dup is fully
120
+ # isolated from the original, leaving leaves (scalars, procs, exception
121
+ # classes, regexps) shared by reference.
122
+ #
123
+ # Copies start from #dup rather than a fresh literal. Rebuilding into a bare
124
+ # `{}` silently downgrades a Hash subclass to Hash and drops its
125
+ # default/default_proc, so a `contexts` hash with indifferent access would
126
+ # stop resolving string keys after the first #configure.
127
+ #
128
+ # Frozen state is deliberately not carried over: a dup is the mutable working
129
+ # copy that a #configure block mutates, and Retriable re-freezes it on
130
+ # publish.
131
+ #
132
+ # `seen` maps each source container to its copy so a self-referential
133
+ # structure terminates instead of recursing until the stack blows.
134
+ def deep_dup(value, seen = {}.compare_by_identity)
135
+ case value
136
+ when Hash, Array, Set
137
+ return seen[value] if seen.key?(value)
138
+
139
+ copy = value.dup
140
+ seen[value] = copy
141
+ deep_dup_into(value, copy, seen)
142
+ copy
143
+ else value
144
+ end
145
+ end
146
+
147
+ def deep_dup_into(value, copy, seen)
148
+ case value
149
+ when Hash then deep_dup_hash(value, copy, seen)
150
+ when Array then value.each_with_index { |val, index| copy[index] = deep_dup(val, seen) }
151
+ when Set then copy.replace(value.map { |val| deep_dup(val, seen) })
152
+ end
153
+ end
154
+
155
+ # Keys are deliberately left alone. Ruby already dups and freezes an unfrozen
156
+ # String key on assignment, and the supported key types (Symbols for
157
+ # `contexts`, exception classes for `on`) are immutable already.
158
+ #
159
+ # A mutable default value is part of the copied graph, because a shared one
160
+ # would let `config.contexts[:absent] << x` mutate the caller's object. A
161
+ # default_proc stays shared: it is a callable leaf, like every other proc a
162
+ # Config holds.
163
+ def deep_dup_hash(value, copy, seen)
164
+ value.each { |key, val| copy[key] = deep_dup(val, seen) }
165
+ copy.default = deep_dup(value.default, seen) unless value.default_proc
166
+ end
167
+
168
+ # Freezes exactly what #deep_dup treats as a container, so the two agree on
169
+ # where a Config's mutable surface ends. `seen` guards the same
170
+ # self-referential case.
171
+ def deep_freeze(value, seen = {}.compare_by_identity)
172
+ case value
173
+ when Hash then deep_freeze_hash(value, seen)
174
+ when Array, Set then deep_freeze_collection(value, seen)
175
+ else value
176
+ end
177
+ end
178
+
179
+ def deep_freeze_hash(value, seen)
180
+ return value if seen[value]
181
+
182
+ seen[value] = true
183
+ value.each_value { |val| deep_freeze(val, seen) }
184
+ deep_freeze(value.default, seen) unless value.default_proc
185
+ value.freeze
186
+ end
187
+
188
+ def deep_freeze_collection(value, seen)
189
+ return value if seen[value]
190
+
191
+ seen[value] = true
192
+ value.each { |val| deep_freeze(val, seen) }
193
+ value.freeze
194
+ end
195
+
92
196
  def validate_backoff_options
93
197
  validate_non_negative_number(:base_interval, base_interval)
94
198
  validate_non_negative_number(:multiplier, multiplier)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Retriable
4
- VERSION = "4.2.0"
4
+ VERSION = "5.0.0"
5
5
  end
data/lib/retriable.rb CHANGED
@@ -12,17 +12,75 @@ module Retriable
12
12
  # break callers that use fiber-based concurrency.
13
13
  OVERRIDE_THREAD_KEY = :retriable_override
14
14
 
15
+ # True thread-local storage marking the Config this thread is currently
16
+ # building inside #configure. It answers one question — "am I mid-configure?"
17
+ # Which snapshot a given #retriable/#with_context call resolves against is a
18
+ # separate question, answered by passing that snapshot as an argument (see
19
+ # #retriable_with_config). Keeping the two questions on two mechanisms is
20
+ # deliberate: a thread-local would otherwise leak the resolved snapshot across
21
+ # the caller's block and change what `Retriable.config` returns inside it.
22
+ CONFIGURING_THREAD_KEY = :retriable_configuring
23
+ private_constant :CONFIGURING_THREAD_KEY
24
+
15
25
  RetryPlan = Struct.new(:max_tries, :interval_for)
16
26
  private_constant :RetryPlan
17
27
 
28
+ # Serializes complete #configure transactions so concurrent read-modify-write
29
+ # swaps cannot drop one another's updates.
30
+ CONFIG_MUTEX = Mutex.new
31
+ private_constant :CONFIG_MUTEX
32
+
33
+ # Guards the single @config reference. Held only for a reference read or the
34
+ # publishing write, never across the #configure block, so a writer blocks a
35
+ # reader for no longer than a pointer swap.
36
+ #
37
+ # One path on every engine. MRI's GVL would make an unsynchronized read safe,
38
+ # but JRuby and TruffleRuby offer no such happens-before guarantee. The common
39
+ # no-options #retriable path uses the snapshot directly, so this mutex has a
40
+ # measurable cost. benchmark/config_publication.rb tracks that cost. Replace
41
+ # this mechanism only when measurements justify a portable alternative.
42
+ CONFIG_PUBLICATION_MUTEX = Mutex.new
43
+ private_constant :CONFIG_PUBLICATION_MUTEX
44
+
45
+ # Eagerly initialized at load time. `require` is serialized in MRI, so this runs
46
+ # exactly once before any thread can reach #config/#configure, closing the
47
+ # `@config ||= Config.new` check-then-act race. Frozen like every snapshot
48
+ # published after it, so reads are immutable from the very first one.
49
+ @config = Config.new.freeze
50
+
18
51
  module_function
19
52
 
53
+ # Copy-on-write: dup the published config, let the caller mutate the copy, then
54
+ # atomically publish it, deeply frozen. Readers therefore always observe a
55
+ # consistent, fully-applied snapshot that nothing can mutate underneath them,
56
+ # and a failed/raising block leaves the old config intact. Does NOT validate
57
+ # (validation stays lazy at #retriable time).
58
+ # Nested calls on the configuring thread share the outer candidate. Only the
59
+ # outermost call takes CONFIG_MUTEX and publishes, so nesting remains safe even
60
+ # though the mutex is not reentrant.
20
61
  def configure
21
- yield(config)
62
+ candidate = configuring_config
63
+ return yield(candidate) if candidate
64
+
65
+ CONFIG_MUTEX.synchronize do
66
+ candidate = config.dup
67
+ Thread.current.thread_variable_set(CONFIGURING_THREAD_KEY, candidate)
68
+ begin
69
+ result = yield(candidate)
70
+ publish_config(candidate)
71
+ result
72
+ ensure
73
+ Thread.current.thread_variable_set(CONFIGURING_THREAD_KEY, nil)
74
+ end
75
+ end
22
76
  end
23
77
 
78
+ # The configuring thread sees its own candidate, still mutable and mid-build.
79
+ # Every other reader sees the last fully published snapshot, which is deeply
80
+ # frozen: mutating it raises FrozenError instead of silently corrupting the
81
+ # config other threads are reading. Use #configure to change configuration.
24
82
  def config
25
- @config ||= Config.new
83
+ configuring_config || published_config
26
84
  end
27
85
 
28
86
  def with_override(opts = {})
@@ -43,22 +101,32 @@ module Retriable
43
101
  def with_context(context_key, options = {}, &)
44
102
  raise ArgumentError, "with_context requires a block" unless block_given?
45
103
 
46
- contexts = available_contexts
104
+ # Resolve the whole call against one snapshot and one traversal of its
105
+ # contexts. Re-reading `config` here would let a concurrent #configure pass
106
+ # the existence check on the old snapshot while options resolve against the
107
+ # new one, silently dropping the context's retry options.
108
+ config_snapshot = config
109
+ configured_contexts = config_contexts(config_snapshot)
110
+ contexts = configured_contexts.merge(override_contexts)
47
111
 
48
112
  if !contexts.key?(context_key)
49
113
  raise ArgumentError,
50
114
  "#{context_key} not found in Retriable contexts (including overrides). Available contexts: #{contexts.keys}"
51
115
  end
52
116
 
53
- retriable(context_options_for(context_key, options), &)
117
+ retriable_with_config(config_snapshot, context_options_for(context_key, configured_contexts, options), &)
54
118
  end
55
119
 
56
120
  def retriable(opts = {}, &)
121
+ retriable_with_config(config, opts, &)
122
+ end
123
+
124
+ def retriable_with_config(base_config, opts = {}, &)
57
125
  override_config = current_override
58
126
  local_config = if opts.empty? && !override_config
59
- config
127
+ base_config
60
128
  else
61
- Config.new(apply_override_options(merge_layer(config.to_h, opts), override_config))
129
+ Config.new(apply_override_options(merge_layer(base_config.to_h, opts), override_config))
62
130
  end
63
131
 
64
132
  # Config is mutable through `configure`, so validate again immediately before use.
@@ -232,12 +300,11 @@ module Retriable
232
300
  merged
233
301
  end
234
302
 
235
- def available_contexts
236
- config_contexts.merge(override_contexts)
237
- end
238
-
239
- def context_options_for(context_key, options)
240
- context_options = config_contexts.fetch(context_key, {})
303
+ # Takes the already-resolved contexts hash rather than the config snapshot, so
304
+ # the snapshot travels exactly one hop (into #retriable_with_config) instead of
305
+ # through every private helper that happens to need a corner of it.
306
+ def context_options_for(context_key, contexts, options)
307
+ context_options = contexts.fetch(context_key, {})
241
308
  context_options = {} unless context_options.is_a?(Hash)
242
309
  context_options = merge_layer(context_options, options)
243
310
 
@@ -247,8 +314,8 @@ module Retriable
247
314
  apply_override_options(context_options, override_context_options)
248
315
  end
249
316
 
250
- def config_contexts
251
- config.contexts.is_a?(Hash) ? config.contexts : {}
317
+ def config_contexts(config_snapshot)
318
+ config_snapshot.contexts.is_a?(Hash) ? config_snapshot.contexts : {}
252
319
  end
253
320
 
254
321
  def override_contexts
@@ -261,7 +328,28 @@ module Retriable
261
328
  Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY)
262
329
  end
263
330
 
331
+ def configuring_config
332
+ Thread.current.thread_variable_get(CONFIGURING_THREAD_KEY)
333
+ end
334
+
335
+ def published_config
336
+ CONFIG_PUBLICATION_MUTEX.synchronize { @config }
337
+ end
338
+
339
+ # Publishes a deeply frozen deep copy of the candidate. The copy matters: it
340
+ # keeps the freeze off objects the caller still owns, so `c.on = my_array`
341
+ # inside a #configure block never leaves my_array frozen. The mutex covers the
342
+ # reference swap only; the copy and freeze happen outside it.
343
+ def publish_config(candidate)
344
+ snapshot = candidate.dup.freeze
345
+ CONFIG_PUBLICATION_MUTEX.synchronize { @config = snapshot }
346
+ end
347
+
264
348
  private_class_method(
349
+ :retriable_with_config,
350
+ :configuring_config,
351
+ :published_config,
352
+ :publish_config,
265
353
  :validate_override_options,
266
354
  :validate_context_override_options,
267
355
  :execute_tries,
@@ -274,7 +362,6 @@ module Retriable
274
362
  :hash_exception_match?,
275
363
  :apply_override_options,
276
364
  :merge_layer,
277
- :available_contexts,
278
365
  :context_options_for,
279
366
  :config_contexts,
280
367
  :override_contexts,
data/sig/retriable.rbs CHANGED
@@ -2,7 +2,7 @@ module Retriable
2
2
  VERSION: String
3
3
  OVERRIDE_THREAD_KEY: Symbol
4
4
 
5
- def self.configure: () { (Config) -> void } -> void
5
+ def self.configure: [Result] () { (Config) -> Result } -> Result
6
6
  def self.config: () -> Config
7
7
  def self.with_override: (Hash[Symbol, untyped] options) { () -> untyped } -> untyped
8
8
  def self.with_context: (Symbol context_key, ?Hash[Symbol, untyped] options) { (Integer) -> untyped } -> untyped
data/spec/config_spec.rb CHANGED
@@ -209,4 +209,153 @@ describe Retriable::Config do
209
209
  expect { described_class.new(contexts: { api: { tries: 3, base_interval: 1.0 } }) }.not_to raise_error
210
210
  end
211
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
212
361
  end