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
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
# Research: Reliable Rollback Across Constructs
|
|
2
|
+
|
|
3
|
+
Baseline `faf90e8d` (0.8.3 + #61, #63). Findings, invariants and scenario ids refer to
|
|
4
|
+
[007 analysis](../007-execution-flow-analysis/analysis/). `[R: file:line]` = read in source.
|
|
5
|
+
|
|
6
|
+
Each decision: **Decision**, **Rationale**, **Alternatives considered**.
|
|
7
|
+
|
|
8
|
+
**Revision 2026-09-27 (PR #65 review)**. The review reversed or narrowed four decisions. They stay
|
|
9
|
+
below for the record, each marked with what replaces it:
|
|
10
|
+
|
|
11
|
+
- R-05 (fresh child per compose attempt) → **superseded by R-14**: nested reactors are never retried
|
|
12
|
+
as a whole.
|
|
13
|
+
- R-06, condition half (`ConditionError`) → **superseded by R-15**: `where`/`guard` are removed. The
|
|
14
|
+
argument half (`ArgumentResolutionError`) stays.
|
|
15
|
+
- R-07 and R-08 (`StandardError` rolls back, every other exception aborts) → **narrowed by R-16**:
|
|
16
|
+
every exception rolls back except interruptions.
|
|
17
|
+
- New: R-17 (what `Skipped` means for rollback), R-18 (rework of the work already on the branch).
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## R-01 · Refactor direction: how much should the step own?
|
|
22
|
+
|
|
23
|
+
The user asked whether steps should own more of their own processing, with the reactor
|
|
24
|
+
coordination getting simpler. Here is where each lifecycle concern lives today:
|
|
25
|
+
|
|
26
|
+
| Concern | Today | Duplicated? |
|
|
27
|
+
| --- | --- | --- |
|
|
28
|
+
| Resolve arguments | `StepExecutor#resolve_arguments` [R: executor/step_executor.rb:438] **and** `StepWorker#resolve_arguments` [R: step_worker.rb:359] | yes, two copies, and neither attributes errors |
|
|
29
|
+
| Conditions (`where`/`guard`) | `StepConfig#should_run?` | no |
|
|
30
|
+
| Forward body | `StepConfig#call_body` (block / impl / duck-typed) | no |
|
|
31
|
+
| Compensate / undo dispatch (block → impl → `Skipped`) | inside `CompensationManager#compensate_step` / `#undo_step` [R: executor/compensation_manager.rb:120-205] | will be, because `StepWorker` now needs compensate (R-09) |
|
|
32
|
+
| Is the step's success recorded for undo? | `ResultHandler#async_unit?` special case [R: executor/result_handler.rb:137] | no, but it is construct knowledge held by the coordinator |
|
|
33
|
+
| What compensate/undo **mean** for composites | `ComposeStep#compensate`/`undo`, `MapStep#compensate` stub | no |
|
|
34
|
+
| Retry delivery (sleep / requeue / element requeue / in-job loop) | `RetryManager`, `StepWorker` | depends on the process the step runs in |
|
|
35
|
+
| Coordination (locks etc.) around body and rollback | `StepCoordination`, taken by the executor (005 D2) | no |
|
|
36
|
+
|
|
37
|
+
**Decision**: adopt a **bounded** version of the direction. "Step" here means the pair
|
|
38
|
+
`StepConfig` (the step as declared in a reactor) plus its construct class (`ComposeStep`,
|
|
39
|
+
`MapStep`, the user's `Step` subclass).
|
|
40
|
+
|
|
41
|
+
1. **`StepConfig` owns every per-step lifecycle operation** that is the same wherever the step
|
|
42
|
+
runs: `resolve_arguments(context)`, `should_run?(context)`, `call_body` (exists),
|
|
43
|
+
`call_compensate(error, arguments, context)`, `call_undo(result, arguments, context)` and
|
|
44
|
+
`rollback_tracked?`. `StepExecutor`, `StepWorker` and `CompensationManager` call these and hold
|
|
45
|
+
no step-kind knowledge.
|
|
46
|
+
2. **Construct classes own what rollback means**: `ComposeStep` (fresh child per retry, R-05) and
|
|
47
|
+
`MapStep` (element replay, R-02) implement `compensate`/`undo` themselves. Async constructs
|
|
48
|
+
answer `rollback_tracked? == false` (R-10).
|
|
49
|
+
3. **The coordinator keeps the orchestration**: dependency order, retry delivery, coordination
|
|
50
|
+
acquisition, the undo stack, rollback ordering, trace, middleware events and
|
|
51
|
+
`rollback_failures`. It applies one rule: a tracked success is pushed, a started-and-failed step
|
|
52
|
+
is compensated, a never-started step is not compensated, and every tracked entry is undone in
|
|
53
|
+
reverse order.
|
|
54
|
+
|
|
55
|
+
**Rationale**: every move in (1) removes a duplicate that the fixes would otherwise have to patch
|
|
56
|
+
twice. Argument attribution (F-03) is needed in both processes. Compensate dispatch is needed in
|
|
57
|
+
both `CompensationManager` and `StepWorker`. That is the second real use case Constitution V asks
|
|
58
|
+
for. (2) is where F-01 and F-02 have to be fixed anyway. The rest stays put, because it depends on
|
|
59
|
+
*where* a step runs, which only the coordinator knows.
|
|
60
|
+
|
|
61
|
+
**Alternatives considered**:
|
|
62
|
+
|
|
63
|
+
- *Full "step executes itself"* (`Step#execute(context)` owning retries, coordination, argument
|
|
64
|
+
resolution and result handling). Rejected for this round. Retry delivery differs by process
|
|
65
|
+
(inline `sleep`, worker requeue, element requeue, in-job loop, 007 INV-14). A step that retries
|
|
66
|
+
itself would need to know about queues. 005 D2 deliberately makes the executor take a step's
|
|
67
|
+
inline and class coordination in one fixed order. Moving it back into the step re-splits
|
|
68
|
+
acquisition across two layers. None of the High fixes needs it, and it touches five executors
|
|
69
|
+
(`Executor`, `StepExecutor`, `StepWorker`, `Map::ElementExecutor`, `Map::Collector`).
|
|
70
|
+
- *Targeted patches only*. Rejected. `resolve_arguments` and the compensate dispatch would each be
|
|
71
|
+
fixed twice, and the `async_unit?` special case would stay.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## R-02 · Map rollback: how succeeded elements are replayed (F-01, FR-001–FR-008)
|
|
76
|
+
|
|
77
|
+
**Facts**:
|
|
78
|
+
|
|
79
|
+
- Both modes already persist every element as its own context: inline via the element executor's
|
|
80
|
+
`save_context` [R: executor.rb:163], fan-out via `ElementExecutor`.
|
|
81
|
+
- Both modes index the element context ids per map: `store_map_element_context_id(map_id, …)`
|
|
82
|
+
[R: step/map_step.rb:130] [R: map/element_executor.rb:58].
|
|
83
|
+
- An element's undo stack is serialized with its context [R: context.rb:173].
|
|
84
|
+
- `map_id` is `"#{parent_context_id}:#{step_name}"` in both modes.
|
|
85
|
+
|
|
86
|
+
**Decision**: `MapStep#undo` and `MapStep#compensate` do the same work (the compose pattern,
|
|
87
|
+
`alias undo compensate`):
|
|
88
|
+
|
|
89
|
+
1. Read the map's element-context index, dedupe it (a parked or retried element re-registers its
|
|
90
|
+
id), and load each element context.
|
|
91
|
+
2. Roll back only elements with status `completed`. A `failed` element already rolled itself back
|
|
92
|
+
(INV-21). A `halted` element is not rolled back (`Halt` semantics, F-07 out of scope). An element
|
|
93
|
+
that never ran has no stored context.
|
|
94
|
+
3. Order: **descending element index**. Inline this is exact reverse completion order. Fan-out has
|
|
95
|
+
no meaningful completion order, so descending index is the one deterministic choice, and it is
|
|
96
|
+
documented.
|
|
97
|
+
4. Per element: `Executor.new(element_class, {}, element_ctx).undo_all`, then save the element
|
|
98
|
+
context. Its undo stack is now empty, so a second rollback (compensate followed by a manual
|
|
99
|
+
`Reactor#undo`) is a no-op.
|
|
100
|
+
5. Each element undo runs under that element's `map_element:<map_id>:<index>` liveness lock, taken
|
|
101
|
+
with `wait: 0`. Elements are only ever rolled back after they settled (R-04), so a held lock
|
|
102
|
+
means a live duplicate delivery. That element is not touched and is reported
|
|
103
|
+
(`reason: :element_in_flight`).
|
|
104
|
+
6. An element whose context is gone (expired past `context_ttl`) is reported as a rollback failure
|
|
105
|
+
(`reason: :context_unavailable`), never skipped silently.
|
|
106
|
+
7. Each element's own rollback failures are flattened into the map's Failure. Each entry is tagged
|
|
107
|
+
with `map_step:` and `element_index:` (FR-005). The other elements still roll back.
|
|
108
|
+
8. The element's reactor class is read from the map step's static `mapped_reactor_class` argument,
|
|
109
|
+
the same source `Map::Helpers#resolve_reactor_class` uses for inline classes. Nothing is read
|
|
110
|
+
from the undo record, so the record can stay small (R-03).
|
|
111
|
+
|
|
112
|
+
`CompensationManager#compensate_step` runs inside `@context.with_step(step_config.name)`, as
|
|
113
|
+
`undo_step` already does. Every construct can then rely on `context.current_step` during both
|
|
114
|
+
rollback moments. Today `ComposeStep#compensate` only works when the caller happened to set it.
|
|
115
|
+
|
|
116
|
+
**Single-writer rule**: the map's rollback writes element contexts, the same way `ComposeStep`
|
|
117
|
+
writes its child. This is safe here only because it runs after every element is terminal (R-04),
|
|
118
|
+
from the one execution that owns the map step, under each element's liveness lock. No live element
|
|
119
|
+
execution can be writing at the same time.
|
|
120
|
+
|
|
121
|
+
**Rationale**: this is the compose-consistent behavior chosen in the spec clarification. No
|
|
122
|
+
element data enters the parent blob (FR-008, SC-006). One code path serves both modes.
|
|
123
|
+
|
|
124
|
+
**Alternatives considered**: embedding element contexts in the parent (`ContextTooLargeError` for
|
|
125
|
+
large maps); map-level `undo_each`/`undo_all` blocks (declined in the clarification; can be added
|
|
126
|
+
later as an override); parallel rollback fan-out (N jobs). The last is YAGNI. The serial loop
|
|
127
|
+
runs in the process that detected the failure, and its ceiling is documented: rollback time grows
|
|
128
|
+
linearly with the number of succeeded elements.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## R-03 · A fan-out map must enter the undo stack (INV-20)
|
|
133
|
+
|
|
134
|
+
**Facts**: inline, `ResultHandler#handle_success` pushes the map step like any step. In fan-out,
|
|
135
|
+
the collector's success branch only does `set_result` and resumes the parent
|
|
136
|
+
[R: map/helpers.rb:97]. The map is never pushed, so a later failure cannot undo it.
|
|
137
|
+
|
|
138
|
+
**Decision**: the collector's success branch pushes the map step on the parent's undo stack before
|
|
139
|
+
it resumes. The record is `{ step: map_step_config, arguments: {}, result: Success(nil) }`.
|
|
140
|
+
`MapStep#undo` needs neither field (R-02 §8), so the parent blob grows by a constant, not by N.
|
|
141
|
+
The push happens in the collector, which is the parent's owning execution at that moment. It
|
|
142
|
+
already writes the parent's result there.
|
|
143
|
+
|
|
144
|
+
**Rationale**: the same rule for both modes: a completed map is tracked for undo.
|
|
145
|
+
|
|
146
|
+
**Alternatives considered**: routing the collector through `ResultHandler#handle_step_result`.
|
|
147
|
+
That would also push the lazy `ResultEnumerator` value into the undo record, a second serialized
|
|
148
|
+
copy of the result. Rejected for size.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## R-04 · Fan-out fail-fast must settle before rollback (F-05, FR-003, INV-22)
|
|
153
|
+
|
|
154
|
+
**Facts**:
|
|
155
|
+
|
|
156
|
+
- Today the failing element triggers the collector at once [R: map/element_executor.rb:188]. The
|
|
157
|
+
collector's failure branch runs before it checks completeness [R: map/collector.rb:73].
|
|
158
|
+
- An element that has not started yet skips itself when the fail-fast marker is set, and
|
|
159
|
+
decrements the counter [R: map/element_executor.rb:155].
|
|
160
|
+
- The dispatcher stops dispatching new batches after the marker, and those indices are never
|
|
161
|
+
counted down [R: map/dispatcher.rb:69].
|
|
162
|
+
- The map sweeper treats the **results hash** as the authority on completion, not the counter
|
|
163
|
+
[R: map/sweeper.rb:7-12]. Skipped indices never store a result, so the sweeper keeps re-dispatching
|
|
164
|
+
them after a fail-fast failure (a latent loop).
|
|
165
|
+
|
|
166
|
+
**Decision**:
|
|
167
|
+
|
|
168
|
+
1. Every index **settles** with a result slot. A skipped element stores `{ _skipped: true }` before
|
|
169
|
+
it finalizes. When the dispatcher sees the fail-fast marker, it atomically claims every index it
|
|
170
|
+
has not dispatched yet (one `increment_map_offset` for the rest), writes `_skipped` slots for
|
|
171
|
+
them, decrements the counter by that amount, and triggers the collector if the counter reaches
|
|
172
|
+
zero.
|
|
173
|
+
2. The collector resolves a fail-fast failure only when **no index is missing**, the same
|
|
174
|
+
authority the sweeper uses. Until then it returns, and the last settling element (counter zero)
|
|
175
|
+
or the sweeper re-triggers it.
|
|
176
|
+
3. The failure is then applied to the parent as today, and the map's compensate (R-02) runs over
|
|
177
|
+
every `completed` element. That includes elements that were in flight when the marker was set.
|
|
178
|
+
|
|
179
|
+
**Rationale**: the set of rolled-back elements is always the set of elements that succeeded
|
|
180
|
+
(SC-003). It also removes the sweeper's re-dispatch loop for skipped indices.
|
|
181
|
+
|
|
182
|
+
**Cost**: failure latency grows to the slowest element in flight. This is documented (spec
|
|
183
|
+
Assumptions).
|
|
184
|
+
|
|
185
|
+
**INV-22 rescoped**: *which* elements run after a fail-fast failure still depends on scheduling.
|
|
186
|
+
That is inherent to fan-out. *What is left in place* is now deterministic: nothing. SC-001's
|
|
187
|
+
"INV-22 HOLDS" applies to the left-in-place clause, and the invariant is restated that way when it
|
|
188
|
+
is updated.
|
|
189
|
+
|
|
190
|
+
**Alternatives considered**: late elements roll themselves back (007 O-05-b). Rejected: it races
|
|
191
|
+
with the collector and wastes a whole saga run before undoing it.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## R-05 · Compose retry starts a fresh child (F-02, FR-009–FR-012) — SUPERSEDED by R-14
|
|
196
|
+
|
|
197
|
+
**Facts**: `ComposeStep#run` reuses `composed_contexts[step][:context]`. If that child was admitted,
|
|
198
|
+
it calls `resume_execution` [R: step/compose_step.rb:98]. Resume calls
|
|
199
|
+
`mark_completed_steps_from_context`, which marks every key of `intermediate_results` as done
|
|
200
|
+
[R: executor/graph_manager.rb:19], including steps the failed attempt undid. The child's retry
|
|
201
|
+
counters also carry over.
|
|
202
|
+
|
|
203
|
+
**Decision**: the construct decides. In `ComposeStep#run`, a stored child context whose status is
|
|
204
|
+
`failed` belongs to a previous **attempt** that already rolled itself back. The compose starts a
|
|
205
|
+
**fresh** child context and appends one entry to the parent's trace:
|
|
206
|
+
`{ type: :compose_attempt_discarded, step:, child_context_id:, rollback_failures: }`. That entry
|
|
207
|
+
keeps an incomplete rollback of an earlier attempt visible (spec edge case). Any other stored child
|
|
208
|
+
(running, paused, parked) is **resumed** as today (FR-011).
|
|
209
|
+
|
|
210
|
+
**FR-012 audit**: every path that resumes a stored context, and whether it can resume one after
|
|
211
|
+
that context rolled back:
|
|
212
|
+
|
|
213
|
+
| Entry point | Can resume after a rollback? |
|
|
214
|
+
| --- | --- |
|
|
215
|
+
| `Worker` (root / `async_reactor` child) | no. `failed`/`cancelled` are terminal and skipped [R: worker.rb:11, :66] |
|
|
216
|
+
| `Map::Collector` → parent `resume_execution` | no. It resumes the parent before any rollback |
|
|
217
|
+
| `Map::ElementExecutor` requeue (retry / park) | no. It requeues before the element rolls back |
|
|
218
|
+
| `Reactor#continue` (interrupt) | no. After an undo the context is `cancelled` |
|
|
219
|
+
| `ComposeStep#run` on a retry | **yes, today**. Fixed by this decision |
|
|
220
|
+
|
|
221
|
+
The compose retry is the only path, so no general "rolled-back marks" mechanism is added (YAGNI).
|
|
222
|
+
The audit table goes into the updated execution-order documentation.
|
|
223
|
+
|
|
224
|
+
**Alternatives considered**: 007 O-02-b, where rollback clears completion marks. Rejected: stale
|
|
225
|
+
child retry counters remain, and the undone results vanish from the dashboard. 007 O-02-c,
|
|
226
|
+
forbidding `retries` on compose, is breaking and loses a real use.
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## R-06 · Argument and condition errors are never-started failures (F-03, F-06, FR-013–FR-015) — condition half SUPERSEDED by R-15
|
|
231
|
+
|
|
232
|
+
**Facts**:
|
|
233
|
+
|
|
234
|
+
- `StepExecutor#execute_step` resolves arguments outside every rescue [R: executor/step_executor.rb:83].
|
|
235
|
+
A raise reaches `Executor#execute`'s `rescue StandardError`, then `build_execution_failure`'s
|
|
236
|
+
"Unknown errors - don't rollback" branch [R: executor/result_handler.rb:68].
|
|
237
|
+
- A `where`/`guard` that raises is caught by `safe_execute_step_sync`'s `rescue StandardError` and
|
|
238
|
+
is compensated like a body failure.
|
|
239
|
+
|
|
240
|
+
**Decision**:
|
|
241
|
+
|
|
242
|
+
- Add two error classes: `Error::ArgumentResolutionError` and `Error::ConditionError`. Both
|
|
243
|
+
subclass `Error::Base` and carry `step`, `original_error` and the cause's `exception_class`. Both
|
|
244
|
+
are non-retryable (the same inputs fail the same way).
|
|
245
|
+
- Add both to `CompensationManager::NEVER_STARTED_ERROR_CLASSES`.
|
|
246
|
+
- `StepConfig#resolve_arguments` and `StepConfig#should_run?` wrap any `StandardError` into these
|
|
247
|
+
classes. `Error::ExecutionParked` and its subclasses (for example `AsyncResultPending`, the
|
|
248
|
+
worker's wait on an async result) propagate unchanged, because a park is not a failure.
|
|
249
|
+
- `StepExecutor#execute_step` catches `ArgumentResolutionError` and sends
|
|
250
|
+
`Failure(error, step_name:, reactor_name:, …)` through `ResultHandler#handle_step_result`. The
|
|
251
|
+
normal failure path then gives it step attribution, no compensation of the step, and an undo of
|
|
252
|
+
the completed steps.
|
|
253
|
+
- `StepWorker` uses the same `StepConfig` methods, so an `async_step`'s worker-side resolution
|
|
254
|
+
follows the same rule (FR-015). A `background before:` hand-off runs the ordinary executor in the
|
|
255
|
+
worker, so it is covered by the executor change.
|
|
256
|
+
- The async result-wait timeout (an `Error::Base` raised during resolution) is wrapped as well. It
|
|
257
|
+
keeps its outcome (no compensation, completed steps undone, INV row "Reader's `result(:unit)`
|
|
258
|
+
wait times out") and gains a step name. `exception_class` still reports the original class.
|
|
259
|
+
|
|
260
|
+
**Rationale**: one never-started rule, as for contention (INV-07). Step attribution fixes the
|
|
261
|
+
resolution half of F-13.
|
|
262
|
+
|
|
263
|
+
**Alternatives considered**: removing the "don't rollback" branch only (007 O-03-b). That rolls
|
|
264
|
+
back but still has no attribution, and still compensates on a raising condition.
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## R-07 · Any other `StandardError` rolls back and is attributed (FR-016, FR-017) — widened by R-16
|
|
269
|
+
|
|
270
|
+
**Decision**:
|
|
271
|
+
|
|
272
|
+
- `build_execution_failure`'s `else` branch rolls back the completed steps, like the `Error::Base`
|
|
273
|
+
branch.
|
|
274
|
+
- Both branches set `step_name` from `error.step` (the `Error::Base` attribute) or from
|
|
275
|
+
`@context.current_step`, plus `reactor_name`.
|
|
276
|
+
- A `CompensationError` already carries `step:` [R: error/base.rb:6], so compensate-failure results
|
|
277
|
+
gain `step_name` (the F-13 compensate half).
|
|
278
|
+
|
|
279
|
+
**Rationale**: the "may not be reactor-related" concern is outweighed by Constitution II. A partial
|
|
280
|
+
saga with no recovery path is forbidden. An unexpected error after completed work is exactly the
|
|
281
|
+
case rollback exists for.
|
|
282
|
+
|
|
283
|
+
**Alternatives considered**: keeping the branch and documenting it. Rejected: it violates INV-06.
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## R-08 · Process-level exceptions mark an inline run `aborted` (FR-018) — narrowed by R-16
|
|
288
|
+
|
|
289
|
+
**Facts**:
|
|
290
|
+
|
|
291
|
+
- A non-`StandardError` passes through `Executor#execute`'s rescues. Its `ensure` persists the
|
|
292
|
+
context with status `running` [R: executor.rb:163].
|
|
293
|
+
- The reactor `Sweeper` re-enqueues any top-level context that is `running` with no `async:` lock
|
|
294
|
+
[R: sweeper.rb:51-54]. Inline runs never hold that lock, so a sweeper would silently resume the
|
|
295
|
+
run forward in a worker.
|
|
296
|
+
|
|
297
|
+
**Decision**:
|
|
298
|
+
|
|
299
|
+
- `Executor#execute` and `#resume_execution` add `rescue Exception` after the existing rescues.
|
|
300
|
+
For an execution in the caller's process (`!@context.inline_async_execution`), the rescue sets
|
|
301
|
+
status `:aborted`, runs no rollback code, and re-raises the same exception object. The existing
|
|
302
|
+
`ensure` persists it. The save is best effort, since the process may be out of memory.
|
|
303
|
+
- Each nested inline executor (compose child) marks its own context the same way on the way out.
|
|
304
|
+
- Worker executions are unchanged. `Sidekiq::Shutdown` and similar leave `running`, and the job is
|
|
305
|
+
redelivered (007 INV-32).
|
|
306
|
+
- `aborted` is added to the dashboard's known statuses (`determine_status`, web API and UI filters)
|
|
307
|
+
so it is visible (Constitution IV).
|
|
308
|
+
- The `Sweeper` only acts on `running`, so it leaves aborted runs alone.
|
|
309
|
+
- `Reactor#undo` needs no change: it runs `undo_all` over the persisted undo stack.
|
|
310
|
+
|
|
311
|
+
**Rationale**: running user undo code during a signal or out-of-memory condition is unsafe.
|
|
312
|
+
Recording the state gives the run a recovery path that is visible and explicit (Constitution II),
|
|
313
|
+
instead of a silent forward resume.
|
|
314
|
+
|
|
315
|
+
**Alternatives considered**: rolling back on `Exception` (unsafe). Leaving the run `running`: the
|
|
316
|
+
sweeper then resumes forward work the caller believes has failed.
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## R-09 · `async_step` unit-local compensate (F-04, FR-019–FR-021)
|
|
321
|
+
|
|
322
|
+
**Decision**:
|
|
323
|
+
|
|
324
|
+
- In `StepWorker#run_step`, after the retry loop, compensate the unit when the final result is a
|
|
325
|
+
`Failure` whose error is not never-started. Never-started means: not in
|
|
326
|
+
`NEVER_STARTED_ERROR_CLASSES` and not an `InputValidationError`, the same classification the
|
|
327
|
+
executor uses, where validation failures are never compensated.
|
|
328
|
+
- The compensate uses `CompensationManager#compensate`. That is the existing private
|
|
329
|
+
`compensate_step` made public. It keeps the coordinated re-take of the unit's own
|
|
330
|
+
locks/semaphores, the middleware events and the rollback-failure recording, all run on the unit's
|
|
331
|
+
in-memory context.
|
|
332
|
+
- `StepWorker` never saves the parent context (single-writer rule). The outcome is written to the
|
|
333
|
+
unit's own record as `compensation: { status:, rollback_failures: }`. The unit's `result`
|
|
334
|
+
remains the body's Failure.
|
|
335
|
+
- A failed attempt that is then retried is not compensated, because the compensate runs once after
|
|
336
|
+
the loop.
|
|
337
|
+
- A `Halt`, `Skipped` or `Success` result is never compensated.
|
|
338
|
+
|
|
339
|
+
**Reader interaction is unchanged**: a reader that surfaces the failure is compensated, and the
|
|
340
|
+
parent's steps are undone. The unit is never on the parent's undo stack, so it is not compensated
|
|
341
|
+
twice (acceptance US4-3).
|
|
342
|
+
|
|
343
|
+
**Definition time (FR-020)**:
|
|
344
|
+
|
|
345
|
+
- An inline `undo` block inside `async_step` raises `Error::ValidationError`. The message names the
|
|
346
|
+
step and points to the reader's `compensate` or to an `async_reactor` child.
|
|
347
|
+
- **Refinement recorded against the spec**: an `undo` inherited from a *step class* used with
|
|
348
|
+
`async_step` emits a definition-time **warning**, not an error. The warning goes through the
|
|
349
|
+
existing `StepBuilder#warn_deprecation` channel, once per site. The same class is legitimately
|
|
350
|
+
reused by ordinary `step`s, where its `undo` does run. Rejecting it would force authors to fork
|
|
351
|
+
a class only to delete a method. The spec's FR-020 and US4-4 are updated to match.
|
|
352
|
+
|
|
353
|
+
**Alternatives considered**: parent-driven compensate on a surfaced failure, and rejecting all
|
|
354
|
+
hooks. Both were declined in the clarification.
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## R-10 · Async units say "not tracked for undo" themselves (FR-022, FR-023)
|
|
359
|
+
|
|
360
|
+
**Decision**: `StepConfig#rollback_tracked?` returns `!async_dispatch?`. `ResultHandler#handle_success`
|
|
361
|
+
pushes a success only when `step_config.rollback_tracked?`. The `async_unit?` helper is deleted.
|
|
362
|
+
`AsyncReactorBuilder`'s config answers the same way through `async_dispatch`.
|
|
363
|
+
|
|
364
|
+
**Rationale**: the coordinator asks the step instead of knowing its kind. There is no behavior
|
|
365
|
+
change: independence (INV-25) is kept.
|
|
366
|
+
|
|
367
|
+
**Alternatives considered**: pushing async units and giving them a no-op `undo`. That emits
|
|
368
|
+
`start_undo`/`complete_undo` events for work that was never undoable, and re-takes the unit's locks
|
|
369
|
+
for nothing (`coordinated_rollback`). Rejected.
|
|
370
|
+
|
|
371
|
+
---
|
|
372
|
+
|
|
373
|
+
## R-11 · Test strategy (FR-027, SC-001–SC-006)
|
|
374
|
+
|
|
375
|
+
**Decision**:
|
|
376
|
+
|
|
377
|
+
- **New specs** under `spec/ruby_reactor/rollback/`, one file per finding group:
|
|
378
|
+
`map_rollback_spec.rb`, `map_fan_out_settle_spec.rb`, `compose_retry_spec.rb`,
|
|
379
|
+
`failure_rollback_spec.rb`, `aborted_execution_spec.rb`, `async_step_compensate_spec.rb`. They
|
|
380
|
+
run against real Redis. Fan-out uses Sidekiq fake mode plus
|
|
381
|
+
`RubyReactor::RSpec::SidekiqHelpers.drain_async_jobs`. `Sidekiq::Testing.inline!` is not allowed
|
|
382
|
+
on async paths (Constitution III).
|
|
383
|
+
- **Sequence assertions**: each example records `run:`, `compensate:` and `undo:` events and asserts
|
|
384
|
+
the exact order, mirroring the 007 probes.
|
|
385
|
+
- **SC-003**: one example drains element jobs in a shuffled order 100 times (seeded) and asserts
|
|
386
|
+
that no completed element is left with a non-empty undo stack.
|
|
387
|
+
- **SC-006**: a 10,000-element inline map example, tagged `:slow` and excluded from the default
|
|
388
|
+
run. It runs in the quickstart and before release.
|
|
389
|
+
- **Tighten** `spec/ruby_reactor/dsl/async_step_spec.rb:111` so it asserts that the unit's own
|
|
390
|
+
`compensate` ran.
|
|
391
|
+
- **SC-002 regression**: re-run the 007 harness (`specs/007-execution-flow-analysis/evidence/run.rb`).
|
|
392
|
+
Only the `expected:` sequences of in-scope scenarios are updated: S-map-01, 03, 04, 04b, 06, 07,
|
|
393
|
+
08, S-compose-05, 05b, S-plain-07, S-edge-03, 04, S-async-02, 07. Every other scenario must
|
|
394
|
+
still print `MATCH` unchanged. The 2026-09-27 revision changes S-compose-05/05b, S-edge-03/04 again
|
|
395
|
+
and adds S-edge-03b (contracts/rollback-semantics.md §3).
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
## R-12 · Versioning and compatibility (FR-026)
|
|
400
|
+
|
|
401
|
+
These changes are intentional and visible to users. Each one gets a CHANGELOG entry. Breaking ones
|
|
402
|
+
use `feat!`/`fix!` commits with a `BREAKING CHANGE:` footer and a migration note.
|
|
403
|
+
|
|
404
|
+
| Change | Kind | Migration note |
|
|
405
|
+
| --- | --- | --- |
|
|
406
|
+
| Element-step `undo` blocks now run when a map is rolled back | breaking (behavior) | Review element `undo`s: they now run on map failure and on later failures. Make them idempotent. |
|
|
407
|
+
| `async_step` `compensate` now runs in the unit's job on final failure | breaking (behavior) | The block now runs, possibly with no reader. Move reader-only cleanup into the reader. |
|
|
408
|
+
| Inline `undo` inside `async_step` raises at definition time | breaking (API) | Move it to the reader's `compensate` or use `async_reactor`. |
|
|
409
|
+
| Class `undo` on an `async_step` class is warned | additive | none |
|
|
410
|
+
| `retries` on `compose`/`async_reactor` raises at definition time (R-14) | breaking (API) | Declare `retries` on the child reactor's steps. The child retries them itself. |
|
|
411
|
+
| `where`/`guard` removed; declaring them raises at definition time (R-15) | breaking (API) | Return `Skipped` (or call `skip!`) from the step body. A `background before:` hand-off at that step now always fires. |
|
|
412
|
+
| Exceptions that are not `StandardError` (except interruptions) fail the step and roll back (R-16) | breaking (behavior) | They no longer propagate out of `Reactor.run`. Test assertion errors raised inside a step body now surface as the step's failure. |
|
|
413
|
+
| Argument and unknown errors roll back and carry `step_name` | fix | The failure message and `exception_class` shape change for these paths. |
|
|
414
|
+
| New `aborted` status, only for interruptions (R-16) | additive | Dashboards and filters gain a status. |
|
|
415
|
+
| `rollback_failures` entries may carry `map_step`/`element_index` and new reasons | additive | none |
|
|
416
|
+
|
|
417
|
+
The project is 0.x. Release-please picks the number from the commit types. Constitution V only
|
|
418
|
+
requires that the breaking items are marked as breaking and carry notes.
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
422
|
+
## R-13 · Documentation to correct (FR-024)
|
|
423
|
+
|
|
424
|
+
These come from the 007 audit rows tied to in-scope findings:
|
|
425
|
+
|
|
426
|
+
- **README.md**: lines 16, 25, 35, 545-550, 1309 and 1398, plus the Compensation section.
|
|
427
|
+
- **documentation/data_pipelines.md**: 167 (map rollback, fail-fast settle latency).
|
|
428
|
+
- **documentation/composition.md**: 184 (compose retry = fresh child).
|
|
429
|
+
- **documentation/background_and_async.md**: 279-292 (unit-local compensate, `undo` rejected).
|
|
430
|
+
- **documentation/core_concepts.md**: 321-331 (rollback rule and construct table).
|
|
431
|
+
- **documentation/DAG.md**: 228-240.
|
|
432
|
+
- **documentation/locks_and_semaphores.md**: 777-778 (never-started now includes argument and
|
|
433
|
+
condition errors).
|
|
434
|
+
- **documentation/interrupts.md**: 155-157. Mention `aborted` next to manual undo.
|
|
435
|
+
- **The 007 analysis**: execution-order.md (failure-kinds table, R-05 audit table) and
|
|
436
|
+
invariants.md (statuses after the fix). Updated so the 007 report stays a correct reference.
|
|
437
|
+
|
|
438
|
+
**Added by the 2026-09-27 revision**:
|
|
439
|
+
|
|
440
|
+
- **documentation/composition.md**: the inline example's compose-level `retries` (line ~33), point 5
|
|
441
|
+
"Retries re-run the whole child" (~185-194), and the `retries` row of the compose vs
|
|
442
|
+
`async_reactor` table (~205).
|
|
443
|
+
- **documentation/background_and_async.md**: ~159, "A step skipped by a `where`/`guard` never
|
|
444
|
+
triggers the hand-off".
|
|
445
|
+
- **documentation/core_concepts.md**: the Rollback Rule (drop `ConditionError`, define `Skipped` per
|
|
446
|
+
R-17, name the interruptions per R-16) and "Skipping a single step".
|
|
447
|
+
- **documentation/DAG.md**: ~248, "a `where` condition that raises".
|
|
448
|
+
- **documentation/interrupts.md**: ~165, `aborted` only for interruptions.
|
|
449
|
+
- **documentation/locks_and_semaphores.md**: ~780, drop `where`/`guard` from the never-started list.
|
|
450
|
+
- **README.md**: ~857 (`Skipped` rollback meaning) and ~1432 (drop `ConditionError`, add
|
|
451
|
+
non-standard exceptions).
|
|
452
|
+
- **CHANGELOG.md**: rewrite the 008 entries that describe compose retries, `ConditionError` and
|
|
453
|
+
`aborted` for every non-standard exception.
|
|
454
|
+
- **007 invariants.md / execution-order.md**: INV-06, INV-07, INV-13 rows; failure-kinds table.
|
|
455
|
+
|
|
456
|
+
---
|
|
457
|
+
|
|
458
|
+
## R-14 · Nested reactors are never retried as a whole (FR-009–FR-012, review 2026-09-27)
|
|
459
|
+
|
|
460
|
+
**Facts**:
|
|
461
|
+
|
|
462
|
+
- `ComposeBuilder` and `AsyncReactorBuilder` include `Dsl::Retryable`, so `retries` inside their
|
|
463
|
+
block sets the construct's own `retry_config` [R: dsl/compose_builder.rb:7, dsl/async_reactor_builder.rb:13].
|
|
464
|
+
- On a compose, that retries the whole child. R-05 then had to start a fresh child per attempt and
|
|
465
|
+
record `compose_attempt_discarded` [R: step/compose_step.rb `discard_failed_attempt`].
|
|
466
|
+
- On an `async_reactor`, it retries the dispatching step. That step validates, runs the deadlock
|
|
467
|
+
guard, then enqueues or, in inline mode, runs the whole child [R: step/async_reactor_step.rb:60].
|
|
468
|
+
- The documentation shows compose-level `retries` inside an inline compose block as if it
|
|
469
|
+
configured the child's steps (documentation/composition.md:33). It does not.
|
|
470
|
+
- Existing removed-DSL pattern: a stub method raising `Error::DeprecatedDslError` with the
|
|
471
|
+
replacement (`ComposeBuilder#async`, `StepBuilder#async`).
|
|
472
|
+
|
|
473
|
+
**Decision**:
|
|
474
|
+
|
|
475
|
+
- `ComposeBuilder` and `AsyncReactorBuilder` stop including `Retryable`. Each gets a `retries(*)`
|
|
476
|
+
stub that raises `Error::DeprecatedDslError` at class definition, naming the step and saying to
|
|
477
|
+
declare `retries` on the child reactor's own steps. Their step configs are built without a
|
|
478
|
+
`retry_config`, so they default to one attempt (the existing default).
|
|
479
|
+
- Delete `ComposeStep#discard_failed_attempt`, `#attempt_rollback_failures` and the
|
|
480
|
+
`compose_attempt_discarded` trace entry.
|
|
481
|
+
- No replacement guard in `ComposeStep#run`. R-05's audit showed the compose retry was the only
|
|
482
|
+
path that re-ran a stored child after it rolled back. With no compose retry, that path is gone,
|
|
483
|
+
and a park or redelivery resumes a child that is still `running` (FR-011, unchanged).
|
|
484
|
+
|
|
485
|
+
**Rationale**: the child already owns its steps' retry policies (#61, step-scoped retries). A
|
|
486
|
+
parent-level retry is a second, conflicting retry layer, and it was the root cause of F-02. Removing
|
|
487
|
+
it closes F-02 with less code than R-05 needed (007 option O-02-c).
|
|
488
|
+
|
|
489
|
+
**Alternatives considered**: keep R-05 (fresh child per attempt). Rejected by the review: the parent
|
|
490
|
+
must never retry the child as a whole. Silently ignoring `retries` on a compose: rejected, because it
|
|
491
|
+
hides the migration.
|
|
492
|
+
|
|
493
|
+
---
|
|
494
|
+
|
|
495
|
+
## R-15 · Remove `where`/`guard` (FR-014, US6, review 2026-09-27)
|
|
496
|
+
|
|
497
|
+
**Facts**:
|
|
498
|
+
|
|
499
|
+
- `where`/`guard` are two `StepBuilder` methods feeding `StepConfig#should_run?` [R: dsl/step_builder.rb:81-87, 463].
|
|
500
|
+
`InterruptBuilder` inherits them. The compose, map and async_reactor builders pass empty
|
|
501
|
+
`conditions: []`, `guards: []`.
|
|
502
|
+
- `should_run?` is consulted in four places: `StepExecutor#execute_step_sync`,
|
|
503
|
+
`#execute_step_sync_without_result_handling`, `#handoff_at?`, and `StepWorker#run_step`.
|
|
504
|
+
- A false condition returns `Success(nil)` before arguments, validation or coordination. The same
|
|
505
|
+
intent is served by returning `Skipped` from the body, which is documented and tested.
|
|
506
|
+
- Tests that exercise conditions: `step_contract_enforcement_spec.rb:297`,
|
|
507
|
+
`step_retries/class_policy_spec.rb:112`, `step_coordination/lock_spec.rb:153` (+ fixture
|
|
508
|
+
`GuardedLockedChargeReactor`), `step_coordination/single_site_spec.rb:289` (+ `GuardedAsyncReactor`,
|
|
509
|
+
`ASYNC_GUARD_FLAG`), `dsl/reactor_background_spec.rb:118` (+ `BackgroundSkippedTriggerReactor`),
|
|
510
|
+
`rollback/failure_rollback_spec.rb` (condition examples), 007 probe S-edge-04.
|
|
511
|
+
|
|
512
|
+
**Decision**:
|
|
513
|
+
|
|
514
|
+
- `StepBuilder#where` and `#guard` become `DeprecatedDslError` stubs that name the step and say to
|
|
515
|
+
return `Skipped` (or call `skip!`) from the step body. Remove `@conditions`/`@guards`, the
|
|
516
|
+
`conditions`/`guards` config keys and attributes, and `StepConfig#should_run?`.
|
|
517
|
+
- Remove the four `should_run?` call sites. A `background` hand-off fires whenever its step is
|
|
518
|
+
reached.
|
|
519
|
+
- Delete `Error::ConditionError`, its `NEVER_STARTED_ERROR_CLASSES` entry, and the
|
|
520
|
+
`ConditionError` branches in `StepExecutor`, `StepWorker` and `ResultHandler#never_started_wrapper?`.
|
|
521
|
+
- Delete the tests listed above that only test conditions, with their fixtures. Add one spec that
|
|
522
|
+
each of `step`, `async_step` and `interrupt` rejects `where` and `guard`.
|
|
523
|
+
- 007 probe S-edge-04 becomes "a step declaring `where` is rejected at definition time".
|
|
524
|
+
|
|
525
|
+
**Rationale**: a second skip mechanism with its own failure rules (F-06) is the stale code the review
|
|
526
|
+
named. `Skipped` covers the use. Removing it deletes a never-started category instead of adding one.
|
|
527
|
+
|
|
528
|
+
**Alternatives considered**: deprecate with a warning for one release. Rejected: the project is 0.x,
|
|
529
|
+
earlier removed DSL (`async`, `retry_defaults`) was removed the same way, and the review asked for
|
|
530
|
+
full removal.
|
|
531
|
+
|
|
532
|
+
---
|
|
533
|
+
|
|
534
|
+
## R-16 · Every exception rolls back except interruptions (FR-013, FR-016, FR-018, FR-028, review 2026-09-27)
|
|
535
|
+
|
|
536
|
+
**Facts** (Ruby 3.4.8, timeout 0.5.0):
|
|
537
|
+
|
|
538
|
+
- Exceptions that are not `StandardError` and can come from reactor code: `NotImplementedError`
|
|
539
|
+
(the library's own `Step#run` default raises it), `LoadError`/`SyntaxError` (lazily loaded code),
|
|
540
|
+
`SystemStackError` (runaway recursion), `SecurityError`, any `class X < Exception`, and test
|
|
541
|
+
assertion errors (`RSpec::Expectations::ExpectationNotMetError`, `Minitest::Assertion`). Today all
|
|
542
|
+
of them skip rollback and mark the run `aborted`.
|
|
543
|
+
- Exceptions that come from outside reactor code: `SignalException` (including `Interrupt` and
|
|
544
|
+
`Sidekiq::Shutdown`), `SystemExit`, `NoMemoryError`, and `Timeout::ExitException`. The last one is
|
|
545
|
+
what an enclosing `Timeout.timeout` raises into the running thread. Probed: rescuing it inside the
|
|
546
|
+
block means the caller's `Timeout.timeout` never raises (`:swallowed`).
|
|
547
|
+
- `rescue` accepts a module whose `self.===` decides the match (probed: `NotImplementedError`,
|
|
548
|
+
`SystemStackError` and custom `Exception` subclasses are rescued, `Interrupt` and `SystemExit`
|
|
549
|
+
escape).
|
|
550
|
+
- User code runs behind these `rescue StandardError` sites: `StepConfig#resolve_arguments`
|
|
551
|
+
(sources, transforms), `StepExecutor#safe_execute_step_sync` (body, validation),
|
|
552
|
+
`CompensationManager#compensate_step`/`#undo_step`, `Executor#execute`/`#resume_execution`,
|
|
553
|
+
`StepWorker#perform` and its body call, `StepCoordination.resolve_key` (key procs), and the map
|
|
554
|
+
collect block (`MapStep#process_results`, `Map::Collector`, `Map::Helpers`). The other
|
|
555
|
+
`rescue StandardError` sites guard infrastructure (release, publish, logging, storage) and stay.
|
|
556
|
+
- `CompensationManager#rollback_completed_steps` clears the undo stack only after the whole loop
|
|
557
|
+
[R: executor/compensation_manager.rb:78-87]. An interruption mid-rollback leaves the stack whole,
|
|
558
|
+
so a manual undo would undo the already-undone steps again.
|
|
559
|
+
|
|
560
|
+
**Decision**:
|
|
561
|
+
|
|
562
|
+
- Add `RubyReactor::Error::Rescuable`, a module whose `self.===` matches any `Exception` that is not
|
|
563
|
+
an interruption. The interruptions are `SignalException`, `SystemExit`, `NoMemoryError` and
|
|
564
|
+
`Timeout::ExitException` (looked up when first needed, since `timeout` may load later).
|
|
565
|
+
- Replace `rescue StandardError` with `rescue Error::Rescuable` at the user-code sites listed above.
|
|
566
|
+
The existing specific rescues (contention, parks, validation) stay first and are unchanged.
|
|
567
|
+
- `Executor#execute`/`#resume_execution` keep `rescue Exception` after the `Rescuable` rescue. Only
|
|
568
|
+
interruptions reach it now, and it marks the run `aborted` as R-08 decided.
|
|
569
|
+
- `rollback_completed_steps` removes each entry from the undo stack once its undo has returned. An
|
|
570
|
+
interruption during a rollback therefore leaves exactly the entries that were not undone yet
|
|
571
|
+
(the interrupted one included) for a manual undo.
|
|
572
|
+
- `aborted_execution_spec.rb` triggers with `Interrupt` instead of a custom `Exception`. The custom
|
|
573
|
+
`Exception`, `NotImplementedError` and `SystemStackError` cases join `failure_rollback_spec.rb`
|
|
574
|
+
as rollbacks. New examples: an enclosing `Timeout.timeout` still fires, and an interruption
|
|
575
|
+
during rollback leaves only the remaining entries.
|
|
576
|
+
- 007 probe S-edge-03 (custom `Exception`) becomes a rollback, `run:a run:b compensate:b undo:a =>
|
|
577
|
+
failure(b)`. A new S-edge-03b (`Interrupt`) keeps the `aborted` evidence.
|
|
578
|
+
|
|
579
|
+
**Rationale**: the review's rule is that every error raised by reactor code rolls back, unless it is
|
|
580
|
+
certain the error comes from outside that code. The interruption set is exactly the exceptions Ruby
|
|
581
|
+
or its host raises into running code from outside. Everything else is the reactor's own failure.
|
|
582
|
+
Keeping the enclosing-timeout exception out avoids breaking the caller's timeouts.
|
|
583
|
+
|
|
584
|
+
**Alternatives considered**:
|
|
585
|
+
|
|
586
|
+
- Rescue `Exception` everywhere and roll back even on signals. Rejected: rollback during shutdown or
|
|
587
|
+
out-of-memory is unsafe (R-08), and it swallows the caller's `Timeout`.
|
|
588
|
+
- An allow-list of rescued classes (`StandardError`, `ScriptError`, ...). Rejected: it misses custom
|
|
589
|
+
`Exception` subclasses, which the review explicitly wants rolled back.
|
|
590
|
+
- Let test assertion errors propagate. Rejected: they are raised by code inside a step body, so by
|
|
591
|
+
the review's rule they are that step's failure. The test still fails, because its assertion on the
|
|
592
|
+
result sees the failure.
|
|
593
|
+
|
|
594
|
+
---
|
|
595
|
+
|
|
596
|
+
## R-17 · What `Skipped` means for rollback (FR-029) — SUPERSEDED by R-19
|
|
597
|
+
|
|
598
|
+
**Facts**: `Skipped` is a `Success` whose step is not pushed on the undo stack [R: lib/ruby_reactor.rb:106-110].
|
|
599
|
+
`ResultHandler` checks `skipped?` before pushing. `Step#undo`/`#compensate` default to returning
|
|
600
|
+
`Skipped()`, which is a different use (nothing to roll back).
|
|
601
|
+
|
|
602
|
+
**Decision**: no code change. `Skipped` keeps meaning "this run caused no effect for this step", so
|
|
603
|
+
it is never undone or compensated. The documentation says so, and says that a step which finds its
|
|
604
|
+
effect already in place and owned by this workflow (for example, a redelivered run whose earlier
|
|
605
|
+
attempt created it) returns `Success(value)`, so its `undo` runs on rollback.
|
|
606
|
+
|
|
607
|
+
**Rationale**: the library cannot tell who created an effect that already exists. The author can.
|
|
608
|
+
Option A from the spec clarification: no breaking change, and one sentence resolves the ambiguity.
|
|
609
|
+
|
|
610
|
+
**Alternatives considered**: undo `Skipped` steps (option B, breaks every `undo` written for "my
|
|
611
|
+
effect happened"); a second "already done" result (option C, new public API for what `Success`
|
|
612
|
+
already expresses).
|
|
613
|
+
|
|
614
|
+
---
|
|
615
|
+
|
|
616
|
+
## R-18 · Reworking the work already on the branch
|
|
617
|
+
|
|
618
|
+
The first implementation (tasks T001–T0xx, all done) built R-05, the condition half of R-06, and
|
|
619
|
+
R-08 for every non-standard exception. What changes:
|
|
620
|
+
|
|
621
|
+
| Area | Files | Action |
|
|
622
|
+
| --- | --- | --- |
|
|
623
|
+
| Compose retry (R-14) | `lib/ruby_reactor/step/compose_step.rb`, `dsl/compose_builder.rb`, `dsl/async_reactor_builder.rb` | delete fresh-child code, add `retries` stubs |
|
|
624
|
+
| | `spec/ruby_reactor/rollback/compose_retry_spec.rb` | rewrite: rejection, child step retries, no re-run, park/resume |
|
|
625
|
+
| | `spec/ruby_reactor/step_retries/declaration_spec.rb:69-84` | the two "validates `retries` in compose/async_reactor block" examples become rejection examples |
|
|
626
|
+
| | `demo_app/.../compose_retry_demo_reactor.rb` (+ spec, rake) | child step declares `retries`; no compose-level retries |
|
|
627
|
+
| | 007 probes S-compose-05, 05b | rejection, and child-step retry sequence |
|
|
628
|
+
| Conditions (R-15) | `dsl/step_builder.rb`, `dsl/interrupt_builder.rb`, `dsl/{compose,map,async_reactor}_builder.rb`, `executor/step_executor.rb`, `step_worker.rb`, `executor/compensation_manager.rb`, `executor/result_handler.rb`, `error/condition_error.rb` (delete), `lib/ruby_reactor.rb` (require) | remove |
|
|
629
|
+
| | specs and fixtures in R-15 | delete; add rejection spec |
|
|
630
|
+
| Exceptions (R-16) | `error/rescuable.rb` (new), the user-code rescue sites, `executor.rb`, `executor/compensation_manager.rb` | widen; pop per entry |
|
|
631
|
+
| | `spec/ruby_reactor/rollback/aborted_execution_spec.rb`, `failure_rollback_spec.rb` | retarget |
|
|
632
|
+
| | `demo_app/.../argument_failure_demo_reactor.rb` (+ spec, rake) | add a non-standard exception case |
|
|
633
|
+
| Docs | R-13 additions, CHANGELOG | rewrite |
|
|
634
|
+
|
|
635
|
+
---
|
|
636
|
+
|
|
637
|
+
## R-19 · `Skipped` is only an instrumentation mark (FR-029, FR-030, review 2026-09-27)
|
|
638
|
+
|
|
639
|
+
**Facts**: a body's `Skipped` differed from `Success` in three places: `ResultHandler#handle_skipped`
|
|
640
|
+
did not enroll it for undo, `StepExecutor#handoff_after?` did not hand off after it, and
|
|
641
|
+
`StepCoordination#plain_success?` did not mark a period bucket for it. `handoff_after?` also let a
|
|
642
|
+
`Halt` through (probed: `background after: :x` + `Halt` returned a `DispatchResult`).
|
|
643
|
+
|
|
644
|
+
**Decision** (the review's rule: `Skipped` shouldn't modify execution; skipped steps do not undo):
|
|
645
|
+
`handoff_after?` excludes `Halt` instead of `Skipped`; the period mark treats `Skipped` like
|
|
646
|
+
`Success`; `handle_skipped` still does not enroll the step for undo. The library's own skips
|
|
647
|
+
(period, ordered lock) come from gates outside the period mark, so they never mark a bucket.
|
|
648
|
+
(Enrolling `Skipped` for undo was tried and reverted the same day, at the user's direction.)
|
|
649
|
+
|
|
650
|
+
## R-20 · Cap a stored failure's backtrace (FR-031)
|
|
651
|
+
|
|
652
|
+
A real stack overflow stored a ~12,000-frame backtrace (context 2.4 MB). `Failure` keeps the first
|
|
653
|
+
100 frames plus a `"... N more frames"` line (probed: 23 KB).
|