chrono_forge 0.9.1 → 0.11.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/CHANGELOG.md +56 -1
- data/README.md +390 -46
- data/Rakefile +4 -0
- data/cliff.toml +62 -0
- data/docs/design/per-child-commit-overhead.md +213 -0
- data/docs/fanout-scale-test.md +247 -0
- data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md +1748 -0
- data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md.tasks.json +17 -0
- data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md +930 -0
- data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md.tasks.json +54 -0
- data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md +241 -0
- data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md.tasks.json +12 -0
- data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md +1378 -0
- data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md.tasks.json +67 -0
- data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md +709 -0
- data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md.tasks.json +19 -0
- data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md +205 -0
- data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md.tasks.json +33 -0
- data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md +1373 -0
- data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md.tasks.json +68 -0
- data/docs/superpowers/specs/2026-06-03-unified-retry-policy-design.md +226 -0
- data/docs/superpowers/specs/2026-06-25-chrono_forge-dashboard-design.md +190 -0
- data/docs/superpowers/specs/2026-06-25-composite-retry-policies-design.md +228 -0
- data/docs/superpowers/specs/2026-06-25-reserved-kwarg-guard-design.md +169 -0
- data/docs/superpowers/specs/2026-06-25-spawn-merge-branches-design.md +468 -0
- data/docs/superpowers/specs/2026-06-26-dashboard-branch-view-design.md +142 -0
- data/docs/superpowers/specs/2026-06-26-deferral-continuation-race-and-catchup-design.md +265 -0
- data/docs/superpowers/specs/2026-07-01-workflow-definition-dag-design.md +203 -0
- data/lib/chrono_forge/branch_merge_job.rb +275 -0
- data/lib/chrono_forge/branch_probe.rb +70 -0
- data/lib/chrono_forge/cleanup.rb +6 -0
- data/lib/chrono_forge/configuration.rb +25 -0
- data/lib/chrono_forge/definition.rb +37 -0
- data/lib/chrono_forge/definition_analyzer.rb +501 -0
- data/lib/chrono_forge/execution_log.rb +6 -0
- data/lib/chrono_forge/executor/composite_retry_policy.rb +47 -0
- data/lib/chrono_forge/executor/context.rb +23 -0
- data/lib/chrono_forge/executor/lock_strategy.rb +10 -3
- data/lib/chrono_forge/executor/methods/branch.rb +185 -0
- data/lib/chrono_forge/executor/methods/continue_if.rb +15 -6
- data/lib/chrono_forge/executor/methods/durably_execute.rb +36 -26
- data/lib/chrono_forge/executor/methods/durably_repeat.rb +148 -39
- data/lib/chrono_forge/executor/methods/merge_branches.rb +84 -0
- data/lib/chrono_forge/executor/methods/wait.rb +2 -4
- data/lib/chrono_forge/executor/methods/wait_until.rb +25 -25
- data/lib/chrono_forge/executor/methods/workflow_states.rb +50 -46
- data/lib/chrono_forge/executor/methods.rb +2 -0
- data/lib/chrono_forge/executor/retry_policy.rb +111 -0
- data/lib/chrono_forge/executor.rb +241 -28
- data/lib/chrono_forge/version.rb +1 -1
- data/lib/chrono_forge/workflow.rb +10 -1
- data/lib/chrono_forge.rb +8 -0
- data/lib/generators/chrono_forge/migration_actions.rb +1 -0
- data/lib/generators/chrono_forge/templates/add_chrono_forge_parent_execution_log.rb +38 -0
- data/lib/tasks/release.rake +212 -0
- metadata +67 -4
- data/lib/chrono_forge/executor/retry_strategy.rb +0 -29
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# Composite Retry Policies — Design
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-06-25
|
|
4
|
+
**Status:** Approved (pending spec review)
|
|
5
|
+
**Scope:** Internal API, additive. Builds on the unified `RetryPolicy`
|
|
6
|
+
(2026-06-03). No breaking change — the common single-policy path is byte-for-byte
|
|
7
|
+
unchanged.
|
|
8
|
+
|
|
9
|
+
## Problem
|
|
10
|
+
|
|
11
|
+
The unified `RetryPolicy` answers "should we retry?" with a single
|
|
12
|
+
`max_attempts`/`backoff`/`retry_on` tuple per retry site. A single tuple cannot
|
|
13
|
+
express *different behavior per error type* — yet that is exactly what real
|
|
14
|
+
workflows (fintech especially) need:
|
|
15
|
+
|
|
16
|
+
- `NetworkError` → retry aggressively, short backoff
|
|
17
|
+
- `RateLimitError` → retry more, longer backoff
|
|
18
|
+
- `PaymentDeclinedError` → fail immediately, do not retry
|
|
19
|
+
|
|
20
|
+
Today you must pick one policy for the whole step. A `retry_on:` allowlist filters
|
|
21
|
+
*which* errors retry, but every retried error shares one `max_attempts` and one
|
|
22
|
+
backoff curve.
|
|
23
|
+
|
|
24
|
+
## Goal
|
|
25
|
+
|
|
26
|
+
Let a retry site be configured with an **ordered list** of `RetryPolicy` objects.
|
|
27
|
+
On failure, the **first** sub-policy whose `retry_on` matches the raised error
|
|
28
|
+
applies its own `max_attempts`/`backoff`. Each error type gets an **independent
|
|
29
|
+
attempt budget**.
|
|
30
|
+
|
|
31
|
+
The single-policy path is untouched: it keeps using the site's own `attempts`
|
|
32
|
+
counter, with no extra state.
|
|
33
|
+
|
|
34
|
+
## Decisions (locked during brainstorming)
|
|
35
|
+
|
|
36
|
+
| Decision | Choice |
|
|
37
|
+
|---|---|
|
|
38
|
+
| Counting semantics | **Per-error budgets.** A sub-policy's `max_attempts` counts only failures routed to it. |
|
|
39
|
+
| Where the count lives | A `retry_counts` map keyed by **matched-policy index**. Steps: in the (execution/repetition) log `metadata`. Workflow-level: in the job args, beside `attempt:`. No new column, no `error_logs` query. |
|
|
40
|
+
| Subclass safety | Routing happens **once, on the live error** via `matches?`/`is_a?` — subclass-correct. The count is keyed by the matched policy, never by error class, so no constantizing or class-string matching at decision time. |
|
|
41
|
+
| Single-policy path | Unchanged — uses the site's `attempts` counter directly, no `retry_counts`. |
|
|
42
|
+
| Construction | Named factory `RetryPolicy.compose(*policies)`; passing an `Array` to `retry_policy:` coerces through the same factory. |
|
|
43
|
+
| Scope | **All four sites**, including the workflow-level class default. |
|
|
44
|
+
| Routing | First match wins. `retry_on: nil` (catch-all) typically last; no match → no retry (fail fast). |
|
|
45
|
+
| Purity | `RetryPolicy` and `CompositeRetryPolicy` stay pure value objects. The per-error count is incremented/read by the executor via a block; the policies never touch storage. |
|
|
46
|
+
|
|
47
|
+
## Why increment-then-check preserves existing semantics
|
|
48
|
+
|
|
49
|
+
`attempts` is 1-based and includes the failure that just happened (so on the
|
|
50
|
+
first failure `attempts == 1`, and `retryable?` checks `attempts < max_attempts`).
|
|
51
|
+
The composite mirrors this: on each failure it **increments** the matched
|
|
52
|
+
policy's counter **then** checks, so the value handed to `retryable?` is "failures
|
|
53
|
+
routed to this policy so far, including the current one" — the same shape the
|
|
54
|
+
single-policy path already uses. The two notions agree, so a per-error count
|
|
55
|
+
substitutes cleanly wherever a plain policy uses `attempts`.
|
|
56
|
+
|
|
57
|
+
## Components
|
|
58
|
+
|
|
59
|
+
### 1. `RetryPolicy` (existing — additions, pure)
|
|
60
|
+
|
|
61
|
+
- `matches?(error)` — public routing predicate; wraps the existing private
|
|
62
|
+
`retryable_error?`. `retry_on: nil` matches any `StandardError` (catch-all);
|
|
63
|
+
`retry_on: []` matches nothing. Subclass-correct (`error.is_a?`).
|
|
64
|
+
- `retry_backoff(error, attempts:) { |policy_index| count }` — returns the backoff
|
|
65
|
+
`Duration` to retry, or `nil` to stop. The plain policy **ignores the block**
|
|
66
|
+
and uses `attempts`:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
def retry_backoff(error, attempts:)
|
|
70
|
+
retryable?(error, attempts) ? backoff_for(attempts) : nil
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- `self.compose(*policies)` — factory returning a `CompositeRetryPolicy`.
|
|
75
|
+
|
|
76
|
+
`retryable?`, `backoff_for`, `max_attempts` are unchanged (tests and the
|
|
77
|
+
single-policy path depend on them).
|
|
78
|
+
|
|
79
|
+
### 2. `CompositeRetryPolicy` (new — `executor/composite_retry_policy.rb`, pure)
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
class CompositeRetryPolicy
|
|
83
|
+
attr_reader :policies
|
|
84
|
+
|
|
85
|
+
def initialize(policies)
|
|
86
|
+
@policies = Array(policies)
|
|
87
|
+
raise ArgumentError, "composite retry policy needs at least one policy" if @policies.empty?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# First sub-policy whose retry_on matches the error, or nil.
|
|
91
|
+
def policy_for(error)
|
|
92
|
+
@policies.find { |p| p.matches?(error) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Routes on the *live* error, yields the matched policy's index so the caller
|
|
96
|
+
# can increment and return that policy's running count, then delegates the
|
|
97
|
+
# decision to the matched sub-policy.
|
|
98
|
+
def retry_backoff(error, attempts:)
|
|
99
|
+
idx = @policies.index { |p| p.matches?(error) }
|
|
100
|
+
return nil if idx.nil?
|
|
101
|
+
|
|
102
|
+
sub = @policies[idx]
|
|
103
|
+
count = block_given? ? yield(idx) : attempts
|
|
104
|
+
sub.retryable?(error, count) ? sub.backoff_for(count) : nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Coarsest bound, for the workflow-level safety-net guard in `perform`.
|
|
108
|
+
# nil if any sub-policy is unbounded.
|
|
109
|
+
def max_attempts
|
|
110
|
+
caps = @policies.map(&:max_attempts)
|
|
111
|
+
caps.include?(nil) ? nil : caps.max
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Routing is by `matches?`, which is `is_a?`-based, so a subclass of a `retry_on`
|
|
117
|
+
class routes to the right policy. The returned **index** — not the error class —
|
|
118
|
+
is the counter key, so subclasses share the budget of the policy they routed to,
|
|
119
|
+
exactly as intended.
|
|
120
|
+
|
|
121
|
+
### 3. Executor wiring
|
|
122
|
+
|
|
123
|
+
- **`coerce_policy(value)`** — `Array` → `RetryPolicy.compose(*value)`; a
|
|
124
|
+
`RetryPolicy` or `CompositeRetryPolicy` passes through; `nil` → `nil`. Applied
|
|
125
|
+
in `step_retry_policy` and `wait_retry_policy`, and to the class DSL.
|
|
126
|
+
|
|
127
|
+
- **Class DSL** `retry_policy(*policies, **opts)` — positional policies →
|
|
128
|
+
`RetryPolicy.compose(*policies)` stored as `default_retry_policy`; kwargs only →
|
|
129
|
+
`RetryPolicy.new(**opts)` (unchanged). Mixing both raises `ArgumentError`.
|
|
130
|
+
|
|
131
|
+
- **Per-error counter, step sites** — one helper, incrementing the matched
|
|
132
|
+
policy's slot in the log metadata and returning the new count:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
RETRY_COUNTS_KEY = "retry_counts"
|
|
136
|
+
|
|
137
|
+
def bump_retry_count!(log, policy_index)
|
|
138
|
+
meta = log.metadata || {}
|
|
139
|
+
counts = meta[RETRY_COUNTS_KEY] || {}
|
|
140
|
+
key = policy_index.to_s
|
|
141
|
+
counts[key] = counts[key].to_i + 1
|
|
142
|
+
meta[RETRY_COUNTS_KEY] = counts
|
|
143
|
+
log.update!(metadata: meta) # explicit reassign so the JSON column is marked dirty
|
|
144
|
+
counts[key]
|
|
145
|
+
end
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
- **Per-error counter, workflow level** — `perform` gains `retry_counts: {}` in
|
|
149
|
+
its signature; the failure path increments the in-memory map and threads it
|
|
150
|
+
through the reschedule, beside `attempt:`. No DB write (mirrors `attempt:`).
|
|
151
|
+
|
|
152
|
+
- **The four retry sites** change from the `retryable? … backoff_for` pair to a
|
|
153
|
+
single `retry_backoff` call carrying the site's counter block. A single policy
|
|
154
|
+
ignores the block, so its path is unchanged and writes no `retry_counts`:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
backoff = policy.retry_backoff(e, attempts: COUNT) { |idx| <bump counter for idx> }
|
|
158
|
+
if backoff
|
|
159
|
+
self.class.set(wait: backoff).perform_later(@workflow.key, *site_args)
|
|
160
|
+
halt_execution!
|
|
161
|
+
else
|
|
162
|
+
# site-specific terminal action
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Per-site `COUNT` / counter store / terminal action:
|
|
167
|
+
|
|
168
|
+
| Site | `COUNT` (single-policy) | Composite counter store | Terminal action |
|
|
169
|
+
|---|---|---|---|
|
|
170
|
+
| `perform` (workflow) | `attempt + 1` | job-args `retry_counts` (rescheduled with `attempt: attempts_made, retry_counts:`) | `fail_workflow!(error_log)` |
|
|
171
|
+
| `durably_execute` | `execution_log.attempts` | `execution_log.metadata` | mark failed, raise `ExecutionFailedError` |
|
|
172
|
+
| `durably_repeat` | `repetition_log.attempts` | `repetition_log.metadata` | `on_error` (`:continue` / `:fail_workflow`) |
|
|
173
|
+
| `wait_until` | `execution_log.attempts` | `execution_log.metadata` | mark failed, raise `ExecutionFailedError` |
|
|
174
|
+
|
|
175
|
+
`durably_repeat` keys its counter on the per-repetition log, so each repetition
|
|
176
|
+
gets its own independent per-error budgets.
|
|
177
|
+
|
|
178
|
+
## Notable properties
|
|
179
|
+
|
|
180
|
+
- **Per-error backoff escalation.** `backoff_for(count)` uses the per-error count
|
|
181
|
+
as the exponent, so each error type's backoff escalates on its own schedule.
|
|
182
|
+
- **No class-string matching at decision time.** Subclass resolution is a single
|
|
183
|
+
`is_a?` on the live error during routing; the counter is keyed by policy index.
|
|
184
|
+
- **`wait_until` still does not inherit the class default.** A per-call array is
|
|
185
|
+
coerced normally; the class-level composite default does not leak in.
|
|
186
|
+
- **Workflow-level safety net.** `perform`'s early guard
|
|
187
|
+
(`policy.max_attempts && attempt >= policy.max_attempts`) keeps working because
|
|
188
|
+
`CompositeRetryPolicy#max_attempts` returns the coarsest bound — a safe
|
|
189
|
+
over-estimate that never kills a workflow prematurely.
|
|
190
|
+
- **Ordering matters.** Specific policies first, catch-all (`retry_on: nil`) last;
|
|
191
|
+
without a catch-all, an unmatched error fails fast. (Documented footgun.)
|
|
192
|
+
- **Mid-flight reorder caveat.** Counts are keyed by policy index, so reordering a
|
|
193
|
+
composite's policies while a long-running workflow is in flight can misattribute
|
|
194
|
+
in-progress counts. Reordering retry config mid-workflow is ambiguous under any
|
|
195
|
+
scheme; documented as a known edge.
|
|
196
|
+
|
|
197
|
+
## Testing
|
|
198
|
+
|
|
199
|
+
**Unit — `CompositeRetryPolicy`**
|
|
200
|
+
- routing: first match wins; specific-before-catch-all
|
|
201
|
+
- catch-all (`retry_on: nil`) matches anything; `retry_on: []` matches nothing
|
|
202
|
+
- subclass of a `retry_on` class routes to that policy (and yields its index)
|
|
203
|
+
- no match → `retry_backoff` returns `nil`
|
|
204
|
+
- `retry_backoff` yields the matched policy's index and uses the yielded count for
|
|
205
|
+
both the cap check and the backoff exponent
|
|
206
|
+
- `max_attempts` = coarsest bound; `nil` if any sub-policy unbounded
|
|
207
|
+
- empty policy list raises `ArgumentError`
|
|
208
|
+
|
|
209
|
+
**Unit — `RetryPolicy` additions**
|
|
210
|
+
- `matches?` semantics for `nil` / `[]` / class list incl. subclasses
|
|
211
|
+
- `retry_backoff` (plain) ignores the block, returns `nil` past the cap
|
|
212
|
+
- `RetryPolicy.compose` builds a `CompositeRetryPolicy`
|
|
213
|
+
|
|
214
|
+
**Unit — `bump_retry_count!`**
|
|
215
|
+
- increments the right index slot; independent slots accumulate independently
|
|
216
|
+
- reassigns `metadata` so the JSON column persists across reload
|
|
217
|
+
- `nil`/absent `metadata` initializes cleanly
|
|
218
|
+
|
|
219
|
+
**Integration**
|
|
220
|
+
- a step raising different error types accumulates independent per-error budgets
|
|
221
|
+
and per-error backoff; fail-fast policy (`max_attempts: 1`) stops immediately;
|
|
222
|
+
subclass of a `retry_on` class draws from the parent policy's budget
|
|
223
|
+
- regression: a single `RetryPolicy` (per-call, class default, built-in) behaves
|
|
224
|
+
identically to today and writes no `retry_counts`
|
|
225
|
+
- array passed to `retry_policy:` is coerced to a composite
|
|
226
|
+
- workflow-level composite default routes correctly, threads `retry_counts`
|
|
227
|
+
through reschedules, and the `perform` safety-net guard honors the coarse
|
|
228
|
+
`max_attempts`
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Reserved-keyword guard + keywords-only enqueue contract
|
|
2
|
+
|
|
3
|
+
Date: 2026-06-25
|
|
4
|
+
Status: Approved (design)
|
|
5
|
+
|
|
6
|
+
## Problem
|
|
7
|
+
|
|
8
|
+
`ChronoForge::Executor#perform` reserves several keyword parameters for internal
|
|
9
|
+
plumbing:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
def perform(key, attempt: 0, retry_counts: {}, retry_workflow: false, options: {}, **kwargs)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Anything not named here lands in `**kwargs`, is persisted on the `Workflow` row,
|
|
16
|
+
and is replayed to the user's job body via `super(**workflow.kwargs.symbolize_keys)`
|
|
17
|
+
(`executor.rb:100`).
|
|
18
|
+
|
|
19
|
+
Two problems follow from the current public enqueue surface
|
|
20
|
+
(`perform_now`/`perform_later`), which only validate that `key` is a String:
|
|
21
|
+
|
|
22
|
+
1. **Silent collision.** A user calling `MyJob.perform_later("k", attempt: 5)`
|
|
23
|
+
silently hijacks the internal retry counter instead of passing their own
|
|
24
|
+
argument. Same risk for `retry_counts` and `retry_workflow`.
|
|
25
|
+
2. **No positional contract.** Extra positional arguments produce Ruby's generic
|
|
26
|
+
`wrong number of arguments` error rather than a contract-specific message —
|
|
27
|
+
even though the executor only ever replays kwargs (keyword-only) to the job
|
|
28
|
+
body, so positionals beyond `key` can never work.
|
|
29
|
+
|
|
30
|
+
## Decisions (settled with maintainer)
|
|
31
|
+
|
|
32
|
+
- **Public keyword surface:** `key` (required, first positional), `options`
|
|
33
|
+
(free-form metadata bag), and user `**kwargs`. Nothing else.
|
|
34
|
+
- **`options` is unstructured.** The framework defines **zero** recognized option
|
|
35
|
+
keys. `options` is written to `workflow.options` (`executor.rb:159`) and only
|
|
36
|
+
ever read back by callers (`workflow.options`); no key in it drives behavior.
|
|
37
|
+
- **Reserved keys (rejected on the public path):** `attempt`, `retry_counts`,
|
|
38
|
+
`retry_workflow`. These are internal threading params; users have no legitimate
|
|
39
|
+
reason to pass them.
|
|
40
|
+
- **`retry_workflow` is internal**, reached only through the `retry_now` /
|
|
41
|
+
`retry_later` helpers — not by passing the flag directly.
|
|
42
|
+
- **Keywords-only:** exactly one positional (`key`); everything else must be a
|
|
43
|
+
keyword, enforced with a clear, contract-specific error.
|
|
44
|
+
- **No job-signature validation.** We do *not* introspect the job's `perform`
|
|
45
|
+
parameters to validate unknown/missing kwargs. Out of scope; mismatches still
|
|
46
|
+
surface at execution time as today.
|
|
47
|
+
|
|
48
|
+
## Mechanism: how internal calls bypass the guard
|
|
49
|
+
|
|
50
|
+
The split between "framework may pass reserved keys" and "users may not" rests on
|
|
51
|
+
an ActiveJob implementation detail, confirmed on ActiveJob 7.1.3.4:
|
|
52
|
+
|
|
53
|
+
> `ActiveJob::ConfiguredJob` (returned by `.set(...)`) defines its own
|
|
54
|
+
> `perform_now` / `perform_later` that build a fresh job instance and call the
|
|
55
|
+
> **instance-level** enqueue path. They do **not** dispatch through the
|
|
56
|
+
> **class-level** `perform_*` override.
|
|
57
|
+
|
|
58
|
+
Therefore any enqueue routed through `.set(...)` bypasses the guard:
|
|
59
|
+
|
|
60
|
+
- All framework continuations already use `.set(wait: …).perform_later(key, …)`
|
|
61
|
+
(`executor.rb:138`, `wait.rb`, `wait_until.rb`, `durably_repeat.rb`,
|
|
62
|
+
`durably_execute.rb`) — their `attempt:`/`retry_counts:`/`wait_condition:`
|
|
63
|
+
ride through untouched.
|
|
64
|
+
- `retry_now` / `retry_later` are rewritten to enqueue via `set.perform_*`,
|
|
65
|
+
legitimately injecting `retry_workflow: true` past the guard.
|
|
66
|
+
|
|
67
|
+
This dependency is non-obvious and now load-bearing, so it is documented inline
|
|
68
|
+
at the guard.
|
|
69
|
+
|
|
70
|
+
## Design
|
|
71
|
+
|
|
72
|
+
All changes land in `lib/chrono_forge/executor.rb`, in the `class << base` block,
|
|
73
|
+
plus one module-level constant. ~30 lines. No schema or behavior changes
|
|
74
|
+
elsewhere.
|
|
75
|
+
|
|
76
|
+
### 1. Reserved-key constant (module level, near `STEP_NAME_DELIMITER`)
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
# Keyword args ChronoForge threads through job args internally. Users must not
|
|
80
|
+
# pass these to perform_now/perform_later; the framework injects them via
|
|
81
|
+
# `.set(...)` continuations, whose ConfiguredJob proxy bypasses the class-level
|
|
82
|
+
# guard below.
|
|
83
|
+
RESERVED_KWARGS = %i[attempt retry_counts retry_workflow].freeze
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### 2. Public guards — `perform_now` / `perform_later`
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
def perform_now(key, *extra, **kwargs)
|
|
90
|
+
__validate_enqueue!(key, extra, kwargs)
|
|
91
|
+
super(key, **kwargs)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def perform_later(key, *extra, **kwargs)
|
|
95
|
+
__validate_enqueue!(key, extra, kwargs)
|
|
96
|
+
super(key, **kwargs)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def __validate_enqueue!(key, extra, kwargs)
|
|
102
|
+
unless key.is_a?(String)
|
|
103
|
+
raise ArgumentError, "Workflow key must be a string as the first argument"
|
|
104
|
+
end
|
|
105
|
+
unless extra.empty?
|
|
106
|
+
raise ArgumentError, "ChronoForge workflows accept only `key` positionally; " \
|
|
107
|
+
"pass everything else as keywords (got #{extra.size} extra positional arg(s))"
|
|
108
|
+
end
|
|
109
|
+
reserved = kwargs.keys & RESERVED_KWARGS
|
|
110
|
+
if reserved.any?
|
|
111
|
+
raise ArgumentError,
|
|
112
|
+
"#{reserved.join(", ")} #{reserved.one? ? "is a reserved" : "are reserved"} " \
|
|
113
|
+
"ChronoForge keyword(s) and cannot be passed to perform_now/perform_later"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`*extra` exists solely to catch stray positionals and produce the clear error;
|
|
119
|
+
after validation it is always empty and discarded (only `super(key, **kwargs)`
|
|
120
|
+
is forwarded).
|
|
121
|
+
|
|
122
|
+
### 3. Retry helpers — route past the guard
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
def retry_now(key, **kwargs)
|
|
126
|
+
__validate_enqueue!(key, [], kwargs)
|
|
127
|
+
set.perform_now(key, retry_workflow: true, **kwargs)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def retry_later(key, **kwargs)
|
|
131
|
+
__validate_enqueue!(key, [], kwargs)
|
|
132
|
+
set.perform_later(key, retry_workflow: true, **kwargs)
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
They still validate the *user's* kwargs (rejecting any reserved key the user
|
|
137
|
+
supplied), then inject `retry_workflow: true` through the `ConfiguredJob` bypass.
|
|
138
|
+
|
|
139
|
+
### 4. Framework continuations — unchanged
|
|
140
|
+
|
|
141
|
+
`executor.rb:138`, `wait.rb`, `wait_until.rb`, `durably_repeat.rb`,
|
|
142
|
+
`durably_execute.rb` already enqueue via `.set(...)`. No change required.
|
|
143
|
+
|
|
144
|
+
## Scope / caveats
|
|
145
|
+
|
|
146
|
+
- **Executor-only.** The guard lives in the `Executor`-prepended singleton.
|
|
147
|
+
`ChronoForge::CleanupJob` is a plain `ActiveJob::Base` and is unaffected
|
|
148
|
+
(its `perform_now(older_than_days: …)` / arg-less `perform_later` keep working).
|
|
149
|
+
- **Backward compatible.** A full scan of `lib/` and `test/` found no call site
|
|
150
|
+
passing a second positional and no user call passing a reserved key, so the
|
|
151
|
+
existing suite passes unchanged.
|
|
152
|
+
- **`wait_condition`** (internal kwarg in `wait_until`) is intentionally *not*
|
|
153
|
+
added to `RESERVED_KWARGS`: it only ever travels via `.set(...)` and so never
|
|
154
|
+
reaches the guard. Adding it later is a harmless one-line hygiene change if
|
|
155
|
+
desired.
|
|
156
|
+
|
|
157
|
+
## Testing (TDD)
|
|
158
|
+
|
|
159
|
+
New tests (Executor-prepended job):
|
|
160
|
+
|
|
161
|
+
1. `perform_later` / `perform_now` raise `ArgumentError` when passed `attempt:`,
|
|
162
|
+
`retry_counts:`, or `retry_workflow:` — and the message names the key(s).
|
|
163
|
+
2. `perform_later` / `perform_now` raise `ArgumentError` with the contract
|
|
164
|
+
message when passed a second positional argument.
|
|
165
|
+
3. `perform_later("k", kwarg: "x", options: {a: 1})` still enqueues; `options`
|
|
166
|
+
and user kwargs reach the workflow (`workflow.options`, `workflow.kwargs`).
|
|
167
|
+
4. `retry_now` / `retry_later` still unlock-and-continue a stalled workflow
|
|
168
|
+
(existing behavior preserved), and reject reserved keys passed by the caller.
|
|
169
|
+
5. Non-String `key` still raises (regression guard for existing behavior).
|