ruby_reactor 0.8.4 → 0.8.5
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/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/CHANGELOG.md +196 -0
- data/CLAUDE.md +1 -1
- data/README.md +47 -11
- data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
- data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
- data/lib/ruby_reactor/dsl/map_builder.rb +0 -2
- data/lib/ruby_reactor/dsl/step_builder.rb +91 -19
- data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
- data/lib/ruby_reactor/error/rescuable.rb +28 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +30 -26
- data/lib/ruby_reactor/executor/result_handler.rb +18 -16
- data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
- data/lib/ruby_reactor/executor/step_executor.rb +59 -49
- data/lib/ruby_reactor/executor.rb +38 -4
- data/lib/ruby_reactor/map/collector.rb +21 -11
- data/lib/ruby_reactor/map/dispatcher.rb +29 -3
- data/lib/ruby_reactor/map/element_executor.rb +9 -3
- data/lib/ruby_reactor/map/helpers.rb +32 -2
- data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
- data/lib/ruby_reactor/reactor.rb +24 -0
- data/lib/ruby_reactor/rspec/matchers.rb +19 -3
- data/lib/ruby_reactor/step/compose_step.rb +7 -1
- data/lib/ruby_reactor/step/map_step.rb +109 -4
- data/lib/ruby_reactor/step.rb +7 -0
- data/lib/ruby_reactor/step_worker.rb +46 -22
- data/lib/ruby_reactor/storage/adapter.rb +4 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +9 -0
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +1 -1
- data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-CQbgHtd0.js} +10 -10
- data/lib/ruby_reactor/web/public/index.html +1 -1
- data/lib/ruby_reactor/worker.rb +3 -1
- data/lib/ruby_reactor.rb +17 -6
- data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
- data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
- data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
- data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
- data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
- data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
- data/specs/007-execution-flow-analysis/data-model.md +83 -0
- data/specs/007-execution-flow-analysis/evidence/harness.rb +229 -0
- data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
- data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
- data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
- data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +232 -0
- data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
- data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
- data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
- data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
- data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
- data/specs/007-execution-flow-analysis/plan.md +127 -0
- data/specs/007-execution-flow-analysis/quickstart.md +51 -0
- data/specs/007-execution-flow-analysis/research.md +202 -0
- data/specs/007-execution-flow-analysis/spec.md +270 -0
- data/specs/007-execution-flow-analysis/tasks.md +257 -0
- data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
- data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
- data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
- data/specs/008-rollback-reliability/data-model.md +139 -0
- data/specs/008-rollback-reliability/plan.md +233 -0
- data/specs/008-rollback-reliability/quickstart.md +105 -0
- data/specs/008-rollback-reliability/research.md +653 -0
- data/specs/008-rollback-reliability/spec.md +561 -0
- data/specs/008-rollback-reliability/tasks.md +1110 -0
- data/specs/future_improvements.md +48 -0
- metadata +35 -2
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<title>ui</title>
|
|
8
|
-
<script type="module" crossorigin src="./assets/index-
|
|
8
|
+
<script type="module" crossorigin src="./assets/index-CQbgHtd0.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="./assets/index-BQvIWPdx.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
data/lib/ruby_reactor/worker.rb
CHANGED
|
@@ -8,7 +8,7 @@ module RubyReactor
|
|
|
8
8
|
# `Adapters::ActiveJob::Compat` on ActiveJob::Base) — nothing here references
|
|
9
9
|
# a specific backend.
|
|
10
10
|
module Worker
|
|
11
|
-
TERMINAL_STATUSES = %w[completed failed cancelled skipped].freeze
|
|
11
|
+
TERMINAL_STATUSES = %w[completed failed cancelled skipped aborted].freeze
|
|
12
12
|
|
|
13
13
|
# Use the error's `retry_after_seconds` hint when available
|
|
14
14
|
# (RateLimit::ExceededError carries the time until the bucket rolls);
|
|
@@ -89,6 +89,8 @@ module RubyReactor
|
|
|
89
89
|
reactor_class_name ||= RubyReactor.reactor_storage_name(nil)
|
|
90
90
|
data = RubyReactor.configuration.storage_adapter.retrieve_context(context_id, reactor_class_name)
|
|
91
91
|
return if data.nil?
|
|
92
|
+
# An aborted run is never resumed forward: only a manual undo applies (008 R-08).
|
|
93
|
+
return if (data["status"] || data[:status]).to_s == "aborted"
|
|
92
94
|
|
|
93
95
|
begin
|
|
94
96
|
context = ContextSerializer.deserialize_hash(data)
|
data/lib/ruby_reactor.rb
CHANGED
|
@@ -103,10 +103,11 @@ module RubyReactor
|
|
|
103
103
|
undef_method :skipped?
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
-
# Marks a single step as skipped
|
|
107
|
-
# exactly
|
|
108
|
-
#
|
|
109
|
-
#
|
|
106
|
+
# Marks a single step as skipped: an instrumentation mark (008 R-19). The
|
|
107
|
+
# run continues exactly as for a Success — the value flows to dependants via
|
|
108
|
+
# `result(:step)`, hand-offs and period marks happen — except `skipped?` is
|
|
109
|
+
# true, the trace records it, and the step is not enrolled for undo (it had
|
|
110
|
+
# nothing to do, so there is nothing to revert).
|
|
110
111
|
class Skipped < Success
|
|
111
112
|
# Sentinel distinguishing "no value argument given" (the old Halt call
|
|
112
113
|
# shape reused this class's name) from an explicit `Skipped(nil)`.
|
|
@@ -133,6 +134,10 @@ module RubyReactor
|
|
|
133
134
|
end
|
|
134
135
|
|
|
135
136
|
class Failure
|
|
137
|
+
# A stack overflow's backtrace runs to thousands of frames; the Failure is
|
|
138
|
+
# stored with the context, so keep the frames that locate the cause.
|
|
139
|
+
MAX_BACKTRACE_FRAMES = 100
|
|
140
|
+
|
|
136
141
|
attr_reader :error, :retryable, :step_name, :inputs, :backtrace, :reactor_name, :step_arguments, :exception_class,
|
|
137
142
|
:file_path, :line_number, :code_snippet, :validation_errors, :rollback_failures
|
|
138
143
|
|
|
@@ -173,7 +178,7 @@ module RubyReactor
|
|
|
173
178
|
@inputs = inputs
|
|
174
179
|
@step_arguments = step_arguments
|
|
175
180
|
raw_backtrace ||= backtrace || (@error.respond_to?(:backtrace) ? @error.backtrace : caller)
|
|
176
|
-
@backtrace = filter_backtrace(raw_backtrace)
|
|
181
|
+
@backtrace = cap_backtrace(filter_backtrace(raw_backtrace))
|
|
177
182
|
@redact_inputs = redact_inputs
|
|
178
183
|
@exception_class = exception_class || (@error.is_a?(Exception) ? @error.class.name : nil)
|
|
179
184
|
@file_path = file_path
|
|
@@ -294,6 +299,12 @@ module RubyReactor
|
|
|
294
299
|
msg << backtrace.take(10).map { |line| " #{line}" }.join("\n")
|
|
295
300
|
end
|
|
296
301
|
|
|
302
|
+
def cap_backtrace(backtrace)
|
|
303
|
+
return backtrace if backtrace.nil? || backtrace.size <= MAX_BACKTRACE_FRAMES
|
|
304
|
+
|
|
305
|
+
backtrace.first(MAX_BACKTRACE_FRAMES) << "... #{backtrace.size - MAX_BACKTRACE_FRAMES} more frames"
|
|
306
|
+
end
|
|
307
|
+
|
|
297
308
|
def filter_backtrace(backtrace)
|
|
298
309
|
return backtrace if ENV["RUBY_REACTOR_DEBUG"] == "true"
|
|
299
310
|
return backtrace if backtrace.nil? || backtrace.empty?
|
|
@@ -345,7 +356,7 @@ module RubyReactor
|
|
|
345
356
|
}
|
|
346
357
|
end
|
|
347
358
|
|
|
348
|
-
ROLLBACK_FAILURE_SYMBOLS = %i[step kind reason].freeze
|
|
359
|
+
ROLLBACK_FAILURE_SYMBOLS = %i[step kind reason map_step].freeze
|
|
349
360
|
private_constant :ROLLBACK_FAILURE_SYMBOLS
|
|
350
361
|
|
|
351
362
|
# A stored failure comes back with string keys (and, through plain JSON,
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Execution Flow & Compensation Analysis
|
|
2
|
+
|
|
3
|
+
Research into how RubyReactor orders forward execution and rollback across every construct, under
|
|
4
|
+
locks, retries and failures. Its purpose is to judge whether compensation is **predictable** and
|
|
5
|
+
whether the DSL makes it **visible**. Documentation only: no library, test-suite or demo-app change.
|
|
6
|
+
|
|
7
|
+
## Scope & baseline
|
|
8
|
+
|
|
9
|
+
- **Code**: commit `faf90e8d` (ruby_reactor 0.8.3, with step-scoped retries #61 and inputs
|
|
10
|
+
protection #63).
|
|
11
|
+
- **Constructs**: plain steps, `compose`, `map` (inline and `fan_out`, `fail_fast` on/off),
|
|
12
|
+
`async_step`, `async_reactor`, `background` reactors, interrupts, manual `cancel`/`undo`.
|
|
13
|
+
- **Conditions**: reactor- and step-level locks/semaphores (ordered lock, rate limit and period by
|
|
14
|
+
reading), retries (inline, worker, element, unit), and every failure kind in
|
|
15
|
+
[execution-order.md §1](execution-order.md#failure-kinds--path).
|
|
16
|
+
- **Evidence**: 63 probe scenarios run against real Redis through the real worker bodies
|
|
17
|
+
(Sidekiq fake mode + drain; `inline!` only where labelled). **64/64 match** the sequences quoted
|
|
18
|
+
in this report. Re-run: [../quickstart.md](../quickstart.md).
|
|
19
|
+
- **Out of scope**: the ActiveJob backend is not probed separately (its adapters delegate to the
|
|
20
|
+
same shared bodies). Neither are rate limits and periods beyond their "never re-taken for rollback"
|
|
21
|
+
rule, the dashboard, or OpenTelemetry spans.
|
|
22
|
+
- **Documentation**: README.md and `./documentation` are **audited, not edited**
|
|
23
|
+
([findings-and-options.md §2](findings-and-options.md#2-documentation-audit)). Writing current
|
|
24
|
+
behavior in as contract before the follow-up decision would lock in behavior that may be
|
|
25
|
+
unintended. Each remedy updates the docs it affects.
|
|
26
|
+
|
|
27
|
+
## How to read
|
|
28
|
+
|
|
29
|
+
- *compensate* = the failing step's own cleanup; *undo* = rollback of a completed step;
|
|
30
|
+
*rollback* = both; *left in place* = completed work nothing rolls back; *unit* = an
|
|
31
|
+
`async_step`/`async_reactor` dispatch.
|
|
32
|
+
- Evidence labels: `[R: file:line]` read in source · `[O: S-…]` observed, see
|
|
33
|
+
[../evidence/output.txt](../evidence/output.txt) · `[T: spec:line]` covered by an existing spec.
|
|
34
|
+
- Invariant status: HOLDS · VIOLATED · CONDITIONAL · UNDETERMINED.
|
|
35
|
+
Finding severity: High · Medium · Low
|
|
36
|
+
([findings-and-options.md](findings-and-options.md) header).
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Answers
|
|
41
|
+
|
|
42
|
+
### Q1 · Are already-executed map elements compensated individually?
|
|
43
|
+
|
|
44
|
+
**No.** Only the element that **fails** is rolled back, individually, by its own reactor.
|
|
45
|
+
Elements that already **succeeded** are never compensated or undone. That holds whether the map
|
|
46
|
+
itself fails or a later step fails.
|
|
47
|
+
|
|
48
|
+
| Situation | Failed element | Elements that succeeded | Evidence |
|
|
49
|
+
|---|---|---|---|
|
|
50
|
+
| Inline, `fail_fast` (default), element k fails | compensated + its steps undone | **left in place**: elements before k. Elements after k never run | [O: S-map-01] |
|
|
51
|
+
| Fan-out, `fail_fast`, element k fails | same, in its own job | **left in place**: whichever elements started before the failure. The set depends on **job scheduling** | [O: S-map-04] [O: S-map-04b] |
|
|
52
|
+
| `fail_fast false` (inline or fan-out) | rolled back individually | kept. The map **succeeds** and the consumer inspects the results | [O: S-map-02] [O: S-map-05] |
|
|
53
|
+
| Map completed, a **later step** fails | — | **all left in place** | [O: S-map-03] [O: S-map-06] |
|
|
54
|
+
| Map inside a compose child / compose inside an element | same rules, nested | same | [O: S-map-07] [O: S-map-08] |
|
|
55
|
+
|
|
56
|
+
Why: `MapStep#compensate` is a stub (`# TODO: Implement compensation for map steps` → `Success()`)
|
|
57
|
+
[R: lib/ruby_reactor/step/map_step.rb:29-31], the map has no `undo`
|
|
58
|
+
[R: lib/ruby_reactor/step.rb:57], and the DSL offers no hook
|
|
59
|
+
[R: lib/ruby_reactor/dsl/map_builder.rb:130-131]. `rollback_failures` stays empty, because nothing
|
|
60
|
+
is attempted. → [F-01](findings-and-options.md#f-01--high--map-elements-that-already-succeeded-are-never-rolled-back),
|
|
61
|
+
[F-05](findings-and-options.md#f-05--medium--fan-out-fail-fast-leaves-a-scheduling-dependent-set-of-elements-in-place),
|
|
62
|
+
INV-19/20/22 in [invariants.md](invariants.md#d-map). No existing spec covers it.
|
|
63
|
+
|
|
64
|
+
### Q2 · When a composed reactor fails, are previously completed composed reactors compensated?
|
|
65
|
+
|
|
66
|
+
**Yes.** Composition is fully wired into rollback, at any depth and in worker mode:
|
|
67
|
+
|
|
68
|
+
1. The failing child rolls **itself** back first (its compensate, then its own undos).
|
|
69
|
+
2. The parent compensates the compose step (a no-op by then), then undoes its own completed steps
|
|
70
|
+
newest-first. **Each earlier compose is undone by replaying its child's undo stack in reverse.**
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
compose x(x1 → x2) → compose y(y1 → y2 ✗)
|
|
74
|
+
run:x.x1 run:x.x2 run:y.y1 run:y.y2 compensate:y.y2 undo:y.y1 undo:x.x2 undo:x.x1 ⇒ failure(y)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
[O: S-compose-03] · also [O: S-compose-01] [O: S-compose-02] [O: S-compose-04] [O: S-compose-06] ·
|
|
78
|
+
[T: spec/compose_spec.rb:192, :197] · INV-15–18.
|
|
79
|
+
|
|
80
|
+
**Except** in three cases:
|
|
81
|
+
|
|
82
|
+
- `compose` with `retries`: a retried child **resumes** with its already-undone steps counted as
|
|
83
|
+
done, so they are not re-run and their stale results flow on
|
|
84
|
+
([F-02](findings-and-options.md#f-02--high--compose-with-retries-resumes-a-child-whose-earlier-steps-were-already-undone),
|
|
85
|
+
[O: S-compose-05]).
|
|
86
|
+
- A child's `Halt` does **not** stop the parent. The parent continues with `nil`
|
|
87
|
+
([F-07](findings-and-options.md#f-07--medium--halt-means-different-things-at-different-nesting-levels),
|
|
88
|
+
[O: S-compose-08]).
|
|
89
|
+
- A `map` or async unit **inside** a child keeps its own rules (Q1, and
|
|
90
|
+
[F-04](findings-and-options.md#f-04--high--an-async_steps-own-compensate--undo-never-run-the-docs-say-they-do)/[F-09](findings-and-options.md#f-09--medium--dispatched-units-run-after-their-dispatcher-rolled-back-including-units-of-a-failed-map-element)).
|
|
91
|
+
|
|
92
|
+
### Q3 · Would `compensate_all` / `compensate_each` on `map` close a real gap?
|
|
93
|
+
|
|
94
|
+
**Yes, the gap is real**: INV-19 and INV-20 are VIOLATED and have no spec coverage. Both proposed
|
|
95
|
+
shapes would close it, and they are complementary rather than alternatives: per-element vs bulk is
|
|
96
|
+
a matter of how the author wants to clean up. Before either works, three things must be settled:
|
|
97
|
+
|
|
98
|
+
1. **Two moments, not one.** Cleanup is needed when the map **fails** (the library's
|
|
99
|
+
*compensate*) and when a **later step** fails after the map completed (the library's *undo*).
|
|
100
|
+
`compensate_*` as named covers only the first. The hooks should be named for both moments, or one
|
|
101
|
+
block should be documented to cover both.
|
|
102
|
+
2. **Plumbing.** In fan-out mode the map step must be on the parent's undo stack (today it never is,
|
|
103
|
+
[R: lib/ruby_reactor/map/helpers.rb:97]). Raw per-element results must also stay available even
|
|
104
|
+
when a `collect` block transformed them.
|
|
105
|
+
3. **In-flight fan-out elements.** Elements still running when the hook fires finish afterwards and
|
|
106
|
+
escape it, unless the collector waits for them
|
|
107
|
+
([O-05-a](findings-and-options.md#options-for-f-05-scheduling-dependent-fan-out-leftovers)).
|
|
108
|
+
|
|
109
|
+
There is also a third shape: implicit **element-undo replay**, which makes `map` behave like
|
|
110
|
+
`compose` and needs no new DSL. It is the most consistent option, but the only breaking one.
|
|
111
|
+
Full comparison on fail_fast, inline/fan-out, retries, later failures and data availability:
|
|
112
|
+
[O-01 evaluation](findings-and-options.md#o-01-evaluation-compensate_all-vs-compensate_each).
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Top findings
|
|
117
|
+
|
|
118
|
+
**High**
|
|
119
|
+
|
|
120
|
+
- [F-01](findings-and-options.md#f-01--high--map-elements-that-already-succeeded-are-never-rolled-back): map elements that succeeded are never rolled back (map failure or later failure).
|
|
121
|
+
- [F-02](findings-and-options.md#f-02--high--compose-with-retries-resumes-a-child-whose-earlier-steps-were-already-undone): `compose` + `retries` resumes a child whose earlier steps were already undone.
|
|
122
|
+
- [F-03](findings-and-options.md#f-03--high--some-failures-skip-rollback-entirely): argument-resolution errors and non-`StandardError` exceptions skip rollback entirely.
|
|
123
|
+
- [F-04](findings-and-options.md#f-04--high--an-async_steps-own-compensate--undo-never-run-the-docs-say-they-do): an `async_step`'s own `compensate`/`undo` never run. The docs say they do.
|
|
124
|
+
|
|
125
|
+
**Medium**: F-05 scheduling-dependent fan-out leftovers · F-06 raising `where`/`guard`
|
|
126
|
+
compensates a never-run step · F-07 nested `Halt` inconsistency · F-08 `Reactor.undo` outside the
|
|
127
|
+
reactor lock · F-09 units run after their dispatcher's rollback (also escaping a failed map
|
|
128
|
+
element) · F-10 composite rollback coverage is asymmetric and invisible in the DSL.
|
|
129
|
+
|
|
130
|
+
**Low**: F-11 to F-16 (lock gap, async retry telemetry, missing failure attribution, dead collect
|
|
131
|
+
default, lock event key asymmetry, interrupt status docs).
|
|
132
|
+
|
|
133
|
+
What reliably **holds**: compensate-then-reverse-undo ordering (DAG included). Rollback failures
|
|
134
|
+
never stop the rollback and are always reported. Retries always finish before compensation, which
|
|
135
|
+
runs exactly once. Never-started steps are not compensated (except F-06). Composition rollback is
|
|
136
|
+
full, at any depth. Worker runs match inline order. Reactor locks are held through rollback.
|
|
137
|
+
Step locks are re-taken for it. A crash re-drives from the last checkpoint.
|
|
138
|
+
29 of 41 invariants hold ([invariants.md](invariants.md#coverage-summary)).
|
|
139
|
+
|
|
140
|
+
## Files
|
|
141
|
+
|
|
142
|
+
| File | Answers |
|
|
143
|
+
|---|---|
|
|
144
|
+
| [execution-order.md](execution-order.md) | Rollback algorithm, failure-kind table, construct lifecycles, full order matrix, lock and retry cross-sections |
|
|
145
|
+
| [invariants.md](invariants.md) | 41 invariants with status, evidence and existing-spec coverage |
|
|
146
|
+
| [findings-and-options.md](findings-and-options.md) | 16 ranked findings, documentation audit, improvement options (proposals) |
|
|
147
|
+
| [../evidence/](../evidence/) | Probe harness, 7 probe files, `output.txt` transcript |
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# Execution Order & Rollback
|
|
2
|
+
|
|
3
|
+
What runs, in what order, and what is **left in place** when something fails, for every
|
|
4
|
+
construct RubyReactor offers. Baseline commit `faf90e8d` (ruby_reactor 0.8.3 + #61, #63).
|
|
5
|
+
|
|
6
|
+
**Updated for 008 (Reliable Rollback Across Constructs).** Rows and rules marked *changed in 008*
|
|
7
|
+
describe the behavior after [specs/008-rollback-reliability](../../008-rollback-reliability/spec.md);
|
|
8
|
+
the text they replace is kept in git history. The transcript was re-run: 64 scenarios, 64 match (S-edge-03b added by the 2026-09-27 revision).
|
|
9
|
+
Line references `[R: …]` in unchanged rows still point at the baseline.
|
|
10
|
+
|
|
11
|
+
Labels: `[R: file:line]` = read in source · `[O: S-…]` = observed, see
|
|
12
|
+
[`../evidence/output.txt`](../evidence/output.txt) · `[T: spec:line]` = existing spec.
|
|
13
|
+
*Compensate* = the failing step's own cleanup; *undo* = rollback of a completed step;
|
|
14
|
+
*rollback* = both. *Unit* = an `async_step` or `async_reactor` dispatch.
|
|
15
|
+
|
|
16
|
+
Contents: [1. Rollback algorithm](#1-rollback-algorithm) ·
|
|
17
|
+
[2. Construct lifecycles](#2-construct-lifecycles) · [3. Order matrix](#3-order-matrix) ·
|
|
18
|
+
[4. Cross-cutting: locks](#4-cross-cutting-locks) · [5. Cross-cutting: retries](#5-cross-cutting-retries)
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 1. Rollback algorithm
|
|
23
|
+
|
|
24
|
+
One mechanism does all rollback: the **undo stack** of the reactor execution that owns the step
|
|
25
|
+
(`Context#undo_stack`, serialized with the context `[R: lib/ruby_reactor/context.rb:173]`).
|
|
26
|
+
|
|
27
|
+
```mermaid
|
|
28
|
+
flowchart TD
|
|
29
|
+
S[Step reaches a result] --> K{Kind}
|
|
30
|
+
K -->|Success| P[Push step, args, result on undo stack<br/>unless async unit / Skipped]
|
|
31
|
+
P --> N[Next ready step]
|
|
32
|
+
K -->|Skipped| N
|
|
33
|
+
K -->|Halt| H[Stop. No rollback]
|
|
34
|
+
K -->|Failure after retries| NS{Body never started?<br/>own contention / key error /<br/>dispatch refused / argument error}
|
|
35
|
+
NS -->|yes| U
|
|
36
|
+
NS -->|no| C[compensate failing step]
|
|
37
|
+
C --> U[Undo stack, newest first:<br/>undo each completed step]
|
|
38
|
+
U --> F[Failure: rollback_failures lists<br/>every compensate/undo that did not complete]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Rules, each confirmed on plain steps:
|
|
42
|
+
|
|
43
|
+
| # | Rule | Evidence |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| R1 | Steps run in dependency order. Among ready steps, definition order. Each `Success` pushes `{step, arguments, result}` on the undo stack. | `[R: lib/ruby_reactor/executor/result_handler.rb:137]` `[O: S-plain-09]` |
|
|
46
|
+
| R2 | On failure the failing step's **compensate runs first**, then **every completed step's undo, newest first** (reverse completion order, also across DAG branches). No later step runs. | `[R: lib/ruby_reactor/executor/compensation_manager.rb:35]` `[R: …/compensation_manager.rb:69]` `[O: S-plain-01, S-plain-02, S-plain-09]` |
|
|
47
|
+
| R3 | A step whose own coordination was never acquired (contention, key error, refused dispatch) is **not** compensated. Earlier steps are still undone. | `[R: …/compensation_manager.rb:11, :44]` `[O: S-lock-03, S-edge-01]` |
|
|
48
|
+
| R4 | A compensate or undo that returns `Failure` or raises does **not** stop the rollback. It is listed on `Failure#rollback_failures`. A compensate failure also turns the final error into `CompensationError` ("Execution error: …", no `step_name`). | `[R: …/compensation_manager.rb:57, :202]` `[O: S-plain-03, S-plain-04, S-compose-07]` |
|
|
49
|
+
| R5 | `Halt` stops without rollback. `Skipped` steps are never pushed, so they are never undone. *Since 008 (R-19)* `Skipped` changes nothing else: an `after:` hand-off and a period mark treat it as a completed step. | `[R: …/result_handler.rb handle_skipped]` `[O: S-plain-05, S-plain-06]` |
|
|
50
|
+
| R6 | Async units (`async_step`, `async_reactor`) are **never pushed**: the parent never undoes them. *Changed in 008:* the step says so itself (`StepConfig#rollback_tracked?` is false for async units); the coordinator has no async special case. | `[R: lib/ruby_reactor/dsl/step_builder.rb rollback_tracked?]` `[O: S-async-03, S-async-06]` |
|
|
51
|
+
| R7 | *Changed in 008.* Every exception after completed work rolls back, `StandardError` or not (R-16). An argument source/transform/result path that raises (`ArgumentResolutionError`) is a never-started failure: the step is not compensated, completed steps are undone. Any other exception outside a step body rolls back too, attributed to the executing step. Only an interruption (signal, exit, out of memory, enclosing timeout) runs no rollback: a caller-process run is stored `aborted` for a manual `Reactor.undo(id)`; a worker run stays `running` and is redelivered. (`where`/`guard` were removed, R-15.) | `[R: lib/ruby_reactor/error/rescuable.rb]` `[R: lib/ruby_reactor/executor.rb mark_aborted]` `[O: S-plain-07, S-edge-03, S-edge-03b]` |
|
|
52
|
+
| R8 | Rollback runs in whichever process detects the failure: the caller (inline), the reactor worker, the map collector, or (for units) nobody. | §2 |
|
|
53
|
+
|
|
54
|
+
### Failure kinds → path
|
|
55
|
+
|
|
56
|
+
| Failure kind | Where it happens | Failing step compensated? | Completed steps undone? | Evidence |
|
|
57
|
+
|---|---|---|---|---|
|
|
58
|
+
| Body returns `Failure` / raises `StandardError` | body | yes | yes | `[O: S-plain-01, S-plain-02]` |
|
|
59
|
+
| Retries exhausted | body, last attempt | yes, **once** | yes | `[O: S-retry-01, S-retry-04, S-bg-03]` |
|
|
60
|
+
| `validate_output` fails | after body | yes (side effect exists) | yes | `[R: …/result_handler.rb:277]` `[O: S-plain-08]` |
|
|
61
|
+
| Step input/argument validation fails | before body | no | yes | `[O: S-edge-05]` |
|
|
62
|
+
| Argument source/transform/result path raises `StandardError` (*changed in 008*) | before body | no (never started, `ArgumentResolutionError`) | yes; failure names the step | `[O: S-plain-07]` |
|
|
63
|
+
| Any other `StandardError` outside a step body (*changed in 008*) | executor | — | yes; failure names the executing step | `spec/ruby_reactor/rollback/failure_rollback_spec.rb` |
|
|
64
|
+
| Non-`StandardError` exception in body, not an interruption (`NotImplementedError`, custom `Exception`; *changed in 008*) | body | yes | yes; failure names the step and the original class | `[O: S-edge-03]` `spec/ruby_reactor/rollback/failure_rollback_spec.rb` |
|
|
65
|
+
| Interruption in body (signal incl. `Interrupt`, `SystemExit`, `NoMemoryError`, enclosing timeout; *changed in 008*) | body | no | no rollback code runs; the exception propagates unchanged; a caller-process run is stored **`aborted`** with the entries not yet undone, and `Reactor.undo(id)` rolls it back | `[O: S-edge-03b]` `spec/ruby_reactor/rollback/aborted_execution_spec.rb` |
|
|
66
|
+
| Own lock/semaphore contended (inline) | before body | no | yes | `[O: S-lock-03]` |
|
|
67
|
+
| Own contention past `lock_snooze_max_attempts` (worker) | before body | no | yes | `[O: S-edge-01]` |
|
|
68
|
+
| Reader's `result(:unit)` wait times out | argument resolution | no (never started; *changed in 008:* wrapped as the reader's `ArgumentResolutionError`, so the failure names the reader) | yes | `[O: S-async-08]` |
|
|
69
|
+
| Compensate fails | rollback | — | yes, continues; *changed in 008:* the `CompensationError` failure names the step | `[O: S-plain-03]` |
|
|
70
|
+
| Undo fails / raises | rollback | — | yes, continues | `[O: S-plain-04]` |
|
|
71
|
+
| Undo cannot re-take its step lock within `rollback_wait` | rollback | — | that undo **skipped**, reported | `[O: S-lock-05]` |
|
|
72
|
+
| Reactor input validation | before any step | — | nothing ran | `[O: S-edge-06]` |
|
|
73
|
+
| `Halt` returned | body | no | **no** (by design) | `[O: S-plain-05]` |
|
|
74
|
+
| Interrupt payload invalid past `max_attempts` | `continue` | — | yes | `[O: S-intr-02]` |
|
|
75
|
+
| Worker crash (process dies) | anywhere | no | no; the job is redelivered and resumes from the last checkpoint | `[O: S-edge-02]` |
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## 2. Construct lifecycles
|
|
80
|
+
|
|
81
|
+
### 2.1 Step (inline)
|
|
82
|
+
|
|
83
|
+
1. Resolve arguments (`result(...)`, `input(...)`, transforms). This is **outside** the step's
|
|
84
|
+
rescue `[R: lib/ruby_reactor/executor/step_executor.rb:83]`.
|
|
85
|
+
2. Retry loop `[R: lib/ruby_reactor/executor/retry_manager.rb:11]`. Each attempt runs
|
|
86
|
+
`argument validation → step coordination (lock, semaphore, rate limit…) → body` (`where`/`guard`
|
|
87
|
+
were removed in 008).
|
|
88
|
+
3. Result handling: Success pushes onto the undo stack. Failure (after the last attempt) runs rollback (§1).
|
|
89
|
+
4. **Rollback hooks**: `compensate` (this step failing), `undo` (a later step failing).
|
|
90
|
+
5. **Locks**: a step-level lock is held only around the body. Rollback re-takes it (§4).
|
|
91
|
+
|
|
92
|
+
### 2.2 `compose`
|
|
93
|
+
|
|
94
|
+
1. `ComposeStep#run` builds (or, on resume, **reuses**) the child context and runs the child
|
|
95
|
+
reactor inline, in the same process, with its own undo stack
|
|
96
|
+
`[R: lib/ruby_reactor/step/compose_step.rb:10, :97]`. *Changed in 008:* a stored child whose
|
|
97
|
+
status is `failed` is a previous **retry attempt** that already rolled itself back, so the
|
|
98
|
+
compose starts a **fresh** child and records a `compose_attempt_discarded` trace entry (with
|
|
99
|
+
that attempt's `rollback_failures`). A running/paused/parked child is still resumed.
|
|
100
|
+
2. Child fails → the child rolls itself back (child compensate, child undos) **before** returning
|
|
101
|
+
`Failure` to the parent. The parent then compensates the compose step and undoes its own
|
|
102
|
+
completed steps.
|
|
103
|
+
3. Child succeeds → the compose step is pushed on the parent's undo stack. Its `undo` (= its
|
|
104
|
+
`compensate`, the same method) replays the **child's** undo stack newest-first
|
|
105
|
+
`[R: …/compose_step.rb:31, :47]`.
|
|
106
|
+
4. **Rollback hooks**: none declarable. `ComposeBuilder` has no `compensate`/`undo`
|
|
107
|
+
`[R: lib/ruby_reactor/dsl/compose_builder.rb:60]`. *Since 008*, `retries` on a compose is rejected
|
|
108
|
+
at definition time: the child's own steps retry `[O: S-compose-05, S-compose-05b]`.
|
|
109
|
+
5. **Locks**: the child re-enters the parent's reactor lock (same root owner, counted) and hands it
|
|
110
|
+
back without releasing the parent's hold `[O: S-lock-06]`.
|
|
111
|
+
|
|
112
|
+
**Resume entry points (008 R-05 audit).** Every path that resumes a stored context, and whether
|
|
113
|
+
it can resume one after that context rolled back:
|
|
114
|
+
|
|
115
|
+
| Entry point | Can resume after a rollback? |
|
|
116
|
+
| --- | --- |
|
|
117
|
+
| `Worker` (root / `async_reactor` child) | no: `failed`/`cancelled` runs have nothing left to run, and an `aborted` run is skipped |
|
|
118
|
+
| `Map::Collector` → parent `resume_execution` | no: it resumes the parent before any rollback, and skips a finished parent |
|
|
119
|
+
| `Map::ElementExecutor` requeue (retry / park) | no: it requeues before the element rolls back |
|
|
120
|
+
| `Reactor#continue` (interrupt) | no: after an undo the context is `cancelled` |
|
|
121
|
+
| `ComposeStep#run` on a retry | was **yes** at the baseline; removed in 008: a compose cannot be retried (R-14) |
|
|
122
|
+
|
|
123
|
+
### 2.3 `map`, inline (default)
|
|
124
|
+
|
|
125
|
+
1. `MapStep#run_inline` runs one child reactor execution **per element, sequentially**, each with
|
|
126
|
+
its own context and undo stack `[R: lib/ruby_reactor/step/map_step.rb:100]`.
|
|
127
|
+
2. An element fails → that element's executor rolls **that element** back (its compensate + its
|
|
128
|
+
undos). With `fail_fast` (default) the map stops at once and returns the element's `Failure`
|
|
129
|
+
`[R: …/map_step.rb:112]`. Otherwise the `Failure` is collected and the map continues.
|
|
130
|
+
3. The parent then treats the map step as failed and compensates it. *Changed in 008:*
|
|
131
|
+
`MapStep#compensate` replays the undo stack of every element whose stored context is
|
|
132
|
+
`completed`, **highest index first**, found through the map's element-context index; failed
|
|
133
|
+
elements already rolled themselves back. Then the parent undoes its earlier steps
|
|
134
|
+
`[O: S-map-01]`. A raising `collect` counts as a map failure.
|
|
135
|
+
4. Map succeeds → the map step is pushed on the parent undo stack. *Changed in 008:* its `undo`
|
|
136
|
+
is the same element replay, so a later failure or a manual undo rolls back every completed
|
|
137
|
+
element `[O: S-map-03]`. Element rollback failures carry `map_step`/`element_index`; an
|
|
138
|
+
expired element context is reported `context_unavailable`.
|
|
139
|
+
5. **Rollback hooks**: none declarable. `MapBuilder` builds `compensate_block: nil, undo_block: nil`
|
|
140
|
+
and has no `retries` `[R: lib/ruby_reactor/dsl/map_builder.rb:130-131]`.
|
|
141
|
+
|
|
142
|
+
### 2.4 `map` with `fan_out`
|
|
143
|
+
|
|
144
|
+
1. The parent persists its context, sets a counter, dispatches one `MapElementWorker` job per
|
|
145
|
+
element (in `batch_size` batches), enqueues an eager collector, and **stops**
|
|
146
|
+
(`DispatchResult`) `[R: lib/ruby_reactor/step/map_step.rb:172-195]`.
|
|
147
|
+
2. Each element job runs its element reactor (`Map::ElementExecutor`). A failed element is rolled
|
|
148
|
+
back inside its own job. With `fail_fast` it records the failure and triggers the collector
|
|
149
|
+
`[R: lib/ruby_reactor/map/element_executor.rb:176-195]`.
|
|
150
|
+
3. An element job that **starts after** a fail-fast failure is recorded skips itself
|
|
151
|
+
`[R: …/element_executor.rb:61, :145]`. *Changed in 008:* it stores a `_skipped` result slot,
|
|
152
|
+
and the dispatcher settles every index it never dispatched the same way. The collector applies
|
|
153
|
+
the failure only once **every index has settled**, so elements in flight finish first. Which
|
|
154
|
+
elements run still depends on scheduling; what is left in place does not: nothing
|
|
155
|
+
`[O: S-map-04, S-map-04b]`.
|
|
156
|
+
4. The collector resumes the parent in a worker. On failure it compensates the map step (the
|
|
157
|
+
element replay above) and runs the parent's rollback there. On success, *since 008*, it
|
|
158
|
+
pushes the map step on the parent's undo stack with an empty record, so a later failure
|
|
159
|
+
undoes every element `[O: S-map-06]`.
|
|
160
|
+
5. Element retries re-enqueue the element job, so other elements run in between
|
|
161
|
+
`[O: S-map-09]`. Inline map retries sleep in place `[O: S-map-10]`.
|
|
162
|
+
|
|
163
|
+
### 2.5 `async_step`
|
|
164
|
+
|
|
165
|
+
1. Dispatch: durable record + enqueue, then the step is marked complete **for scheduling only**
|
|
166
|
+
(no result) and the parent keeps going `[R: lib/ruby_reactor/executor/async_step_dispatch.rb:25-47]`.
|
|
167
|
+
Nothing is pushed on the parent's undo stack.
|
|
168
|
+
2. `StepWorker` runs the body in its own job, retries in a **loop inside that job**, and writes a
|
|
169
|
+
terminal record. *Changed in 008:* after the **final** attempt fails (body started), it calls
|
|
170
|
+
the step's `compensate` **once**, in that job, and records `compensation` on the unit's record
|
|
171
|
+
before completing it. An inline `undo` on `async_step` is rejected at class definition (a step
|
|
172
|
+
class's `undo` is warned about) `[O: S-async-01, S-async-07]`.
|
|
173
|
+
3. A reader (`result(:u)`) blocks until the record is terminal and receives the value, or the
|
|
174
|
+
`Failure` **object** as an argument `[R: lib/ruby_reactor/template/result.rb:68-88]`. The parent
|
|
175
|
+
is affected only if the reader itself returns `Failure`. Then the **reader** is compensated and
|
|
176
|
+
the parent's completed steps are undone. The unit is not compensated a second time
|
|
177
|
+
`[O: S-async-02]`.
|
|
178
|
+
4. A parent that fails and rolls back does not stop or undo the unit. In fake-queue ordering the
|
|
179
|
+
unit body runs **after** the parent's rollback `[O: S-async-03]`.
|
|
180
|
+
|
|
181
|
+
### 2.6 `async_reactor`
|
|
182
|
+
|
|
183
|
+
1. Dispatch validates the child inputs, persists the child as its own execution (linked only by
|
|
184
|
+
`parent_context_id`), and enqueues it. The step returns `Success(nil)` and is not pushed
|
|
185
|
+
`[R: lib/ruby_reactor/step/async_reactor_step.rb:5-8, :146]`.
|
|
186
|
+
2. The child is an ordinary reactor. On its own failure it rolls back its own steps in its worker
|
|
187
|
+
`[O: S-async-04]`.
|
|
188
|
+
3. A reader gets the child's real `Success`/`Failure` `[R: lib/ruby_reactor/template/result.rb:170]`.
|
|
189
|
+
Opting in (reader fails) rolls back the parent **after** the child has already rolled itself
|
|
190
|
+
back `[O: S-async-05]`.
|
|
191
|
+
4. A parent failure never undoes a completed child. A child still queued runs after the parent's
|
|
192
|
+
rollback `[O: S-async-06]`.
|
|
193
|
+
|
|
194
|
+
### 2.7 `background` reactor (`all:` / `before:` / `after:`)
|
|
195
|
+
|
|
196
|
+
1. The caller runs steps up to the hand-off point, checkpoints the context (**including the undo
|
|
197
|
+
stack**), and enqueues the worker `[R: lib/ruby_reactor/executor/step_executor.rb:364]`.
|
|
198
|
+
2. The worker resumes (`Executor#resume_execution`) with the persisted undo stack. So a worker-side
|
|
199
|
+
failure also undoes steps that ran **in the caller** `[O: S-bg-02]`.
|
|
200
|
+
3. Retries inside the worker re-enqueue the job with backoff. Compensation happens once, in the
|
|
201
|
+
delivery that exhausts the attempts `[R: lib/ruby_reactor/executor/retry_manager.rb:143]`
|
|
202
|
+
`[O: S-bg-03]`.
|
|
203
|
+
4. Order is identical to inline for the same shape `[O: S-bg-01, S-compose-06]`.
|
|
204
|
+
|
|
205
|
+
### 2.8 Interrupt (pause / continue / cancel / undo)
|
|
206
|
+
|
|
207
|
+
1. Reaching an `interrupt` step returns `InterruptResult`. The context is saved `paused`, and a
|
|
208
|
+
reactor-level lock is **released** (a pause is not a park) `[R: lib/ruby_reactor/executor.rb:161]`
|
|
209
|
+
`[O: S-intr-01]`.
|
|
210
|
+
2. `continue` re-acquires the reactor lock and resumes. A later failure undoes the steps completed
|
|
211
|
+
**before** the pause (persisted undo stack). The interrupt step itself is never on the stack
|
|
212
|
+
`[O: S-intr-01]`.
|
|
213
|
+
3. Invalid payload past `max_attempts` → `undo` + `failed` `[R: lib/ruby_reactor/reactor.rb:411]`
|
|
214
|
+
`[O: S-intr-02]`.
|
|
215
|
+
4. `Reactor.cancel` never rolls back `[R: …/reactor.rb:194]` `[O: S-intr-03]`. `Reactor.undo(id)`
|
|
216
|
+
undoes the stack and cancels, **without taking the reactor-level lock**
|
|
217
|
+
`[R: …/reactor.rb:63, :188]` `[O: S-intr-04]`.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## 3. Order matrix
|
|
222
|
+
|
|
223
|
+
Rows are probes, and every row is `MATCH` in the transcript. Events are exactly what the probe
|
|
224
|
+
recorded. `⇒` is the final outcome of the top-level execution. In *Left in place*, `—` means
|
|
225
|
+
nothing is left in place. Rows without a scenario id are combinations that are not reachable or
|
|
226
|
+
not probed, with the reason given.
|
|
227
|
+
|
|
228
|
+
### 3.1 Plain steps
|
|
229
|
+
|
|
230
|
+
| Scenario | Shape | Failure at | Mode | Ordered events | Left in place | Evidence |
|
|
231
|
+
|---|---|---|---|---|---|---|
|
|
232
|
+
| S-plain-01 | a → b → c | b returns Failure | inline | run:a run:b compensate:b undo:a ⇒ failure(b) | — | [O: S-plain-01] |
|
|
233
|
+
| S-plain-02 | a → b → c | b raises | inline | run:a run:b compensate:b undo:a ⇒ failure(b) | — | [O: S-plain-02] |
|
|
234
|
+
| S-plain-03 | a → b | b fails, its compensate fails | inline | run:a run:b compensate:b undo:a ⇒ failure(b) (CompensationError) *(changed in 008)* | — (reported: b/compensate) | [O: S-plain-03] |
|
|
235
|
+
| S-plain-04 | a → b → c | c fails, b's undo raises | inline | run:a run:b run:c compensate:c undo:b undo:a ⇒ failure(c) | b's effect, if its undo failed (reported) | [O: S-plain-04] |
|
|
236
|
+
| S-plain-05 | a → b → c | b returns Halt | inline | run:a run:b ⇒ halt | a, b (by design) | [O: S-plain-05] |
|
|
237
|
+
| S-plain-06 | a → b(Skipped) → c | c fails | inline | run:a run:b run:c compensate:c undo:a ⇒ failure(c) | — | [O: S-plain-06] |
|
|
238
|
+
| S-plain-07 | a → b | b's argument transform raises | inline | run:a undo:a ⇒ failure(b) *(changed in 008)* | — | [O: S-plain-07] |
|
|
239
|
+
| S-plain-08 | a → b | b's output fails `validate_output` | inline | run:a run:b compensate:b undo:a ⇒ failure(b) | — | [O: S-plain-08] |
|
|
240
|
+
| S-plain-09 | a, b → c → d | d fails | inline | run:a run:b run:c run:d compensate:d undo:c undo:b undo:a ⇒ failure(d) | — | [O: S-plain-09] |
|
|
241
|
+
|
|
242
|
+
### 3.2 `compose`
|
|
243
|
+
|
|
244
|
+
| Scenario | Shape | Failure at | Mode | Ordered events | Left in place | Evidence |
|
|
245
|
+
|---|---|---|---|---|---|---|
|
|
246
|
+
| S-compose-01 | a → compose(c1 → c2) → b | c2 (in child) | inline | run:a run:child.c1 run:child.c2 compensate:child.c2 undo:child.c1 undo:a ⇒ failure(child) | — | [O: S-compose-01] |
|
|
247
|
+
| S-compose-02 | a → compose(c1 → c2) → b | b (after child) | inline | run:a run:child.c1 run:child.c2 run:b compensate:b undo:child.c2 undo:child.c1 undo:a ⇒ failure(b) | — | [O: S-compose-02] |
|
|
248
|
+
| S-compose-03 | compose x(x1 → x2) → compose y(y1 → y2) | y2 | inline | run:x.x1 run:x.x2 run:y.y1 run:y.y2 compensate:y.y2 undo:y.y1 undo:x.x2 undo:x.x1 ⇒ failure(y) | — | [O: S-compose-03] |
|
|
249
|
+
| S-compose-04 | a → compose outer(o1 → compose inner(i1 → i2)) | i2 (depth 2) | inline | run:a run:outer.o1 run:inner.i1 run:inner.i2 compensate:inner.i2 undo:inner.i1 undo:outer.o1 undo:a ⇒ failure(outer) | — | [O: S-compose-04] |
|
|
250
|
+
| S-compose-05 | compose(c1 → c2) declaring `retries max_attempts: 2` | class definition | inline | ⇒ `DeprecatedDslError` *(changed in 008, R-14)* | — | [O: S-compose-05] |
|
|
251
|
+
| S-compose-05b | compose(c1 → c2 with its own `retries`) → b | c2 fails once, then b fails | inline | run:child.c1 run:child.c2 retry:c2#1 run:child.c2 run:b compensate:b undo:child.c2 undo:child.c1 ⇒ failure(b) *(changed in 008)* | — | [O: S-compose-05b] |
|
|
252
|
+
| S-compose-06 | `background all:` a → compose(c1 → c2) → b | b | worker | same as S-compose-02 | — | [O: S-compose-06] |
|
|
253
|
+
| S-compose-07 | a → compose(c1(undo raises) → c2) | c2 | inline | run:a run:child.c1 run:child.c2 compensate:child.c2 undo:child.c1 undo:a ⇒ failure(child) | c1's effect (reported: c1/undo, flattened into parent) | [O: S-compose-07] |
|
|
254
|
+
| S-compose-08 | a → compose(c1 → c2) → b | c2 returns **Halt** | inline | run:a run:child.c1 run:child.c2 **run:b** ⇒ success | — (the child's halt does not stop the parent) | [O: S-compose-08] |
|
|
255
|
+
| — | compose with `fan_out` map inside | — | worker | **not probed: known bug.** The root never resumes after the map (`specs/future_improvements.md:225`) | — | [R: lib/ruby_reactor/map/helpers.rb:128-138] |
|
|
256
|
+
| — | compose-level `compensate`/`undo` hook | — | — | **not reachable**: the DSL offers none | — | [R: lib/ruby_reactor/dsl/compose_builder.rb:60] |
|
|
257
|
+
|
|
258
|
+
### 3.3 `map`
|
|
259
|
+
|
|
260
|
+
Element reactor: `e1 → e2`, both with compensate/undo. Element `i == 2` fails at `e2`. Items `[0,1,2,3]`.
|
|
261
|
+
|
|
262
|
+
| Scenario | Shape | Failure at | Mode | Ordered events | Left in place | Evidence |
|
|
263
|
+
|---|---|---|---|---|---|---|
|
|
264
|
+
| S-map-01 | a → map → b | element 2 | inline, fail_fast | run:a, e1/e2 [0], e1/e2 [1], run:e1[2] run:e2[2] compensate:e2[2] undo:e1[2], undo:e2[1] undo:e1[1] undo:e2[0] undo:e1[0], undo:a ⇒ failure(m) *(changed in 008)* | — (element 3 never runs) | [O: S-map-01] |
|
|
265
|
+
| S-map-02 | a → map → b | element 2 | inline, `fail_fast false` | run:a, [0], [1], [2] + compensate:e2[2] undo:e1[2], [3], run:b ⇒ **success** | elements 0, 1, 3 (by design, the map succeeds) | [O: S-map-02] |
|
|
266
|
+
| S-map-03 | a → map → b | b (after map) | inline | run:a, [0..3], run:b compensate:b, undo [3], [2], [1], [0], undo:a ⇒ failure(b) *(changed in 008)* | — | [O: S-map-03] |
|
|
267
|
+
| S-map-04 | a → map → b | element 2 | fan_out, fail_fast (jobs in order 0..3) | same as S-map-01 *(changed in 008)* | — (element 3 skipped) | [O: S-map-04] |
|
|
268
|
+
| S-map-04b | a → map → b | element 2 | fan_out, fail_fast, jobs performed 3,2,1,0 | run:a, [3], run:e1[2] run:e2[2] compensate:e2[2] undo:e1[2], undo:e2[3] undo:e1[3], undo:a ⇒ failure(m) *(changed in 008)* | — (0, 1 skipped) | [O: S-map-04b] |
|
|
269
|
+
| S-map-05 | a → map → b | element 2 | fan_out, `fail_fast false` | same as S-map-02 | elements 0, 1, 3 | [O: S-map-05] |
|
|
270
|
+
| S-map-06 | a → map → b | b (after map) | fan_out | same as S-map-03 *(changed in 008)* | — | [O: S-map-06] |
|
|
271
|
+
| S-map-07 | a → compose(c0 → map) | element 2 | inline | run:a run:child.c0, [0], [1], [2]+rollback, undo [1], [0], undo:child.c0 undo:a ⇒ failure(child) *(changed in 008)* | — | [O: S-map-07] |
|
|
272
|
+
| S-map-08 | map(element: e1 → compose(k1) → e2) | element 2's e2 | inline | …, run:e1[2] run:k.k1 run:e2[2] compensate:e2[2] undo:k.k1 undo:e1[2], then elements 1, 0 each undo:e2 undo:k.k1 undo:e1 ⇒ failure(m) *(changed in 008)* | — | [O: S-map-08] |
|
|
273
|
+
| S-map-09 | a → map → b | every e2 fails once, `retries 2` | fan_out | run:a run:e1[0] run:e2[0] retry:e2#1 run:e1[1] run:e2[1] retry:e2#1 run:e2[0] run:e2[1] run:b ⇒ success | — | [O: S-map-09] |
|
|
274
|
+
| S-map-10 | a → map → b | same | inline | run:a run:e1[0] run:e2[0] retry:e2#1 run:e2[0] run:e1[1] run:e2[1] retry:e2#1 run:e2[1] run:b ⇒ success | — | [O: S-map-10] |
|
|
275
|
+
| S-map-11 | a → map → b | element 1 returns **Halt** | inline | run:a, [0], run:e1[1] run:e2[1] ⇒ **halt** | a, elements 0, 1 (by design) | [O: S-map-11] |
|
|
276
|
+
| — | map-level `retries` / `compensate` / `undo` | — | — | **not reachable**: `MapBuilder` has none (the element steps' `undo`s are the map's rollback, 008) | — | [R: lib/ruby_reactor/dsl/map_builder.rb:130-131] |
|
|
277
|
+
| S-map-12 | a → map(element: e1 → async_step u, e2) → b | element 1's e2 | fan_out, fail_fast | run:a, [0] e1 e2, run:e1[1] run:e2[1] compensate:e2[1] undo:e1[1], undo:e2[0] undo:e1[0], undo:a, **run:u[0] run:u[1]** ⇒ failure(m) *(changed in 008)* | **both units, incl. failed element 1's** (async units are independent, INV-25; F-09) | [O: S-map-12] [R: lib/ruby_reactor/executor/async_step_dispatch.rb:20-24] vs [R: lib/ruby_reactor/map/element_executor.rb:51-55] |
|
|
278
|
+
|
|
279
|
+
### 3.4 `async_step` / `async_reactor`
|
|
280
|
+
|
|
281
|
+
| Scenario | Shape | Failure at | Mode | Ordered events | Left in place | Evidence |
|
|
282
|
+
|---|---|---|---|---|---|---|
|
|
283
|
+
| S-async-01 | a → async_step u; b | u, no reader | inline + StepWorker | run:a run:b run:u compensate:u ⇒ success *(changed in 008)* | a, b (by design: no reader) | [O: S-async-01] |
|
|
284
|
+
| S-async-02 | a → async_step u → r reads u | u, r fails on it | inline + Sidekiq inline! | run:a run:u compensate:u run:r compensate:r undo:a ⇒ failure(r) *(changed in 008)* | — | [O: S-async-02] |
|
|
285
|
+
| S-async-03 | a → async_step u; b | b, u succeeds | inline + StepWorker | run:a run:b compensate:b undo:a **run:u** ⇒ failure(b) | **u (runs after the rollback)** | [O: S-async-03] |
|
|
286
|
+
| S-async-04 | a → async_reactor child(c1 → c2); b | c2 in child, no reader | inline + Worker | run:a run:b run:child.c1 run:child.c2 compensate:child.c2 undo:child.c1 ⇒ success | a, b | [O: S-async-04] |
|
|
287
|
+
| S-async-05 | a → async_reactor child → r reads child | c2 in child, r fails | inline + Sidekiq inline! | run:a [child rolls back itself] run:r compensate:r undo:a ⇒ failure(r) | — | [O: S-async-05] |
|
|
288
|
+
| S-async-06 | a → async_reactor child; b | b, child succeeds | inline + Worker | run:a run:b compensate:b undo:a **run:child.c1 run:child.c2** ⇒ failure(b) | **child's c1, c2** | [O: S-async-06] |
|
|
289
|
+
| S-async-07 | a → async_step u(`retries 3`); b | u always fails | inline + StepWorker | run:a run:b run:u run:u run:u compensate:u ⇒ success *(changed in 008)* | a, b (no reader); no retry events | [O: S-async-07] |
|
|
290
|
+
| S-async-08 | a → async_step u → r reads u | reader wait times out | inline (fake queue) | run:a undo:a **run:u** compensate:u ⇒ failure(r) *(changed in 008)* | **u, if it succeeds (runs after the rollback, F-09)** | [O: S-async-08] |
|
|
291
|
+
|
|
292
|
+
### 3.5 `background`
|
|
293
|
+
|
|
294
|
+
| Scenario | Shape | Failure at | Mode | Ordered events | Left in place | Evidence |
|
|
295
|
+
|---|---|---|---|---|---|---|
|
|
296
|
+
| S-bg-01 | `all:` a → b → c | c | worker | run:a run:b run:c compensate:c undo:b undo:a ⇒ failure(c) | — | [O: S-bg-01] |
|
|
297
|
+
| S-bg-02 | a \| `after: :a` \| b → c | c | caller + worker | run:a (caller) run:b run:c compensate:c undo:b undo:a (worker) ⇒ failure(c) | — | [O: S-bg-02] |
|
|
298
|
+
| S-bg-03 | `all:` a → b(`retries 3`) | b always | worker | run:a run:b retry:b#1 run:b retry:b#2 run:b compensate:b undo:a ⇒ failure(b) | — | [O: S-bg-03] |
|
|
299
|
+
| S-bg-04 | `all:` a → b(`retries 2`) → c | b once | worker | run:a run:b retry:b#1 run:b run:c ⇒ success | — | [O: S-bg-04] |
|
|
300
|
+
|
|
301
|
+
### 3.6 Coordination, retries, interrupts, edge cases
|
|
302
|
+
|
|
303
|
+
| Scenario | Shape | Failure at | Mode | Ordered events | Left in place | Evidence |
|
|
304
|
+
|---|---|---|---|---|---|---|
|
|
305
|
+
| S-lock-01 | reactor `with_lock rk`: a → b | b | inline | lock_acquired:rk run:a run:b compensate:b undo:a lock_released:lock:rk ⇒ failure(b) | — | [O: S-lock-01] |
|
|
306
|
+
| S-lock-02 | a → b(step lock sk) → c | c | inline | run:a lock_acquired:sk run:b lock_released:sk run:c compensate:c lock_acquired:sk undo:b lock_released:sk undo:a ⇒ failure(c) | — | [O: S-lock-02] |
|
|
307
|
+
| S-lock-03 | a → b(step lock sk, held elsewhere) | b contended | inline | run:a undo:a ⇒ failure(b) | — (b never ran, not compensated) | [O: S-lock-03] |
|
|
308
|
+
| S-lock-04 | a → b(step semaphore 1) → c | c | inline | as S-lock-02 with semaphore events | — | [O: S-lock-04] |
|
|
309
|
+
| S-lock-05 | a → b(step lock, `rollback_wait 0.3`) → c (grabs sk) | c | inline | run:a lock_acquired:sk run:b lock_released:sk run:c compensate:c undo:a ⇒ failure(c) | **b** (undo skipped, reported `coordination_unavailable`) | [O: S-lock-05] |
|
|
310
|
+
| S-lock-06 | reactor lock rk: a → compose(child lock rk) → b | b | inline | lock_acquired:rk run:a lock_acquired:rk run:child.c1 lock_released:lock:rk run:b(rk count=1) compensate:b undo:child.c1 undo:a lock_released:lock:rk ⇒ failure(b) | — | [O: S-lock-06] |
|
|
311
|
+
| S-retry-01 | a → b(`retries 3`) | b always | inline | run:a run:b retry:b#1 run:b retry:b#2 run:b compensate:b undo:a ⇒ failure(b) | — | [O: S-retry-01] |
|
|
312
|
+
| S-retry-02 | a → b(`fail!(retry: false)`) | b | inline | run:a run:b compensate:b undo:a ⇒ failure(b) | — | [O: S-retry-02] |
|
|
313
|
+
| S-retry-03 | a → b(`retries 2`) → c | b once | inline | run:a run:b retry:b#1 run:b run:c ⇒ success | — | [O: S-retry-03] |
|
|
314
|
+
| S-retry-04 | a → b raises (`retries 2`) | b always | inline | run:a run:b retry:b#1 run:b compensate:b undo:a ⇒ failure(b) | — | [O: S-retry-04] |
|
|
315
|
+
| S-intr-01 | reactor lock: a → interrupt → c | c after continue | inline | lock_acquired:rk run:a lock_released:lock:rk ⏸ lock_acquired:rk run:c compensate:c undo:a lock_released:lock:rk ⇒ failure(c) | — | [O: S-intr-01] |
|
|
316
|
+
| S-intr-02 | a → interrupt(`max_attempts 1`) | invalid payload | inline | run:a undo:a ⇒ failure(approval) | — | [O: S-intr-02] |
|
|
317
|
+
| S-intr-03 | a → interrupt | `Reactor.cancel` | inline | run:a ⇒ cancelled | **a** (by design) | [O: S-intr-03] |
|
|
318
|
+
| S-intr-04 | completed a → b, reactor lock | `Reactor.undo(id)` while rk held by another owner | inline | … undo:b undo:a ⇒ cancelled | — (undo ran **outside** the reactor lock) | [O: S-intr-04] |
|
|
319
|
+
| S-edge-01 | `all:` a → b(step lock held elsewhere) | contention ceiling | worker | run:a undo:a ⇒ failure(b) | — | [O: S-edge-01] |
|
|
320
|
+
| S-edge-02 | `all:` a → b → c | worker crash in b | worker, redelivered | run:a run:b ✖ run:b run:c ⇒ success | b's first partial run (at-least-once) | [O: S-edge-02] |
|
|
321
|
+
| S-edge-03 | a → b | b raises a custom `Exception` | inline | run:a run:b compensate:b undo:a ⇒ failure(b) *(changed in 008, R-16)* | — | [O: S-edge-03] |
|
|
322
|
+
| S-edge-03b | a → b | b raises `Interrupt` | inline | run:a run:b ⇒ exception raised to caller; stored status `aborted` *(new in 008)* | **a**, until a manual `Reactor.undo(id)` | [O: S-edge-03b] |
|
|
323
|
+
| S-edge-04 | a → b | b declares `where` | inline | ⇒ `DeprecatedDslError` at class definition *(changed in 008, R-15)* | — | [O: S-edge-04] |
|
|
324
|
+
| S-edge-05 | a → b | b input type invalid | inline | run:a undo:a ⇒ failure(b) | — | [O: S-edge-05] |
|
|
325
|
+
| S-edge-06 | reactor input invalid | before start | inline | ⇒ failure | — | [O: S-edge-06] |
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## 4. Cross-cutting: locks
|
|
330
|
+
|
|
331
|
+
| Primitive | Held from → to | During rollback | Evidence |
|
|
332
|
+
|---|---|---|---|
|
|
333
|
+
| Reactor `with_lock` / `with_semaphore` | admission → executor `ensure` (after the last undo) | **held** through rollback | `[R: lib/ruby_reactor/executor.rb:161]` `[O: S-lock-01]` |
|
|
334
|
+
| … across a park (worker contention / pending async read) | kept (detached, re-adopted on redelivery) | — | `[R: lib/ruby_reactor/executor.rb:639-668]` |
|
|
335
|
+
| … across an interrupt pause | **released**, re-acquired on `continue` | — | `[O: S-intr-01]` |
|
|
336
|
+
| … for `Reactor.undo(id)` | **not taken** | undo runs without it | `[R: lib/ruby_reactor/reactor.rb:188]` `[O: S-intr-04]` |
|
|
337
|
+
| … composed child with the same key | re-entrant (same root owner, counter) | parent hold intact | `[O: S-lock-06]` |
|
|
338
|
+
| … `async_reactor` child with the same key | **refused at dispatch** (deadlock guard): that step fails, normal rollback | — | `[R: lib/ruby_reactor/step/async_reactor_step.rb:104]` |
|
|
339
|
+
| Step `with_lock` / `with_semaphore` | body only | **re-taken** around that step's compensate/undo, waiting `rollback_wait` (default lock `ttl` / 60 s) | `[R: lib/ruby_reactor/executor/step_coordination.rb:207-251]` `[O: S-lock-02, S-lock-04]` |
|
|
340
|
+
| … when it cannot be re-taken | — | that rollback **skipped** and reported, the rest continues | `[O: S-lock-05]` `[T: spec/ruby_reactor/step_coordination/rollback_under_contention_spec.rb:32]` |
|
|
341
|
+
| Step rate limit / period / ordered lock | body only | **not** re-taken (quota never blocks cleanup) | `[R: …/step_coordination.rb:196-206]` `[T: spec/ruby_reactor/step_coordination/rollback_spec.rb:101]` |
|
|
342
|
+
| Contention on a step's own lock | — | step never started → not compensated. Inline: fails at once. Worker: parks, then fails at the ceiling | `[O: S-lock-03, S-edge-01]` |
|
|
343
|
+
|
|
344
|
+
Between a step's forward release and its rollback re-acquire there is a window in which
|
|
345
|
+
another execution can take the key and change the protected resource. The re-acquire serializes
|
|
346
|
+
the undo with that execution. It does not guarantee the resource is still as the step left it.
|
|
347
|
+
|
|
348
|
+
## 5. Cross-cutting: retries
|
|
349
|
+
|
|
350
|
+
| Where the step runs | How a retry happens | Compensation | Evidence |
|
|
351
|
+
|---|---|---|---|
|
|
352
|
+
| Inline reactor | `sleep(backoff)` in the caller's thread | once, after the last attempt | `[R: lib/ruby_reactor/executor/retry_manager.rb:171-175]` `[O: S-retry-01]` |
|
|
353
|
+
| `background` worker | job re-enqueued with backoff (`RetryQueuedResult`) | once, in the delivery that exhausts | `[O: S-bg-03]` |
|
|
354
|
+
| Map element, inline | `sleep` in place, element after element | per element, once | `[O: S-map-10]` |
|
|
355
|
+
| Map element, fan_out | element job re-enqueued, other elements interleave | per element, once | `[O: S-map-09]` |
|
|
356
|
+
| `async_step` | loop **inside** the StepWorker job, no retry middleware event | **never** (unit rollback hooks do not run) | `[R: lib/ruby_reactor/step_worker.rb:275-289]` `[O: S-async-07]` |
|
|
357
|
+
| `compose` | *since 008*, `retries` on the compose is rejected at definition time; the child's own steps retry inside the child (baseline: the child was resumed with its undone steps counted as completed) | as for the child's steps | `[O: S-compose-05, S-compose-05b]` |
|
|
358
|
+
| `fail!(…, retry: false)` | not retried | once | `[O: S-retry-02]` |
|
|
359
|
+
| Successful retry | — | none (no compensate for the failed attempts) | `[O: S-retry-03, S-bg-04]` |
|