retriable 3.8.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.
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+
5
+ require "rbconfig"
6
+ require "retriable"
7
+
8
+ RUN_SECONDS = Float(ENV.fetch("RETRIABLE_BENCH_SECONDS", "1.0"))
9
+ THREAD_COUNTS = ENV.fetch("RETRIABLE_BENCH_THREADS", "1,2,4,8")
10
+ .split(",")
11
+ .map { |value| Integer(value, 10) }
12
+ .uniq
13
+ .freeze
14
+ BATCH_SIZE = 100
15
+
16
+ raise ArgumentError, "RETRIABLE_BENCH_SECONDS must be positive" unless RUN_SECONDS.positive?
17
+ raise ArgumentError, "RETRIABLE_BENCH_THREADS must contain positive integers" unless THREAD_COUNTS.all?(&:positive?)
18
+
19
+ SnapshotHolder = Struct.new(:value)
20
+ snapshot_holder = SnapshotHolder.new(Retriable.config)
21
+
22
+ BENCHMARK_CASES = {
23
+ "plain_snapshot_read" => -> { snapshot_holder.value },
24
+ "published_config_read" => -> { Retriable.config },
25
+ "successful_retriable" => -> { Retriable.retriable { nil } }
26
+ }.freeze
27
+
28
+ def monotonic_time
29
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
30
+ end
31
+
32
+ def measure(operation, thread_count)
33
+ ready = Queue.new
34
+ start = Queue.new
35
+ threads = Array.new(thread_count) do
36
+ Thread.new do
37
+ ready << true
38
+ start.pop
39
+ count = 0
40
+ deadline = monotonic_time + RUN_SECONDS
41
+
42
+ loop do
43
+ BATCH_SIZE.times { operation.call }
44
+ count += BATCH_SIZE
45
+ break if monotonic_time >= deadline
46
+ end
47
+
48
+ count
49
+ end
50
+ end
51
+
52
+ thread_count.times { ready.pop }
53
+ started_at = monotonic_time
54
+ thread_count.times { start << true }
55
+ operation_count = threads.sum(&:value)
56
+ elapsed = monotonic_time - started_at
57
+
58
+ operation_count / elapsed
59
+ end
60
+
61
+ BENCHMARK_CASES.each_value do |operation|
62
+ 5_000.times { operation.call }
63
+ end
64
+
65
+ puts "ruby=#{RUBY_DESCRIPTION}"
66
+ puts "engine=#{RUBY_ENGINE}"
67
+ puts "host_cpu=#{RbConfig::CONFIG.fetch("host_cpu")}"
68
+ puts "seconds_per_case=#{RUN_SECONDS}"
69
+ puts "case,threads,operations_per_second"
70
+
71
+ BENCHMARK_CASES.each do |name, operation|
72
+ THREAD_COUNTS.each do |thread_count|
73
+ operations_per_second = measure(operation, thread_count)
74
+ puts format(
75
+ "%<name>s,%<threads>d,%<operations>d",
76
+ name: name, threads: thread_count, operations: operations_per_second.round,
77
+ )
78
+ end
79
+ end
@@ -0,0 +1,124 @@
1
+ # 1. Copy-on-write config publication
2
+
3
+ ## Status
4
+
5
+ Accepted.
6
+
7
+ ## Context
8
+
9
+ `Retriable.config` is a single mutable object read on every `Retriable.retriable`
10
+ call and written rarely, usually once at boot. That shape had three races:
11
+
12
+ - `@config ||= Config.new` was check-then-act, so two threads booting at once
13
+ could build two different `Config` objects.
14
+ - `retriable` read attributes off the live shared config one at a time, so a
15
+ concurrent `configure` could hand a single call an inconsistent mix of old and
16
+ new values.
17
+ - `with_context` read the config more than once. The existence check and the
18
+ option resolution could straddle a `configure`. The check could pass against
19
+ the old snapshot before resolution silently dropped the context's retry
20
+ options from the new one.
21
+
22
+ Reads vastly outnumber writes, so a scheme that keeps readers cheap and pays the
23
+ cost on the writer is the right trade.
24
+
25
+ ## Decision
26
+
27
+ Configuration is copy-on-write.
28
+
29
+ `configure` takes `CONFIG_MUTEX`, duplicates the published config, yields the
30
+ duplicate, and publishes it only if the block returns without raising. Writers
31
+ serialize against each other; a raising block leaves the previous config in
32
+ place. Publication does not change the method's return contract: `configure`
33
+ returns the block's result, not the candidate or published snapshot.
34
+
35
+ **One publication seam, one engine path.** `CONFIG_PUBLICATION_MUTEX` guards the
36
+ `@config` reference and is held only for a reference read or the publishing
37
+ write, never across the user's block, so a writer blocks a reader for no longer
38
+ than a pointer swap. We do not special-case MRI. An unsynchronized read would be
39
+ safe there thanks to the GVL, but it would leave a second memory model to
40
+ maintain for JRuby and TruffleRuby.
41
+
42
+ The mutex is part of the hot path. A `retriable` call with no local options or
43
+ override uses the published `Config` directly, so it does not otherwise pay for
44
+ `Config.new`, `to_h`, or a merge. `benchmark/config_publication.rb` measures both
45
+ raw config reads and successful retry calls with one or more reader threads. We
46
+ accept the mutex cost for a portable memory-visibility guarantee, but we do not
47
+ assume that cost is free. If the benchmark shows material contention on a
48
+ supported engine, `Concurrent::AtomicReference` is the preferred alternative.
49
+ An engine-conditional unsynchronized read is not.
50
+
51
+ **The published snapshot is deeply frozen.** Atomic publication alone does not
52
+ deliver a consistent read: if the published object stays mutable, any caller can
53
+ still do `Retriable.config.contexts[:api][:tries] = 1` and corrupt what every
54
+ other thread is reading. `Config#freeze` therefore freezes `on`, `intervals` and
55
+ `contexts` recursively before freezing the config itself. `configure` publishes a
56
+ *copy* of the candidate so freezing never reaches a container the caller still
57
+ owns (`c.on = my_array` must not leave `my_array` frozen).
58
+
59
+ **`dup` copies containers, not leaves, and never preserves frozen state.**
60
+ `Config#initialize_copy` deep-copies `on`, `intervals` and `contexts`; scalars,
61
+ procs, exception classes and regexps are shared by reference. A Hash's mutable
62
+ default *value* is part of the copied graph, because a shared one would let
63
+ `config.contexts[:absent] << x` reach the caller's object; a `default_proc`
64
+ remains a shared callable leaf. Copies start from `#dup` rather than a fresh
65
+ literal so a container's class and a Hash's default behavior survive. Rebuilding
66
+ into a bare `{}` would silently downgrade an indifferent-access `contexts` hash
67
+ and break string-key lookups. Frozen state is deliberately dropped, because a dup
68
+ is the mutable working copy a `configure` block mutates; publication re-freezes
69
+ it.
70
+
71
+ Hash *keys* are left as-is. Ruby already dups and freezes an unfrozen String key
72
+ on assignment. The supported key types, Symbols for `contexts` and exception
73
+ classes for `on`, are immutable. Copying keys would buy immutability only for
74
+ container keys, still miss arbitrary mutable objects, and break `compare_by_identity`
75
+ lookups, so the boundary stays where Ruby puts it.
76
+
77
+ Deep immutability covers the containers owned by `Config`. Callable leaves can
78
+ hold their own mutable state, and a shared `default_proc` can mutate state outside
79
+ the config. Callers remain responsible for synchronizing that state when the
80
+ callable runs from multiple threads.
81
+
82
+ **Two mechanisms, two questions.** A thread-local (`CONFIGURING_THREAD_KEY`)
83
+ answers "is this thread mid-`configure`?", which is what lets the configuring
84
+ thread and its fibers see their own candidate. Which snapshot a *single*
85
+ `retriable`/`with_context` call resolves against is a different question,
86
+ answered by passing that snapshot as an argument to `retriable_with_config`. A
87
+ thread-local would leak the resolved snapshot across the caller's block and
88
+ change what `Retriable.config` returns inside it, so the two are kept apart. The
89
+ snapshot travels one hop; `with_context` resolves its contexts hash once and
90
+ passes that.
91
+
92
+ **Nested calls join the outer transaction.** A nested `configure` sees the
93
+ candidate stored in `CONFIGURING_THREAD_KEY` and yields that same object without
94
+ taking `CONFIG_MUTEX` again. This preserves the behavior supported before
95
+ copy-on-write and avoids recursive locking. The outermost block alone publishes.
96
+ If it raises, Retriable discards every change made by nested blocks. A nested call
97
+ does not create an independent commit or savepoint. Fibers on the configuring
98
+ thread share the transaction because `CONFIGURING_THREAD_KEY` is a true thread
99
+ local.
100
+
101
+ ## Consequences
102
+
103
+ - Direct mutation of `Retriable.config` now raises `FrozenError`. This is a
104
+ breaking change for code that reached around `configure`; the migration is to
105
+ use `configure`. Reading `Retriable.config` is unaffected. This change will
106
+ ship in Retriable 5.0.
107
+ - Nested `configure` remains supported. Nested blocks mutate the outer working
108
+ copy and do not publish separately.
109
+ - Writer blocks are serialized for their full duration. A block must not wait
110
+ for work that may call `configure`, because that work cannot acquire
111
+ `CONFIG_MUTEX` until the current block returns. Readers do not take that mutex
112
+ and continue using the last published snapshot.
113
+ - `configure` pays two deep copies per call (one to build the candidate, one to
114
+ take ownership before freezing). `configure` is a rare, usually boot-time
115
+ operation, so this is not on any hot path.
116
+ - Every `retriable` call takes one uncontended mutex to read the config
117
+ reference. The checked-in benchmark measures the cost on CRuby and JRuby. A
118
+ measured optimization may replace the publication mechanism, but it must keep
119
+ one memory model across supported engines.
120
+ - The structural snapshot is thread-safe. User-supplied callbacks and other
121
+ callable leaves must synchronize their own mutable state.
122
+ - `Retriable.config` is still a global. This ADR makes the global safe to read
123
+ concurrently; it does not introduce per-thread, per-instance, or Ractor-safe
124
+ configuration.
@@ -0,0 +1,38 @@
1
+ # Domain Docs
2
+
3
+ How the engineering skills should consume this repo's domain documentation when exploring the codebase.
4
+
5
+ This repo is **single-context**: one `CONTEXT.md` and one `docs/adr/` at the root.
6
+
7
+ ## Before exploring, read these
8
+
9
+ - **`CONTEXT.md`** at the repo root
10
+ - **`docs/adr/`**: read ADRs that touch the area you're about to work in.
11
+
12
+ If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
13
+
14
+ ## File structure
15
+
16
+ ```
17
+ /
18
+ ├── CONTEXT.md
19
+ ├── docs/adr/
20
+ │ ├── 0001-copy-on-write-config.md
21
+ │ └── 0002-randomized-exponential-backoff.md
22
+ ├── lib/
23
+ └── spec/
24
+ ```
25
+
26
+ Design decisions belong in `docs/adr/` with a numbered filename. Don't scatter them into other `docs/` subdirectories.
27
+
28
+ ## Use the glossary's vocabulary
29
+
30
+ When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
31
+
32
+ If the concept you need isn't in the glossary yet, that's a signal: either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
33
+
34
+ ## Flag ADR conflicts
35
+
36
+ If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
37
+
38
+ > _Contradicts ADR-0007 (event-sourced orders), but worth reopening because…_
@@ -0,0 +1,45 @@
1
+ # Issue tracker: GitHub
2
+
3
+ Issues and specs for this repo live as GitHub issues on `kamui/retriable`. Use the `gh` CLI for all operations.
4
+
5
+ ## Conventions
6
+
7
+ - **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
8
+ - **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels.
9
+ - **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
10
+ - **Comment on an issue**: `gh issue comment <number> --body "..."`
11
+ - **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
12
+ - **Close**: `gh issue close <number> --comment "..."`
13
+
14
+ Infer the repo from `git remote -v`; `gh` does this automatically when run inside a clone.
15
+
16
+ ## Pull requests as a triage surface
17
+
18
+ **PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_
19
+
20
+ When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents:
21
+
22
+ - **Read a PR**: `gh pr view <number> --comments` and `gh pr diff <number>` for the diff.
23
+ - **List external PRs for triage**: `gh pr list --state open --json number,title,body,labels,author,authorAssociation,comments` then keep only `authorAssociation` of `CONTRIBUTOR`, `FIRST_TIME_CONTRIBUTOR`, or `NONE` (drop `OWNER`/`MEMBER`/`COLLABORATOR`).
24
+ - **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`.
25
+
26
+ GitHub shares one number space across issues and PRs, so a bare `#42` may be either: resolve with `gh pr view 42` and fall back to `gh issue view 42`.
27
+
28
+ ## When a skill says "publish to the issue tracker"
29
+
30
+ Create a GitHub issue.
31
+
32
+ ## When a skill says "fetch the relevant ticket"
33
+
34
+ Run `gh issue view <number> --comments`.
35
+
36
+ ## Wayfinding operations
37
+
38
+ Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets.
39
+
40
+ - **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `gh issue create --label wayfinder:map`.
41
+ - **Child ticket**: an issue linked to the map as a GitHub sub-issue (`gh api` on the sub-issues endpoint). Where sub-issues aren't enabled, add the child to a task list in the map body and put `Part of #<map>` at the top of the child body. Labels: `wayfinder:<type>` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev.
42
+ - **Blocking**: GitHub's **native issue dependencies**, the canonical, UI-visible representation. Add an edge with `gh api --method POST repos/<owner>/<repo>/issues/<child>/dependencies/blocked_by -F issue_id=<blocker-db-id>`, where `<blocker-db-id>` is the blocker's numeric **database id** (`gh api repos/<owner>/<repo>/issues/<n> --jq .id`, _not_ the `#number` or `node_id`). GitHub reports `issue_dependencies_summary.blocked_by` (open blockers only, the live gate). Where dependencies aren't available, fall back to a `Blocked by: #<n>, #<n>` line at the top of the child body. A ticket is unblocked when every blocker is closed.
43
+ - **Frontier query**: list the map's open children (`gh issue list --state open`, scoped to the map's sub-issues / task list), drop any with an open blocker (`issue_dependencies_summary.blocked_by > 0`, or an open issue in the `Blocked by` line) or an assignee; first in map order wins.
44
+ - **Claim**: `gh issue edit <n> --add-assignee @me`, the session's first write.
45
+ - **Resolve**: `gh issue comment <n> --body "<answer>"`, then `gh issue close <n>`, then append a context pointer (gist + link) to the map's Decisions-so-far.
@@ -0,0 +1,17 @@
1
+ # Triage Labels
2
+
3
+ The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
4
+
5
+ | Label in mattpocock/skills | Label in our tracker | Meaning |
6
+ | -------------------------- | -------------------- | ---------------------------------------- |
7
+ | `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
8
+ | `needs-info` | `needs-info` | Waiting on reporter for more information |
9
+ | `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
10
+ | `ready-for-human` | `ready-for-human` | Requires human implementation |
11
+ | `wontfix` | `wontfix` | Will not be actioned |
12
+
13
+ When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
14
+
15
+ Edit the right-hand column to match whatever vocabulary you actually use.
16
+
17
+ None of these labels exist in `kamui/retriable` yet. Create one on first use with `gh label create <name>`; the repo's existing labels (`question`, `dependencies`, `ruby`) don't collide with any of them.
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.
@@ -11,43 +11,36 @@ module Retriable
11
11
  sleep_disabled
