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
@@ -0,0 +1,126 @@
1
+ # Contract: Public API Surface Changes
2
+
3
+ These are the changes visible to gem users. No DSL keyword is added. The 2026-09-27 revision
4
+ removes three: `retries` on `compose`/`async_reactor`, `where` and `guard` (§1).
5
+
6
+ ## 1. DSL
7
+
8
+ ### `async_step`: rollback declarations
9
+
10
+ ```ruby
11
+ async_step :notify, NotifyStep do
12
+ argument :user_id, input(:user_id)
13
+ compensate { |error, inputs, ctx| Audit.log_failed_notification(inputs.user_id) } # runs in the unit's job
14
+ undo { |value, inputs, ctx| ... } # => RubyReactor::Error::ValidationError at class definition
15
+ end
16
+ ```
17
+
18
+ **Inline `undo` in `async_step`** raises `Error::ValidationError` when the class is defined. The
19
+ message names the step and explains:
20
+
21
+ - An `async_step` is independent. Its parent never undoes it.
22
+ - Cleanup for a surfaced failure belongs in the reading step's `compensate`.
23
+ - Cleanup that must run when the parent rolls back needs a construct the parent tracks: a `step`,
24
+ a `compose` or a `map`.
25
+
26
+ **A step class that defines `undo`**, used with `async_step`: a warning goes out through the
27
+ existing definition-time warning channel (`StepBuilder#warn_deprecation`), once per reactor and
28
+ step. It says the class's `undo` will not run for this async use. There is no error.
29
+
30
+ **`compensate` in `async_step`** (inline or class) runs **once**, in the unit's own job, after the
31
+ body's final attempt fails. It does not run on attempts that are retried, on `Halt`, or on
32
+ never-started failures.
33
+
34
+ ### `map`: no DSL change
35
+
36
+ Rollback replays the `undo` blocks already declared on the element reactor's steps.
37
+
38
+ ### Removed: `retries` on `compose` / `async_reactor` (R-14)
39
+
40
+ ```ruby
41
+ compose :reserve, ReservationReactor do
42
+ retries max_attempts: 3 # => RubyReactor::Error::DeprecatedDslError at class definition
43
+ end
44
+ ```
45
+
46
+ The message names the step and says to declare `retries` on the child reactor's own steps. The same
47
+ applies inside an inline `compose` block and in an `async_reactor` block. Without `retries`, both
48
+ constructs run their child exactly once per parent step.
49
+
50
+ ### Removed: `where` / `guard` (R-15)
51
+
52
+ ```ruby
53
+ step :sync_user, SyncUserStep do
54
+ where { |ctx| ctx.get_input(:enabled) } # => RubyReactor::Error::DeprecatedDslError
55
+ end
56
+ ```
57
+
58
+ The message names the step and says to return `Skipped(value)` (or call `skip!`) from the step
59
+ body. It applies to `step`, `async_step` and `interrupt` blocks.
60
+
61
+ ### `Skipped`: meaning defined (R-17)
62
+
63
+ `Skipped` is an instrumentation mark and never changes execution: a `background after:` hand-off
64
+ fires and a `with_period` bucket is marked, as for a `Success` (before: neither). A skipped step is
65
+ never undone (unchanged).
66
+
67
+ ## 2. Errors (`RubyReactor::Error`)
68
+
69
+ | Class | Parent | Raised by | Carried on the Failure as |
70
+ | --- | --- | --- | --- |
71
+ | `ArgumentResolutionError` (new) | `Error::Base` | a step's `argument` source, `transform` or result path raising | `step_name`, `exception_class` = the cause's class, `retryable: false` |
72
+ | `Rescuable` (new, a matcher module, not an exception class) | — | used in `rescue Error::Rescuable` | matches every `Exception` except `SignalException`, `SystemExit`, `NoMemoryError`, `Timeout::ExitException` |
73
+
74
+ `ArgumentResolutionError` does not propagate out of `Reactor.run`. The run returns a `Failure` as
75
+ for any step failure. (`ConditionError` from the first implementation is removed, R-15.)
76
+
77
+ ## 3. `RubyReactor::Failure`
78
+
79
+ | Path | Before | After |
80
+ | --- | --- | --- |
81
+ | argument resolution raises | `"Execution failed: <msg>"`, no `step_name`, nothing rolled back | step failure message, `step_name`, `reactor_name`, `step_arguments: {}`, completed steps undone |
82
+ | unknown exception outside a step body (standard or not, except interruptions) | `"Execution failed: <msg>"`, no rollback | completed steps undone. `step_name` = the executing step when known |
83
+ | non-`StandardError` raised by a step body (e.g. `NotImplementedError`, custom `Exception`) | propagated out of `Reactor.run`, no rollback | returned as the step's `Failure`: step compensated, completed steps undone, `exception_class` = the original class |
84
+ | compensation of the failing step fails (`CompensationError`) | `"Execution error: <msg>"`, no `step_name` | same message, `step_name` set |
85
+
86
+ ### `rollback_failures` entries
87
+
88
+ The existing keys are `step`, `kind`, `key`, `reason` and `message`. New optional keys:
89
+
90
+ - `map_step`: Symbol
91
+ - `element_index`: Integer
92
+
93
+ New `reason` values:
94
+
95
+ - `:context_unavailable`: an element context expired before rollback
96
+ - `:element_in_flight`: the element was still running at rollback time
97
+
98
+ ## 4. Execution status
99
+
100
+ - **New status `aborted`**:
101
+ - Readable on the stored context: `status`, dashboard, web API.
102
+ - Set only for executions in the caller's process that are cut short by an interruption
103
+ (`SignalException`, `SystemExit`, `NoMemoryError`, `Timeout::ExitException`). The exception is
104
+ re-raised unchanged. Its stored undo stack holds only the entries not yet undone.
105
+ - The reactor `Sweeper` does not resume aborted executions.
106
+ - `Reactor#undo` (manual undo) rolls them back.
107
+ - **Dashboard and web API**:
108
+ - `aborted` is accepted in status filters and shown like the other terminal-looking states.
109
+ - It is not grouped with `failed`, because no rollback ran.
110
+
111
+ ## 5. Stored records
112
+
113
+ - **Async step result record**: gains `compensation: { status, rollback_failures, completed_at }`
114
+ when the unit's compensate ran (data-model §8).
115
+ - **Map results hash**: an index can hold `{ "_skipped" => true }`. `ResultEnumerator` never yields
116
+ skipped slots. They exist only on a failed fail-fast map, which is never collected as a success.
117
+
118
+ ## 6. Middleware events
119
+
120
+ No new event names. Changes in when existing events fire:
121
+
122
+ - `start_compensation` / `complete_compensation` / `failed_compensation` now fire in the async step
123
+ unit's job, with the unit's context and step name.
124
+ - `start_undo` / `complete_undo` / `failed_undo` fire for each element step undone by a map
125
+ rollback, with the **element's** context.
126
+ - `failed_step` for argument-resolution failures now carries the step name, like any step failure.
@@ -0,0 +1,76 @@
1
+ # Contract: Rollback Semantics
2
+
3
+ This is the behavior contract reactor authors can rely on after this feature. The tests (R-11) and
4
+ the updated 007 harness (SC-002) check it event by event.
5
+
6
+ Notation: `run:x`, `compensate:x`, `undo:x`. `e.s[i]` is step `s` of map element `i`.
7
+ `child.s` / `k.s` is a step of a composed child. `=> failure(x)` means the failure is attributed to
8
+ step `x`.
9
+
10
+ ## 1. The one rule
11
+
12
+ 1. A construct that **completed** is tracked for undo, unless it is an async unit
13
+ (`rollback_tracked? == false`, which is the documented independence).
14
+ 2. On failure, the failing construct is **compensated** if its work started. It is **not**
15
+ compensated if it never started: contention, key error, refused dispatch, argument resolution
16
+ error, argument/type validation.
17
+ 3. Then every tracked construct is **undone**, newest first.
18
+ 4. A compensate or undo that fails does not stop the rest. It is listed in `rollback_failures`.
19
+ 5. `Halt` stops without rollback. `Skipped` never changes execution and is never undone; it
20
+ marks the trace.
21
+ 6. Every exception counts as a failure under rules 2–4, standard or not, except an interruption
22
+ (`SignalException`, `SystemExit`, `NoMemoryError`, an enclosing timeout). An interruption runs no
23
+ rollback. An execution in the caller's process is marked `aborted`, keeping only the entries not
24
+ yet undone, and `Reactor#undo` rolls it back later. A worker execution is redelivered.
25
+ 7. A nested reactor (`compose`, `async_reactor`) is never retried as a whole. Only steps retry.
26
+
27
+ ## 2. What compensate and undo mean per construct
28
+
29
+ | Construct | compensate (its own failure) | undo (a later failure, or manual undo) |
30
+ | --- | --- | --- |
31
+ | step | its `compensate` (inline block, else class, else skipped) | its `undo` (same order) |
32
+ | `compose` | replay the child's undo stack. The child already rolled itself back, so this is a no-op | replay the child's undo stack, newest first |
33
+ | `map` (inline and fan-out) | replay the undo stack of every **completed** element, in descending element index. Failed elements rolled themselves back already | the same, for every completed element |
34
+ | `map` fan-out, fail-fast | as above, after **every** index has settled. Elements that were in flight when the failure happened are included | as above |
35
+ | `async_step` | not tracked by the parent. The **unit** compensates itself once, in its own job, after its final attempt fails | none. An inline `undo` block is rejected at definition time; a class `undo` is warned about and not run |
36
+ | `async_reactor` | not tracked by the parent. The child rolls itself back (unchanged) | none (unchanged) |
37
+
38
+ ## 3. Canonical sequences (old → new)
39
+
40
+ These are the 007 scenarios whose sequence changes. All other 007 scenarios are unchanged. The last
41
+ four rows were found when the harness was re-run (T069): each is a direct consequence of an in-scope
42
+ fix (F-13, F-01, F-04, R-06), not a separate change.
43
+
44
+ | Scenario | Baseline (0.8.3) | After this feature |
45
+ | --- | --- | --- |
46
+ | S-map-01 inline, fail-fast, elem 2 fails | `… compensate:e.e2[2] undo:e.e1[2] undo:a` | `… compensate:e.e2[2] undo:e.e1[2] undo:e.e2[1] undo:e.e1[1] undo:e.e2[0] undo:e.e1[0] undo:a => failure(m)` |
47
+ | S-map-03 inline, all ok, b fails | `… run:b compensate:b undo:a` | `… run:b compensate:b undo:e.e2[3] undo:e.e1[3] undo:e.e2[2] undo:e.e1[2] undo:e.e2[1] undo:e.e1[1] undo:e.e2[0] undo:e.e1[0] undo:a => failure(b)` |
48
+ | S-map-04 fan-out, fail-fast, jobs 0..3 | `… compensate:e.e2[2] undo:e.e1[2] undo:a` | `… compensate:e.e2[2] undo:e.e1[2] undo:e.e2[1] undo:e.e1[1] undo:e.e2[0] undo:e.e1[0] undo:a => failure(m)` (elem 3 skipped) |
49
+ | S-map-04b fan-out, fail-fast, jobs 3,2,1,0 | `… run:e.e1[3] run:e.e2[3] … compensate:e.e2[2] undo:e.e1[2] undo:a` | `… compensate:e.e2[2] undo:e.e1[2] undo:e.e2[3] undo:e.e1[3] undo:a => failure(m)` (elems 1, 0 skipped) |
50
+ | S-map-06 fan-out, all ok, b fails | `… run:b compensate:b undo:a` | same as S-map-03 |
51
+ | S-map-07 compose(c0 → map(elem 2 fails)) | `… undo:e.e1[2] undo:child.c0 undo:a` | `… undo:e.e1[2] undo:e.e2[1] undo:e.e1[1] undo:e.e2[0] undo:e.e1[0] undo:child.c0 undo:a => failure(child)` |
52
+ | S-map-08 element = e1 → compose(k1) → e2 | `… compensate:e.e2[2] undo:k.k1 undo:e.e1[2]` | `… compensate:e.e2[2] undo:k.k1 undo:e.e1[2] undo:e.e2[1] undo:k.k1 undo:e.e1[1] undo:e.e2[0] undo:k.k1 undo:e.e1[0] => failure(m)` |
53
+ | S-compose-05 compose declares `retries` | `… retry:child#1 run:child.c2 => success` | `=> raised(DeprecatedDslError)` at class definition (R-14) |
54
+ | S-compose-05b child's c2 declares `retries`, fails once, then b fails | `… run:child.c2 run:b compensate:b undo:child.c2` (compose retries) | `run:child.c1 run:child.c2 retry:c2#1 run:child.c2 run:b compensate:b undo:child.c2 undo:child.c1 => failure(b)`: c1 runs once, c2 retries inside the child (R-14) |
55
+ | S-plain-07 b's transform raises | `run:a => failure(?)` | `run:a undo:a => failure(b)` |
56
+ | S-edge-03 b raises a custom `Exception` subclass | `run:a run:b => raised` (status `running`) | `run:a run:b compensate:b undo:a => failure(b)` (R-16) |
57
+ | S-edge-03b (new) b raises `Interrupt` | — | `run:a run:b => raised(Interrupt)` (same object), status **`aborted`**. `Reactor#undo` then gives `undo:a` |
58
+ | S-edge-04 b declares `where` | `run:a compensate:b undo:a => failure(b)` | `=> raised(DeprecatedDslError)` at class definition (R-15) |
59
+ | S-async-02 unit u fails, reader r fails | `run:a run:u run:r compensate:r undo:a` | `run:a run:u compensate:u run:r compensate:r undo:a => failure(r)` |
60
+ | S-async-07 unit u retries 3× then fails, no reader | `run:a run:b run:u run:u run:u => success` | `run:a run:b run:u run:u run:u compensate:u => success` (`compensate:u` recorded on the unit's record) |
61
+ | S-plain-03 b fails, its compensate fails | `run:a run:b compensate:b undo:a => failure(?)` | same sequence `=> failure(b)` (a `CompensationError` carries the step name, FR-017) |
62
+ | S-map-12 fan-out map, element has an async_step, elem 1 fails | `… compensate:e.e2[1] undo:e.e1[1] undo:a run:e.u[0] run:e.u[1]` | `… compensate:e.e2[1] undo:e.e1[1] undo:e.e2[0] undo:e.e1[0] undo:a run:e.u[0] run:e.u[1] => failure(m)` (element 0 is rolled back; its async unit is not, INV-25) |
63
+ | S-async-01 unit u fails, no reader | `run:a run:b run:u => success` | `run:a run:b run:u compensate:u => success` |
64
+ | S-async-08 reader's wait on u times out | `run:a undo:a run:u => failure(?)` | `run:a undo:a run:u compensate:u => failure(r)` (the timeout is wrapped as the reader's `ArgumentResolutionError`) |
65
+
66
+ ## 4. Guarantees tied to invariants
67
+
68
+ | Invariant | After this feature |
69
+ | --- | --- |
70
+ | INV-06 every failure after completed work rolls back | HOLDS for every exception except interruptions, which give `aborted` plus manual undo |
71
+ | INV-07 never-started is never compensated | HOLDS, including argument resolution errors (`where`/`guard` no longer exist) |
72
+ | INV-13 a retried unit does not treat rolled-back work as done | HOLDS: only steps retry; a nested reactor cannot be retried as a whole |
73
+ | INV-19 / INV-20 succeeded map elements are rolled back | HOLDS in both modes |
74
+ | INV-22 left-in-place set after fail-fast is deterministic | HOLDS (it is always empty). Which elements run stays scheduling-dependent |
75
+ | INV-24 a unit's own compensate runs | HOLDS (unit-local, once, after the final attempt) |
76
+ | INV-25 parent rollback never touches async units | HOLDS (unchanged; expressed via `rollback_tracked?`) |
@@ -0,0 +1,139 @@
1
+ # Data Model: Reliable Rollback Across Constructs
2
+
3
+ These are the records and state this feature adds or changes. The existing shapes are in
4
+ `lib/ruby_reactor/context.rb` (context blob) and `lib/ruby_reactor/storage/redis_adapter.rb`
5
+ (map and step records). Decisions are referenced as `R-nn` ([research.md](research.md)).
6
+
7
+ ## 1. Construct (step config): lifecycle operations (R-01)
8
+
9
+ `StepConfig` is the step as declared in a reactor. Behavior only, nothing is stored.
10
+
11
+ | Operation | Returns / raises | Notes |
12
+ | --- | --- | --- |
13
+ | `resolve_arguments(context)` | `Hash` of resolved arguments. Raises `Error::ArgumentResolutionError` on any `Error::Rescuable` exception (R-16), except `Error::ExecutionParked` and its subclasses, which propagate | Replaces `StepExecutor#resolve_arguments` and `StepWorker#resolve_arguments` |
14
+ | `call_body(arguments, context)` | step result (exists) | unchanged |
15
+ | `call_compensate(error, arguments, context)` | step result | Dispatch order: inline block, then impl `.compensate`, then `Skipped`. Moved out of `CompensationManager` |
16
+ | `call_undo(result_value, arguments, context)` | step result | Dispatch order: inline block, then impl `.undo`, then `Skipped` |
17
+ | `rollback_tracked?` | `true` unless `async_dispatch?` | `ResultHandler` pushes a success only when this is true |
18
+
19
+ Coordination re-take, trace entries, middleware events and `rollback_failures` stay in
20
+ `CompensationManager`, which wraps these calls.
21
+
22
+ ## 2. Never-started errors (R-06)
23
+
24
+ | Class | Parent | Attributes | Retryable |
25
+ | --- | --- | --- | --- |
26
+ | `Error::ArgumentResolutionError` | `Error::Base` | `step`, `original_error`, `exception_class` (the cause's class name), `message` | no |
27
+ `CompensationManager::NEVER_STARTED_ERROR_CLASSES` becomes `Contended`, `KeyError`,
28
+ `DispatchRefused`, `ArgumentResolutionError`. (`ConditionError` was removed with `where`/`guard`,
29
+ R-15.)
30
+
31
+ **Rule**: a failure whose error is in this set is not compensated, and the completed steps are
32
+ undone.
33
+
34
+ ## 3. Context status (R-08)
35
+
36
+ | Status | Meaning | Set by | Terminal for `Worker`? | Swept? |
37
+ | --- | --- | --- | --- | --- |
38
+ | `pending`, `running`, `paused`, `completed`, `failed`, `halted`, `cancelled` | unchanged | unchanged | unchanged | only `running` |
39
+ | **`aborted`** (new) | An execution in the caller's process was cut short by an interruption (R-16: signal, exit, out of memory, enclosing timeout). Its completed work is still outstanding, and `undo_stack` keeps exactly the entries not yet undone | `Executor#execute`/`#resume_execution` `rescue Exception` (reached only by interruptions), when `!inline_async_execution` | not resumed forward (only a manual undo applies) | no |
40
+
41
+ State transitions:
42
+
43
+ ```text
44
+ running --(interruption, caller process)--> aborted --(Reactor#undo)--> cancelled
45
+ running --(interruption, worker)----------> running (job redelivered, unchanged)
46
+ running --(any other exception)-----------> failed (rolled back, like any step failure)
47
+ ```
48
+
49
+ ## 4. Undo record (context `undo_stack` entry)
50
+
51
+ The shape is unchanged: `{ step_name, arguments, result }` serialized.
52
+
53
+ | Construct | Pushed when | `arguments` / `result` stored |
54
+ | --- | --- | --- |
55
+ | step | success and `rollback_tracked?` | resolved arguments / result (unchanged) |
56
+ | compose | success | unchanged |
57
+ | map, inline | success (unchanged) | unchanged (resolved args + collected result) |
58
+ | **map, fan-out** (new, R-03) | collector success branch, before resuming the parent | `{}` / `Success(nil)`. `MapStep#undo` reads neither field |
59
+ | `async_step`, `async_reactor` | never (`rollback_tracked? == false`) | — |
60
+
61
+ ## 5. Map element outcome (R-02, R-04)
62
+
63
+ Element outcome is derived. It is not stored as a new field.
64
+
65
+ | Outcome | Source of truth | Rolled back by the map? |
66
+ | --- | --- | --- |
67
+ | succeeded | element context `status == completed` | **yes**: replay its undo stack, then save the element |
68
+ | failed | element context `status == failed` (it already rolled itself back) | no |
69
+ | halted | element context `status == halted` | no (`Halt` semantics unchanged) |
70
+ | skipped (fail-fast) | results hash slot `{ "_skipped" => true }`. No executed context | no |
71
+ | never dispatched (fail-fast) | results hash slot `{ "_skipped" => true }`, written by the dispatcher claim | no |
72
+ | context expired | id in the element index, context row missing | reported: `reason: :context_unavailable` |
73
+ | live duplicate | the `map_element:<map_id>:<index>` lock is held when rollback runs | reported: `reason: :element_in_flight` |
74
+
75
+ **Element index**: the existing list `store_map_element_context_id(map_id, context_id, parent_class)`.
76
+ Rollback dedupes the ids. The element index number comes from the element context's
77
+ `map_metadata[:index]`.
78
+
79
+ **Settled**: every `0...count` index has a slot in the results hash. The slot is a value, an
80
+ `_error`, a `_halt` or a `_skipped`. A fail-fast failure is applied to the parent only once the map
81
+ is settled.
82
+
83
+ **Order**: descending element index.
84
+
85
+ ## 6. Rollback failure entry
86
+
87
+ These are the existing keys: `step`, `kind` (`:compensate`/`:undo`), `key`, `reason`, `message`.
88
+
89
+ | Addition | When |
90
+ | --- | --- |
91
+ | `map_step:` (Symbol) | the entry came from a map element's rollback |
92
+ | `element_index:` (Integer, or `nil` for `:context_unavailable`) | same. It is `nil` when the element's row expired, because the index lives only in the row |
93
+ | `reason: :context_unavailable` | an element context expired before rollback |
94
+ | `reason: :element_in_flight` | the element's liveness lock was held at rollback time |
95
+
96
+ Entries from an element's own steps keep `step:`, the element step's name.
97
+
98
+ ## 7. Trace entry: discarded compose attempt (removed, R-14)
99
+
100
+ The `compose_attempt_discarded` entry R-05 added is removed. A compose is never retried, so there
101
+ are no discarded attempts.
102
+
103
+ ## 8. Async step record: compensation (R-09)
104
+
105
+ This adds one field to the existing step result record (`store_step_result`). The unit writes it
106
+ in its own job and never writes the parent context.
107
+
108
+ ```text
109
+ "compensation" => {
110
+ "status" => "completed" | "failed" | "skipped", # skipped: compensate returned Skipped / not declared
111
+ "rollback_failures" => [<entries>],
112
+ "completed_at" => iso8601
113
+ }
114
+ ```
115
+
116
+ The field is present only when the body ran and finally failed. It is absent for successes, halts,
117
+ never-started failures and retried attempts that later succeeded.
118
+
119
+ ## 9. Failure attribution (FR-017)
120
+
121
+ Every failure returned on these paths carries `reactor_name`, `step_name` (when a step was
122
+ executing), redacted `inputs` and a reason. `exception_class` is the original cause's class:
123
+
124
+ - argument resolution
125
+ - any other `Error::Rescuable` exception, standard or not (R-16)
126
+ - a failed compensation (`CompensationError`)
127
+
128
+ ## 10. Exception classes (R-16)
129
+
130
+ | Class | Rolls back? | Outcome |
131
+ | --- | --- | --- |
132
+ | `Error::Rescuable` (any `Exception` except the four below) | yes | failure returned, step compensated if its body started, completed steps undone |
133
+ | `SignalException` (incl. `Interrupt`, `Sidekiq::Shutdown`), `SystemExit`, `NoMemoryError`, `Timeout::ExitException` | no | re-raised unchanged. A caller-process run is stored `aborted`; a worker job is redelivered |
134
+
135
+ ## 11. Undo stack during rollback (R-16)
136
+
137
+ `rollback_completed_steps` pops each entry after its undo returns (a failed undo is still popped and
138
+ recorded in `rollback_failures`, as before). The stored stack of an aborted run is therefore the set
139
+ of entries still to undo, newest last.
@@ -0,0 +1,233 @@
1
+ # Implementation Plan: Reliable Rollback Across Constructs
2
+
3
+ **Branch**: `execution_flow_analysis` | **Date**: 2026-09-26, revised 2026-09-27 (PR #65 review) | **Spec**: [spec.md](spec.md)
4
+
5
+ **Input**: Feature specification from `specs/008-rollback-reliability/spec.md`
6
+
7
+ ## Summary
8
+
9
+ This plan closes the four High findings from the 007 analysis, and the smaller findings the same
10
+ fixes cover, so that every construct rolls back by one rule:
11
+
12
+ - **F-01**: map elements that succeeded are never rolled back.
13
+ - **F-02**: a retried `compose` resumes a child that was already undone.
14
+ - **F-03**: some failures skip rollback entirely.
15
+ - **F-04**: an `async_step`'s own `compensate`/`undo` never run.
16
+ - **Also fixed**: F-05 (fan-out leftovers depend on scheduling), F-06 (a raising condition
17
+ compensates a step that never ran) and F-13 (failures without a step name).
18
+
19
+ **Revision 2026-09-27 (PR #65 review)**. The first implementation is on the branch. The review
20
+ reverses three of its decisions, and this plan now covers the rework (research R-14–R-18):
21
+
22
+ - **R-14**: nested reactors are never retried as a whole. `retries` on `compose`/`async_reactor`
23
+ raises at class definition. The fresh-child code (R-05) is deleted. This closes F-02.
24
+ - **R-15**: `where`/`guard` are removed. `ConditionError` goes with them. This closes F-06.
25
+ - **R-16**: every exception rolls back except interruptions (`SignalException`, `SystemExit`,
26
+ `NoMemoryError`, `Timeout::ExitException`). One matcher module, `Error::Rescuable`, replaces
27
+ `rescue StandardError` at the user-code boundaries. `aborted` is kept for interruptions only, and
28
+ the rollback loop pops each undo entry as it completes.
29
+ - **R-17**: `Skipped` is documented as "this run caused no effect". No code change.
30
+
31
+ Approach of the first implementation ([research.md](research.md)), still valid except where marked:
32
+
33
+ - **Bounded "step owns its lifecycle" refactor (R-01)**:
34
+ - `StepConfig` becomes the single owner of each step's lifecycle operations: resolve arguments,
35
+ conditions, body, compensate, undo, and whether a success is tracked for undo.
36
+ - Construct classes own what rollback means for them: `MapStep` replays its elements, and
37
+ `ComposeStep` starts a fresh child on retry.
38
+ - The executor keeps only orchestration.
39
+ - **Map (R-02–R-04)**:
40
+ - `MapStep#compensate`/`#undo` replay each completed element's own undo stack, found through the
41
+ element index both modes already write.
42
+ - A fan-out map joins the parent's undo stack.
43
+ - A fail-fast fan-out waits until every index has settled before it rolls back.
44
+ - **Compose (R-05, superseded by R-14)**: ~~a retry after a failed attempt starts a fresh child~~.
45
+ A compose cannot be retried.
46
+ - **Failures (R-06–R-08, revised by R-15/R-16)**:
47
+ - Argument errors become attributed never-started failures (condition errors no longer exist).
48
+ - Every exception except interruptions rolls back.
49
+ - Interruptions mark an inline run `aborted`.
50
+ - **async_step (R-09, R-10)**:
51
+ - The unit compensates itself once in its own job after its final failure.
52
+ - An inline `undo` is rejected at definition time; an `undo` inherited from a step class is
53
+ warned.
54
+ - Async units declare themselves as not tracked for undo.
55
+
56
+ ## Technical Context
57
+
58
+ **Language/Version**: Ruby >= 3.0
59
+
60
+ **Primary Dependencies**: Sidekiq (async router), Redis (state, locks), dry-validation (contracts),
61
+ optional OpenTelemetry middleware. No new dependencies.
62
+
63
+ **Storage**: Redis via `RubyReactor::Storage::RedisAdapter`. The map element-context index,
64
+ map results hash, context rows and step result records already exist. New stored data:
65
+ `_skipped` result slots (R-04), `compensation` on the async step record (R-09), status value
66
+ `aborted` (R-08, R-16). The `compose_attempt_discarded` trace entry (R-05) is removed by R-14.
67
+
68
+ **Testing**: RSpec against real Redis (`redis://localhost:6780`). Sidekiq fake mode plus
69
+ `drain_async_jobs` for fan-out and async paths. The 007 evidence harness is used as the
70
+ regression check (SC-002). The demo app specs use the shipped matchers only.
71
+
72
+ **Target Platform**: Ruby gem (MRI), Sidekiq workers, Rails demo app in Docker
73
+
74
+ **Project Type**: Library (gem)
75
+
76
+ **Performance Goals**:
77
+
78
+ - Rollback of N succeeded map elements is linear in N and runs serially in the process that
79
+ detected the failure.
80
+ - A 10,000-element map fails and rolls back without a storage size error (SC-006).
81
+ - Fail-fast fan-out reports failure only after the slowest element in flight settles. This is
82
+ accepted and documented.
83
+
84
+ **Constraints**:
85
+
86
+ - Single-writer context rule: an async unit never writes its parent's context. Map rollback writes
87
+ element contexts only after they settle, under each element's liveness lock (R-02 §5).
88
+ - No new per-element data in the parent blob (FR-008).
89
+ - Park signals (`Error::ExecutionParked`) never turn into failures.
90
+
91
+ **Scale/Scope**:
92
+
93
+ - About 14 library files touched (below).
94
+ - 6 new spec files, 1 tightened spec, and 1 updated evidence harness.
95
+ - 4 demo artifacts per behavior group (reactor, rake task, spec).
96
+ - 9 documentation files.
97
+
98
+ ## Constitution Check
99
+
100
+ *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
101
+
102
+ | Principle | Status | Note |
103
+ | --- | --- | --- |
104
+ | I. Gem-First Design | PASS | All changes are inside `lib/`, behind the existing public API. No host coupling. The Sidekiq-specific parts stay in the existing workers and adapters. |
105
+ | II. Saga Pattern Integrity | PASS (this feature exists for it) | It closes four violations of "partial execution without a recovery path is forbidden". Every exception raised by reactor code now rolls back (R-16). Interruptions get an explicit recovery path (`aborted` plus manual undo, with only the not-yet-undone entries kept) instead of a silent forward resume. |
106
+ | III. Test-First with Real Infrastructure | PASS | Every behavior task writes its failing spec first against real Redis. Async paths use fake mode plus drain, never `inline!` (R-11). |
107
+ | IV. Observability by Default | PASS | New failures carry reactor name, step name, redacted inputs and reason (FR-017). `aborted` is added to the dashboard state model. Unit compensation is recorded on the unit's record, and compensation middleware events fire. Map rollback failures carry `map_step`/`element_index`. |
108
+ | V. Simplicity & SemVer | PASS with notes | The refactor is bounded to removing real duplicates (R-01). Full step self-execution was rejected. The revision deletes code: compose retries, `where`/`guard`, `ConditionError`. Six changes are breaking: element `undo`s run, `async_step` `compensate` runs, an inline `undo` on `async_step` raises, `retries` on `compose`/`async_reactor` raises, `where`/`guard` raise, and non-`StandardError` exceptions roll back instead of propagating. They are marked `!` and carry CHANGELOG migration notes (R-12). |
109
+ | VI. Demo-App Proof of Feature | PASS (planned) | Demo reactor, rake task and matcher-based spec for map rollback, a compose whose child step retries itself, failure rollback (argument error and non-`StandardError`), `async_step` compensate and `aborted`. See Project Structure. The tasks are added to `demo:all`. The removed DSL needs no demo: a reactor that uses it cannot load. |
110
+
111
+ - [x] Documentation impact identified (research R-13):
112
+ - **README.md**: lines 16, 25, 35, 545-550, 1309 and 1398, plus the Compensation section.
113
+ - **documentation/**: `data_pipelines.md`, `composition.md`, `background_and_async.md`,
114
+ `core_concepts.md`, `DAG.md`, `locks_and_semaphores.md`, `interrupts.md`.
115
+ - **007 analysis**: `execution-order.md` and `invariants.md`, refreshed.
116
+ - **CHANGELOG.md**: entries with migration notes.
117
+ - This is carried into tasks.md as a required task per user story.
118
+
119
+ **Post-design re-check (after Phase 1, revised 2026-09-27)**: PASS. No new abstraction beyond the
120
+ `StepConfig` methods that replace existing duplicates, and one matcher module (`Error::Rescuable`)
121
+ that replaces a repeated `rescue StandardError` with the review's rule. The only new public error
122
+ class is `ArgumentResolutionError`. There are no new DSL keywords, and three are removed.
123
+
124
+ ## Project Structure
125
+
126
+ ### Documentation (this feature)
127
+
128
+ ```text
129
+ specs/008-rollback-reliability/
130
+ ├── plan.md # This file
131
+ ├── research.md # Phase 0: decisions R-01..R-13
132
+ ├── data-model.md # Phase 1: records, statuses, entry shapes
133
+ ├── quickstart.md # Phase 1: validation guide
134
+ ├── contracts/
135
+ │ ├── rollback-semantics.md # Per-construct rollback contract + sequences
136
+ │ └── api-surface.md # DSL, errors, Failure/rollback_failures, statuses, events
137
+ ├── checklists/requirements.md
138
+ └── tasks.md # Phase 2 (/speckit-tasks)
139
+ ```
140
+
141
+ ### Source Code (repository root)
142
+
143
+ ```text
144
+ lib/ruby_reactor/
145
+ ├── dsl/step_builder.rb # StepConfig: resolve_arguments, call_compensate, call_undo,
146
+ │ # rollback_tracked? (R-01/R-06/R-10); where/guard stubs (R-15)
147
+ ├── dsl/{compose,async_reactor}_builder.rb # retries stub, no Retryable (R-14)
148
+ ├── dsl/{interrupt,map}_builder.rb # drop conditions/guards (R-15)
149
+ ├── error/rescuable.rb # new: rescue matcher, everything but interruptions (R-16)
150
+ ├── dsl/async_macros.rb # async_step: reject inline undo, warn on class undo (R-09)
151
+ ├── error/argument_resolution_error.rb # new (R-06)
152
+ ├── executor.rb # rescue Rescuable → failure; rescue Exception → :aborted (R-08/R-16)
153
+ ├── executor/step_executor.rb # route resolution failures through result handling; use StepConfig ops
154
+ ├── executor/compensation_manager.rb # NEVER_STARTED += ArgumentResolutionError; compensate under with_step;
155
+ │ # public #compensate; dispatch via StepConfig; Rescuable; pop per entry
156
+ │ # (R-01/R-02/R-06/R-16)
157
+ ├── executor/result_handler.rb # rollback on unknown errors + attribution; rollback_tracked? (R-07/R-10)
158
+ ├── step/map_step.rb # real compensate/undo = element replay (R-02)
159
+ ├── step/compose_step.rb # fresh-child code removed (R-14)
160
+ ├── map/helpers.rb # collector success pushes map step on parent undo stack (R-03)
161
+ ├── map/collector.rb # fail-fast resolves only when no index is missing (R-04)
162
+ ├── map/element_executor.rb # skipped element stores _skipped slot (R-04)
163
+ ├── map/dispatcher.rb # on fail-fast, claim + settle undispatched indices (R-04)
164
+ ├── map/result_enumerator.rb # tolerate _skipped slots (R-04)
165
+ ├── step_worker.rb # StepConfig ops; unit-local compensate + record (R-09)
166
+ ├── storage/redis_reactor_scan.rb # 'aborted' known status (R-08)
167
+ └── web/ (api.rb + UI filters) # 'aborted' shown (R-08)
168
+
169
+ spec/ruby_reactor/rollback/
170
+ ├── map_rollback_spec.rb
171
+ ├── map_fan_out_settle_spec.rb
172
+ ├── compose_retry_spec.rb # rewritten for R-14
173
+ ├── failure_rollback_spec.rb # condition examples out, non-StandardError examples in (R-15/R-16)
174
+ ├── aborted_execution_spec.rb # Interrupt, mid-rollback interruption, enclosing Timeout (R-16)
175
+ ├── removed_dsl_spec.rb # new: where/guard rejected (R-15)
176
+ └── async_step_compensate_spec.rb
177
+ spec/ruby_reactor/dsl/async_step_spec.rb # tightened (line ~111)
178
+
179
+ specs/007-execution-flow-analysis/evidence/probes/*.rb # expected sequences updated for in-scope scenarios
180
+
181
+ demo_app/
182
+ ├── app/reactors/{map_refund,compose_retry,argument_failure,async_step_compensate}_demo_reactor.rb
183
+ ├── lib/tasks/demo_reactors.rake # demo:map_rollback, :compose_retry, :failure_rollback,
184
+ │ # :async_step_compensate, aggregate :rollback_reliability (in demo:all)
185
+ └── spec/reactors/<same names>_demo_reactor_spec.rb
186
+
187
+ README.md, documentation/*.md, CHANGELOG.md # per R-13 / R-12
188
+ ```
189
+
190
+ **Structure Decision**: single gem project. Changes stay within the existing `lib/ruby_reactor`
191
+ module layout. There are no new directories under `lib/`. The new specs are grouped under
192
+ `spec/ruby_reactor/rollback/` so the feature's coverage reads as one unit.
193
+
194
+ ## Delivery Order
195
+
196
+ The user stories are independent. This order minimizes rework:
197
+
198
+ 1. **Foundation (R-01)**:
199
+ - `StepConfig` lifecycle methods: `resolve_arguments`, wrapping `should_run?` (removed by R-15), `call_compensate`,
200
+ `call_undo`, `rollback_tracked?`.
201
+ - `CompensationManager` dispatches through them and runs compensate under `with_step`.
202
+ - This is a refactor with no behavior change. The existing suite stays green.
203
+ 2. **US3 (P1) Failures**: R-06, R-07, R-08. These are the smallest changes, and they give every
204
+ later failure its attribution.
205
+ 3. **US2 (P1) Compose retry**: R-05.
206
+ 4. **US1 (P1) Map**: R-02, then R-03 (fan-out undo stack), then R-04 (settle). This is the largest
207
+ change.
208
+ 5. **US4 (P2) async_step**: R-09.
209
+ 6. **US5 (P3) One rule**: R-10, the refreshed F-10 table, the 007 docs refresh and the harness
210
+ re-run (SC-002).
211
+ 7. **Per story**: README and documentation edits, the demo artifacts and CHANGELOG. Each story ships
212
+ its own docs and demo, not in a final batch (Constitution Development Workflow).
213
+ 8. **Revision 2026-09-27** (steps 1-7 are done on the branch): R-14 (US2 rework), R-15 (US6), R-16
214
+ (US3 rework), R-17 (docs), each with its specs, demo, docs and CHANGELOG, then the harness re-run
215
+ and the full suite. R-18 lists the files.
216
+
217
+ ## Risks
218
+
219
+ | Risk | Mitigation |
220
+ | --- | --- |
221
+ | Existing specs pin the old failure shapes ("Execution failed: …", "Execution error: …", no `step_name`) | Update them in the story that changes the shape, and note it in the CHANGELOG |
222
+ | Sweeper re-enqueues **in-progress** inline runs (running, no `async:` lock). This is pre-existing, and unchanged except that aborted runs are now excluded | Out of scope. Record it in `specs/future_improvements.md` |
223
+ | Element contexts that expire before a late rollback | Reported as `context_unavailable` in `rollback_failures`, never silent. `context_ttl` is documented as the rollback horizon |
224
+ | Flaky specs when suites share the test Redis | Run the new specs alone before debugging. Don't run the demo and gem suites concurrently |
225
+ | R-16 turns test assertion/mock errors raised inside step bodies into step failures, so an existing spec that relied on them propagating now sees a `Failure` | Run the full suite after the swap. A spec asserting on the result still fails loudly. Fix any spec that asserted inside a body by asserting on the result instead, and note the change in CHANGELOG |
226
+ | Removing compose `retries` and `where`/`guard` makes 007 probe files fail at load | Move those reactor definitions inside their scenario blocks so the rejection is the observed outcome |
227
+ | Demo Docker stack collides with other worktrees | Use an isolated compose project (`-p rr_<worktree>`) and an override file (quickstart) |
228
+
229
+ ## Complexity Tracking
230
+
231
+ No Constitution violations to justify. The `StepConfig` lifecycle methods replace two duplicate
232
+ implementations (`resolve_arguments` in the executor and the worker, and compensate dispatch in
233
+ `CompensationManager` plus the new `StepWorker` need). They are not a new layer.