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,1110 @@
|
|
|
1
|
+
---
|
|
2
|
+
|
|
3
|
+
description: "Task list for 008 Reliable Rollback Across Constructs"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Tasks: Reliable Rollback Across Constructs
|
|
7
|
+
|
|
8
|
+
**Input**: Design documents from `specs/008-rollback-reliability/`
|
|
9
|
+
|
|
10
|
+
**Prerequisites**: [plan.md](plan.md), [spec.md](spec.md), [research.md](research.md),
|
|
11
|
+
[data-model.md](data-model.md), [contracts/](contracts/), [quickstart.md](quickstart.md)
|
|
12
|
+
|
|
13
|
+
**Tests**: REQUIRED. Constitution III (test-first, real Redis) and spec FR-027. In every story,
|
|
14
|
+
write the spec tasks first and confirm they FAIL on the current code before implementing.
|
|
15
|
+
|
|
16
|
+
**Organization**: tasks are grouped by user story (spec.md US1–US6). Phases 1–8 (T001–T079) are
|
|
17
|
+
the first implementation, done. Phases 9–13 (T080–T121) are the 2026-09-27 revision after the PR #65
|
|
18
|
+
review (research R-14–R-18). Research decisions are cited
|
|
19
|
+
as `R-nn`, data-model sections as `DM §n`, contracts as `RS §n` (rollback-semantics.md) and
|
|
20
|
+
`API §n` (api-surface.md).
|
|
21
|
+
|
|
22
|
+
## Format: `[ID] [P?] [Story] Description`
|
|
23
|
+
|
|
24
|
+
- **[P]**: can run in parallel (different files, no dependency on an incomplete task)
|
|
25
|
+
- **[Story]**: US1–US6 from spec.md
|
|
26
|
+
|
|
27
|
+
## Path Conventions
|
|
28
|
+
|
|
29
|
+
Single gem project: `lib/ruby_reactor/`, `spec/`, `demo_app/`, `gui/`, `documentation/`.
|
|
30
|
+
|
|
31
|
+
**Test rules**:
|
|
32
|
+
|
|
33
|
+
- Run specs against the test Redis at `redis://localhost:6780`.
|
|
34
|
+
- Async paths use `for_each_async_backend` from `spec/support/async_backends.rb` plus
|
|
35
|
+
`drain_async_jobs`. Never use `Sidekiq::Testing.inline!` (Constitution III).
|
|
36
|
+
- Don't run the gem suite and the demo suite at the same time: they flush the same Redis.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Phase 1: Setup (Shared Infrastructure)
|
|
41
|
+
|
|
42
|
+
**Purpose**: test scaffolding shared by every story's specs.
|
|
43
|
+
|
|
44
|
+
- [X] T001 Create `spec/support/rollback_recorder.rb`:
|
|
45
|
+
- Module `RollbackRecorder` with `.log` (Array of strings), `.reset!` and `.record(event)`.
|
|
46
|
+
- An `RSpec.configure` hook that calls `RollbackRecorder.reset!` before each example under
|
|
47
|
+
`spec/ruby_reactor/rollback/`.
|
|
48
|
+
- A reactor base class `RollbackRecorder::Reactor < RubyReactor::Reactor` with a class macro
|
|
49
|
+
`recording_step(name, after: nil, fail: nil, idx: false, undo_fails: false, compensate_raises: false)`.
|
|
50
|
+
Port it from the `pstep` macro in `specs/007-execution-flow-analysis/evidence/harness.rb`.
|
|
51
|
+
Each recording step logs `run:<tag>.<name>[<i>]`, `compensate:…` and `undo:…` exactly like the
|
|
52
|
+
007 probes, so assertions can compare whole sequences such as
|
|
53
|
+
`%w[run:a run:e.e1[0] … undo:a]`.
|
|
54
|
+
- [X] T002 [P] Add `config.filter_run_excluding :slow` to `spec/spec_helper.rb`, so `:slow` examples
|
|
55
|
+
run only with `--tag slow`.
|
|
56
|
+
- [X] T003 [P] Baseline check, with no file changes:
|
|
57
|
+
- Run `bundle exec ruby specs/007-execution-flow-analysis/evidence/run.rb`.
|
|
58
|
+
- Confirm the tail line reads `63 scenarios, 63 match, 0 mismatch`, and that
|
|
59
|
+
`git diff specs/007-execution-flow-analysis/evidence/output.txt` is empty after re-teeing.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Phase 2: Foundational (Blocking Prerequisites), R-01 bounded refactor
|
|
64
|
+
|
|
65
|
+
**Purpose**: `StepConfig` becomes the single owner of per-step lifecycle operations. This is a
|
|
66
|
+
**pure refactor with no behavior change**. Every story builds on it.
|
|
67
|
+
|
|
68
|
+
**⚠️ CRITICAL**: no user story work can start until T009 is green.
|
|
69
|
+
|
|
70
|
+
- [X] T004 Add `StepConfig#resolve_arguments(context)` in `lib/ruby_reactor/dsl/step_builder.rb`.
|
|
71
|
+
The body is exactly `StepExecutor#resolve_arguments` today: for each
|
|
72
|
+
`arguments[name] = { source:, transform: }`, `value = source.resolve(context)`, then apply
|
|
73
|
+
`transform.call(value)` if a transform is present. It returns a Hash. Nothing is wrapped yet
|
|
74
|
+
(US3 adds that). `InterruptStepConfig` inherits it.
|
|
75
|
+
- [X] T005 In `lib/ruby_reactor/executor/step_executor.rb`, replace every call to the private
|
|
76
|
+
`resolve_arguments(step_config)` with `step_config.resolve_arguments(@context)`, and delete the
|
|
77
|
+
private method. Run `grep -rn "resolve_arguments(" lib/` and switch any other caller that
|
|
78
|
+
resolves a step config's arguments (e.g. in `executor/async_step_dispatch.rb`).
|
|
79
|
+
- [X] T006 [P] In `lib/ruby_reactor/step_worker.rb`, replace `resolve_arguments(step_config, context)`
|
|
80
|
+
with `step_config.resolve_arguments(context)` and delete the private method (~line 359).
|
|
81
|
+
- [X] T007 Add `StepConfig#call_compensate(error, arguments, context)` and
|
|
82
|
+
`StepConfig#call_undo(result_value, arguments, context)` in `lib/ruby_reactor/dsl/step_builder.rb`,
|
|
83
|
+
each wrapped in `catch(StepSignals::TAG)`. Dispatch order is identical to
|
|
84
|
+
`CompensationManager#compensate_step`/`#undo_step` today:
|
|
85
|
+
- inline block: `compensate_block.call(error, wrap_inputs(arguments), context)` /
|
|
86
|
+
`undo_block.call(result_value, wrap_inputs(arguments), context)`
|
|
87
|
+
- else `impl.compensate(error, arguments, context)` / `impl.undo(result_value, arguments, context)`
|
|
88
|
+
when `has_impl?`
|
|
89
|
+
- else `RubyReactor.Skipped()`
|
|
90
|
+
- [X] T008 Update `lib/ruby_reactor/executor/compensation_manager.rb`:
|
|
91
|
+
- `compensate_step` and `undo_step` call `step_config.call_compensate` / `call_undo` inside the
|
|
92
|
+
existing `coordinated_rollback` block. Trace, middleware and `record_rollback_failure` are
|
|
93
|
+
unchanged.
|
|
94
|
+
- Wrap `compensate_step`'s body in `@context.with_step(step_config.name) { … }`, as
|
|
95
|
+
`rollback_completed_steps` already does for undo, so constructs can read `context.current_step`
|
|
96
|
+
during compensate (R-02).
|
|
97
|
+
- Add public `def compensate(step_config, error, arguments) = compensate_step(step_config, error, arguments)`.
|
|
98
|
+
`StepWorker` uses it in US4.
|
|
99
|
+
- [X] T009 Run `bundle exec rspec` and `bundle exec rubocop`. Both must be green with **no spec
|
|
100
|
+
edits**: the refactor changes no behavior.
|
|
101
|
+
|
|
102
|
+
**Checkpoint**: the foundation is ready and the user stories can proceed (in parallel if staffed).
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Phase 3: User Story 1 — Succeeded map elements are rolled back (Priority: P1) 🎯 MVP
|
|
107
|
+
|
|
108
|
+
**Goal**: a map rolls back every completed element, both when the map fails (compensate) and when
|
|
109
|
+
a later step fails or the run is undone manually (undo). This holds in inline and fan-out modes,
|
|
110
|
+
and a fail-fast fan-out settles before it rolls back (R-02, R-03, R-04; F-01, F-05).
|
|
111
|
+
|
|
112
|
+
**Independent Test**: `bundle exec rspec spec/ruby_reactor/rollback/map_rollback_spec.rb spec/ruby_reactor/rollback/map_fan_out_settle_spec.rb`.
|
|
113
|
+
The sequences must match RS §3 rows S-map-01/03/04/04b/06/07/08.
|
|
114
|
+
|
|
115
|
+
### Tests for User Story 1 ⚠️ write first, confirm they FAIL
|
|
116
|
+
|
|
117
|
+
- [X] T010 [P] [US1] Create `spec/ruby_reactor/rollback/map_rollback_spec.rb` (inline mode, using
|
|
118
|
+
`RollbackRecorder`). Examples:
|
|
119
|
+
- (a) S-map-01: fail-fast with element 2 failing gives exactly the RS §3 sequence, ending
|
|
120
|
+
`undo:e.e2[1] undo:e.e1[1] undo:e.e2[0] undo:e.e1[0] undo:a`, and `failure.step_name == :m`.
|
|
121
|
+
- (b) S-map-03: all elements ok and `b` fails. Every element is undone, highest index first,
|
|
122
|
+
before `undo:a`.
|
|
123
|
+
- (c) `fail_fast false`, element 2 fails, then `b` fails. Elements 0, 1 and 3 are undone.
|
|
124
|
+
Element 2's steps appear exactly once in the undo sequence (its own self-rollback).
|
|
125
|
+
- (d) A `collect` block that raises after all elements succeeded. Every element is undone
|
|
126
|
+
(FR-007).
|
|
127
|
+
- (e) S-map-07: map inside a compose.
|
|
128
|
+
- (f) S-map-08: a compose inside the element. Nested steps unwind innermost-first.
|
|
129
|
+
- (g) `Reactor.undo(result.execution_id)` after a completed map undoes every element. A second
|
|
130
|
+
`Reactor.undo` records no new undo events (idempotent).
|
|
131
|
+
- (h) An element step whose `undo` returns `Failure` for element 1 only. The final failure's
|
|
132
|
+
`rollback_failures` contains an entry with `map_step: :m, element_index: 1`, and elements 0, 2
|
|
133
|
+
and 3 are still undone (FR-005).
|
|
134
|
+
- (i) Delete element 1's context row from storage between the map's success and `b`'s failure.
|
|
135
|
+
An entry with `map_step: :m, reason: :context_unavailable` appears (its `element_index` is
|
|
136
|
+
`nil`, since the index lives in the deleted row), and elements 0, 2 and 3 are still undone.
|
|
137
|
+
- (j) An empty source succeeds, and a later failure raises no error from the map's undo.
|
|
138
|
+
- (k) US1-8: an existing element reactor whose steps declare `undo` needs no map-level
|
|
139
|
+
declaration for those undos to run.
|
|
140
|
+
- [X] T011 [P] [US1] Create `spec/ruby_reactor/rollback/map_fan_out_settle_spec.rb`
|
|
141
|
+
(`for_each_async_backend`, `fan_out(true)`, drain). Examples:
|
|
142
|
+
- (a) S-map-04: jobs drained in index order give the RS §3 sequence, and element 3 is skipped.
|
|
143
|
+
- (b) S-map-04b: jobs performed in order 3, 2, 1, 0. Reorder the fake queue the same way
|
|
144
|
+
`specs/007-execution-flow-analysis/evidence/harness.rb` does. Element 3 is undone and elements
|
|
145
|
+
1 and 0 never run.
|
|
146
|
+
- (c) S-map-06: all elements ok and `b` fails in the collector-resumed parent. Every element is
|
|
147
|
+
undone, then `a` (R-03).
|
|
148
|
+
- (d) SC-003: 100 iterations with `Random.new(seed)`-shuffled element job order. After each run,
|
|
149
|
+
every element context with status `completed` has an empty `undo_stack`, and `RollbackRecorder`
|
|
150
|
+
shows an `undo:` for every `run:` of a completed element.
|
|
151
|
+
- (e) `batch_size 2` over 6 elements, element 1 fails. Indices 2–5 hold `{"_skipped"=>true}`
|
|
152
|
+
slots, the map counter is `0`, and the collector resolves the failure exactly once.
|
|
153
|
+
- (f) After (e), `RubyReactor::Map::Sweeper.run_once` returns `redispatched: 0`.
|
|
154
|
+
- (g) Hold `RubyReactor::Lock.new("map_element:<map_id>:0", owner: "x", ttl: 30, wait: 0)`
|
|
155
|
+
while the map rollback runs. The result has `reason: :element_in_flight, element_index: 0`, and
|
|
156
|
+
the other elements are undone.
|
|
157
|
+
- [X] T012 [P] [US1] Create `spec/ruby_reactor/rollback/map_scale_spec.rb`, tagged `:slow`
|
|
158
|
+
(SC-006). An inline map over 10,000 trivial elements whose `collect` raises: all 10,000 elements
|
|
159
|
+
are undone, there is no `ContextTooLargeError`, and the parent's serialized context size is
|
|
160
|
+
within 2× of the same reactor with 10 elements.
|
|
161
|
+
|
|
162
|
+
### Implementation for User Story 1
|
|
163
|
+
|
|
164
|
+
- [X] T013 [US1] Implement map rollback in `lib/ruby_reactor/step/map_step.rb` (R-02, DM §5–§6):
|
|
165
|
+
- Replace the stub with `def compensate` and add `alias undo compensate`.
|
|
166
|
+
- `step_name = context.current_step`; `map_id = "#{context.context_id}:#{step_name}"`.
|
|
167
|
+
- Element class: `context.reactor_class.steps[step_name].arguments[:mapped_reactor_class][:source].value`.
|
|
168
|
+
- Ids: `storage.retrieve_map_element_context_ids(map_id, context.reactor_class.name).uniq`.
|
|
169
|
+
- For each id, load `storage.retrieve_context(id, RubyReactor.reactor_storage_name(element_class))`:
|
|
170
|
+
- missing → a
|
|
171
|
+
`{ step: step_name, kind: :undo, reason: :context_unavailable, map_step: step_name, element_index: nil, message: "element context #{id} expired" }`
|
|
172
|
+
entry. The index is stored only in the row itself.
|
|
173
|
+
- otherwise `Context.deserialize_from_retry`, and keep it only when `status.to_s == "completed"`.
|
|
174
|
+
- Sort the kept elements by `map_metadata[:index]` (string or symbol key) descending.
|
|
175
|
+
- Per element:
|
|
176
|
+
- Acquire `RubyReactor::Lock.new("map_element:#{map_id}:#{index}", owner: SecureRandom.uuid, ttl: RubyReactor.configuration.context_lock_ttl, wait: 0)`.
|
|
177
|
+
Skip the lock when `Map::ElementExecutor.inline_testing_mode?`. On
|
|
178
|
+
`Lock::AcquisitionError`, record `reason: :element_in_flight`.
|
|
179
|
+
- Otherwise run `ex = Executor.new(element_class, {}, element_ctx); ex.undo_all; ex.save_context`,
|
|
180
|
+
then tag each of `ex.compensation_manager.rollback_failures` with `map_step: step_name, element_index: index`.
|
|
181
|
+
- Release the lock in `ensure`.
|
|
182
|
+
- Return `RubyReactor.Success()` when no entries were collected, else
|
|
183
|
+
`RubyReactor.Failure("map :#{step_name} rollback incomplete", rollback_failures: entries)`, the
|
|
184
|
+
same shape as `ComposeStep#compensate`.
|
|
185
|
+
- Mark the serial loop with a `# ponytail:` comment naming its ceiling (linear in the number of
|
|
186
|
+
elements) and the upgrade path (rollback fan-out).
|
|
187
|
+
- [X] T014 [US1] In `lib/ruby_reactor/map/helpers.rb` `resume_parent_execution`, success branch:
|
|
188
|
+
before `resume_parked_aware`, push
|
|
189
|
+
`{ step: parent_context.reactor_class.steps[step_name_sym], arguments: {}, result: RubyReactor.Success(nil) }`
|
|
190
|
+
onto `parent_context.undo_stack` (R-03, DM §4). Add a comment on why the record is empty:
|
|
191
|
+
`MapStep#undo` reads the element index, and the record stays constant-size.
|
|
192
|
+
- [X] T015 [P] [US1] Add `decrement_map_counter_by(map_id, amount, reactor_class_name)` to
|
|
193
|
+
`lib/ruby_reactor/storage/redis_adapter.rb`: `DECRBY` on `map_counter_key`, refresh
|
|
194
|
+
`durability_ttl`, return the new value.
|
|
195
|
+
- [X] T016 [US1] In `lib/ruby_reactor/map/element_executor.rb` `check_fail_fast?`, before
|
|
196
|
+
`finalize_execution`, write `storage.store_map_result(map_id, arguments[:index], { "_skipped" => true }, parent_reactor_class_name, strict_ordering: arguments[:strict_ordering])`
|
|
197
|
+
(R-04 §1).
|
|
198
|
+
- [X] T017 [US1] In `lib/ruby_reactor/map/dispatcher.rb` `dispatch_batch`, fail-fast branch
|
|
199
|
+
(~line 69). When the failed-context marker is set, do not simply `return`:
|
|
200
|
+
- Read the total from `storage.retrieve_map_metadata(map_id, reactor_class_name)["count"]`.
|
|
201
|
+
- Claim the rest with `new_offset = storage.increment_map_offset(map_id, total, reactor_class_name)`,
|
|
202
|
+
so `claimed = (new_offset - total)...[new_offset, total].min` is empty for a later dispatcher.
|
|
203
|
+
- Write a `_skipped` slot for each claimed index.
|
|
204
|
+
- `left = storage.decrement_map_counter_by(map_id, claimed.size, reactor_class_name)` if any were
|
|
205
|
+
claimed.
|
|
206
|
+
- If `left <= 0`, enqueue `perform_map_collection_async` with the same arguments
|
|
207
|
+
`ElementExecutor.finalize_execution` uses.
|
|
208
|
+
- Depends on T015.
|
|
209
|
+
- [X] T018 [US1] In `lib/ruby_reactor/map/collector.rb` `perform_collection`, compute
|
|
210
|
+
`results_count` before the fail-fast branch and change it to
|
|
211
|
+
`if (failed_context_id = …) then return if results_count < total_count; handle_failure(…); return; end`
|
|
212
|
+
(R-04 §2). Comment: the failure is applied only once every index has settled, so the map's
|
|
213
|
+
compensate sees every completed element.
|
|
214
|
+
- [X] T019 [P] [US1] In `lib/ruby_reactor/map/result_enumerator.rb`, make `each` (and therefore
|
|
215
|
+
`successes`/`failures`/`count` if derived from it) skip slots that are
|
|
216
|
+
`Hash` with key `"_skipped"`, and make `[](index)` return `nil` for one. Add examples to the
|
|
217
|
+
existing enumerator spec (`grep -rln ResultEnumerator spec/`).
|
|
218
|
+
- [X] T020 [US1] Run the new specs plus `spec/map/`, `spec/ruby_reactor/map/`,
|
|
219
|
+
`spec/single_worker_map_spec.rb` and `spec/compose_spec.rb`. Fix only expectations that asserted
|
|
220
|
+
element effects stay in place, and add a comment on each changed expectation citing F-01.
|
|
221
|
+
|
|
222
|
+
### Documentation & demo for User Story 1
|
|
223
|
+
|
|
224
|
+
- [X] T021 [P] [US1] Update `documentation/data_pipelines.md` (~line 167 and the fail-fast section):
|
|
225
|
+
- Completed elements are rolled back when the map fails and when a later step fails, by replaying
|
|
226
|
+
each element's own step `undo`s, highest index first.
|
|
227
|
+
- Fail-fast fan-out waits for elements in flight before it reports failure.
|
|
228
|
+
- `context_ttl` is the rollback horizon (`context_unavailable`).
|
|
229
|
+
- Element `undo`s should be idempotent.
|
|
230
|
+
- `fail_fast false`: failed elements roll back individually, successes are undone on a later
|
|
231
|
+
failure.
|
|
232
|
+
- [X] T022 [US1] Update `README.md`:
|
|
233
|
+
- Map and Compensation sections, lines ~1309 and ~1398: add maps to "undoes completed steps", and
|
|
234
|
+
list the new `rollback_failures` keys `map_step`/`element_index` and the reasons
|
|
235
|
+
`context_unavailable`/`element_in_flight`.
|
|
236
|
+
- Map example text: no map-level rollback DSL is needed.
|
|
237
|
+
- [X] T023 [P] [US1] Add to `CHANGELOG.md` under Unreleased → Features (BREAKING): "map rolls back
|
|
238
|
+
completed elements". Include a migration note: element-step `undo` blocks now run on map failure
|
|
239
|
+
and on later failures, so make them idempotent.
|
|
240
|
+
- [X] T024 [P] [US1] Create `demo_app/app/reactors/map_refund_demo_reactor.rb`. It contains:
|
|
241
|
+
- `MapRefundChargeStep < RubyReactor::Step`, whose `run` charges and whose `undo` refunds. Both
|
|
242
|
+
append to a class-level `charges`/`refunds` Array with `reset!`. It fails for a configured
|
|
243
|
+
`fail_order_id`.
|
|
244
|
+
- An element reactor (`MapRefundElementReactor`).
|
|
245
|
+
- `MapRefundDemoReactor`, with inputs `orders` and an optional `fail_after_map` flag that makes a
|
|
246
|
+
final `notify` step fail.
|
|
247
|
+
- [X] T025 [US1] Add `demo:map_rollback` to `demo_app/lib/tasks/demo_reactors.rake` (`desc` plus
|
|
248
|
+
`[:environment, :flush_redis]`). It runs both scenarios (an element fails; the step after the map
|
|
249
|
+
fails) and prints charges and refunds.
|
|
250
|
+
- [X] T026 [P] [US1] Create `demo_app/spec/reactors/map_refund_demo_reactor_spec.rb`
|
|
251
|
+
(`type: :reactor`, `test_reactor`, `be_failure`, `have_rollback_failure`). It asserts the refunds
|
|
252
|
+
match the charges in both scenarios. Use only `lib/ruby_reactor/rspec.rb` helpers.
|
|
253
|
+
|
|
254
|
+
**Checkpoint**: US1 is complete and independently testable. This is the MVP.
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Phase 4: User Story 2 — A retried composed reactor re-runs rolled-back work (Priority: P1)
|
|
259
|
+
|
|
260
|
+
**Goal**: a compose retry after a failed attempt starts a fresh child, while a park/resume still
|
|
261
|
+
resumes (R-05, F-02, DM §7).
|
|
262
|
+
|
|
263
|
+
**Independent Test**: `bundle exec rspec spec/ruby_reactor/rollback/compose_retry_spec.rb`, matching
|
|
264
|
+
RS §3 rows S-compose-05 and 05b.
|
|
265
|
+
|
|
266
|
+
### Tests for User Story 2 ⚠️ write first, confirm they FAIL
|
|
267
|
+
|
|
268
|
+
- [X] T027 [P] [US2] Create `spec/ruby_reactor/rollback/compose_retry_spec.rb`. Examples:
|
|
269
|
+
- (a) S-compose-05: `compose :child` with `retries max_attempts: 2`, where child `c2` fails on the
|
|
270
|
+
first attempt only, gives `… retry … run:child.c1 run:child.c2`. The result value is from
|
|
271
|
+
attempt 2.
|
|
272
|
+
- (b) S-compose-05b: the same, plus a parent step `b` that fails, gives
|
|
273
|
+
`… run:b compensate:b undo:child.c2 undo:child.c1`.
|
|
274
|
+
- (c) The child's inner step `c2` declares `retries max_attempts: 2` and fails twice per compose
|
|
275
|
+
attempt. It is attempted twice in **each** compose attempt (the retry budget resets with the
|
|
276
|
+
fresh child).
|
|
277
|
+
- (d) The parent `execution_trace` has one `type: :compose_attempt_discarded` entry with
|
|
278
|
+
`step: :child` and the old `child_context_id`. When attempt 1's `c1` undo returns `Failure`,
|
|
279
|
+
that entry's `rollback_failures` lists it (DM §7).
|
|
280
|
+
- (e) Worker path: `background all: true`, drain. The retry requeue still re-runs `c1`.
|
|
281
|
+
- (f) Park is not retry: model on `spec/ruby_reactor/step_coordination/park_spec.rb` ("a parent
|
|
282
|
+
lock across a composed park (R4)"). A child that parks on contention after `c1` completed
|
|
283
|
+
resumes without logging a second `run:child.c1`.
|
|
284
|
+
|
|
285
|
+
### Implementation for User Story 2
|
|
286
|
+
|
|
287
|
+
- [X] T028 [US2] In `lib/ruby_reactor/step/compose_step.rb` `run`, right after reading
|
|
288
|
+
`composed_data`:
|
|
289
|
+
- If `composed_data&.dig(:context)&.status.to_s == "failed"`, append
|
|
290
|
+
`{ type: :compose_attempt_discarded, step: step_name, child_context_id: old.context_id, rollback_failures: (old.failure_reason.respond_to?(:rollback_failures) ? old.failure_reason.rollback_failures : []), timestamp: Time.now }`
|
|
291
|
+
to `context.execution_trace`, and set `composed_data = nil`. `prepare_child_context` then builds
|
|
292
|
+
a fresh child, and `execute_child_reactor` calls `execute`, not resume.
|
|
293
|
+
- Add a comment explaining why a failed child is a previous attempt (it already rolled itself
|
|
294
|
+
back), while any other status is a park/resume (FR-011).
|
|
295
|
+
- [X] T029 [US2] Run `spec/compose_spec.rb`, `spec/nested_reactor_inline_execution_spec.rb`,
|
|
296
|
+
`spec/ruby_reactor/step_coordination/park_spec.rb` and
|
|
297
|
+
`spec/ruby_reactor/step_coordination/rollback_under_contention_spec.rb`. All must be green.
|
|
298
|
+
|
|
299
|
+
### Documentation & demo for User Story 2
|
|
300
|
+
|
|
301
|
+
- [X] T030 [P] [US2] Update `documentation/composition.md` (~line 184 and the Compensation table
|
|
302
|
+
~195): `retries` on a compose retry the **whole** child from a fresh start, child steps without
|
|
303
|
+
`undo` run again, and the discarded attempt stays visible in the trace.
|
|
304
|
+
- [X] T031 [P] [US2] Add to `CHANGELOG.md` under Bug Fixes: "compose retries re-run the whole child
|
|
305
|
+
instead of resuming a rolled-back one". Note that child steps without `undo` now run again on
|
|
306
|
+
retry.
|
|
307
|
+
- [X] T032 [P] [US2] Create `demo_app/app/reactors/compose_retry_demo_reactor.rb`. A child reactor
|
|
308
|
+
runs `reserve` (with `undo`) and then `confirm`, which fails on the first call only (a class-level
|
|
309
|
+
counter with `reset!`). The parent composes it with `retries max_attempts: 2`.
|
|
310
|
+
- [X] T033 [US2] Add `demo:compose_retry` to `demo_app/lib/tasks/demo_reactors.rake`. It prints that
|
|
311
|
+
`reserve` ran twice and the final result.
|
|
312
|
+
- [X] T034 [P] [US2] Create `demo_app/spec/reactors/compose_retry_demo_reactor_spec.rb`
|
|
313
|
+
(`test_reactor`, `be_success`, `have_run_step`/`have_retried_step`). It asserts `reserve` ran on
|
|
314
|
+
both attempts.
|
|
315
|
+
|
|
316
|
+
**Checkpoint**: US2 works independently of US1.
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## Phase 5: User Story 3 — Every failure after completed work rolls back (Priority: P1)
|
|
321
|
+
|
|
322
|
+
**Goal**:
|
|
323
|
+
|
|
324
|
+
- Argument-resolution and condition errors become attributed never-started failures.
|
|
325
|
+
- Unknown standard errors roll back.
|
|
326
|
+
- Failures from a compensation that failed carry the step name.
|
|
327
|
+
- Process-level exceptions mark an inline run `aborted`.
|
|
328
|
+
|
|
329
|
+
(R-06, R-07, R-08; F-03, F-06, F-13; DM §2, §3, §9; API §2–§4)
|
|
330
|
+
|
|
331
|
+
**Independent Test**: `bundle exec rspec spec/ruby_reactor/rollback/failure_rollback_spec.rb spec/ruby_reactor/rollback/aborted_execution_spec.rb`,
|
|
332
|
+
matching RS §3 rows S-plain-07, S-edge-03 and S-edge-04.
|
|
333
|
+
|
|
334
|
+
### Tests for User Story 3 ⚠️ write first, confirm they FAIL
|
|
335
|
+
|
|
336
|
+
- [X] T035 [P] [US3] Create `spec/ruby_reactor/rollback/failure_rollback_spec.rb`. Examples:
|
|
337
|
+
- (a) S-plain-07: `b`'s argument `transform:` raises `ArgumentError`. The sequence is
|
|
338
|
+
`run:a undo:a`, with `failure.step_name == :b`, `failure.reactor_name` set,
|
|
339
|
+
`failure.exception_class == "ArgumentError"`, and no `compensate:b`.
|
|
340
|
+
- (b) The same when `b`'s argument source is `result(:a, path)` and the path raises.
|
|
341
|
+
- (c) S-edge-04: `where` raises. The sequence is `run:a undo:a`, with no `compensate:b`.
|
|
342
|
+
- (d) The same for `guard`.
|
|
343
|
+
- (e) `b` has `retries max_attempts: 3` and its `where` raises. It is attempted once (not
|
|
344
|
+
retried).
|
|
345
|
+
- (f) A middleware whose `complete_step` hook raises `RuntimeError` for step `a` (a StandardError
|
|
346
|
+
outside any body). Completed steps are undone and the failure has `reactor_name` (FR-016).
|
|
347
|
+
- (g) `b` fails and its `compensate` raises. `failure.step_name == :b` (CompensationError
|
|
348
|
+
attribution, FR-017).
|
|
349
|
+
- (h) Worker path: `background before: :b`, where `b`'s transform raises, then drain. `undo:a`
|
|
350
|
+
is logged and the failure is attributed to `b`.
|
|
351
|
+
- (i) An `async_step :u` whose argument transform raises, then drain. The unit record's
|
|
352
|
+
failure has `step_name: :u` and `retryable: false`, and its `compensate` block was not called.
|
|
353
|
+
- [X] T036 [P] [US3] Create `spec/ruby_reactor/rollback/aborted_execution_spec.rb` with
|
|
354
|
+
`class AbortCrash < Exception; end`. Examples:
|
|
355
|
+
- (a) S-edge-03: `a → b` where `b` raises `AbortCrash`. `Reactor.run` re-raises the **same**
|
|
356
|
+
object (`raise_error(AbortCrash) { |e| expect(e).to equal(crash) }`), and no `undo:a` is logged.
|
|
357
|
+
- (b) The stored context (`storage.retrieve_context`) has status `"aborted"` and a non-empty
|
|
358
|
+
`undo_stack`.
|
|
359
|
+
- (c) `RubyReactor::Sweeper.run_once` enqueues nothing for it.
|
|
360
|
+
- (d) `RubyReactor::Reactor.undo(id)` (via the reactor class) logs `undo:a` and the status
|
|
361
|
+
becomes `"cancelled"`.
|
|
362
|
+
- (e) A composed child raising `AbortCrash` marks both the child and the root `aborted`.
|
|
363
|
+
- (f) An executor whose context has `inline_async_execution = true` leaves the status `running`
|
|
364
|
+
(the worker path is unchanged).
|
|
365
|
+
- (g) `RubyReactor::Worker` given an aborted context id does not resume it.
|
|
366
|
+
|
|
367
|
+
### Implementation for User Story 3
|
|
368
|
+
|
|
369
|
+
- [X] T037 [P] [US3] Create `lib/ruby_reactor/error/argument_resolution_error.rb`: `class ArgumentResolutionError < Base`
|
|
370
|
+
with `attr_reader :exception_class`,
|
|
371
|
+
`initialize(message, step:, original_error:, context: nil)` setting
|
|
372
|
+
`@exception_class = original_error.class.name`, and `def retryable? = false` (DM §2).
|
|
373
|
+
- [X] T038 [P] [US3] Create `lib/ruby_reactor/error/condition_error.rb`: `class ConditionError < Base`,
|
|
374
|
+
with the same shape as T037.
|
|
375
|
+
- [X] T039 [US3] In `lib/ruby_reactor/dsl/step_builder.rb`:
|
|
376
|
+
- `resolve_arguments` rescues `Error::ExecutionParked` (re-raise unchanged). Any other
|
|
377
|
+
`StandardError => e` raises
|
|
378
|
+
`Error::ArgumentResolutionError.new("Step '#{name}' could not resolve its arguments: #{e.message}", step: name, original_error: e)`,
|
|
379
|
+
with `set_backtrace(e.backtrace)`.
|
|
380
|
+
- `should_run?` wraps a raising condition or guard the same way into `Error::ConditionError`.
|
|
381
|
+
- [X] T040 [US3] In `lib/ruby_reactor/executor/compensation_manager.rb`, add
|
|
382
|
+
`RubyReactor::Error::ArgumentResolutionError` and `RubyReactor::Error::ConditionError` to
|
|
383
|
+
`NEVER_STARTED_ERROR_CLASSES`, and update the constant's comment.
|
|
384
|
+
- [X] T041 [US3] In `lib/ruby_reactor/executor/step_executor.rb`:
|
|
385
|
+
- `execute_step`: move argument resolution inside the existing `begin` so `:start_step`
|
|
386
|
+
(with `{}`) and `:failed_step` fire. On `Error::ArgumentResolutionError => e`, return
|
|
387
|
+
`@result_handler.handle_step_result(step_config, RubyReactor::Failure(e, step_name: step_config.name, reactor_name: @reactor_class.name, inputs: @context.inputs, redact_inputs: <same as safe_execute_step_sync>, step_arguments: {}, retryable: false, exception_class: e.exception_class), {})`.
|
|
388
|
+
- `safe_execute_step_sync`: add
|
|
389
|
+
`rescue Error::ArgumentResolutionError, Error::ConditionError => e` **before**
|
|
390
|
+
`rescue StandardError`, building the same `Failure(…, retryable: false, exception_class: e.exception_class)`.
|
|
391
|
+
`RetryManager` then does not retry it, and `handle_step_failure` sees a never-started error.
|
|
392
|
+
- [X] T042 [US3] In `lib/ruby_reactor/executor/result_handler.rb` `build_execution_failure`:
|
|
393
|
+
- The `Error::Base` branch adds `step_name: error.step` and
|
|
394
|
+
`reactor_name: @context.reactor_class&.name`.
|
|
395
|
+
- The `else` branch calls `@compensation_manager.rollback_completed_steps`, returns
|
|
396
|
+
`RubyReactor.Failure("Execution failed: #{error.message}", exception_class: error.class.name, step_name: @context.current_step, reactor_name: @context.reactor_class&.name)`,
|
|
397
|
+
and replaces the "don't rollback" comment with the R-07 rationale.
|
|
398
|
+
- `CompensationError` gets `step_name` through the `Error::Base` branch.
|
|
399
|
+
- [X] T043 [US3] In `lib/ruby_reactor/step_worker.rb` `perform_unit`: add
|
|
400
|
+
`rescue Error::ArgumentResolutionError, Error::ConditionError => e` before `rescue StandardError`.
|
|
401
|
+
Log `failed`, then `complete(RubyReactor.Failure(e, step_name: @step_name, reactor_name: @reactor_class_name, retryable: false, exception_class: e.exception_class), context)`.
|
|
402
|
+
- [X] T044 [US3] In `lib/ruby_reactor/executor.rb`, in both `execute` and `resume_execution`, add
|
|
403
|
+
after `rescue StandardError`:
|
|
404
|
+
`rescue Exception # rubocop:disable Lint/RescueException` →
|
|
405
|
+
`@context.status = :aborted unless @context.inline_async_execution; raise`.
|
|
406
|
+
Comment it with R-08: no rollback code runs on process-level exceptions, the caller-process run
|
|
407
|
+
is recorded as aborted for a manual undo, and a worker run is redelivered. The existing `ensure`
|
|
408
|
+
persists it.
|
|
409
|
+
- [X] T045 [P] [US3] Add `"aborted"` to `TERMINAL_STATUSES` in `lib/ruby_reactor/worker.rb`, so a
|
|
410
|
+
worker never resumes an aborted run forward.
|
|
411
|
+
- [X] T046 [P] [US3] Add `aborted` to the known-status lists in
|
|
412
|
+
`lib/ruby_reactor/storage/redis_reactor_scan.rb` (`determine_status`, ~line 70) and
|
|
413
|
+
`lib/ruby_reactor/web/api.rb` (~line 176).
|
|
414
|
+
- [X] T047 [P] [US3] Dashboard, in `gui/src/lib/reactors.ts`:
|
|
415
|
+
- Add `'aborted'` to the `errors` status group.
|
|
416
|
+
- `gui/src/components/StatusBadge.tsx`: add an `aborted` style and icon, distinct from `failed`.
|
|
417
|
+
- `gui/src/components/ReactorClassInstances.tsx`: add `<option value="aborted">Aborted</option>`.
|
|
418
|
+
- Add a case to `gui/src/lib/__tests__/reactors.test.ts`.
|
|
419
|
+
- Run `npm --prefix gui test`, then `bundle exec rake build:ui` to refresh
|
|
420
|
+
`lib/ruby_reactor/web/public/`.
|
|
421
|
+
- [X] T048 [US3] Run the US3 specs, then `spec/ruby_reactor/error_handling_spec.rb`,
|
|
422
|
+
`failure_reporting_spec.rb`, `compensation_failure_spec.rb`, `validations_spec.rb`,
|
|
423
|
+
`sweeper_spec.rb` and `undo_spec.rb`. Update only expectations that pinned the old unattributed
|
|
424
|
+
or no-rollback shapes, with a comment citing F-03/F-13.
|
|
425
|
+
|
|
426
|
+
### Documentation & demo for User Story 3
|
|
427
|
+
|
|
428
|
+
- [X] T049 [P] [US3] Update `documentation/locks_and_semaphores.md` (~777-778) so the never-started
|
|
429
|
+
list includes argument-resolution and condition errors. Update `documentation/interrupts.md`
|
|
430
|
+
(~155-157) to describe the `aborted` status and that `Reactor.undo(id)` rolls an aborted run back.
|
|
431
|
+
- [X] T050 [US3] Update `README.md` lines ~16 and ~25 (the compensation promise): every standard
|
|
432
|
+
error after completed work rolls back, and a process-level exception marks the run `aborted` for
|
|
433
|
+
a manual undo. Document the `ArgumentResolutionError`/`ConditionError` classes in the errors
|
|
434
|
+
section.
|
|
435
|
+
- [X] T051 [P] [US3] Add to `CHANGELOG.md`:
|
|
436
|
+
- Bug Fixes: argument, condition and unknown errors roll back and carry `step_name`; a
|
|
437
|
+
compensation failure carries `step_name`.
|
|
438
|
+
- Features: the `aborted` status.
|
|
439
|
+
- [X] T052 [P] [US3] Create `demo_app/app/reactors/argument_failure_demo_reactor.rb`: `reserve`
|
|
440
|
+
(with `undo`) then `charge`, whose argument `transform:` raises for a configured input.
|
|
441
|
+
- [X] T053 [US3] Add `demo:failure_rollback` to `demo_app/lib/tasks/demo_reactors.rake`. It prints
|
|
442
|
+
the undo and the failure's `step_name`.
|
|
443
|
+
- [X] T054 [P] [US3] Create `demo_app/spec/reactors/argument_failure_demo_reactor_spec.rb`
|
|
444
|
+
(`test_reactor`, `be_failure`). It asserts that `reserve` was undone and that the failure names
|
|
445
|
+
`charge`.
|
|
446
|
+
|
|
447
|
+
**Checkpoint**: US3 works independently.
|
|
448
|
+
|
|
449
|
+
---
|
|
450
|
+
|
|
451
|
+
## Phase 6: User Story 4 — An async step's rollback hooks run as declared (Priority: P2)
|
|
452
|
+
|
|
453
|
+
**Goal**:
|
|
454
|
+
|
|
455
|
+
- A unit compensates itself once, in its own job, after its final attempt fails.
|
|
456
|
+
- An inline `undo` on `async_step` is rejected.
|
|
457
|
+
- A class `undo` is warned about.
|
|
458
|
+
|
|
459
|
+
(R-09; F-04; DM §8; API §1, §5, §6)
|
|
460
|
+
|
|
461
|
+
**Independent Test**: `bundle exec rspec spec/ruby_reactor/rollback/async_step_compensate_spec.rb spec/ruby_reactor/dsl/async_step_spec.rb`,
|
|
462
|
+
matching RS §3 rows S-async-02 and S-async-07.
|
|
463
|
+
|
|
464
|
+
### Tests for User Story 4 ⚠️ write first, confirm they FAIL
|
|
465
|
+
|
|
466
|
+
- [X] T055 [P] [US4] Create `spec/ruby_reactor/rollback/async_step_compensate_spec.rb`
|
|
467
|
+
(`for_each_async_backend`, drain). Examples:
|
|
468
|
+
- (a) S-async-07: `async_step :u` with `retries max_attempts: 3`, always failing, no reader.
|
|
469
|
+
The sequence is `run:u ×3 compensate:u`, `compensate:u` is logged exactly once, and the unit
|
|
470
|
+
record has `compensation.status == "completed"`.
|
|
471
|
+
- (b) Fails once then succeeds: no `compensate:u` and no `compensation` key.
|
|
472
|
+
- (c) S-async-02: reader `r` returns `Failure`. The sequence contains `compensate:u` once,
|
|
473
|
+
`compensate:r` and `undo:a`.
|
|
474
|
+
- (d) Invalid arguments (`validate_args`) or a raising transform: no `compensate:u`.
|
|
475
|
+
- (e) The body returns `Halt`: no compensate.
|
|
476
|
+
- (f) `compensate` raises. The record has `compensation.status == "failed"` with one
|
|
477
|
+
`rollback_failures` entry, and the unit record's `result` is still the body failure.
|
|
478
|
+
- (g) The step class declares `with_lock`. The compensate re-takes the unit's lock (assert via a
|
|
479
|
+
`lock_acquired` middleware event during compensation).
|
|
480
|
+
- (h) The parent context is not written by the unit job. Mirror the assertion style in
|
|
481
|
+
`spec/ruby_reactor/async_step_single_writer_spec.rb`.
|
|
482
|
+
- (i) Middleware `start_compensation`/`complete_compensation` fire with step name `:u`.
|
|
483
|
+
- (j) Definition time:
|
|
484
|
+
- `Class.new(RubyReactor::Reactor) { async_step(:u) { run { … }; undo { … } } }` raises
|
|
485
|
+
`RubyReactor::Error::ValidationError`, with a message matching `/async_step :u/` and
|
|
486
|
+
`/compensate/`.
|
|
487
|
+
- A step class overriding `undo` used as `async_step :u, ThatClass` warns once to stderr
|
|
488
|
+
(`expect { … }.to output(/undo.*will not run/).to_stderr`) and does not raise.
|
|
489
|
+
- [X] T056 [US4] Tighten `spec/ruby_reactor/dsl/async_step_spec.rb` (~line 111, "compensates when a
|
|
490
|
+
reader inspects the failure"). Add a `compensate` to the fixture's async step that records into
|
|
491
|
+
the fixture log, and assert it ran exactly once.
|
|
492
|
+
|
|
493
|
+
### Implementation for User Story 4
|
|
494
|
+
|
|
495
|
+
- [X] T057 [US4] In `lib/ruby_reactor/step_worker.rb` `run_step`, after the retry loop:
|
|
496
|
+
- When `result.is_a?(RubyReactor::Failure)` and the error is neither an
|
|
497
|
+
`Error::InputValidationError` nor in
|
|
498
|
+
`Executor::CompensationManager::NEVER_STARTED_ERROR_CLASSES`, compensate the unit:
|
|
499
|
+
```ruby
|
|
500
|
+
manager = Executor::CompensationManager.new(context)
|
|
501
|
+
outcome = context.with_step(@step_name) { manager.compensate(step_config, result.error, arguments) }
|
|
502
|
+
```
|
|
503
|
+
- Set
|
|
504
|
+
`@compensation = { "status" => (outcome.is_a?(RubyReactor::Failure) ? "failed" : (outcome.respond_to?(:skipped?) && outcome.skipped? ? "skipped" : "completed")), "rollback_failures" => ContextSerializer.serialize_value(manager.rollback_failures), "completed_at" => Time.now.iso8601 }`.
|
|
505
|
+
- Merge `"compensation" => @compensation` into the record in `complete` when it is set (via
|
|
506
|
+
`run_fields` or next to it).
|
|
507
|
+
- Never call `save_context`/`store_context` for the parent. Comment it with the single-writer
|
|
508
|
+
rule.
|
|
509
|
+
- [X] T058 [US4] In `lib/ruby_reactor/dsl/step_builder.rb`, extract the dedupe/print part of
|
|
510
|
+
`warn_deprecation` into `warn_definition(site, message)`. It prints
|
|
511
|
+
`"[RubyReactor] #{location} #{reactor_label} #{message}"` once per location, using
|
|
512
|
+
`StepBuilder.deprecation_sites`. `warn_deprecation` keeps its exact current output by calling it
|
|
513
|
+
with its DEPRECATION prefix and suffix.
|
|
514
|
+
- [X] T059 [US4] In `lib/ruby_reactor/dsl/async_macros.rb` `async_step`, after `builder.build`:
|
|
515
|
+
- If `config.undo_block`, raise `RubyReactor::Error::ValidationError` with: "`undo` on async_step
|
|
516
|
+
:#{name} would never run: the parent never undoes an independent async unit. Put
|
|
517
|
+
failure cleanup in the reading step's `compensate`, or use a `step`/`compose`/`map` (tracked
|
|
518
|
+
for undo) or an `async_reactor` child whose steps declare `undo`." Use the wording from API §1.
|
|
519
|
+
- Elsif `impl.is_a?(Class) && impl < RubyReactor::Step && impl.instance_method(:undo).owner != RubyReactor::Step`,
|
|
520
|
+
call `builder.send(:warn_definition, caller_locations(1, 1).first, "async_step :#{name} uses #{impl}; its `undo` will not run for this async use (async units are never undone).")`.
|
|
521
|
+
- [X] T060 [US4] Grep `spec/` and `demo_app/` for `async_step` blocks that declare `undo`
|
|
522
|
+
(`grep -rn -A15 "async_step" spec demo_app/app | grep -n "undo"`). Remove or move them so the
|
|
523
|
+
suite loads. Then run `spec/ruby_reactor/dsl/async_step_spec.rb`,
|
|
524
|
+
`spec/ruby_reactor/async_step_single_writer_spec.rb`, `spec/ruby_reactor/step_contract_async_spec.rb`,
|
|
525
|
+
`spec/async_retry_dsl_spec.rb` and `spec/async_retry_integration_spec.rb`. All must be green.
|
|
526
|
+
|
|
527
|
+
### Documentation & demo for User Story 4
|
|
528
|
+
|
|
529
|
+
- [X] T061 [P] [US4] Update `documentation/background_and_async.md` (~279-292):
|
|
530
|
+
- The unit's `compensate` runs once in its own job after its final attempt, whether or not it is
|
|
531
|
+
read.
|
|
532
|
+
- `undo` is rejected (inline) or warned (class).
|
|
533
|
+
- A reader surfacing the failure compensates itself and undoes the parent, and never compensates
|
|
534
|
+
the unit twice.
|
|
535
|
+
- [X] T062 [US4] Update `README.md` ~545-550 (the async_step compensation paragraph) to match T061.
|
|
536
|
+
- [X] T063 [P] [US4] Add to `CHANGELOG.md` two BREAKING entries with migration notes (R-12 rows 2–3):
|
|
537
|
+
"`async_step` `compensate` runs in the unit's job on final failure", and "inline `undo` in
|
|
538
|
+
`async_step` raises at definition time".
|
|
539
|
+
- [X] T064 [P] [US4] Create `demo_app/app/reactors/async_step_compensate_demo_reactor.rb`: an
|
|
540
|
+
`async_step :notify` whose body always fails, with `retries max_attempts: 2` and a `compensate`
|
|
541
|
+
that records to a class-level log. Remove any inline `undo` from
|
|
542
|
+
`demo_app/app/reactors/async_step_demo_reactor.rb` if T060 found one.
|
|
543
|
+
- [X] T065 [US4] Add `demo:async_step_compensate` to `demo_app/lib/tasks/demo_reactors.rake`. It
|
|
544
|
+
drains or waits for the unit, then prints the unit record's `compensation`.
|
|
545
|
+
- [X] T066 [P] [US4] Create `demo_app/spec/reactors/async_step_compensate_demo_reactor_spec.rb`.
|
|
546
|
+
If no shipped matcher can assert the unit's compensation:
|
|
547
|
+
- Add `have_compensated_async_step(step_name)` to `lib/ruby_reactor/rspec/matchers.rb`. It reads
|
|
548
|
+
`retrieve_step_result(...)["compensation"]["status"] == "completed"`, with a
|
|
549
|
+
`.because_failed` chain for `"failed"`.
|
|
550
|
+
- Add an example for the matcher in `spec/ruby_reactor/rspec/`.
|
|
551
|
+
- Never hand-roll storage reads in the demo spec (Constitution VI).
|
|
552
|
+
|
|
553
|
+
**Checkpoint**: US4 works independently.
|
|
554
|
+
|
|
555
|
+
---
|
|
556
|
+
|
|
557
|
+
## Phase 7: User Story 5 — One rollback rule for every construct (Priority: P3)
|
|
558
|
+
|
|
559
|
+
**Goal**:
|
|
560
|
+
|
|
561
|
+
- The coordinator asks the step whether a success is tracked for undo.
|
|
562
|
+
- The F-10 table, the 007 analysis and the core docs describe one rule plus a per-construct table.
|
|
563
|
+
- The 007 harness confirms that only the intended sequences changed.
|
|
564
|
+
|
|
565
|
+
(R-10; FR-022, FR-023; SC-002, SC-007, SC-008)
|
|
566
|
+
|
|
567
|
+
**Independent Test**: `bundle exec rspec spec/ruby_reactor/rollback/rollback_rule_spec.rb`, plus the
|
|
568
|
+
007 harness printing `63 scenarios, 63 match, 0 mismatch`.
|
|
569
|
+
|
|
570
|
+
### Tests for User Story 5 ⚠️ write first, confirm they FAIL
|
|
571
|
+
|
|
572
|
+
- [X] T067 [P] [US5] Create `spec/ruby_reactor/rollback/rollback_rule_spec.rb`:
|
|
573
|
+
- `rollback_tracked?` is `true` for `step`, `compose`, `map` and `interrupt` configs, and
|
|
574
|
+
`false` for `async_step` and `async_reactor` configs. This fails until T068.
|
|
575
|
+
- A reactor `a → async_reactor child → b(fails)` still never undoes the child (INV-25 unchanged).
|
|
576
|
+
|
|
577
|
+
### Implementation for User Story 5
|
|
578
|
+
|
|
579
|
+
- [X] T068 [US5] Add `def rollback_tracked? = !async_dispatch?` to `StepConfig` in
|
|
580
|
+
`lib/ruby_reactor/dsl/step_builder.rb`. In `lib/ruby_reactor/executor/result_handler.rb`
|
|
581
|
+
`handle_success`, push only `if step_config.rollback_tracked?`. Delete `async_unit?` and move its
|
|
582
|
+
comment onto `rollback_tracked?`. Confirm `RubyReactor::Dsl::AsyncReactorBuilder#build` produces a
|
|
583
|
+
`StepConfig` with `async_dispatch` set.
|
|
584
|
+
- [X] T069 [US5] Update the `expected:` sequences of exactly the 14 scenarios in RS §3 in
|
|
585
|
+
`specs/007-execution-flow-analysis/evidence/probes/`: `01_plain.rb` (S-plain-07), `02_compose.rb`
|
|
586
|
+
(S-compose-05, 05b), `03_map.rb` (S-map-01, 03, 04, 04b, 06, 07, 08), `04_async.rb`
|
|
587
|
+
(S-async-02, 07) and `07_interrupts_manual.rb` (S-edge-03, 04).
|
|
588
|
+
- For S-edge-03, add a `note:` printing the stored status (`aborted`).
|
|
589
|
+
- Re-run
|
|
590
|
+
`bundle exec ruby specs/007-execution-flow-analysis/evidence/run.rb | tee specs/007-execution-flow-analysis/evidence/output.txt`
|
|
591
|
+
and require `63 scenarios, 63 match, 0 mismatch`.
|
|
592
|
+
- Depends on US1–US4.
|
|
593
|
+
- [X] T070 [US5] Update `specs/007-execution-flow-analysis/analysis/execution-order.md`:
|
|
594
|
+
- §1 failure-kinds table rows (argument, condition and unknown errors; non-`StandardError` →
|
|
595
|
+
`aborted`).
|
|
596
|
+
- Rule R7 text.
|
|
597
|
+
- §2 construct lifecycles for map (compensate and undo replay, settle), compose (fresh child on
|
|
598
|
+
retry) and async_step (unit-local compensate).
|
|
599
|
+
- Add the R-05 resume-entry-point audit table.
|
|
600
|
+
- Mark changed rows "changed in 008".
|
|
601
|
+
- [X] T071 [P] [US5] Update `specs/007-execution-flow-analysis/analysis/invariants.md` and
|
|
602
|
+
`specs/007-execution-flow-analysis/analysis/findings-and-options.md`:
|
|
603
|
+
- `invariants.md`: INV-06, 07, 13, 19, 20, 22 and 24 become HOLDS with 008 spec coverage cited.
|
|
604
|
+
INV-22 is restated per R-04 (left-in-place set).
|
|
605
|
+
- `findings-and-options.md`: add a "Resolved by 008" line to F-01..F-06 and F-13, and rebuild
|
|
606
|
+
the F-10 table from RS §2.
|
|
607
|
+
- [X] T072 [US5] Update `documentation/core_concepts.md` (~321-331):
|
|
608
|
+
- Add "The rollback rule" (RS §1) and the per-construct table (RS §2).
|
|
609
|
+
- `README.md` line ~35: change "Auto compensation/undo" to cover all constructs, with async units
|
|
610
|
+
independent by design.
|
|
611
|
+
- `documentation/DAG.md` (~228-240): cascading compensation now includes maps.
|
|
612
|
+
|
|
613
|
+
**Checkpoint**: all stories are complete, and the docs describe one rule.
|
|
614
|
+
|
|
615
|
+
---
|
|
616
|
+
|
|
617
|
+
## Phase 8: Polish & Cross-Cutting Concerns
|
|
618
|
+
|
|
619
|
+
- [X] T073 Add an aggregate `demo:rollback_reliability` task to
|
|
620
|
+
`demo_app/lib/tasks/demo_reactors.rake`. It depends on `:map_rollback`, `:compose_retry`,
|
|
621
|
+
`:failure_rollback` and `:async_step_compensate`. Add `:rollback_reliability` to the `demo:all`
|
|
622
|
+
prerequisite list.
|
|
623
|
+
- [X] T074 [P] Add a note to `specs/future_improvements.md`: the reactor `Sweeper` re-enqueues
|
|
624
|
+
**in-progress** inline runs (status `running`, no `async:` lock). This is pre-existing, and 008
|
|
625
|
+
only excludes `aborted` runs. Also note a possible later map-level `undo_all` override and a
|
|
626
|
+
rollback fan-out for very large maps (R-02 alternatives).
|
|
627
|
+
- [X] T075 Documentation consistency pass (REQUIRED, Constitution Development Workflow). Check every
|
|
628
|
+
row of the 007 documentation audit (`findings-and-options.md` §2) tied to F-01..F-06 or F-13
|
|
629
|
+
against the new behavior:
|
|
630
|
+
- README.md 16, 25, 35, 545-550, 1309, 1398
|
|
631
|
+
- data_pipelines.md 167
|
|
632
|
+
- composition.md 184, 195
|
|
633
|
+
- background_and_async.md 279-292
|
|
634
|
+
- core_concepts.md 321-331
|
|
635
|
+
- DAG.md 228-240
|
|
636
|
+
- locks_and_semaphores.md 777-778
|
|
637
|
+
- interrupts.md 155-157
|
|
638
|
+
|
|
639
|
+
Each must now read CONFIRMED (SC-007). Fix any stragglers.
|
|
640
|
+
- [X] T076 Consolidate the CHANGELOG. `CHANGELOG.md` Unreleased has one "Migration notes" block
|
|
641
|
+
listing every R-12 breaking row, with a before/after snippet for each.
|
|
642
|
+
- [X] T077 Run `bundle exec rubocop` and `bundle exec rspec` (full suite, alone), then
|
|
643
|
+
`bundle exec rspec spec/ruby_reactor/rollback --tag slow` (SC-006, SC-009).
|
|
644
|
+
- [X] T078 Docker demo acceptance per [quickstart.md](quickstart.md) §5, using an isolated compose
|
|
645
|
+
project (`-p rr_rollback` plus an override with unique `container_name`s and
|
|
646
|
+
`ports: !reset []`). Run `bin/rails demo:rollback_reliability` and
|
|
647
|
+
`bundle exec rspec spec/reactors/map_refund_demo_reactor_spec.rb spec/reactors/compose_retry_demo_reactor_spec.rb spec/reactors/argument_failure_demo_reactor_spec.rb spec/reactors/async_step_compensate_demo_reactor_spec.rb`,
|
|
648
|
+
then the whole demo spec suite (existing map and async demos may change behavior).
|
|
649
|
+
- [X] T079 Run [quickstart.md](quickstart.md) §1–§6 end to end and record the results
|
|
650
|
+
(SC-001..SC-009) in the PR description.
|
|
651
|
+
|
|
652
|
+
---
|
|
653
|
+
|
|
654
|
+
## Phase 9: Revision — User Story 2: a nested reactor is never retried as a whole (Priority: P1)
|
|
655
|
+
|
|
656
|
+
**Goal**: `retries` on `compose`/`async_reactor` raises at class definition. The fresh-child code is
|
|
657
|
+
deleted. A child retries its own steps (R-14, FR-009–FR-012, API §1, RS §1 rule 7).
|
|
658
|
+
|
|
659
|
+
**Independent Test**: `bundle exec rspec spec/ruby_reactor/rollback/compose_retry_spec.rb spec/ruby_reactor/step_retries`,
|
|
660
|
+
matching RS §3 rows S-compose-05 and S-compose-05b.
|
|
661
|
+
|
|
662
|
+
### Tests for User Story 2 (revision) ⚠️ write first, confirm they FAIL
|
|
663
|
+
|
|
664
|
+
- [X] T080 [P] [US2] Rewrite `spec/ruby_reactor/rollback/compose_retry_spec.rb`. Change the describe
|
|
665
|
+
text to "a nested reactor is never retried as a whole". Delete the examples "re-runs the whole
|
|
666
|
+
child from a fresh start", "undoes the final attempt's child steps on a later failure", "gives the
|
|
667
|
+
fresh child a fresh retry budget", "keeps the discarded attempt … in the parent's trace" and
|
|
668
|
+
"re-runs the child when the retry is requeued to a worker", with any fixtures only they use. Keep
|
|
669
|
+
"resumes, not retries, a child that parked after c1 completed". Add:
|
|
670
|
+
- (a) `Class.new(RubyReactor::Reactor) { compose(:child, Child) { retries max_attempts: 2 } }`
|
|
671
|
+
raises `RubyReactor::Error::DeprecatedDslError` matching `/:child/` and `/own steps/`.
|
|
672
|
+
- (b) An inline compose block (`compose :child do retries max_attempts: 2; step(:c1) { … } end`)
|
|
673
|
+
raises the same.
|
|
674
|
+
- (c) `async_reactor(:child, Child) { retries max_attempts: 2 }` raises the same.
|
|
675
|
+
- (d) Child `c1 → c2`, where `c2` declares `retries max_attempts: 2, base_delay: 0` and fails on its
|
|
676
|
+
first call only: the recorded sequence is `run:child.c1 run:child.c2 run:child.c2` (c1 once) and
|
|
677
|
+
the result is a success.
|
|
678
|
+
- (e) The same child plus a parent step `b` that fails: `… run:b compensate:b undo:child.c2
|
|
679
|
+
undo:child.c1`, and each `undo:` appears exactly once.
|
|
680
|
+
- (f) `c2` always fails (its retries run out): the child undoes `c1`, the compose fails, the
|
|
681
|
+
parent's earlier step is undone, and `run:child.c1` appears exactly once.
|
|
682
|
+
- [X] T081 [P] [US2] In `spec/ruby_reactor/step_retries/declaration_spec.rb` (~lines 69-84), change
|
|
683
|
+
"validates `retries` in a compose block" and "validates `retries` in an async_reactor block" so
|
|
684
|
+
they expect `RubyReactor::Error::DeprecatedDslError` for any `retries` call, including a valid one.
|
|
685
|
+
Rename them "rejects `retries` in a … block".
|
|
686
|
+
|
|
687
|
+
### Implementation for User Story 2 (revision)
|
|
688
|
+
|
|
689
|
+
- [X] T082 [US2] In `lib/ruby_reactor/dsl/compose_builder.rb`: remove `include RubyReactor::Dsl::Retryable`,
|
|
690
|
+
`@retry_config = nil` and the `retry_config: @retry_config` build key. Add a `retries(*)` stub
|
|
691
|
+
next to `async(*)` that raises `RubyReactor::Error::DeprecatedDslError.new(message, step: @name)`.
|
|
692
|
+
Message: "`retries` on a `compose` has been removed: a parent never retries a nested reactor as a
|
|
693
|
+
whole. Declare `retries` on the child reactor's own steps (e.g. `step :x do retries max_attempts: 3
|
|
694
|
+
… end`, or `retries` in the step class); the child retries them itself." Add a short comment
|
|
695
|
+
citing 008 R-14.
|
|
696
|
+
- [X] T083 [P] [US2] Do the same in `lib/ruby_reactor/dsl/async_reactor_builder.rb`, with "`retries` on
|
|
697
|
+
an `async_reactor`" in the message.
|
|
698
|
+
- [X] T084 [US2] In `lib/ruby_reactor/step/compose_step.rb`: delete `discard_failed_attempt` and
|
|
699
|
+
`attempt_rollback_failures`, and restore `composed_data = context.composed_contexts[step_name]` in
|
|
700
|
+
`run`. `grep -rn compose_attempt_discarded lib gui/src spec` must return nothing.
|
|
701
|
+
- [X] T085 [US2] Run `spec/ruby_reactor/rollback/compose_retry_spec.rb`,
|
|
702
|
+
`spec/ruby_reactor/step_retries/`, `spec/compose_spec.rb`,
|
|
703
|
+
`spec/nested_reactor_inline_execution_spec.rb` and `spec/ruby_reactor/step_coordination/park_spec.rb`.
|
|
704
|
+
All must be green.
|
|
705
|
+
|
|
706
|
+
### Documentation & demo for User Story 2 (revision)
|
|
707
|
+
|
|
708
|
+
- [X] T086 [P] [US2] Update `documentation/composition.md`:
|
|
709
|
+
- The inline example (~line 33): move `retries max_attempts: 3` from the compose block into the
|
|
710
|
+
`step :update_bio` block, with the comment "retries belong on the child's steps".
|
|
711
|
+
- Point 5 (~185-194): rewrite as "**No whole-child retries**": a parent never retries a nested
|
|
712
|
+
reactor; `retries` on a `compose` raises at definition time; declare `retries` on the child's
|
|
713
|
+
steps; a parked child is resumed, not re-run. Replace the example to show `retries` inside a
|
|
714
|
+
child step.
|
|
715
|
+
- The compose vs `async_reactor` table (~205): the `retries` row reads "not allowed: declare it on
|
|
716
|
+
the child's own steps" in both columns.
|
|
717
|
+
- [X] T087 [P] [US2] In `demo_app/app/reactors/compose_retry_demo_reactor.rb`, remove `retries` from
|
|
718
|
+
the `compose :reservation` block and declare `retries max_attempts: 2, base_delay: 0` at class level
|
|
719
|
+
in `ComposeRetryConfirmStep`. Update the file's header comment: `confirm` retries inside the child,
|
|
720
|
+
`reserve` runs once.
|
|
721
|
+
- [X] T088 [US2] Update `demo:compose_retry` in `demo_app/lib/tasks/demo_reactors.rake` (~line 284) to
|
|
722
|
+
print that `reserve` ran once and `confirm` twice, then the result.
|
|
723
|
+
- [X] T089 [P] [US2] Update `demo_app/spec/reactors/compose_retry_demo_reactor_spec.rb` (shipped
|
|
724
|
+
matchers only): success, `reserve` logged once, `confirm` called twice.
|
|
725
|
+
- [X] T090 [P] [US2] In `CHANGELOG.md` Unreleased: delete the Bug Fixes entry "A `compose` with
|
|
726
|
+
`retries` re-runs the whole child…" (~line 247) and replace migration note 4 (~153-160) with a
|
|
727
|
+
BREAKING entry: "`retries` on `compose`/`async_reactor` raises
|
|
728
|
+
`RubyReactor::Error::DeprecatedDslError`". Before/after snippet: `compose(:booking,
|
|
729
|
+
BookingReactor) { retries max_attempts: 2 }` → `retries max_attempts: 2` inside the child step
|
|
730
|
+
(or its class) that can fail transiently.
|
|
731
|
+
|
|
732
|
+
**Checkpoint**: US2 revision works on its own.
|
|
733
|
+
|
|
734
|
+
---
|
|
735
|
+
|
|
736
|
+
## Phase 10: Revision — User Story 6: one way to skip a step (Priority: P2)
|
|
737
|
+
|
|
738
|
+
**Goal**: `where`/`guard` are removed, and `ConditionError` with them. `Skipped` is documented as
|
|
739
|
+
"this run caused no effect" (R-15, R-17, FR-014, FR-029, API §1).
|
|
740
|
+
|
|
741
|
+
**Independent Test**: `bundle exec rspec spec/ruby_reactor/rollback/removed_dsl_spec.rb`, and
|
|
742
|
+
`grep -rn "ConditionError\|should_run?\|@conditions\|@guards" lib` returns nothing.
|
|
743
|
+
|
|
744
|
+
### Tests for User Story 6 ⚠️ write first, confirm they FAIL
|
|
745
|
+
|
|
746
|
+
- [X] T091 [P] [US6] Create `spec/ruby_reactor/rollback/removed_dsl_spec.rb`:
|
|
747
|
+
- (a) For each of `step :s do … end`, `async_step :s do … end` and `interrupt :s do … end`, and for
|
|
748
|
+
each of `where { true }` and `guard { true }`: defining the reactor raises
|
|
749
|
+
`RubyReactor::Error::DeprecatedDslError` matching `/:s/` and `/Skipped/`.
|
|
750
|
+
- (b) A step whose body returns `Skipped(:v)` lets the next step run and read `:v` through
|
|
751
|
+
`result(:s)`, and a later failure does not undo it (FR-029).
|
|
752
|
+
- [X] T092 [US6] Delete the tests that only exercise conditions, with the fixtures only they use:
|
|
753
|
+
- `spec/ruby_reactor/rollback/failure_rollback_spec.rb`: `WhereRaises`, `GuardRaises`,
|
|
754
|
+
`RetriedWhereRaises` and the three condition examples (~lines 90-110). Update the header comment.
|
|
755
|
+
- `spec/ruby_reactor/step_contract_enforcement_spec.rb`: "does not validate a step whose where is
|
|
756
|
+
false" (~297).
|
|
757
|
+
- `spec/ruby_reactor/step_retries/class_policy_spec.rb`: "makes no attempt when the step is
|
|
758
|
+
skipped by `where`" (~112).
|
|
759
|
+
- `spec/ruby_reactor/step_coordination/lock_spec.rb`: the "a step suppressed by `where` (FR-012)"
|
|
760
|
+
describe (~153), and `GuardedLockedChargeReactor` in `spec/support/reactors/step_coordination_reactors.rb`.
|
|
761
|
+
- `spec/ruby_reactor/step_coordination/single_site_spec.rb`: the "an async_step suppressed by its
|
|
762
|
+
guard" describe (~289), and `GuardedAsyncStep`, `GuardedAsyncReactor` and `ASYNC_GUARD_FLAG`
|
|
763
|
+
in `spec/support/reactors/step_coordination_reactors.rb`.
|
|
764
|
+
- `spec/ruby_reactor/dsl/reactor_background_spec.rb`: "never fires when the named step is skipped
|
|
765
|
+
by a guard" (~118), and `BackgroundSkippedTriggerReactor` in `spec/support/reactors/background_reactors.rb`.
|
|
766
|
+
|
|
767
|
+
### Implementation for User Story 6
|
|
768
|
+
|
|
769
|
+
- [X] T093 [US6] In `lib/ruby_reactor/dsl/step_builder.rb`:
|
|
770
|
+
- Remove `:conditions, :guards` from the builder's `attr_accessor` and `StepConfig`'s `attr_reader`,
|
|
771
|
+
`@conditions = []`/`@guards` initialization, the `conditions:`/`guards:` build keys and config
|
|
772
|
+
reads, and `StepConfig#should_run?`.
|
|
773
|
+
- Replace `where`/`guard` with `where(*)`/`guard(*)` stubs raising
|
|
774
|
+
`RubyReactor::Error::DeprecatedDslError.new(message, step: @name)`. Message: "`where`/`guard` have
|
|
775
|
+
been removed. To skip :<name>, return `Skipped(value)` (or call `skip!(value)`) from its `run`
|
|
776
|
+
body; the reactor continues with that value." Cite 008 R-15 in a comment.
|
|
777
|
+
- [X] T094 [P] [US6] Remove the `conditions:`/`guards:` keys from `lib/ruby_reactor/dsl/interrupt_builder.rb`,
|
|
778
|
+
`compose_builder.rb`, `map_builder.rb` and `async_reactor_builder.rb`, and from `InterruptStepConfig`
|
|
779
|
+
if it reads them.
|
|
780
|
+
- [X] T095 [US6] In `lib/ruby_reactor/executor/step_executor.rb`: remove the two `should_run?` early
|
|
781
|
+
returns (`execute_step_sync`, `execute_step_sync_without_result_handling`). In `handoff_at?`,
|
|
782
|
+
return `true` after the mode/step/`inline_async_execution` checks, and update its comment (the
|
|
783
|
+
hand-off fires whenever the named step is reached). Drop `Error::ConditionError` from the rescue
|
|
784
|
+
(~237) and fix its comment.
|
|
785
|
+
- [X] T096 [US6] In `lib/ruby_reactor/step_worker.rb`: remove the suppression block at the top of
|
|
786
|
+
`run_step` (~246-255), and drop `Error::ConditionError` from the rescue (~93) and its comment.
|
|
787
|
+
- [X] T097 [US6] Delete `lib/ruby_reactor/error/condition_error.rb` (Zeitwerk loads errors, so there is
|
|
788
|
+
no require to remove). Remove it from `NEVER_STARTED_ERROR_CLASSES` in
|
|
789
|
+
`lib/ruby_reactor/executor/compensation_manager.rb`, and from `never_started_wrapper?` in
|
|
790
|
+
`lib/ruby_reactor/executor/result_handler.rb` (only `ArgumentResolutionError` stays; fix the
|
|
791
|
+
"Argument/condition" comment). `grep -rn "ConditionError\|should_run?" lib spec` must return nothing.
|
|
792
|
+
- [X] T098 [US6] Run `removed_dsl_spec.rb`, `failure_rollback_spec.rb`, `reactor_background_spec.rb`,
|
|
793
|
+
`step_coordination/`, `step_contract_enforcement_spec.rb`, `step_retries/` and every
|
|
794
|
+
`spec/**/*interrupt*_spec.rb`. All must be green.
|
|
795
|
+
|
|
796
|
+
### Documentation & demo for User Story 6
|
|
797
|
+
|
|
798
|
+
- [X] T099 [P] [US6] Remove `where`/`guard`/`ConditionError` from the docs:
|
|
799
|
+
`documentation/background_and_async.md` (~159, the "skipped by a `where`/`guard`" bullet),
|
|
800
|
+
`documentation/DAG.md` (~248, "or a `where` condition that raises"),
|
|
801
|
+
`documentation/locks_and_semaphores.md` (~780, the `where`/`guard` clause),
|
|
802
|
+
`documentation/core_concepts.md` (Rollback Rule item 2), and `README.md` (~1432).
|
|
803
|
+
- [X] T100 [P] [US6] Document `Skipped` for rollback (R-17, FR-029) in `documentation/core_concepts.md`
|
|
804
|
+
("Skipping a single step", and Rollback Rule item 5) and `README.md` (~857). Wording: `Skipped`
|
|
805
|
+
means "this run caused no effect for this step", so it is never compensated or undone. A step
|
|
806
|
+
that finds its effect already in place and owned by this workflow (for example, a redelivered run
|
|
807
|
+
whose earlier attempt created it) returns `Success(value)` so its `undo` runs on rollback.
|
|
808
|
+
- [X] T101 [P] [US6] In `CHANGELOG.md` Unreleased: remove every `ConditionError` mention (~257) and add
|
|
809
|
+
a BREAKING entry "`where`/`guard` removed". Snippet: `where { |ctx| ctx.get_input(:enabled) }` →
|
|
810
|
+
`run { |args, ctx| next Skipped(nil) unless ctx.get_input(:enabled); … }`. Note that a
|
|
811
|
+
`background before:` hand-off at that step now always fires.
|
|
812
|
+
- [X] T102 [US6] Demo check (Constitution VI, FR-025): `demo_app/app/reactors/signal_demo_reactor.rb`,
|
|
813
|
+
its rake task and `demo_app/spec/reactors/signal_demo_reactor_spec.rb` already show a step
|
|
814
|
+
returning `Skipped`. Confirm with `grep -n "Skipped\|skip!"`. Confirm no demo file uses `where`/`guard`
|
|
815
|
+
(`grep -rnE "\b(where|guard)\s*(\{|do)" demo_app`).
|
|
816
|
+
|
|
817
|
+
**Checkpoint**: US6 works on its own.
|
|
818
|
+
|
|
819
|
+
---
|
|
820
|
+
|
|
821
|
+
## Phase 11: Revision — User Story 3: every exception rolls back except interruptions (Priority: P1)
|
|
822
|
+
|
|
823
|
+
**Goal**: every exception raised by reactor code, standard or not, fails the step and rolls back.
|
|
824
|
+
Only interruptions (`SignalException`, `SystemExit`, `NoMemoryError`, `Timeout::ExitException`)
|
|
825
|
+
skip rollback and mark an inline run `aborted`. An interruption mid-rollback leaves only the entries
|
|
826
|
+
not yet undone (R-16, FR-013, FR-016, FR-018, FR-028, DM §3/§10/§11).
|
|
827
|
+
|
|
828
|
+
**Independent Test**: `bundle exec rspec spec/ruby_reactor/rollback/failure_rollback_spec.rb spec/ruby_reactor/rollback/aborted_execution_spec.rb`,
|
|
829
|
+
matching RS §3 rows S-edge-03 and S-edge-03b.
|
|
830
|
+
|
|
831
|
+
### Tests for User Story 3 (revision) ⚠️ write first, confirm they FAIL
|
|
832
|
+
|
|
833
|
+
- [X] T103 [P] [US3] Add to `spec/ruby_reactor/rollback/failure_rollback_spec.rb` (define
|
|
834
|
+
`class Crash < Exception; end` in the spec module; add `# rubocop:disable Lint/InheritException`
|
|
835
|
+
if needed). Each on `a → b`:
|
|
836
|
+
- (a) `b`'s body raises `Crash`: `run:a run:b compensate:b undo:a`, a `Failure` is returned (no
|
|
837
|
+
raise), `step_name == :b`, `exception_class == "…Crash"`.
|
|
838
|
+
- (b) `b` is a step class with no `run` (so `NotImplementedError`): `a` undone, failure names `b`,
|
|
839
|
+
`exception_class == "NotImplementedError"`.
|
|
840
|
+
- (c) `b`'s body raises `SystemStackError`: rolled back the same way.
|
|
841
|
+
- (d) `b`'s argument transform raises `Crash`: `run:a undo:a`, `b` not compensated,
|
|
842
|
+
`exception_class` names `Crash`.
|
|
843
|
+
- (e) FR-028: `a → b → c`, `c` fails, `b`'s undo raises `Crash`: `a` is still undone, and
|
|
844
|
+
`rollback_failures` has one entry for `b` with `kind: :undo`.
|
|
845
|
+
- (f) FR-028: `b` fails and its compensate raises `Crash`: `a` is still undone, and the failure
|
|
846
|
+
names `b`.
|
|
847
|
+
- [X] T104 [P] [US3] Rework `spec/ruby_reactor/rollback/aborted_execution_spec.rb`:
|
|
848
|
+
- Change the `Crashes` fixture to `raise Interrupt` (keep the exception-identity assertion) and
|
|
849
|
+
the describe text to "a run cut short by an interruption".
|
|
850
|
+
- Add: `SystemExit` raised by a body also aborts (re-raised, status `aborted`).
|
|
851
|
+
- Add: `Timeout.timeout(0.05) { reactor.run(...) }` around a body that sleeps 1s raises
|
|
852
|
+
`Timeout::Error` to the caller, and the stored run is `aborted`.
|
|
853
|
+
- Add: interruption during rollback. `a → b → c`, `c` fails, `b`'s undo raises `Interrupt`: the
|
|
854
|
+
`Interrupt` reaches the caller, and the stored undo stack holds `a` and `b` but not `c`'s
|
|
855
|
+
entry. A manual `Reactor#undo` then records `undo:b undo:a` and nothing else.
|
|
856
|
+
|
|
857
|
+
### Implementation for User Story 3 (revision)
|
|
858
|
+
|
|
859
|
+
- [X] T105 [US3] Create `lib/ruby_reactor/error/rescuable.rb`: `module RubyReactor::Error::Rescuable`
|
|
860
|
+
with `def self.===(exception)`. It is true for any `Exception` that is not an interruption. The
|
|
861
|
+
interruptions are `SignalException`, `SystemExit`, `NoMemoryError`, and `::Timeout::ExitException`
|
|
862
|
+
when it is defined (check with `defined?` at call time, since `timeout` may load later). Add a
|
|
863
|
+
comment citing 008 R-16: why these four, and that the rest is reactor code's own failure.
|
|
864
|
+
- [X] T106 [US3] Replace `rescue StandardError` with `rescue Error::Rescuable` (qualified as each
|
|
865
|
+
file's namespace requires) at the user-code sites. Keep every more specific rescue before it,
|
|
866
|
+
unchanged:
|
|
867
|
+
- `lib/ruby_reactor/dsl/step_builder.rb` `resolve_arguments`
|
|
868
|
+
- `lib/ruby_reactor/executor/step_executor.rb` `safe_execute_step_sync`
|
|
869
|
+
- `lib/ruby_reactor/executor/compensation_manager.rb` `compensate_step` and `undo_step`
|
|
870
|
+
- `lib/ruby_reactor/executor.rb` `execute` and `resume_execution`
|
|
871
|
+
- `lib/ruby_reactor/step_worker.rb` `perform` (~97) and the body call (~373)
|
|
872
|
+
- `lib/ruby_reactor/executor/step_coordination.rb` `resolve_key` (~102)
|
|
873
|
+
- `lib/ruby_reactor/step/map_step.rb` `process_results` (~261)
|
|
874
|
+
- `lib/ruby_reactor/map/collector.rb` (~100, ~121)
|
|
875
|
+
- `lib/ruby_reactor/map/helpers.rb` `apply_collect_block` (~44)
|
|
876
|
+
- [X] T107 [US3] In `lib/ruby_reactor/executor.rb`, update the `mark_aborted` comment and the two
|
|
877
|
+
`rescue Exception` comments: only interruptions reach them now (R-16).
|
|
878
|
+
- [X] T108 [US3] In `lib/ruby_reactor/executor/compensation_manager.rb` `rollback_completed_steps`,
|
|
879
|
+
undo newest first and pop each entry only after its `undo_step` returns (`until undo_stack.empty?`
|
|
880
|
+
over `undo_stack.last`, then `undo_stack.pop`), instead of `reverse_each` plus `clear`. Comment:
|
|
881
|
+
an interruption leaves exactly the entries not yet undone for a manual undo (R-16).
|
|
882
|
+
- [X] T109 [US3] Run `failure_rollback_spec.rb`, `aborted_execution_spec.rb`, `spec/ruby_reactor/rollback/`,
|
|
883
|
+
`spec/ruby_reactor/executor/` and `spec/ruby_reactor/map/`. All must be green.
|
|
884
|
+
|
|
885
|
+
### Documentation & demo for User Story 3 (revision)
|
|
886
|
+
|
|
887
|
+
- [X] T110 [P] [US3] Docs: `documentation/interrupts.md` (~165): `aborted` only for interruptions
|
|
888
|
+
(signal, exit, out of memory, an enclosing timeout); any other exception, standard or not, rolls
|
|
889
|
+
back. `documentation/core_concepts.md` Rollback Rule: add the exception rule (RS §1 rule 6).
|
|
890
|
+
`README.md` (~1432): "Every exception after a completed step rolls back, standard or not, except
|
|
891
|
+
interruptions", with `NotImplementedError` and a custom `Exception` as examples.
|
|
892
|
+
- [X] T111 [P] [US3] `CHANGELOG.md` Unreleased: rewrite the `aborted` entries (~173, ~181) to say
|
|
893
|
+
interruptions only, and add a BREAKING entry: exceptions that are not `StandardError` (except
|
|
894
|
+
interruptions) now fail the step and roll back instead of propagating out of `Reactor.run`. Note
|
|
895
|
+
that a test assertion error raised inside a step body now surfaces as the step's `Failure`.
|
|
896
|
+
- [X] T112 [US3] Demo (FR-025): in `demo_app/app/reactors/argument_failure_demo_reactor.rb`, give
|
|
897
|
+
`ArgumentFailureChargeStep` an `argument :sku` input and make its `run` raise
|
|
898
|
+
`NotImplementedError, "legacy gateway"` when `sku == "legacy"`. Update the header comment: the
|
|
899
|
+
transform case is not compensated, the `NotImplementedError` case is compensated, and both undo
|
|
900
|
+
`reserve`. In `demo:failure_rollback` (`demo_app/lib/tasks/demo_reactors.rake` ~265), run both
|
|
901
|
+
cases and print each log and failure (`step_name`, `exception_class`).
|
|
902
|
+
- [X] T113 [P] [US3] Extend `demo_app/spec/reactors/argument_failure_demo_reactor_spec.rb` (shipped
|
|
903
|
+
matchers only) with the `legacy` case: `be_failure`, `reserve` released, charge compensated,
|
|
904
|
+
`exception_class == "NotImplementedError"`.
|
|
905
|
+
|
|
906
|
+
**Checkpoint**: US3 revision works on its own.
|
|
907
|
+
|
|
908
|
+
---
|
|
909
|
+
|
|
910
|
+
## Phase 12: Revision — User Story 5: harness and 007 references
|
|
911
|
+
|
|
912
|
+
- [X] T114 [US5] In `specs/007-execution-flow-analysis/evidence/probes/02_compose.rb`:
|
|
913
|
+
- Move `Compose05` inside the S-compose-05 scenario block, and expect
|
|
914
|
+
`=> raised(RubyReactor::Error::DeprecatedDslError)` (rescue it and return that label, as S-edge-03
|
|
915
|
+
does).
|
|
916
|
+
- Change `Compose05b` to use a child whose `c2` declares `retries: { max_attempts: 2, base_delay: 0 }`
|
|
917
|
+
with `fail_times: 1`, and no compose-level retries. Expected (RS §3): `run:child.c1 run:child.c2
|
|
918
|
+
retry:c2#1 run:child.c2 run:b compensate:b undo:child.c2 undo:child.c1 => failure(b)`. Update
|
|
919
|
+
both scenario titles.
|
|
920
|
+
- [X] T115 [US5] In `specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb`:
|
|
921
|
+
- S-edge-03 (`Crash < Exception`) expects `run:a run:b compensate:b undo:a => failure(b)`.
|
|
922
|
+
- Add S-edge-03b: `b` raises `Interrupt`, expecting `run:a run:b => raised(Interrupt)`, with a note
|
|
923
|
+
of the stored status (`aborted`).
|
|
924
|
+
- Move `Edge04` inside S-edge-04's block and expect `=> raised(RubyReactor::Error::DeprecatedDslError)`.
|
|
925
|
+
Then run `bundle exec ruby specs/007-execution-flow-analysis/evidence/run.rb | tee specs/007-execution-flow-analysis/evidence/output.txt`:
|
|
926
|
+
`64 scenarios, 64 match, 0 mismatch`, and `git diff` on the probes touches only those scenarios.
|
|
927
|
+
- [X] T116 [P] [US5] Update `specs/007-execution-flow-analysis/analysis/invariants.md` rows INV-06, 07 and
|
|
928
|
+
13 (RS §4), and the failure-kinds table in `execution-order.md` (non-`StandardError` rows, and the
|
|
929
|
+
compose-retry row of the R-05 audit table: it is now rejected at definition time).
|
|
930
|
+
|
|
931
|
+
---
|
|
932
|
+
|
|
933
|
+
## Phase 13: Revision — Polish & cross-cutting
|
|
934
|
+
|
|
935
|
+
- [X] T117 Documentation consistency pass (SC-007, SC-011): `grep -rnE "\bwhere\b|\bguard\b|ConditionError"
|
|
936
|
+
README.md documentation` and a search for `retries` inside a `compose`/`async_reactor` must hit
|
|
937
|
+
only migration notes or unrelated prose (the English word "where", the deadlock guard). Also check
|
|
938
|
+
that `aborted` is described as interruptions-only everywhere.
|
|
939
|
+
- [X] T118 Consolidate `CHANGELOG.md`: the Unreleased "Migration notes" block lists every R-12
|
|
940
|
+
breaking row, including the three revision rows, each with a before/after snippet. No entry
|
|
941
|
+
still describes compose retries re-running the child, `ConditionError`, or `aborted` for every
|
|
942
|
+
non-standard exception.
|
|
943
|
+
- [X] T119 Run `bundle exec rubocop` and `bundle exec rspec` (full suite, alone), then
|
|
944
|
+
`bundle exec rspec spec/ruby_reactor/rollback --tag slow`. Fix every spec that relied on a
|
|
945
|
+
non-`StandardError` propagating out of a step body (plan Risks).
|
|
946
|
+
- [X] T120 Demo acceptance per [quickstart.md](quickstart.md) §5 (isolated compose project): run
|
|
947
|
+
`bin/rails demo:rollback_reliability` and the four rollback demo specs, then the whole demo spec
|
|
948
|
+
suite.
|
|
949
|
+
- [X] T121 Run [quickstart.md](quickstart.md) §1–§6 end to end and record the results
|
|
950
|
+
(SC-001..SC-011) for the PR.
|
|
951
|
+
|
|
952
|
+
---
|
|
953
|
+
|
|
954
|
+
## Phase 14: Review follow-ups (2026-09-27, after `/speckit-review`)
|
|
955
|
+
|
|
956
|
+
Review answers: `Skipped` is only an instrumentation mark and has every effect of `Success`
|
|
957
|
+
(FR-029, FR-030, R-19); cap stored backtraces (FR-031, R-20); a resume is accepted only while
|
|
958
|
+
paused at an interrupt (FR-032).
|
|
959
|
+
|
|
960
|
+
- [X] T122 [US6] Specs first: `spec/ruby_reactor/dsl/reactor_background_spec.rb` (an `after:` step
|
|
961
|
+
returning `Skipped` hands off; one returning `Halt` does not), `spec/ruby_reactor/step_coordination/primitives_spec.rb`
|
|
962
|
+
(a body `Skipped` marks the period bucket), `spec/ruby_reactor/rollback/removed_dsl_spec.rb` and
|
|
963
|
+
`spec/ruby_reactor/skipped_rollback_spec.rb` (a `Skipped` step is undone like a `Success`).
|
|
964
|
+
- [X] T123 [US6] `lib/ruby_reactor/executor/result_handler.rb` `handle_skipped` goes through
|
|
965
|
+
`handle_success` (undo enrollment) and only adds the trace entry; `lib/ruby_reactor/executor/step_executor.rb`
|
|
966
|
+
`handoff_after?` excludes `Halt` instead of `Skipped`; `lib/ruby_reactor/executor/step_coordination.rb`
|
|
967
|
+
marks the period for a body `Skipped` and clears contention state on any `Error::Rescuable`.
|
|
968
|
+
- [X] T124 [P] [US6] Docs: README (signals list, "Skipping a single step"), `documentation/core_concepts.md`,
|
|
969
|
+
`documentation/background_and_async.md` (hand-off bullet), `documentation/locks_and_semaphores.md`
|
|
970
|
+
(acquisition order, period), `documentation/testing.md`; `lib/ruby_reactor.rb` `Skipped` comment.
|
|
971
|
+
- [X] T125 [P] [US3] `lib/ruby_reactor.rb` `Failure::MAX_BACKTRACE_FRAMES = 100`, with a spec in
|
|
972
|
+
`spec/ruby_reactor/failure_reporting_spec.rb`.
|
|
973
|
+
- [X] T126 [US3] Spec first, `spec/ruby_reactor/rollback/resume_guard_spec.rb` (resume during
|
|
974
|
+
compensation fails; resume of an aborted run fails); then `lib/ruby_reactor/reactor.rb` `continue`
|
|
975
|
+
rejects a non-`paused` run and persists `running` before executing.
|
|
976
|
+
- [X] T127 [P] Docs: `documentation/interrupts.md` (paused-only resume); `CHANGELOG.md` (breaking
|
|
977
|
+
`Skipped` undo with migration note, `after:` hand-off and `Halt`, period mark, backtrace cap,
|
|
978
|
+
paused-only resume, migration note 5 on validation/coordination before the body skips).
|
|
979
|
+
- [X] T128 Re-run the full suite, rubocop, the 007 harness and the Docker demo suite.
|
|
980
|
+
- [X] T129 Sync spec.md (FR-029 revised, FR-030–FR-032), research R-19/R-20, contracts.
|
|
981
|
+
- [X] T130 [US6] Revert undo enrollment of `Skipped` (user direction: skipped steps do not undo):
|
|
982
|
+
`lib/ruby_reactor/executor/result_handler.rb` `handle_skipped`, the `Skipped` comment in
|
|
983
|
+
`lib/ruby_reactor.rb`, `spec/ruby_reactor/rollback/removed_dsl_spec.rb`,
|
|
984
|
+
`spec/ruby_reactor/skipped_rollback_spec.rb`, probe S-plain-06, README, `documentation/`,
|
|
985
|
+
CHANGELOG (drop the "`Skipped` steps are undone" migration note), 007 INV-05/R5, 008 artifacts.
|
|
986
|
+
- [X] T131 Record the review's deferred items in `specs/future_improvements.md` (concurrent resume of
|
|
987
|
+
a second pending interrupt, interrupted failing-step `compensate`, same-instant resumes) and in
|
|
988
|
+
spec.md (FR-032, Assumptions).
|
|
989
|
+
|
|
990
|
+
---
|
|
991
|
+
|
|
992
|
+
## Dependencies & Execution Order
|
|
993
|
+
|
|
994
|
+
### Phase Dependencies
|
|
995
|
+
|
|
996
|
+
- **Setup (T001–T003)**: none.
|
|
997
|
+
- **Foundational (T004–T009)**: after Setup. **Blocks every story.**
|
|
998
|
+
- **US1, US2, US3, US4**: each depends only on Foundational, so they can run in parallel.
|
|
999
|
+
- **US5**: T067/T068 depend only on Foundational. T069–T072 depend on US1–US4 (the harness and
|
|
1000
|
+
docs describe their final behavior).
|
|
1001
|
+
- **Polish (T073–T079)**: after all stories.
|
|
1002
|
+
- **Revision (T080–T121)**: after T079. Phases 9 (US2), 10 (US6) and 11 (US3) are independent of each
|
|
1003
|
+
other, except for the shared files listed below. Phase 12 needs 9–11. Phase 13 comes last.
|
|
1004
|
+
|
|
1005
|
+
### Shared-file ordering (tasks without [P] across stories)
|
|
1006
|
+
|
|
1007
|
+
These files are edited by more than one story. Serialize the edits in story order, or rebase before
|
|
1008
|
+
editing:
|
|
1009
|
+
|
|
1010
|
+
- `README.md`: T022, T050, T062, T072, T075
|
|
1011
|
+
- `CHANGELOG.md`: T023, T031, T051, T063, T076
|
|
1012
|
+
- `demo_app/lib/tasks/demo_reactors.rake`: T025, T033, T053, T065, T073
|
|
1013
|
+
- `lib/ruby_reactor/dsl/step_builder.rb`: T004, T007, T039, T058, T068
|
|
1014
|
+
- `lib/ruby_reactor/step_worker.rb`: T006, T043, T057
|
|
1015
|
+
- `lib/ruby_reactor/executor/compensation_manager.rb`: T008, T040
|
|
1016
|
+
- `lib/ruby_reactor/executor/result_handler.rb`: T042, T068, T097
|
|
1017
|
+
- Revision: `CHANGELOG.md` T090, T101, T111, T118; `README.md` T099, T100, T110;
|
|
1018
|
+
`documentation/core_concepts.md` T099, T100, T110; `step_builder.rb` T093, T106;
|
|
1019
|
+
`step_executor.rb` T095, T106; `step_worker.rb` T096, T106; `compensation_manager.rb` T097, T106,
|
|
1020
|
+
T108; `compose_builder.rb` / `async_reactor_builder.rb` T082/T083, T094;
|
|
1021
|
+
`failure_rollback_spec.rb` T092, T103
|
|
1022
|
+
|
|
1023
|
+
### Within each story
|
|
1024
|
+
|
|
1025
|
+
The spec tasks (FAIL first) come before the implementation, then a green run of the story's specs,
|
|
1026
|
+
then docs, demo and CHANGELOG. The story is not done until its docs and demo land (Constitution
|
|
1027
|
+
Development Workflow and VI).
|
|
1028
|
+
|
|
1029
|
+
### Story independence
|
|
1030
|
+
|
|
1031
|
+
- **US1**: needs only T008 (compensate runs under `with_step`).
|
|
1032
|
+
- **US2**: needs no other story.
|
|
1033
|
+
- **US3**: US4's T057 treats a never-started error as not compensated. Without US3's classes, a
|
|
1034
|
+
worker-side resolution error still never reaches the compensate call, because it raises before
|
|
1035
|
+
the body.
|
|
1036
|
+
- **US4**: independent of US3.
|
|
1037
|
+
- **US5**: T068 is independent. Its doc and harness tasks come last.
|
|
1038
|
+
|
|
1039
|
+
---
|
|
1040
|
+
|
|
1041
|
+
## Parallel Examples
|
|
1042
|
+
|
|
1043
|
+
```text
|
|
1044
|
+
# After Phase 2, write the failing specs of all P1 stories together:
|
|
1045
|
+
T010 map_rollback_spec.rb T011 map_fan_out_settle_spec.rb T012 map_scale_spec.rb
|
|
1046
|
+
T027 compose_retry_spec.rb T035 failure_rollback_spec.rb T036 aborted_execution_spec.rb
|
|
1047
|
+
|
|
1048
|
+
# US1 implementation: independent files in parallel
|
|
1049
|
+
T015 redis_adapter.rb T019 result_enumerator.rb (then T013/T014/T016/T017/T018)
|
|
1050
|
+
|
|
1051
|
+
# US3 implementation: new files and status lists in parallel
|
|
1052
|
+
T037 argument_resolution_error.rb T038 condition_error.rb T045 worker.rb T046 scan/api T047 gui
|
|
1053
|
+
|
|
1054
|
+
# Demo artifacts per story ([P]): reactor + spec in parallel, rake task after
|
|
1055
|
+
T024 + T026 → T025
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
---
|
|
1059
|
+
|
|
1060
|
+
## Implementation Strategy
|
|
1061
|
+
|
|
1062
|
+
### MVP (US1 only)
|
|
1063
|
+
|
|
1064
|
+
1. Phase 1 → Phase 2 (T009 green, no behavior change).
|
|
1065
|
+
2. Phase 3 (US1): map rollback in both modes, plus its docs and demo.
|
|
1066
|
+
3. **Stop and validate**: quickstart §1 for the map specs, plus demo `demo:map_rollback`. This
|
|
1067
|
+
closes the widest gap (F-01, F-05).
|
|
1068
|
+
|
|
1069
|
+
### Incremental delivery
|
|
1070
|
+
|
|
1071
|
+
After the MVP, in order. Each step is one PR-sized increment with its own docs, demo and CHANGELOG:
|
|
1072
|
+
|
|
1073
|
+
1. US3 (failures): the smallest change. It gives every later failure its attribution.
|
|
1074
|
+
2. US2 (compose retry).
|
|
1075
|
+
3. US4 (async_step).
|
|
1076
|
+
4. US5 (one rule, harness and 007 docs refresh).
|
|
1077
|
+
5. Polish.
|
|
1078
|
+
|
|
1079
|
+
### Revision order (T080–T121)
|
|
1080
|
+
|
|
1081
|
+
1. US6 first (T091–T102). It deletes code and specs that US3's rescue swap would otherwise have to
|
|
1082
|
+
touch.
|
|
1083
|
+
2. US2 (T080–T090).
|
|
1084
|
+
3. US3 (T103–T113).
|
|
1085
|
+
4. Phase 12, then Phase 13.
|
|
1086
|
+
|
|
1087
|
+
### Commits
|
|
1088
|
+
|
|
1089
|
+
- Breaking items use `feat!`/`fix!` with a `BREAKING CHANGE:` footer (R-12): T013/T014 (map), T057
|
|
1090
|
+
(async compensate) and T059 (inline `undo` rejected).
|
|
1091
|
+
- Everything else uses `feat:`/`fix:`/`docs:`/`test:`/`refactor:` (Phase 2 is `refactor:`).
|
|
1092
|
+
- Revision breaking commits (`feat!:` + `BREAKING CHANGE:` footer): T082/T083 (`retries` on nested
|
|
1093
|
+
reactors removed), T093 (`where`/`guard` removed), T106 (non-`StandardError` exceptions roll back).
|
|
1094
|
+
|
|
1095
|
+
---
|
|
1096
|
+
|
|
1097
|
+
## Phase 15: Convergence
|
|
1098
|
+
|
|
1099
|
+
From `/speckit-review` (2026-09-30), findings F1–F7. Each fix is spec-first against real Redis;
|
|
1100
|
+
the review probes (P1, P1b, P2, P3, P5, P6) are the failing cases.
|
|
1101
|
+
|
|
1102
|
+
- [X] T132 CRITICAL Spec first in `spec/ruby_reactor/rollback/aborted_execution_spec.rb`: an interruption raised during a rollback that runs from the executor's `rescue Error::Rescuable` body (a step-argument validation failure after `a` completed, `a`'s undo interrupted; and the same on `resume_execution`) stores the run `aborted`, keeps the not-yet-undone entries, and `RubyReactor::Sweeper.run_once` leaves it alone; then make `lib/ruby_reactor/executor.rb` `execute`/`resume_execution` mark aborted for interruptions raised inside the rescue body per Constitution II, FR-018, Edge Cases (interruption during a running rollback) (contradicts)
|
|
1103
|
+
- [X] T133 CRITICAL Spec first in `spec/ruby_reactor/rollback/aborted_execution_spec.rb`: after an interruption inside a composed child (`a → compose(c1 → c2 raises Interrupt)`) and inside an inline map element (`a → map[0,1,2]`, element 1 interrupted), `Reactor.undo(id)` undoes `child.c1` / `e.e1[0]` before `a`; then, when a run is aborted while a `compose` or inline `map` step is in flight, track that step for undo (`ComposeStep#undo` / `MapStep#undo` replay from stored state) per Constitution II, FR-018, US3/AC5, SC-004 (partial)
|
|
1104
|
+
- [X] T134 Spec first in `spec/ruby_reactor/rollback/resume_guard_spec.rb`: a `continue` that hits reactor-level lock or semaphore contention raises the contention error and leaves the run `paused`, and a `continue` after the holder releases resumes it; then fix `lib/ruby_reactor/reactor.rb` `continue` (restore `paused` and save on contention before re-raising) per FR-032 (partial)
|
|
1105
|
+
- [X] T135 Spec first in `spec/ruby_reactor/rollback/map_rollback_spec.rb` (inline) and `spec/ruby_reactor/rollback/map_fan_out_settle_spec.rb` (fan-out): with the map's element index expired while the parent is alive, every completed element is reported `reason: :context_unavailable`, never skipped silently; then store the element count on the map's undo record (one Integer: `lib/ruby_reactor/map/helpers.rb` fan-out entry, derived from the source for inline) and have `lib/ruby_reactor/step/map_step.rb` `completed_elements` report each index the stored ids do not cover per FR-005, FR-008, plan Risks (element contexts that expire) (partial)
|
|
1106
|
+
- [X] T136 Spec first in `spec/ruby_reactor/rollback/map_fan_out_settle_spec.rb`: a collector that finds `map_collect:<map_id>` held re-enqueues itself with a short delay instead of dropping, so a fail-fast failure is applied without `Map::Sweeper`; then fix `lib/ruby_reactor/map/collector.rb` `perform` (and the router call it needs) per FR-003, US1/AC3, research R-04 (partial)
|
|
1107
|
+
- [X] T137 Spec first: the same element-undo failure gives the same top-level `Failure` shape (`exception_class`, `step_name`, message) in inline and fan-out mode, with identical `rollback_failures`; then align `lib/ruby_reactor/map/helpers.rb` `resume_parent_execution` with the inline `CompensationError` path per FR-005, US1/AC5, FR-017 (partial)
|
|
1108
|
+
- [X] T138 [P] Delete the stale "`compose` with `retries`" row from the Rollback Rule table in `documentation/core_concepts.md` per FR-024, SC-011 (contradicts)
|
|
1109
|
+
- [X] T139 [P] Docs and CHANGELOG for T132–T137: `documentation/interrupts.md` (aborted undo covers an in-flight compose child / inline map element; a contended `continue` stays `paused` and can be retried), `documentation/locks_and_semaphores.md` (contention on `continue`), `documentation/data_pipelines.md` (`context_ttl` horizon counted from the map's run; missing elements reported), `CHANGELOG.md` `### Fixed` entries per Constitution Development Workflow, FR-024, FR-026 (missing)
|
|
1110
|
+
- [X] T140 Re-run the full gem suite, `bundle exec rubocop`, the four rollback demo specs (`demo_app`, Redis DB 5) and the 007 harness; confirm probes P1–P6 now pass as regression specs per SC-009 (missing)
|