12
12
  max_elapsed_time
13
13
  intervals
14
- timeout
15
14
  on
16
15
  retry_if
17
16
  on_retry
17
+ on_give_up
18
18
  contexts
19
19
  ]).freeze
20
20
 
21
- TIMEOUT_DEPRECATION_MESSAGE = "NOTE: Retriable's `timeout:` option is deprecated and will be removed in " \
22
- "Retriable 4.0. It is a thin wrapper around `Timeout.timeout`, which " \
23
- "can interrupt execution at arbitrary lines and corrupt internal state " \
24
- "in libraries that are not interrupt-safe. Prefer your library's native " \
25
- "timeout, or wrap your block in `Timeout.timeout(...)` yourself."
26
- private_constant :TIMEOUT_DEPRECATION_MESSAGE
21
+ CONTEXT_ATTRIBUTES = (ATTRIBUTES - %i[contexts]).freeze
22
+ private_constant :CONTEXT_ATTRIBUTES
27
23
 
28
- @timeout_deprecation_warned = false
29
-
30
- class << self
31
- attr_accessor :timeout_deprecation_warned
32
- end
24
+ OWNED_CONTAINER_ATTRIBUTES = %i[on intervals contexts].freeze
25
+ private_constant :OWNED_CONTAINER_ATTRIBUTES
33
26
 
