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