ruby_reactor 0.8.4 → 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/CHANGELOG.md +196 -0
- data/CLAUDE.md +1 -1
- data/README.md +47 -11
- data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
- data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
- data/lib/ruby_reactor/dsl/map_builder.rb +0 -2
- data/lib/ruby_reactor/dsl/step_builder.rb +91 -19
- data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
- data/lib/ruby_reactor/error/rescuable.rb +28 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +30 -26
- data/lib/ruby_reactor/executor/result_handler.rb +18 -16
- data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
- data/lib/ruby_reactor/executor/step_executor.rb +59 -49
- data/lib/ruby_reactor/executor.rb +38 -4
- data/lib/ruby_reactor/map/collector.rb +21 -11
- data/lib/ruby_reactor/map/dispatcher.rb +29 -3
- data/lib/ruby_reactor/map/element_executor.rb +9 -3
- data/lib/ruby_reactor/map/helpers.rb +32 -2
- data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
- data/lib/ruby_reactor/reactor.rb +24 -0
- data/lib/ruby_reactor/rspec/matchers.rb +19 -3
- data/lib/ruby_reactor/step/compose_step.rb +7 -1
- data/lib/ruby_reactor/step/map_step.rb +109 -4
- data/lib/ruby_reactor/step.rb +7 -0
- data/lib/ruby_reactor/step_worker.rb +46 -22
- data/lib/ruby_reactor/storage/adapter.rb +4 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +9 -0
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +1 -1
- data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-CQbgHtd0.js} +10 -10
- data/lib/ruby_reactor/web/public/index.html +1 -1
- data/lib/ruby_reactor/worker.rb +3 -1
- data/lib/ruby_reactor.rb +17 -6
- data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
- data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
- data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
- data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
- data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
- data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
- data/specs/007-execution-flow-analysis/data-model.md +83 -0
- data/specs/007-execution-flow-analysis/evidence/harness.rb +229 -0
- data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
- data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
- data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
- data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +232 -0
- data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
- data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
- data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
- data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
- data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
- data/specs/007-execution-flow-analysis/plan.md +127 -0
- data/specs/007-execution-flow-analysis/quickstart.md +51 -0
- data/specs/007-execution-flow-analysis/research.md +202 -0
- data/specs/007-execution-flow-analysis/spec.md +270 -0
- data/specs/007-execution-flow-analysis/tasks.md +257 -0
- data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
- data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
- data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
- data/specs/008-rollback-reliability/data-model.md +139 -0
- data/specs/008-rollback-reliability/plan.md +233 -0
- data/specs/008-rollback-reliability/quickstart.md +105 -0
- data/specs/008-rollback-reliability/research.md +653 -0
- data/specs/008-rollback-reliability/spec.md +561 -0
- data/specs/008-rollback-reliability/tasks.md +1110 -0
- data/specs/future_improvements.md +48 -0
- metadata +35 -2
|
@@ -0,0 +1,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.
|