34
27
  attr_accessor(*ATTRIBUTES)
35
28
 
36
29
  def initialize(opts = {})
37
- backoff = ExponentialBackoff.new
30
+ defaults = ExponentialBackoff::DEFAULTS
38
31
 
39
- @tries = backoff.tries
40
- @base_interval = backoff.base_interval
41
- @max_interval = backoff.max_interval
42
- @rand_factor = backoff.rand_factor
43
- @multiplier = backoff.multiplier
32
+ @tries = defaults[:tries]
33
+ @base_interval = defaults[:base_interval]
34
+ @max_interval = defaults[:max_interval]
35
+ @rand_factor = defaults[:rand_factor]
36
+ @multiplier = defaults[:multiplier]
44
37
  @sleep_disabled = false
45
38
  @max_elapsed_time = 900 # 15 min
46
39
  @intervals = nil
47
- @timeout = nil
48
40
  @on = [StandardError]
49
41
  @retry_if = nil
50
42
  @on_retry = nil
43
+ @on_give_up = nil
51
44
  @contexts = {}
52
45
 
53
46
  opts.each do |k, v|
@@ -60,14 +53,14 @@ module Retriable
60
53
  end
61
54
 
62
55
  def to_h
63
- ATTRIBUTES.each_with_object({}) do |key, hash|
64
- hash[key] = public_send(key)
65
- end
56
+ ATTRIBUTES.to_h { |key| [key, public_send(key)] }
66
57
  end
