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/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.
|
data/lib/retriable/config.rb
CHANGED
|
@@ -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)
|
data/lib/retriable/version.rb
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
+
base_config
|
|
60
128
|
else
|
|
61
|
-
Config.new(apply_override_options(merge_layer(
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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) ->
|
|
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
|