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.
- checksums.yaml +4 -4
- data/.github/workflows/main.yml +38 -9
- data/.hound.yml +1 -1
- data/.rubocop.yml +4 -1
- data/AGENTS.md +15 -0
- data/CHANGELOG.md +159 -0
- data/Gemfile +6 -1
- data/README.md +131 -50
- 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 +123 -51
- data/lib/retriable/core_ext/kernel.rb +6 -4
- data/lib/retriable/exponential_backoff.rb +13 -5
- data/lib/retriable/validation.rb +11 -7
- data/lib/retriable/version.rb +1 -1
- data/lib/retriable.rb +155 -45
- data/retriable.gemspec +2 -7
- data/sig/retriable.rbs +29 -1
- data/spec/config_spec.rb +212 -97
- data/spec/retriable_spec.rb +666 -104
- data/spec/spec_helper.rb +3 -14
- metadata +14 -53
|
@@ -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.
|
data/lib/retriable/config.rb
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
+
defaults = ExponentialBackoff::DEFAULTS
|
|
38
31
|
|
|
39
|
-
@tries =
|
|
40
|
-
@base_interval =
|
|
41
|
-
@max_interval =
|
|
42
|
-
@rand_factor =
|
|
43
|
-
@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.
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
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
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
|
119
|
-
return
|
|
188
|
+
def deep_freeze_collection(value, seen)
|
|
189
|
+
return value if seen[value]
|
|
120
190
|
|
|
121
|
-
|
|
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 = {}, &
|
|
7
|
-
Retriable.retriable(opts, &
|
|
6
|
+
def retriable(opts = {}, &)
|
|
7
|
+
Retriable.retriable(opts, &)
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def retriable_with_context(context_key, opts = {}, &
|
|
11
|
-
Retriable.with_context(context_key, opts, &
|
|
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 =
|
|
21
|
-
@base_interval =
|
|
22
|
-
@max_interval =
|
|
23
|
-
@rand_factor =
|
|
24
|
-
@multiplier =
|
|
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)
|