67
58
 
68
59
  def validate!
69
- warn_timeout_deprecation
70
- validate_optional_non_negative_number(:timeout, timeout)
60
+ validate_contexts
61
+ validate_callable(:retry_if, retry_if)
62
+ validate_callable(:on_retry, on_retry)
63
+ validate_callable(:on_give_up, on_give_up)
71
64
  validate_on(on)
72
65
  validate_intervals
73
66
  if unbounded_tries?(tries)
@@ -82,43 +75,122 @@ module Retriable
82
75
  validate_backoff_options
83
76
  end
84
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
+
85
95
  private
86
96
 
87
- # Emits the `timeout:` deprecation notice at most once per process.
97
+ def validate_contexts
98
+ return unless contexts.is_a?(Hash)
99
+ return if contexts.empty?
100
+
101
+ contexts.each_value do |options|
102
+ next unless options.is_a?(Hash)
103
+
104
+ options.each_key do |k|
105
+ next if CONTEXT_ATTRIBUTES.include?(k)
106
+
107
+ raise ArgumentError, "#{k} is not a valid option"
108
+ end
109
+ end
110
+ end
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.
88
122
  #
89
- # On Rubies that support `Kernel#warn(category: :deprecated)` (2.7+), the
90
- # notice is emitted under the `:deprecated` category, so callers can use the
91
- # standard controls (`Warning[:deprecated] = false`, `-W:no-deprecated`,
92
- # `Warning.warn` override) to silence it. On older Rubies the kwarg is not
93
- # available and we fall back to plain `Kernel.warn`.
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.
94
127
  #
