ruby_reactor 0.8.4 → 0.8.6

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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/.specify/memory/constitution.md +17 -14
  5. data/CHANGELOG.md +254 -0
  6. data/CLAUDE.md +1 -1
  7. data/README.md +66 -15
  8. data/lib/ruby_reactor/adapters/active_job/map_element_rollback_worker.rb +20 -0
  9. data/lib/ruby_reactor/adapters/active_job/router.rb +32 -4
  10. data/lib/ruby_reactor/adapters/sidekiq/map_element_rollback_worker.rb +16 -0
  11. data/lib/ruby_reactor/adapters/sidekiq/router.rb +32 -4
  12. data/lib/ruby_reactor/context.rb +14 -1
  13. data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
  14. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
  15. data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
  16. data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
  17. data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
  18. data/lib/ruby_reactor/dsl/map_builder.rb +27 -9
  19. data/lib/ruby_reactor/dsl/step_builder.rb +93 -34
  20. data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
  21. data/lib/ruby_reactor/error/rescuable.rb +33 -0
  22. data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
  23. data/lib/ruby_reactor/executor/compensation_manager.rb +63 -35
  24. data/lib/ruby_reactor/executor/result_handler.rb +85 -63
  25. data/lib/ruby_reactor/executor/retry_manager.rb +4 -3
  26. data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
  27. data/lib/ruby_reactor/executor/step_executor.rb +61 -49
  28. data/lib/ruby_reactor/executor.rb +221 -7
  29. data/lib/ruby_reactor/map/collector.rb +31 -89
  30. data/lib/ruby_reactor/map/dispatcher.rb +65 -8
  31. data/lib/ruby_reactor/map/element_executor.rb +21 -11
  32. data/lib/ruby_reactor/map/element_rollback.rb +158 -0
  33. data/lib/ruby_reactor/map/helpers.rb +11 -125
  34. data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
  35. data/lib/ruby_reactor/map/step_rollback.rb +136 -0
  36. data/lib/ruby_reactor/map/sweeper.rb +66 -8
  37. data/lib/ruby_reactor/map.rb +17 -0
  38. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
  39. data/lib/ruby_reactor/reactor.rb +65 -2
  40. data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
  41. data/lib/ruby_reactor/rspec/matchers.rb +59 -24
  42. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
  43. data/lib/ruby_reactor/rspec/test_subject.rb +13 -3
  44. data/lib/ruby_reactor/rspec.rb +3 -6
  45. data/lib/ruby_reactor/step/compose_step.rb +10 -1
  46. data/lib/ruby_reactor/step/map_step.rb +144 -36
  47. data/lib/ruby_reactor/step.rb +7 -0
  48. data/lib/ruby_reactor/step_worker.rb +46 -22
  49. data/lib/ruby_reactor/storage/adapter.rb +18 -0
  50. data/lib/ruby_reactor/storage/redis_adapter.rb +27 -2
  51. data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
  52. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  53. data/lib/ruby_reactor/sweeper.rb +3 -1
  54. data/lib/ruby_reactor/version.rb +1 -1
  55. data/lib/ruby_reactor/web/api.rb +11 -1
  56. data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-C6ejszCQ.js} +12 -12
  57. data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
  58. data/lib/ruby_reactor/web/public/index.html +2 -2
  59. data/lib/ruby_reactor/worker.rb +7 -3
  60. data/lib/ruby_reactor.rb +17 -6
  61. data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
  62. data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
  63. data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
  64. data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
  65. data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
  66. data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
  67. data/specs/007-execution-flow-analysis/data-model.md +83 -0
  68. data/specs/007-execution-flow-analysis/evidence/harness.rb +231 -0
  69. data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
  70. data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
  71. data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
  72. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +235 -0
  73. data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
  74. data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
  75. data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
  76. data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
  77. data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
  78. data/specs/007-execution-flow-analysis/plan.md +127 -0
  79. data/specs/007-execution-flow-analysis/quickstart.md +51 -0
  80. data/specs/007-execution-flow-analysis/research.md +202 -0
  81. data/specs/007-execution-flow-analysis/spec.md +270 -0
  82. data/specs/007-execution-flow-analysis/tasks.md +257 -0
  83. data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
  84. data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
  85. data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
  86. data/specs/008-rollback-reliability/data-model.md +139 -0
  87. data/specs/008-rollback-reliability/plan.md +233 -0
  88. data/specs/008-rollback-reliability/quickstart.md +105 -0
  89. data/specs/008-rollback-reliability/research.md +653 -0
  90. data/specs/008-rollback-reliability/spec.md +561 -0
  91. data/specs/008-rollback-reliability/tasks.md +1110 -0
  92. data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
  93. data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
  94. data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
  95. data/specs/009-distributed-map-undo/data-model.md +151 -0
  96. data/specs/009-distributed-map-undo/plan.md +323 -0
  97. data/specs/009-distributed-map-undo/pr-description.md +78 -0
  98. data/specs/009-distributed-map-undo/quickstart.md +154 -0
  99. data/specs/009-distributed-map-undo/research.md +585 -0
  100. data/specs/009-distributed-map-undo/spec.md +360 -0
  101. data/specs/009-distributed-map-undo/tasks.md +1060 -0
  102. data/specs/future_improvements.md +64 -28
  103. metadata +54 -3
  104. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +0 -1
@@ -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]` |