95
- # When the warning is suppressed (either because `Warning[:deprecated]` is
96
- # false or the runtime has otherwise muted the category), we deliberately
97
- # leave the once-per-process flag unset so a future call with the category
98
- # re-enabled still surfaces the notice.
99
- def warn_timeout_deprecation
100
- return if timeout.nil?
101
- return if self.class.timeout_deprecation_warned
102
-
103
- category_supported = deprecated_warning_category_supported?
104
- return if category_supported && !deprecated_warnings_enabled?
105
-
106
- self.class.timeout_deprecation_warned = true
107
- if category_supported
108
- Kernel.warn(TIMEOUT_DEPRECATION_MESSAGE, category: :deprecated)
109
- else
110
- Kernel.warn(TIMEOUT_DEPRECATION_MESSAGE)
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) })
111
152
  end
112
153
  end
113
154
 
114
- def deprecated_warning_category_supported?
115
- defined?(Warning) && Kernel.method(:warn).parameters.any? { |type, name| type == :key && name == :category }
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
116
186
  end
117
187
 
118
- def deprecated_warnings_enabled?
119
- return true unless defined?(Warning) && Warning.respond_to?(:[])
188
+ def deep_freeze_collection(value, seen)
189
+ return value if seen[value]
120
190
 
121
- Warning[:deprecated]
191
+ seen[value] = true
192
+ value.each { |val| deep_freeze(val, seen) }
193
+ value.freeze
122
194
  end
123
195
 
124
196
  def validate_backoff_options
@@ -3,11 +3,13 @@
3
3
  require_relative "../../retriable"
4
4
 
5
5
  module Kernel
6
- def retriable(opts = {}, &block)
7
- Retriable.retriable(opts, &block)
6
+ def retriable(opts = {}, &)
7
+ Retriable.retriable(opts, &)
8
8
  end
9
9
 
10
- def retriable_with_context(context_key, opts = {}, &block)
11
- Retriable.with_context(context_key, opts, &block)
10
+ def retriable_with_context(context_key, opts = {}, &)
11
+ Retriable.with_context(context_key, opts, &)
12
12
  end
13
+
14
+ private :retriable, :retriable_with_context
13
15
  end
@@ -14,14 +14,22 @@ module Retriable
14
14
  rand_factor
15
15
  ].freeze
16
16
 
17
+ DEFAULTS = {
18
+ tries: 3,
19
+ base_interval: 0.5,
20
+ max_interval: 60,
21
+ rand_factor: 0.5,
22
+ multiplier: 1.5
23
+ }.freeze
24
+
17
25
  attr_accessor(*ATTRIBUTES)
18
26
 
19
27
  def initialize(opts = {})
20
- @tries = 3
21
- @base_interval = 0.5
22
- @max_interval = 60
23
- @rand_factor = 0.5
24
- @multiplier = 1.5
28
+ @tries = DEFAULTS[:tries]
29
+ @base_interval = DEFAULTS[:base_interval]
30
+ @max_interval = DEFAULTS[:max_interval]
31
+ @rand_factor = DEFAULTS[:rand_factor]
32
+ @multiplier = DEFAULTS[:multiplier]
25
33
 
26
34
  opts.each do |k, v|
27
35
  raise ArgumentError, "#{k} is not a valid option" if !ATTRIBUTES.include?(k)