ruby_reactor 0.8.5 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/.specify/memory/constitution.md +17 -14
  5. data/CHANGELOG.md +58 -0
  6. data/CLAUDE.md +1 -1
  7. data/README.md +23 -8
  8. data/lib/ruby_reactor/adapters/active_job/map_element_rollback_worker.rb +20 -0
  9. data/lib/ruby_reactor/adapters/active_job/router.rb +32 -4
  10. data/lib/ruby_reactor/adapters/sidekiq/map_element_rollback_worker.rb +16 -0
  11. data/lib/ruby_reactor/adapters/sidekiq/router.rb +32 -4
  12. data/lib/ruby_reactor/context.rb +14 -1
  13. data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
  14. data/lib/ruby_reactor/dsl/map_builder.rb +27 -7
  15. data/lib/ruby_reactor/dsl/step_builder.rb +8 -21
  16. data/lib/ruby_reactor/error/rescuable.rb +6 -1
  17. data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
  18. data/lib/ruby_reactor/executor/compensation_manager.rb +33 -9
  19. data/lib/ruby_reactor/executor/result_handler.rb +72 -52
  20. data/lib/ruby_reactor/executor/retry_manager.rb +4 -3
  21. data/lib/ruby_reactor/executor/step_executor.rb +3 -1
  22. data/lib/ruby_reactor/executor.rb +186 -6
  23. data/lib/ruby_reactor/map/collector.rb +25 -93
  24. data/lib/ruby_reactor/map/dispatcher.rb +38 -7
  25. data/lib/ruby_reactor/map/element_executor.rb +14 -10
  26. data/lib/ruby_reactor/map/element_rollback.rb +158 -0
  27. data/lib/ruby_reactor/map/helpers.rb +11 -155
  28. data/lib/ruby_reactor/map/step_rollback.rb +136 -0
  29. data/lib/ruby_reactor/map/sweeper.rb +66 -8
  30. data/lib/ruby_reactor/map.rb +17 -0
  31. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
  32. data/lib/ruby_reactor/reactor.rb +41 -2
  33. data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
  34. data/lib/ruby_reactor/rspec/matchers.rb +40 -21
  35. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
  36. data/lib/ruby_reactor/rspec/test_subject.rb +13 -3
  37. data/lib/ruby_reactor/rspec.rb +3 -6
  38. data/lib/ruby_reactor/step/compose_step.rb +3 -0
  39. data/lib/ruby_reactor/step/map_step.rb +117 -114
  40. data/lib/ruby_reactor/storage/adapter.rb +14 -0
  41. data/lib/ruby_reactor/storage/redis_adapter.rb +18 -2
  42. data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
  43. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  44. data/lib/ruby_reactor/sweeper.rb +3 -1
  45. data/lib/ruby_reactor/version.rb +1 -1
  46. data/lib/ruby_reactor/web/api.rb +11 -1
  47. data/lib/ruby_reactor/web/public/assets/{index-CQbgHtd0.js → index-C6ejszCQ.js} +12 -12
  48. data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
  49. data/lib/ruby_reactor/web/public/index.html +2 -2
  50. data/lib/ruby_reactor/worker.rb +4 -2
  51. data/specs/007-execution-flow-analysis/evidence/harness.rb +2 -0
  52. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +4 -1
  53. data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
  54. data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
  55. data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
  56. data/specs/009-distributed-map-undo/data-model.md +151 -0
  57. data/specs/009-distributed-map-undo/plan.md +323 -0
  58. data/specs/009-distributed-map-undo/pr-description.md +78 -0
  59. data/specs/009-distributed-map-undo/quickstart.md +154 -0
  60. data/specs/009-distributed-map-undo/research.md +585 -0
  61. data/specs/009-distributed-map-undo/spec.md +360 -0
  62. data/specs/009-distributed-map-undo/tasks.md +1060 -0
  63. data/specs/future_improvements.md +18 -30
  64. metadata +21 -3
  65. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +0 -1
@@ -0,0 +1,1060 @@
1
+ ---
2
+
3
+ description: "Task list for 009 Distributed Map Rollback and Bounded Fan-out"
4
+ ---
5
+
6
+ # Tasks: Distributed Map Rollback and Bounded Fan-out
7
+
8
+ **Input**: Design documents from `specs/009-distributed-map-undo/`
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 by Constitution III (test-first, real Redis). In every phase, write the spec
14
+ tasks first and confirm they FAIL on the current code before implementing.
15
+
16
+ **Revised 2026-09-30 after `/speckit-analyze`**: every finding is addressed in place, with the
17
+ finding IDs cited (C1…A3). The task IDs are unchanged. T076 was repurposed to the leftover
18
+ `fail_fast` sweep (G4), because its log-shape spec moved to T020(g) (C3). The constitution's
19
+ worker-path drift (C5) was amended directly, as version 1.3.1.
20
+
21
+ **Citations**:
22
+
23
+ - research decisions: `R-nn`;
24
+ - data-model sections: `DM §n`;
25
+ - rollback protocol invariants and sequences: `I-n` / `S-n` (contracts/rollback-protocol.md);
26
+ - API contract: `API` (contracts/api-surface.md).
27
+
28
+ ## Format: `[ID] [P?] [Story] Description`
29
+
30
+ - **[P]**: can run in parallel (different files, no dependency on an incomplete task)
31
+ - **[Story]**: US1–US4 from spec.md
32
+
33
+ ## Path Conventions
34
+
35
+ Single gem project: `lib/ruby_reactor/`, `spec/`, `demo_app/`, `gui/`, `documentation/`.
36
+
37
+ **Test rules**:
38
+
39
+ - Run specs against the test Redis at `redis://localhost:6780`.
40
+ - Async paths use `for_each_async_backend` (from `spec/support/async_backends.rb`) plus
41
+ `drain_async_jobs`.
42
+ - Add no new `Sidekiq::Testing.inline!`, except T041, which tests inline mode itself and is
43
+ justified in plan.md Complexity Tracking. Specs that already use `inline!` (`map_recovery_spec`,
44
+ `map_batch_size_spec`, `fail_fast_spec`) keep it, but new examples in them run in fake mode.
45
+ - Don't run the gem suite and the demo suite at the same time: they flush the same Redis. If
46
+ another worktree's suite shares the test Redis, rerun failures alone first.
47
+ - Demo specs locally: `cd demo_app && REDIS_URL=redis://localhost:6780/5 RAILS_ENV=test bundle exec rspec …`.
48
+
49
+ ---
50
+
51
+ ## Phase 1: Setup (Shared Infrastructure)
52
+
53
+ **Purpose**: test scaffolding shared by every story's specs.
54
+
55
+ - [X] T001 Create `spec/support/map_rollback_fixtures.rb`, module `MapRollbackFixtures`, built on
56
+ `RollbackRecorder` (`spec/support/rollback_recorder.rb`).
57
+ - **Element classes**:
58
+ - `Elem` (steps e1, e2; e2 fails when `inputs.i == fail_at`, passed in as input);
59
+ - `ElemOk` (e1, e2);
60
+ - `ElemUndoFails` (the e1 undo fails for `i == 1`);
61
+ - `ElemInterruptOnce`: the e1 undo raises `Interrupt` the first time it runs for `i == 0`,
62
+ tracked in a class-level Set, and succeeds after that.
63
+ - **Builder**: `MapRollbackFixtures.parent(namespace, name, element_class, fan_out: false,
64
+ batch_size: nil, b_fails: false, atomic: true, collect: nil)`. It defines
65
+ `recording_step :a` → `map :m` (`source input(:items)`, `argument :i, element(:m)`, the given
66
+ options) → `recording_step :b, after: :m, fail: b_fails`, and sets the constant under
67
+ `namespace`. The oracle specs (I-7) build the same reactor with `fan_out: false` and with
68
+ `fan_out: true`.
69
+ - [X] T002 [P] Create `spec/support/queue_probe.rb`, module `QueueProbe`, for back-pressure
70
+ assertions, backend-agnostic like `AsyncTestHelpers`:
71
+ - `QueueProbe.enqueued(worker_class_name)` returns the count of jobs of that class in the Sidekiq
72
+ fake queues or the ActiveJob test adapter's `enqueued_jobs`.
73
+ - `QueueProbe.drain_tracking(worker_class_name)` performs queued jobs of every class one at a
74
+ time (FIFO) until none remain, and returns `{ max_burst: }`: the largest number of jobs of that
75
+ class enqueued while a single job ran. That includes the owner job's first throw.
76
+ - Queue depth is deliberately not reported: the back-pressure bound is per throw (FR-002, R-02),
77
+ and a FIFO drain would make any depth assertion pass by accident.
78
+ - [X] T003 [P] Baseline, with no file changes. Run
79
+ `bundle exec rspec spec/map spec/ruby_reactor/rollback spec/ruby_reactor/map spec/compose_spec.rb spec/single_worker_map_spec.rb; bundle exec ruby specs/007-execution-flow-analysis/evidence/run.rb | tail -1`
80
+ and record the pass counts and the evidence tail line in the PR description draft. They are the
81
+ regression bar for T092.
82
+
83
+ ---
84
+
85
+ ## Phase 2: Foundational (Blocking Prerequisites)
86
+
87
+ **Purpose**: the pieces every story builds on:
88
+
89
+ - map metadata that knows its owner and batch size;
90
+ - save-before-release;
91
+ - argument-free map undo records;
92
+ - the `atomic` internal rename with legacy normalization;
93
+ - the owner-resume + adopt-on-re-entry mechanism (R-03) that US1's map-failure rollback and US2
94
+ both need.
95
+
96
+ **⚠️ CRITICAL**: No user story work can begin until this phase is complete.
97
+
98
+ ### Specs first
99
+
100
+ - [X] T004 [P] Write `spec/ruby_reactor/executor/context_lock_save_order_spec.rb` (R-13, I-6).
101
+ Run a reactor through `RubyReactor::Worker` (fake mode) whose resume ends with a
102
+ `DispatchResult`. Spy (`and_call_original`) on `RubyReactor::Lock#release` for the
103
+ `async:<id>` key and on `storage.store_context` for that id, and record their call order. Assert
104
+ the last `store_context` happens before the context lock's `release`. It must FAIL on current
105
+ code (the release happens first).
106
+ - [X] T005 [P] Write `spec/map/map_owner_resume_spec.rb` (R-03, S-1), with a root-level fan-out
107
+ map, `for_each_async_backend`:
108
+ - (a) after the last element, the collector enqueues exactly one `Worker` job for the owner id,
109
+ and `Map::Collector.perform` never calls `storage.store_context` (spy);
110
+ - (b) two collector deliveries for a settled map enqueue one owner `Worker` (`owner_signalled`);
111
+ - (c) running the owner `Worker` before the map settles returns a `DispatchResult`, enqueues no
112
+ new `MapElementWorker` job, and leaves `map_operations` unchanged;
113
+ - (d) an atomic element failure fails the run through the executor: `be_failure`, same
114
+ message and `rollback_failures` as before the change (copy expectations from
115
+ `spec/map/map_fail_fast_spec.rb`);
116
+ - (e) map metadata written by an older version (delete `owner_context_id` /
117
+ `owner_reactor_class_name` from the metadata hash before the collector runs) falls back to
118
+ `parent_context_id` and the run completes.
119
+ - [X] T006 [P] Write `spec/map/map_legacy_payload_spec.rb` (R-11, FR-023, S-6):
120
+ - Enqueue a `MapElementWorker` job by hand with the pre-upgrade payload shape (string keys,
121
+ `"fail_fast" => true`, no `"atomic"`) for a map whose element fails. Assert the map is
122
+ atomic: failed context id stored, remaining indexes `_skipped`.
123
+ - Do the same through `Dispatcher.perform` and `Collector.perform` with legacy args.
124
+ - [X] T007 [P] Extend `spec/map/map_recovery_spec.rb` with the latent bug from research §Current
125
+ behavior: a map with `batch_size 3`, atomic, one element job dropped. After
126
+ `Map::Sweeper.run_once`, the re-dispatched element job's args carry `batch_size: 3` and
127
+ `atomic: true`, and the map settles.
128
+ - [X] T008 [P] Add to `spec/ruby_reactor/rollback/map_rollback_spec.rb`: after an inline map
129
+ completes, the parent's undo stack entry for `:m` has `arguments == {}` (R-14). The source array
130
+ is not stored in the parent blob: assert the serialized context does not contain the source
131
+ items' marker values.
132
+
133
+ ### Implementation
134
+
135
+ - [X] T009 [P] Create `lib/ruby_reactor/map.rb`, the Zeitwerk namespace file:
136
+ `module RubyReactor; module Map; DEFAULT_BATCH_SIZE = 50; ROLLBACK_CHUNK = 100; end; end`
137
+ (R-10, R-01, DM §7). Confirm `bin/console`/specs still load `RubyReactor::Map::Collector` etc.
138
+ - [X] T010 In `lib/ruby_reactor/executor.rb` `#resume_execution` `ensure`, move
139
+ `save_context unless skip_context_persist?` above `@acquired_context_lock&.release` (R-13).
140
+ T004 passes. Keep `release_locks` and `leave_ordered_lock_scope` order unchanged.
141
+ - [X] T011 Storage metadata and owner signal (DM §2, §3):
142
+ - `lib/ruby_reactor/storage/redis_adapter.rb#initialize_map_operation` accepts and stores
143
+ `owner_context_id:`, `owner_reactor_class_name:`, `batch_size:`, `atomic:`.
144
+ - Add `claim_map_owner_signal(map_id, reactor_class_name)`: `SET NX EX durability_ttl` on
145
+ `reactor:<P>:map:<map_id>:owner_signalled`, returning true when claimed.
146
+ - Declare it in `lib/ruby_reactor/storage/adapter.rb`.
147
+ - Unit examples in `spec/ruby_reactor/storage/redis_adapter_spec.rb`.
148
+ - [X] T012 Internal rename `fail_fast` → `atomic`, with legacy normalization (R-11). The DSL
149
+ alias and warning come in US4.
150
+ - Add `Map::Helpers.normalize_arguments(arguments)` in `lib/ruby_reactor/map/helpers.rb`
151
+ (module function). It symbolizes keys, and sets `:atomic` from `:fail_fast` when
152
+ `:atomic` is absent, then deletes `:fail_fast`.
153
+ - Call it first in `Map::ElementExecutor.perform`, `Map::Dispatcher.perform` and
154
+ `Map::Collector.perform`, and where `lib/ruby_reactor/executor/retry_manager.rb` reads
155
+ `map_args`.
156
+ - Rename every internal use to `atomic`:
157
+ - the `MapStep` input and `inputs.fail_fast` reads (`lib/ruby_reactor/step/map_step.rb`);
158
+ - `Dispatcher` (`dispatch_batch`, `requeue_index`, `queue_element_job`);
159
+ - `ElementExecutor` (`check_fail_fast?` → `check_atomic?`, `handle_result`,
160
+ `requeue_parked_element`);
161
+ - `lib/ruby_reactor/executor/result_handler.rb#store_failed_map_context`
162
+ (`map_metadata[:atomic]`);
163
+ - the `perform_map_element_async` / `perform_map_element_in` kwargs and payload key in
164
+ `lib/ruby_reactor/adapters/sidekiq/router.rb` and `lib/ruby_reactor/adapters/active_job/router.rb`;
165
+ - `build_step_config` argument key in `lib/ruby_reactor/dsl/map_builder.rb`. Keep the
166
+ `fail_fast` DSL method setting the same ivar for now.
167
+ - T006 passes.
168
+ - [X] T013 Argument-free map undo records (R-14). This is not parallel with T012: both edit
169
+ `map_step.rb` and `result_handler.rb`.
170
+ - Add `StepConfig#rollback_arguments(resolved)` in `lib/ruby_reactor/dsl/step_builder.rb`. It
171
+ returns `impl.rollback_arguments(resolved)` when the impl responds to it, else `resolved`.
172
+ - Use it at every `add_to_undo_stack` push: `lib/ruby_reactor/executor/result_handler.rb` (two
173
+ sites) and `lib/ruby_reactor/executor/step_executor.rb#track_interrupted_construct`.
174
+ - Add `def self.rollback_arguments(_resolved) = {}` to `RubyReactor::Step::MapStep`.
175
+ - T008 passes.
176
+ - [X] T014 Map metadata carries owner and batch size (R-03, R-10), in
177
+ `lib/ruby_reactor/step/map_step.rb`:
178
+ - Add `#owner_context`, returning `context.root_context || context`.
179
+ - Add `#effective_batch_size`, returning `inputs.batch_size || inputs.source.size` (today's
180
+ behavior; US3 changes this one method).
181
+ - Pass `owner_context_id`, `owner_reactor_class_name` (`RubyReactor.reactor_storage_name`),
182
+ `batch_size: effective_batch_size` and `atomic` into `initialize_map_operation`.
183
+ - `dispatch_async_map` uses `effective_batch_size`.
184
+ - T007 passes, with `Dispatcher.requeue_index` now finding both fields.
185
+ - [X] T015 Collector signals the owner and writes nothing (R-03, S-1), in
186
+ `lib/ruby_reactor/map/collector.rb#perform_collection`:
187
+ - Keep the settle check (`results_count < total_count` → return).
188
+ - Remove the parent-context load, the idempotency check on the parent, `handle_failure`,
189
+ `apply_collect_block` and `resume_parent_execution`.
190
+ - When settled: `return unless storage.claim_map_owner_signal(map_id, class)`, then
191
+ `async_router.perform_async(owner_id, owner_class)`. The owner comes from the metadata, falling
192
+ back to `parent_context_id` / `parent_reactor_class_name` (S-6).
193
+ - Log `event=ruby_reactor.map.settled map_id=… owner=…`.
194
+ - [X] T016 `MapStep#run` adopts a settled map on re-entry (R-03, S-1), in
195
+ `lib/ruby_reactor/step/map_step.rb`:
196
+ - If `context.map_operations[step]` is present, call `adopt_dispatched_map(step)` instead of
197
+ `run_async`.
198
+ - Not settled (`storage.count_map_results < metadata count`): return a `DispatchResult` for
199
+ `"map:#{map_id}"` without dispatching.
200
+ - Settled with `retrieve_map_failed_context_id`: load that element context
201
+ (`resolve_reactor_class(metadata["reactor_class_info"])`), and return its `failure_reason` as a
202
+ `Failure` carrying its `rollback_failures`. The logic comes from the deleted
203
+ `Collector.handle_failure`.
204
+ - Otherwise: `Success(collect_block ? collect_block.call(ResultEnumerator) : ResultEnumerator)`,
205
+ rescuing `Error::Rescuable` into `Failure`, as the deleted `Collector.apply_collect_block` did.
206
+ Also set `composed_contexts[step][:started] = metadata count` (from the deleted
207
+ `Helpers#record_elements_started`).
208
+ - T005 passes.
209
+ - [X] T017 Delete dead code in `lib/ruby_reactor/map/helpers.rb`: `resume_parent_execution`,
210
+ `resume_parked_aware`, `record_elements_started`, `store_parent` and `apply_collect_block`, once
211
+ `grep -rn` shows no callers. Remove the ponytail note about composed children.
212
+ - [X] T018 Run `bundle exec rspec spec/map spec/ruby_reactor/map spec/ruby_reactor/rollback spec/compose_spec.rb spec/single_worker_map_spec.rb`.
213
+ It must reach the T003 bar or better; fix regressions in the files above only.
214
+
215
+ **Checkpoint**:
216
+
217
+ - map completion resumes the owner run;
218
+ - the collector writes no context;
219
+ - legacy payloads work;
220
+ - US1–US4 can start.
221
+
222
+ ---
223
+
224
+ ## Phase 3: User Story 1 - Roll back a large fan-out map the way it ran (Priority: P1) 🎯 MVP
225
+
226
+ **Goal**: A fan-out map's compensate/undo dispatches one rollback job per completed element,
227
+ `batch_size` per throw. The run hands off as `rolling_back` and resumes when the last element
228
+ reports. It finishes with the same `Failure` the inline path gives. Inline maps roll back in
229
+ process in chunks of 100.
230
+
231
+ **Independent Test**: a fan-out map of N elements, `batch_size B`, and a step after the map
232
+ fails. Each completed element is rolled back by its own job, no throw enqueues more than B, the
233
+ steps before the map are undone after the last element, and the run ends `failed` with every
234
+ element undone once.
235
+
236
+ ### Tests for User Story 1 ⚠️ (write first, confirm FAIL)
237
+
238
+ - [X] T019 [P] [US1] Write `spec/ruby_reactor/executor/resume_rollback_spec.rb` (R-04), with a
239
+ stub construct: a step class `HandsOffOnce` whose `undo` raises
240
+ `RubyReactor::Error::RollbackHandedOff.new(map_id: "stub")` while a Redis key `stub:pending`
241
+ exists, and returns `Success` once it is deleted.
242
+ - (a) Run `a → HandsOffOnce → b(fails)` through `Worker` (fake mode). Status is `rolling_back`,
243
+ and `a` has not been undone yet (RollbackRecorder). `context.rollback` is stored with string
244
+ keys and also carries `"failure"`, so assert with `include`:
245
+ `expect(ctx.rollback).to include("trigger" => "failure", "step" => "b", "compensated" => true, "failures" => [])`
246
+ and `expect(ctx.rollback["failure"]).to be_present`.
247
+ - (b) Delete the key, then `Worker.new.perform(id, class)`. `a` is undone, the status is
248
+ `failed`, `rollback` is nil, and the final `Failure` equals a run of the same reactor with a
249
+ plain undo step.
250
+ - (c) `RubyReactor::Error::Rescuable === RollbackHandedOff.new(map_id: "x")` is false.
251
+ - (d) The run is never marked `aborted` by the hand-off.
252
+ - [X] T020 [P] [US1] Write `spec/ruby_reactor/rollback/distributed_map_rollback_spec.rb` (US1
253
+ acceptance 1–4, FR-001…FR-009, I-4, I-7). Use `for_each_async_backend`, `MapRollbackFixtures`,
254
+ 20 items, `batch_size 5`, and `test_reactor(..., process_jobs: false)` where intermediate state is
255
+ asserted.
256
+ - (a) Later step fails:
257
+ - perform `pending_async_jobs` one at a time until a pending job's `worker_class` is
258
+ `MapElementRollbackWorker`, then `be_rolling_back`;
259
+ - after `drain_async_jobs`, `be_failure`;
260
+ - `RollbackRecorder.log` has `undo:e.e2[i]` and `undo:e.e1[i]` exactly once for every i;
261
+ - every element undo precedes `undo:a`.
262
+ - (b) Oracle: the same fixture with `fan_out: false` and `fan_out: true` has an equal
263
+ `error.message`, `step_name`, the same `rollback_failures` sorted by `element_index`, and the
264
+ same set of undone elements.
265
+ - (c) Atomic element failure at i=7 (spec US1-AS2, FR-007):
266
+ - `MapElementRollbackWorker` jobs performed == elements that **started** (the element-context
267
+ index length);
268
+ - the outcome for index 7 is `not_needed`, and no `undo:e.*[7]` is logged;
269
+ - no outcome and no job exist for skipped indexes, which never started;
270
+ - every completed element is undone once.
271
+ - (d) `ElemUndoFails`:
272
+ - `rollback_failures` contains an entry with symbol values:
273
+ `include(step: :e1, kind: :undo, map_step: :m, element_index: 1)`. Assert with `eq` against
274
+ the inline oracle's entry (U1: re-symbolized after the JSON round-trip);
275
+ - `undo:a` is still logged.
276
+ - (e) Back pressure (via `QueueProbe.drain_tracking("…MapElementRollbackWorker")`):
277
+ `max_burst <= 5`. Queue depth is not asserted, because the bound is per throw (FR-002, R-02).
278
+ - (f) No rollback job loads more than one element context: spy on `storage.retrieve_context`
279
+ with the element class name, grouped by job. `MapStep` never calls
280
+ `retrieve_map_element_context_ids` (the full `LRANGE`).
281
+ - (g) Structured logs (FR-011, `API` §Structured logs), written first here, not in Polish.
282
+ Capture `RubyReactor.configuration.logger` into a `StringIO`. Assert exactly one
283
+ `event=ruby_reactor.map.rollback.started`, one `…element` line per started element and one
284
+ `…completed` line, each carrying `reactor=`, `context_id=` and `map_step=`, plus
285
+ `total=`/`batch_size=`, `index=`/`outcome=`/`failures=`, or `total=`/`failed=` respectively.
286
+ - (h) Expired element (I2): delete element 3's context row before `b` fails. With the fan-out map
287
+ and with the inline map (oracle), `rollback_failures` holds exactly **one**
288
+ `context_unavailable` entry, for `element_index: 3`.
289
+ - [X] T021 [US1] Add a manual undo context (same file as T020, so not parallel with it) to
290
+ `spec/ruby_reactor/rollback/distributed_map_rollback_spec.rb` (US1 acceptance 3, R-12, FR-027):
291
+ - a completed run with a fan-out map, then `Reactor.undo(id)`: status `rolling_back`, the steps
292
+ after the map undone, the steps before it not yet;
293
+ - after `drain_async_jobs`: `cancelled`, everything undone once;
294
+ - a second `Reactor.undo(id)` while `rolling_back` raises `ValidationError` "rollback already in
295
+ progress";
296
+ - with the `async:<id>` lock held by another owner, `Reactor.undo(id)` raises
297
+ `Lock::AcquisitionError`;
298
+ - `Reactor.cancel(id:, reason: "x")` while `rolling_back` raises `ValidationError` "rollback in
299
+ progress; cannot cancel". The status stays `rolling_back`, and after draining the run is
300
+ `cancelled` with the steps before the map undone (G1).
301
+ - [X] T022 [P] [US1] Write `spec/ruby_reactor/rollback/map_rollback_recovery_spec.rb` (US1
302
+ acceptance 5, FR-006, FR-010, S-5, SC-003), with `for_each_async_backend`:
303
+ - (a) `ElemInterruptOnce`: performing the element-0 rollback job raises `Interrupt`, which is
304
+ rescued in the spec. Re-perform the same job args: `undo:e.e2[0]` is logged once and
305
+ `undo:e.e1[0]` succeeds on the redelivery. The run finishes `failed`.
306
+ - (b) Delete one queued rollback job, drain, then `Map::Sweeper.run_once`: the missing position
307
+ is re-dispatched and the run finishes.
308
+ - (c) Delete the job at the batch-trigger position after it stores its outcome (perform it with
309
+ the trigger stubbed off), then `Map::Sweeper.run_once`: the next batch is claimed and the run
310
+ finishes.
311
+ - (d) Delete the owner `Worker` job after `rollback:signalled` is set, then
312
+ `RubyReactor::Sweeper.run_once`: the owner is re-enqueued and the run finishes.
313
+ - (e) Duplicate delivery of one rollback job, performed sequentially: the element is undone once,
314
+ and the second outcome is `undone` with no failures.
315
+ - (f) Contended element lock (R-07, U2): hold `map_element:<map>:<index>` with another owner while
316
+ performing that element's rollback job.
317
+ - The job stores no outcome, and enqueues a `MapElementRollbackWorker` job with
318
+ `attempt: 1` through `perform_map_element_rollback_in`.
319
+ - Release the lock and drain: the element is undone and there is no `element_in_flight` entry.
320
+ - Repeat with the lock held through `lock_snooze_max_attempts` requeues: the outcome is
321
+ `element_in_flight`, as today.
322
+ - (g) Superseded context for the same index: register a second, completed element context id
323
+ for index 2, as a sweeper re-dispatch would. Both contexts are undone once each, and there is no
324
+ false `element_in_flight`.
325
+ - [X] T023 [P] [US1] Update `spec/ruby_reactor/rollback/map_rollback_spec.rb`:
326
+ - Wrap the existing 008 examples in `[false, true].each { |fan_out| context "fan_out: #{fan_out}" }`,
327
+ using `MapRollbackFixtures.parent`, so every 008 guarantee holds on both paths.
328
+ - Add an inline-chunking example: 250 elements inline, and the element contexts are read in 3
329
+ range reads of at most 100, highest index first (spy on the new range-read storage method).
330
+ - Nested map inside an element (spec Edge Cases, G3): the element reactor contains an inline map
331
+ `n` of `ElemOk`, then a step after it. For `fan_out: true` on the outer map and a later failure:
332
+ - the nested map is rolled back inline inside the outer element's rollback job: its undos are
333
+ logged between that element's later-step undo and its earlier-step undo;
334
+ - no rollback job is dispatched for the nested map.
335
+ - [X] T024 [P] [US1] Update `spec/ruby_reactor/rollback/map_scale_spec.rb` (`:slow`) with a fan-out
336
+ variant: 10,000 elements, `batch_size 50`, `collect` raises.
337
+ - Every element is undone once.
338
+ - The owner's serialized context size is independent of N (reuse the existing size assertion).
339
+ - `QueueProbe` `max_burst <= 50`.
340
+
341
+ ### Implementation for User Story 1
342
+
343
+ - [X] T025 [P] [US1] Create `lib/ruby_reactor/error/rollback_handed_off.rb`:
344
+ `class RollbackHandedOff < Base`, with `attr_reader :map_id` and
345
+ `initialize(map_id:, message: "rollback handed off at map #{map_id}")`. Document the contract as
346
+ a control signal: never a failure, rescued only by `Executor` and `Reactor#undo` (R-04).
347
+ - [X] T026 [US1] In `lib/ruby_reactor/error/rescuable.rb`, make `===` return false for
348
+ `Error::RollbackHandedOff`, with a comment explaining why it passes the `Rescuable` sites
349
+ (R-04). T019(c) passes.
350
+ - [X] T027 [P] [US1] Add the context `rollback` field (DM §1): `attr_accessor :rollback` in
351
+ `lib/ruby_reactor/context.rb`, included in both serialization hashes and restored in
352
+ deserialization (`context.rollback = data["rollback"]`), with `failure` serialized via
353
+ `ContextSerializer.serialize_value`. Add `rolling_back` to the known statuses in
354
+ `lib/ruby_reactor/storage/redis_reactor_scan.rb`. Confirm `Worker::TERMINAL_STATUSES` does not
355
+ include it.
356
+ - [X] T028 [US1] `lib/ruby_reactor/executor/compensation_manager.rb` (R-04, R-07):
357
+ - `handle_step_failure` records `@pending = { step: step_config.name, error: error,
358
+ compensated: false }` before `compensate_step`, and sets `compensated: true` after it returns.
359
+ - Expose `attr_reader :pending`.
360
+ - `rollback_completed_steps(&after_pop)` yields each popped entry after `undo_stack.pop`
361
+ (no-op without a block).
362
+ - Add `restore_rollback_failures(list)`.
363
+ - [X] T029 [US1] In `lib/ruby_reactor/executor.rb`, rescue the hand-off (R-04, S-2, S-3):
364
+ - **`#undo_all` records and restores its level's failures around a hand-off** (G2). This is the
365
+ path `ComposeStep#compensate` and `Reactor#undo` take into a child; they never go through
366
+ `execute` / `resume_execution`.
367
+ - On entry, if `@context.rollback` is present, call
368
+ `compensation_manager.restore_rollback_failures(@context.rollback["failures"])`.
369
+ - Wrap `rollback_completed_steps` in `rescue Error::RollbackHandedOff`: set
370
+ `(@context.rollback ||= {})["failures"] = compensation_manager.rollback_failures`, then
371
+ re-raise.
372
+ - On normal completion, clear the saved failures and keep any `trigger` the caller owns.
373
+ - Add `rescue Error::RollbackHandedOff => e` in `#execute` and `#resume_execution`, placed
374
+ before the contention/park/`Rescuable`/`Exception` clauses.
375
+ - Also let it through `#aborting_on_interruption`: re-raise without `mark_aborted`.
376
+ - The handler calls `record_rollback_handoff(e)`:
377
+ - Build `rollback = { "trigger" => "failure", "step" => pending_step, "compensated" => …,
378
+ "failure" => serialize(provisional Failure built by
379
+ `result_handler.create_failure_from_error` from the pending error), "failures" =>
380
+ rollback_failures }`, keeping any existing `@context.rollback` trigger (for `undo`).
381
+ - **Composed child** (`@context.root_context` present and this executor is not the owner):
382
+ store it on `@context` and re-raise.
383
+ - **Top-level**, inside the rescue while the lock is still held (I-6):
384
+ - set status `:rolling_back` and `save_context`;
385
+ - then run the handshake (R-05, U3): `storage.mark_map_rollback_handed_off(e.map_id, rc)`;
386
+ then, if `count_map_rollback_outcomes == total` and `claim_map_rollback_signal`, enqueue the
387
+ owner `Worker`;
388
+ - log `event=ruby_reactor.rollback.handed_off context_id=… map_id=…`;
389
+ - set `@result = DispatchResult.new(job_id: "map_rollback:#{e.map_id}", execution_id: …)`.
390
+ - Put the handshake in one private method `hand_off_rollback!(map_id)`, reused by T039.
391
+ - For a hand-off raised while no step failure is pending (a compose whose child handed off from
392
+ inside `ComposeStep#run`), `step` is the current step and `compensated` is false.
393
+ - Emit `:complete_reactor` with the `DispatchResult`, as a forward hand-off does.
394
+ - [X] T030 [US1] Add `Executor#resume_rollback` in `lib/ruby_reactor/executor.rb` (R-04):
395
+ - Emit the `start_reactor` event and `acquire_context_lock`, then
396
+ `compensation_manager.restore_rollback_failures(@context.rollback["failures"])`.
397
+ - If `compensated` is false, `handle_step_failure(steps[step], provisional_error, {})`, rescuing
398
+ `CompensationError` as `Map::Helpers#resume_parent_execution` did (see git history of
399
+ `lib/ruby_reactor/map/helpers.rb`).
400
+ - `rollback_completed_steps`.
401
+ - Finalize:
402
+ - `failure` trigger: rebuild the error and call `result_handler.handle_execution_error`, as
403
+ `Helpers#resume_parent_execution` did (U6):
404
+ `StepFailureError.new(failure.error, step: step, context: @context, original_error:
405
+ (failure.error if failure.error.is_a?(Exception)), exception_class: failure.exception_class)`,
406
+ plus `set_backtrace(failure.backtrace)` when present. `context:` supplies the reactor name and
407
+ the redacted inputs of the final `Failure`;
408
+ - `undo` trigger: `status = :cancelled`, `cancelled = true`,
409
+ `cancellation_reason = "Undo triggered"`.
410
+ - Clear `@context.rollback`, `update_context_status`, save, release (the same ensure ordering as
411
+ T010).
412
+ - Rescue `RollbackHandedOff` again, through the same handler, for a second fan-out map.
413
+ - Emit `failed_reactor` / `complete_reactor`.
414
+ - [X] T031 [US1] In `lib/ruby_reactor/worker.rb#perform`, after deserialization, call
415
+ `executor.resume_rollback` instead of `resume_execution` when `context.status.to_s ==
416
+ "rolling_back"`. The snooze and contention rescue branches apply unchanged. T019 passes.
417
+ - [X] T032 [P] [US1] In `lib/ruby_reactor/sweeper.rb#run_once`, treat `rolling_back` like
418
+ `running`: re-enqueue it when `async:<id>` is not held (R-05, S-5). Add a unit example to the
419
+ existing sweeper spec (`spec/ruby_reactor/sweeper_spec.rb` or its current location).
420
+ - [X] T033 [US1] Storage for map rollback records (DM §5), in
421
+ `lib/ruby_reactor/storage/redis_adapter.rb`, declared in `lib/ruby_reactor/storage/adapter.rb`,
422
+ all keys with `durability_ttl`:
423
+ - `count_map_element_context_ids(map_id, rc)`: `LLEN`.
424
+ - `retrieve_map_element_context_ids_from_tail(map_id, rc, position, count)`: `LRANGE` of the
425
+ given tail positions, returned in tail order.
426
+ - `start_map_rollback(map_id, rc, **meta)`: `HSETNX` on a `created_at` sentinel plus `HSET` of
427
+ the fields; returns `[created_bool, metadata]`.
428
+ - `retrieve_map_rollback_metadata`.
429
+ - `claim_map_rollback_positions(map_id, rc, count)`: `INCRBY`, returning a `start…stop` range
430
+ clipped to `total`.
431
+ - `store_map_rollback_outcome(map_id, rc, position, outcome_hash)`: `HSET` JSON, plus `SADD`
432
+ indexes when `index` is present.
433
+ - `count_map_rollback_outcomes`: `HLEN`.
434
+ - `each_map_rollback_outcome(map_id, rc, &)`: `HSCAN`, count 500.
435
+ - `map_rollback_indexes_seen(map_id, rc, indexes)`: a pipelined `SISMEMBER` returning booleans.
436
+ - `mark_map_rollback_handed_off(map_id, rc)`: `SET`. `map_rollback_handed_off?(map_id, rc)`:
437
+ `EXISTS` (R-05).
438
+ - `claim_map_rollback_signal`: `SET NX`.
439
+ - `map_rollback_summary(map_id, rc)`: `{total, settled, outstanding, failed}`, with
440
+ `outstanding = total - settled` and failed counted from outcomes (A3).
441
+ - `scan_map_rollbacks(count:)`: for the sweeper.
442
+ - Unit examples in `spec/ruby_reactor/storage/redis_adapter_spec.rb`.
443
+ - [X] T034 [US1] Create `lib/ruby_reactor/map/element_rollback.rb`, `Map::ElementRollback` (R-07,
444
+ DM §5 outcomes). Move `acquire_element_lock` and `rollback_element` out of `MapStep`.
445
+ - `.call(map_id:, element_context_id:, element_class:, step_name:)` returns
446
+ `{ "index" =>, "outcome" =>, "failures" => [] }`:
447
+ - no row: `context_unavailable` (index nil, one entry);
448
+ - `map_element:<map>:<index>` lock held after `MapStep::ELEMENT_LOCK_WAIT`: return `:contended`.
449
+ The inline caller maps this to `element_in_flight`, which is today's inline behavior;
450
+ - status `completed`/`aborted`: `Executor#undo_all`, passing a block to
451
+ `rollback_completed_steps` that calls `executor.save_context` after each pop, then a final
452
+ save; `undone` or `failed`, with failures tagged `map_step:`, `element_index:`;
453
+ - else (`failed`, `halted`, or a superseded `running` context) `not_needed`.
454
+ - Skip the lock in inline testing mode, as today.
455
+ - `.perform(args)` is the job entry:
456
+ - `Map::Helpers.normalize_arguments`;
457
+ - `.call`;
458
+ - on `:contended` with `attempt < lock_snooze_max_attempts`: call
459
+ `perform_map_element_rollback_in(Worker.snooze_delay(config, nil), **args, attempt: attempt + 1)`
460
+ and return, storing nothing (R-07, U2);
461
+ - on `:contended` at the max: an `element_in_flight` outcome;
462
+ - `store_map_rollback_outcome`;
463
+ - log `event=ruby_reactor.map.rollback.element` with the `API` fields (T020(g));
464
+ - if `(position + 1) % batch_size == 0`, `Dispatcher.dispatch_rollback_batch`;
465
+ - if `count_map_rollback_outcomes == total` **and** `map_rollback_handed_off?` **and**
466
+ `claim_map_rollback_signal`, enqueue the owner `Worker`. The `handed_off` gate is the
467
+ handshake (R-05, U3): it never enqueues before the hand-off is saved, and never in inline
468
+ mode.
469
+ - [X] T035 [US1] Rollback batch dispatch in `lib/ruby_reactor/map/dispatcher.rb`, new
470
+ `Dispatcher.dispatch_rollback_batch(map_id:, parent_reactor_class_name:)` (R-02, R-06, R-08):
471
+ - read the rollback metadata, `claim_map_rollback_positions(batch_size)`;
472
+ - `retrieve_map_element_context_ids_from_tail`;
473
+ - one `async_router.perform_map_element_rollback_async(...)` per position, with the args listed
474
+ in `API` §Background workers.
475
+ - Unit examples in `spec/ruby_reactor/map/dispatcher_spec.rb`: claims at most `batch_size`,
476
+ newest-first ids, and no enqueue past `total`.
477
+ - [X] T036 [P] [US1] Routers and workers (`API` §Background workers):
478
+ - Add `perform_map_element_rollback_async(**args)` and `perform_map_element_rollback_in(delay,
479
+ **args)` to `lib/ruby_reactor/adapters/sidekiq/router.rb` and
480
+ `lib/ruby_reactor/adapters/active_job/router.rb`. They mirror `perform_map_element_async` /
481
+ `perform_map_element_in`: queue, string-key payload, `attempt` defaulting to 0.
482
+ - Create `lib/ruby_reactor/adapters/sidekiq/map_element_rollback_worker.rb` and
483
+ `lib/ruby_reactor/adapters/active_job/map_element_rollback_worker.rb`, mirroring the
484
+ `map_element_worker.rb` files, whose `perform` calls `RubyReactor::Map::ElementRollback.perform(args)`.
485
+ - Register them wherever the existing map workers are registered or required.
486
+ - Add `RubyReactor::Adapters::Sidekiq::MapElementRollbackWorker` to
487
+ `RubyReactor::RSpec::SidekiqHelpers.worker_classes` in `lib/ruby_reactor/rspec/sidekiq_helpers.rb`.
488
+ Without it, `drain_async_jobs` never runs rollback jobs. The ActiveJob helper drains
489
+ generically and needs no change.
490
+ - Unit example in `spec/ruby_reactor/adapters/` that each router enqueues the right worker with
491
+ string-key args.
492
+ - [X] T037 [US1] Rewrite `MapStep#compensate` / `#undo` in `lib/ruby_reactor/step/map_step.rb`
493
+ (R-01, R-02, R-05, R-06, R-09, S-2, S-4):
494
+ - **Fan-out** (`context.map_operations[step]` present):
495
+ - `start_map_rollback` with `total = count_map_element_context_ids`, `batch_size` (metadata
496
+ `batch_size`, else `Map::DEFAULT_BATCH_SIZE`), the owner ids and `reactor_class_info`;
497
+ - on create, log `rollback.started` and `Dispatcher.dispatch_rollback_batch`;
498
+ - then, if settled (`count_map_rollback_outcomes == total`), aggregate and return;
499
+ - else raise `RollbackHandedOff.new(map_id:)`.
500
+ - **Inline**: iterate tail positions in `Map::ROLLBACK_CHUNK` pages through
501
+ `retrieve_map_element_context_ids_from_tail`, calling `Map::ElementRollback.call` per id, and
502
+ collect failures and seen indexes.
503
+ - **Aggregation**, shared by both modes (DM §5):
504
+ - fan-out: failures from `each_map_rollback_outcome` (`failed` and `element_in_flight` only),
505
+ **re-symbolized**: symbol keys, and symbol `step` / `kind` / `reason` / `map_step` values
506
+ (U1);
507
+ - **unavailable elements, reported once** (I2):
508
+ - `started = composed_contexts[step][:started]` known: add a `context_unavailable` entry
509
+ (`rollback_entry`) per started-but-unseen index (fan-out via `map_rollback_indexes_seen` in
510
+ chunks of 1,000, inline from the in-memory Set), and **drop** nil-index
511
+ `context_unavailable` outcomes;
512
+ - `started` unknown: one unnamed entry per nil-index outcome;
513
+ - log `rollback.completed` with the `API` fields (T020(g));
514
+ - return `Success` or `Failure("map :#{step} rollback incomplete", rollback_failures:)`.
515
+ - **Log** `rollback.started` with the `API` fields when `start_map_rollback` creates the
516
+ records.
517
+ - Delete `completed_elements`, `report_unavailable`, `rollback_element`, `acquire_element_lock`
518
+ and the ponytail comment.
519
+ - T020 and T023 pass.
520
+ - [X] T038 [US1] In `lib/ruby_reactor/map/element_executor.rb#perform_element`, call
521
+ `store_map_element_context_id` only when `arguments[:serialized_context]` is nil. This is a fresh
522
+ element context, not a parked or retried re-entry (R-06).
523
+ - [X] T039 [US1] Manual undo (R-12, DM §7, `API` §Reactor.undo), in `lib/ruby_reactor/reactor.rb`:
524
+ - Add `UNDO_LOCK_WAIT = 5`.
525
+ - `#undo` raises `Error::ValidationError, "rollback already in progress"` when the status is
526
+ `rolling_back`.
527
+ - It acquires `RubyReactor::Lock.new("async:#{root id}", owner: SecureRandom.uuid, ttl:
528
+ context_lock_ttl, wait: UNDO_LOCK_WAIT)`, skipped in inline testing mode as
529
+ `Executor#acquire_context_lock` does.
530
+ - It sets `@context.rollback = { "trigger" => "undo", "compensated" => true, "failures" => [] }`
531
+ before `executor.undo_all`. `undo_all` records its failures on a hand-off (T029).
532
+ - It rescues `RollbackHandedOff`: status `rolling_back`, save inside the lock, then
533
+ `executor.hand_off_rollback!(e.map_id)` (the T029 handshake), and returns `:handed_off`.
534
+ - Otherwise it clears `rollback`, saves, and releases in `ensure`.
535
+ - `Reactor.undo` skips `cancel` when `#undo` returned `:handed_off`.
536
+ - **Cancel guard** (G1, FR-027): `#cancel` raises
537
+ `Error::ValidationError, "rollback in progress; cannot cancel"` when the status is
538
+ `rolling_back`. `Reactor.cancel` inherits it, and nothing else changes.
539
+ - T021 passes.
540
+ - [X] T040 [US1] Rollback pass in `lib/ruby_reactor/map/sweeper.rb` (R-05, S-5): for each entry
541
+ from `scan_map_rollbacks`,
542
+ - re-dispatch every position below the claimed offset that has no outcome and no live
543
+ `map_element:<map>:<index>` lock (the index comes from the element row, or dispatch it if the
544
+ row is gone);
545
+ - if every claimed position is settled and `offset < total`, call
546
+ `Dispatcher.dispatch_rollback_batch`;
547
+ - return a `rollback_redispatched:` count alongside the existing counts.
548
+ - Unit examples in `spec/ruby_reactor/map/sweeper_spec.rb`.
549
+ - T022(b)(c) pass.
550
+ - [X] T041 [US1] Inline testing mode (R-05): add an example to
551
+ `spec/ruby_reactor/rollback/distributed_map_rollback_spec.rb` under `Sidekiq::Testing.inline!`
552
+ (the one justified use; plan.md Complexity Tracking). A later failure after a fan-out map
553
+ finishes:
554
+ - no `rolling_back` status is ever persisted (spy on `store_context` payload statuses);
555
+ - `mark_map_rollback_handed_off` is never called, and no owner `Worker` is performed nested inside
556
+ the rollback (spy on `Worker#perform` call depth);
557
+ - the final `Failure` equals the fake-mode result.
558
+
559
+ The design guarantees this through the handshake (R-05); no fix-if-it-happens is left to
560
+ implementation.
561
+ - [X] T042 [P] [US1] RSpec surface (`API` §RSpec):
562
+ - Add a `be_rolling_back` matcher in `lib/ruby_reactor/rspec/matchers.rb`, with the same shape
563
+ as `be_paused`.
564
+ - In `lib/ruby_reactor/rspec/test_subject.rb`, add `rolling_back?`, and make `ensure_executed!`
565
+ drain when the status is `running` or `rolling_back`.
566
+ - In `lib/ruby_reactor/rspec/active_job_helpers.rb`, add `alias worker_class job_class` on
567
+ `PendingJob`, so `pending_async_jobs.first.worker_class` works on both backends (U4). T020(a)
568
+ and T048 use it under `for_each_async_backend`.
569
+ - Add a spec in `spec/ruby_reactor/rspec/be_rolling_back_spec.rb`, including one example that uses
570
+ `worker_class` on the ActiveJob backend.
571
+ - [X] T043 [US1] Dashboard API (R-15): in `lib/ruby_reactor/web/api.rb#hydrate_map_ref`, add
572
+ `"rollback" => storage.map_rollback_summary(map_id, reactor_class_name)`
573
+ (`{total, settled, outstanding, failed}`) when rollback metadata exists. Add a request example to
574
+ the existing web API spec, using `spec/support/rack_test.rb`.
575
+ - [X] T044 [P] [US1] GUI (R-15, C4, A3). Every status surface learns `rolling_back`:
576
+ - `gui/src/lib/reactors.ts`: add `'rolling_back'` to `STATUS_GROUPS.running`, with a comment, so
577
+ the run counts and filters with live runs;
578
+ - `gui/src/components/StatusBadge.tsx`: amber style and an undo icon for `rolling_back` (no grey
579
+ fallback);
580
+ - `gui/src/components/LiveView.tsx` and `gui/src/components/ReactorClassInstances.tsx`: add
581
+ `rolling_back` to the status filter options;
582
+ - `gui/src/components/ReactorDetail.tsx`: amber status colour, and disable "Cancel" for
583
+ `rolling_back`, since the API rejects it (FR-027);
584
+ - `gui/src/components/StepInspector.tsx`: show "Rolling back {settled}/{total} ({outstanding}
585
+ outstanding, {failed} failed)" **only when the run's status is `rolling_back`** and the
586
+ hydrated map ref has `rollback`.
587
+ - Tests:
588
+ - `gui/src/lib/__tests__/` for `matchesStatusFilter('rolling_back', 'running')`;
589
+ - `gui/src/components/__tests__/ReactorDetail.test.tsx` for the colour, the disabled Cancel,
590
+ and progress shown only while `rolling_back`.
591
+ - `cd gui && npm test && npm run build`, and commit the rebuilt `lib/ruby_reactor/web/public/`
592
+ bundle.
593
+ - [X] T045 [US1] Run `bundle exec rspec spec/ruby_reactor/rollback spec/ruby_reactor/executor spec/map`.
594
+ T019–T024 and T041 must be green and the T003 bar held. Run the `:slow` T024 once:
595
+ `bundle exec rspec spec/ruby_reactor/rollback/map_scale_spec.rb --tag slow`.
596
+
597
+ ### Demo and docs for User Story 1 (Constitution VI + Development Workflow)
598
+
599
+ - [X] T046 [P] [US1] Create the demo reactors, **one reactor per file** named after its class
600
+ (Constitution VI.1, C1). The demo runs with `eager_load = false`, so a fresh Sidekiq process
601
+ resolves the element class by name through Zeitwerk and needs it in its own file. Use class-based
602
+ steps, following `map_refund_demo_reactor.rb`:
603
+ - `demo_app/app/reactors/distributed_refund_element_reactor.rb`: `DistributedRefundElementReactor`
604
+ (`charge` step with an `undo` refund), plus its `DistributedRefundChargeStep` class in the same
605
+ file, which is only referenced from it;
606
+ - `demo_app/app/reactors/distributed_refund_demo_reactor.rb`: `DistributedRefundDemoReactor`:
607
+ - `load_orders` (40 orders);
608
+ - `charge_orders` map with `fan_out batch_size: 10` of `DistributedRefundElementReactor`;
609
+ - `notify`, which fails when input `fail_notify: true`;
610
+ - class-level `charges` / `refunds` logs, as in the existing demo.
611
+ - [X] T047 [US1] Register `demo:distributed_map_rollback` in
612
+ `demo_app/lib/tasks/demo_reactors.rake`:
613
+ - `desc "DistributedRefundDemoReactor — a fan-out map's elements are refunded by one rollback job each, at most batch_size per throw"`,
614
+ `[:environment, :flush_redis]`.
615
+ - Run with `fail_notify: true`, then print: the dispatch, a `rolling_back` line with
616
+ `map_rollback_summary`, the polled final status, and `charges == refunds`.
617
+ - [X] T048 [US1] Create `demo_app/spec/reactors/distributed_refund_demo_reactor_spec.rb`
618
+ (`type: :reactor`), using shipped helpers and matchers only:
619
+ - `test_reactor(DistributedRefundDemoReactor, {fail_notify: true}, process_jobs: false)`;
620
+ perform `pending_async_jobs.first.perform!` in a loop until a pending job's `worker_class`
621
+ name ends in `MapElementRollbackWorker`, then `be_rolling_back`. These are shipped helpers
622
+ only; `worker_class` works on both backends after T042;
623
+ - then `drain_async_jobs`, `be_failure`, refunds equal charges;
624
+ - a happy path `be_success`.
625
+ - [X] T049 [US1] Add a benchmark for SC-002 (quickstart §5, U7):
626
+ - Create `demo_app/app/reactors/inline_refund_benchmark_reactor.rb`: `InlineRefundBenchmarkReactor`,
627
+ the same steps as `DistributedRefundDemoReactor` with the same `DistributedRefundElementReactor`,
628
+ but an inline map (no `fan_out`), and `notify` failing.
629
+ - Register `demo:map_rollback_benchmark[count]` in `demo_app/lib/tasks/demo_reactors.rake`:
630
+ - `desc "Times the serial in-process rollback against the distributed rollback of the same N elements (SC-002)"`,
631
+ depending on `[:environment, :flush_redis]`;
632
+ - build `count` orders;
633
+ - time `InlineRefundBenchmarkReactor` (the baseline: today's serial algorithm, the inline-map
634
+ rollback path);
635
+ - time `DistributedRefundDemoReactor` (its declared `batch_size 10`), polling the status until
636
+ terminal;
637
+ - print both times and the ratio.
638
+ - [X] T050 [P] [US1] Documentation (FR-025, R-17):
639
+ - `documentation/data_pipelines.md` §Rollback:
640
+ - distributed per element with back pressure;
641
+ - the `rolling_back` status;
642
+ - steps before the map undone after every element;
643
+ - at-least-once for an undo that was cut off, so undos must be idempotent;
644
+ - the inline map reads 100 at a time.
645
+ - §Back Pressure: it applies to rollback.
646
+ - `documentation/background_and_async.md` §Fan-out maps:
647
+ - rollback hand-off and resume;
648
+ - `MapElementRollbackWorker`;
649
+ - both sweepers cover rollback.
650
+ - `documentation/core_concepts.md`: the rollback table `map` row, and `rolling_back` in the
651
+ statuses.
652
+ - `documentation/testing.md`: `be_rolling_back`.
653
+ - `README.md`: the map rollback paragraph (~line 902), the status list, the Durability section,
654
+ `Reactor.undo` behavior with a fan-out map, and `cancel` / `undo` rejected while
655
+ `rolling_back`.
656
+ - [X] T051 [P] [US1] Add `CHANGELOG.md` entries:
657
+ - Features: distributed map rollback, `rolling_back` status, `MapElementRollbackWorker` /
658
+ router method, `be_rolling_back`;
659
+ - Bug Fixes: sweeper re-dispatch kept `batch_size` / `atomic` (T007), save before
660
+ releasing the context lock (T010);
661
+ - a note on the extra owner-resume job per fan-out map (R-03).
662
+
663
+ **Checkpoint**: US1 works on its own. A large fan-out map rolls back distributed, the oracle holds,
664
+ recovery works, and the demo passes locally.
665
+
666
+ ---
667
+
668
+ ## Phase 4: User Story 2 - Fan-out map inside a composed child finishes (Priority: P2)
669
+
670
+ **Goal**: A root composing a child (at any depth) whose fan-out map hands off resumes and
671
+ finishes. Rollback travels through the root, distributed when US1 is in.
672
+
673
+ **Independent Test**: Root composes Child, and Child runs `map … fan_out batch_size: 1`. After
674
+ draining, the root is `completed`. Repeat with an interrupt after the map, and with a failing root
675
+ step after the compose step.
676
+
677
+ ### Tests for User Story 2 ⚠️ (write first, confirm FAIL)
678
+
679
+ - [X] T052 [P] [US2] Write `spec/map/map_compose_fan_out_spec.rb` (US2 acceptance 1, 2, 5,
680
+ FR-013–FR-015), with `for_each_async_backend` and `RollbackRecorder` fixtures:
681
+ - (a) Root(`r1` → compose `c`(Child: `c1` → fan-out map `m` of `ElemOk` → `c2`) → `r2`):
682
+ `be_success`, and `step_result(:r2)` present;
683
+ - (b) Child has an interrupt `wait` after `m`: `be_paused_at(:c, :wait)` or the nested path the
684
+ matcher supports, then `resume`, then `be_success`;
685
+ - (c) Root → compose → Child → compose → Grandchild with the fan-out map: `be_success`;
686
+ - (d) the owner `Worker` job enqueued by the collector carries the ROOT id, not the child id;
687
+ - (e) the child's standalone row is never resumed: its stored status never becomes `completed`
688
+ on its own.
689
+ - [X] T053 [US2] Add a rollback context to `spec/map/map_compose_fan_out_spec.rb` (same file as
690
+ T052; US2 acceptance 3, 4, FR-016, S-3, spec Edge Cases):
691
+ - (a) `r2` fails: the log shows every element undo, then `undo:c1`, then `undo:r1`, and the root
692
+ is `be_failure`;
693
+ - (b) an element of `m` fails under `atomic`: the ROOT is `be_failure`, not only the child's row;
694
+ completed elements undone, then `c1`, then `r1`;
695
+ - (c) `c2` (the child step after the map) fails: element undos, `undo:c1`, `undo:r1`;
696
+ - (d) the oracle (I-7) against the same tree with an inline map;
697
+ - (e) manual undo from the root (G2, spec Edge Cases): a completed root whose child ran the fan-out
698
+ map; `Reactor.undo(root id)` makes the root `rolling_back`. After draining it is `cancelled`,
699
+ and every element, `c2`, `c1` and `r1` is undone exactly once;
700
+ - (f) failures survive the child hand-off (G2): `r2` fails, and `c2`'s undo returns a `Failure`.
701
+ `c2` is undone before the child's map hands off, and after the resume its entry is still in the
702
+ root's final `rollback_failures`.
703
+
704
+ ### Implementation for User Story 2
705
+
706
+ - [X] T054 [US2] Store the owner tree before dispatch (R-03), in
707
+ `lib/ruby_reactor/step/map_step.rb#prepare_async_execution`:
708
+ - after `before_async_enqueue`, also `store_context(owner_context.context_id,
709
+ ContextSerializer.serialize(owner_context), reactor_storage_name(owner_context.reactor_class))`
710
+ when `owner_context != context`;
711
+ - keep the child snapshot write, which the Dispatcher reads to resolve the source.
712
+ - T052(a)(d)(e) pass.
713
+ - [X] T055 [US2] Verify and fix the re-entry path through `ComposeStep#run` →
714
+ `executor.resume_execution` of the admitted child → `MapStep#run` adopt (T016). Touch
715
+ `lib/ruby_reactor/step/compose_step.rb` only if T052(b)(c) show the child re-executing `c1` or
716
+ re-dispatching. Record the finding in research.md R-03 if a change was needed.
717
+ - [X] T056 [US2] In `lib/ruby_reactor/map/sweeper.rb`, `recollect?`/`parent_live_lock?` check
718
+ `async:<owner_context_id>` (falling back to the parent id), and `parent_already_collected?`
719
+ reads the owner's status (terminal → collected) (S-5). Update the matching examples in
720
+ `spec/ruby_reactor/map/sweeper_spec.rb`.
721
+ - [X] T057 [US2] Composed-child hand-off (S-3; needs US1 T029, T030, T037). Confirm that:
722
+ - `Executor#record_rollback_handoff` (the `execute` path) and `Executor#undo_all` (the
723
+ `ComposeStep#compensate` and manual-undo path, T029) both store the child's `rollback`,
724
+ including its failures so far, on the embedded child context and re-raise;
725
+ - root `resume_rollback` with `compensated: false` on the compose step finishes the child's
726
+ rollback through `ComposeStep#compensate`, and the restored child failures reach the root's
727
+ final `Failure`.
728
+
729
+ Make T053 pass, touching `lib/ruby_reactor/executor.rb` / `lib/ruby_reactor/step/compose_step.rb`
730
+ as needed.
731
+ - [X] T058 [US2] Run `bundle exec rspec spec/map spec/ruby_reactor/map spec/compose_spec.rb spec/ruby_reactor/rollback`.
732
+ All green.
733
+
734
+ ### Demo and docs for User Story 2
735
+
736
+ - [X] T059 [P] [US2] Create the demo reactors, class-based, **one reactor per file** named after its
737
+ class (Constitution VI.1, C1), each step class living in the file of the reactor that uses it:
738
+ - `demo_app/app/reactors/composed_fan_out_item_reactor.rb`: `ComposedFanOutItemReactor` (`ship`
739
+ step with an `undo` "unship");
740
+ - `demo_app/app/reactors/composed_fan_out_child_reactor.rb`: `ComposedFanOutChildReactor`
741
+ (`reserve` → fan-out map `ship_items`, `batch_size: 2`, of `ComposedFanOutItemReactor` →
742
+ `confirm`);
743
+ - `demo_app/app/reactors/composed_fan_out_demo_reactor.rb`: `ComposedFanOutDemoReactor`
744
+ (`prepare` → compose `fulfil` of `ComposedFanOutChildReactor` → `notify`, which fails when
745
+ `fail_notify: true`), with class-level `shipped` / `unshipped` logs.
746
+ - [X] T060 [US2] Register `demo:composed_fan_out` in `demo_app/lib/tasks/demo_reactors.rake`:
747
+ - desc: "ComposedFanOutDemoReactor — a fan-out map inside a composed child: the root resumes
748
+ and finishes, and rolls back through the root", with `[:environment, :flush_redis]`;
749
+ - print the happy path final status `completed`, and the failure path `failed` with
750
+ `shipped == unshipped`.
751
+ - [X] T061 [US2] Create `demo_app/spec/reactors/composed_fan_out_demo_reactor_spec.rb`
752
+ (`type: :reactor`), with shipped matchers only: the happy path is `be_success`; the failure path
753
+ is `be_failure` with every shipped item unshipped.
754
+ - [X] T062 [P] [US2] Documentation:
755
+ - `documentation/composition.md`: `compose` + `fan_out` supported; the top-level run resumes;
756
+ how rollback travels.
757
+ - `documentation/background_and_async.md` §Fan-out maps: the collector signals the owner run,
758
+ and the map adopts its outcome on resume.
759
+ - `README.md`: the composition section note.
760
+ - `specs/future_improvements.md`: delete §"Fan-out map inside a composed child"; mark the
761
+ "Map collector, failure branch" writer row as fixed by 009 R-03.
762
+ - [X] T063 [P] [US2] `CHANGELOG.md` Bug Fixes entry: "a fan-out map inside a composed reactor no
763
+ longer leaves the root running forever". Add the S-6 note: maps started before the upgrade
764
+ inside a composed child keep the old behavior.
765
+
766
+ **Checkpoint**: US1 and US2 both work independently.
767
+
768
+ ---
769
+
770
+ ## Phase 5: User Story 3 - Fan-out never floods the queue by default (Priority: P2)
771
+
772
+ **Goal**: `fan_out` without `batch_size` enqueues at most 50 element jobs per throw, forward and
773
+ rollback.
774
+
775
+ **Independent Test**: a fan-out map of 500 elements with no declared batch size never enqueues
776
+ more than 50 element jobs in one throw, and all 500 outcomes are collected.
777
+
778
+ ### Tests for User Story 3 ⚠️ (write first, confirm FAIL)
779
+
780
+ - [X] T064 [P] [US3] Extend `spec/map/map_batch_size_spec.rb` (US3 acceptance 1–4, FR-017–FR-019,
781
+ SC-004), with `for_each_async_backend` and `QueueProbe.drain_tracking("…MapElementWorker")`:
782
+ - (a) 500 elements, no `batch_size`: `max_burst <= 50`, 500 results collected, and map metadata
783
+ `batch_size == 50`. Queue depth is not asserted; the bound is per throw (FR-002, R-02);
784
+ - (b) 20 elements: all 20 enqueued by the first dispatch;
785
+ - (c) `fan_out batch_size: 200` over 500: `max_burst == 200`;
786
+ - (d) 120 elements, no `batch_size`, a later step fails: the rollback probe on
787
+ `MapElementRollbackWorker` has `max_burst <= 50` (requires US1; mark `pending` if US1 is
788
+ absent).
789
+
790
+ ### Implementation for User Story 3
791
+
792
+ - [X] T065 [US3] Change `MapStep#effective_batch_size` in `lib/ruby_reactor/step/map_step.rb` to
793
+ `inputs.batch_size || RubyReactor::Map::DEFAULT_BATCH_SIZE`. Change the `Dispatcher.dispatch_batch`
794
+ fallback in `lib/ruby_reactor/map/dispatcher.rb` to `arguments[:batch_size] ||
795
+ RubyReactor::Map::DEFAULT_BATCH_SIZE`. Update the `MapBuilder#fan_out` comment in
796
+ `lib/ruby_reactor/dsl/map_builder.rb` ("without it the whole source fans out at once" → default
797
+ 50). T064 passes.
798
+ - [X] T066 [US3] Run `bundle exec rspec spec/map`. Update any existing example that asserted
799
+ "every element enqueued at once" for a source over 50, citing R-10 in the change.
800
+
801
+ ### Demo and docs for User Story 3
802
+
803
+ - [X] T067 [P] [US3] Demo:
804
+ - Add `DefaultBatchFanOutDemoReactor` in
805
+ `demo_app/app/reactors/default_batch_fan_out_demo_reactor.rb`: 120 elements, `fan_out` with no
806
+ `batch_size`, and a `collect` that counts.
807
+ - Register `demo:default_batch_size` in `demo_app/lib/tasks/demo_reactors.rake` (desc, and
808
+ `[:environment, :flush_redis]`), printing the map metadata `batch_size` and the collected
809
+ count.
810
+ - Add `demo_app/spec/reactors/default_batch_fan_out_demo_reactor_spec.rb` with shipped matchers:
811
+ `be_success`, and `step_result` count 120.
812
+ - **G5 (revised by /speckit-demo-tests)**: no burst matcher was added. The demo specs read the
813
+ first throw with the shipped `pending_async_jobs` under `process_jobs: false`: 50 element jobs
814
+ here, and 10 forward plus 10 rollback in the T048 spec. Gem spec T064 covers every throw, and
815
+ the rake task prints the stored `batch_size` for the operator.
816
+ - [X] T068 [P] [US3] Documentation:
817
+ - rewrite `documentation/data_pipelines.md` §"`fan_out` Without `batch_size`": default 50, no
818
+ unbounded mode, set `batch_size` to change it;
819
+ - `README.md` (~line 908, the "`batch_size` is optional" paragraph);
820
+ - `demo_app/documentation/data_pipelines.md`, kept in sync;
821
+ - `CHANGELOG.md` Features entry, with the throughput note (R-18).
822
+
823
+ **Checkpoint**: US1–US3 work independently.
824
+
825
+ ---
826
+
827
+ ## Phase 6: User Story 4 - The map failure policy says what it means (Priority: P3)
828
+
829
+ **Goal**: `atomic` names the policy. `fail_fast` still works with one deprecation warning per
830
+ call site, and declaring both is an error.
831
+
832
+ **Independent Test**: `atomic false` with one failing element completes with that failure
833
+ among the results. `fail_fast false` behaves the same and prints one deprecation line naming
834
+ `atomic`.
835
+
836
+ ### Tests for User Story 4 ⚠️ (write first, confirm FAIL)
837
+
838
+ - [X] T069 [P] [US4] Write `spec/map/map_atomic_spec.rb` (US4 acceptance 1–4, FR-020–FR-022,
839
+ SC-006):
840
+ - (a) no declaration: one failing element fails the map and rolls back the completed elements;
841
+ - (b) `atomic false`: the map completes, and the `ResultEnumerator` has one `Failure`;
842
+ - (c) `fail_fast false` in a class body:
843
+ `expect { define }.to output(/\[RubyReactor\] DEPRECATION: .* fail_fast.*atomic/).to_stderr`,
844
+ with the behavior identical to (b), and the same site defined twice warns once;
845
+ - (d) `fail_fast` and `atomic` in one map: `ValidationError` "declares both fail_fast and
846
+ atomic" at class definition.
847
+
848
+ ### Implementation for User Story 4
849
+
850
+ - [X] T070 [US4] Extract `lib/ruby_reactor/dsl/definition_warnings.rb`, module
851
+ `Dsl::DefinitionWarnings`, holding `warn_definition(site, prefix, message)`,
852
+ `warn_deprecation(site, message)` and the shared `deprecation_sites` Set, moved from
853
+ `lib/ruby_reactor/dsl/step_builder.rb`. `StepBuilder` includes it, with no behavior change:
854
+ existing step deprecation specs stay green.
855
+ - [X] T071 [US4] In `lib/ruby_reactor/dsl/map_builder.rb`:
856
+ - include `DefinitionWarnings`;
857
+ - add `atomic(enabled = true)`, which records `@atomic_declared = true`;
858
+ - `fail_fast(enabled = true)` records `@fail_fast_site = caller_locations(1, 1).first`, sets the
859
+ same ivar and warns via `warn_deprecation` with the `API` §Map DSL message;
860
+ - `build` raises `Error::ValidationError` when both were declared;
861
+ - rename `@fail_fast` to `@atomic`.
862
+ - T069 passes.
863
+ - [X] T072 [US4] Migrate internal usages to `atomic`, keeping exactly one deprecated-alias
864
+ example in `spec/map/map_atomic_spec.rb`:
865
+ - `spec/map/fail_fast_spec.rb` and `spec/map/map_fail_fast_spec.rb`, renamed to
866
+ `spec/map/atomic_inline_spec.rb` / `spec/map/map_atomic_async_spec.rb` via
867
+ `git mv`;
868
+ - `spec/map/map_retry_spec.rb`, `spec/map/map_async_retry_spec.rb`;
869
+ - `spec/ruby_reactor/rollback/map_rollback_spec.rb`, `spec/ruby_reactor/rspec/helpers_spec.rb`;
870
+ - `spec/support/map_rollback_fixtures.rb`.
871
+ - [X] T073 [P] [US4] Demo: in `demo_app/app/reactors/ar_map_reactor_not_fail.rb`, change
872
+ `fail_fast false` to `atomic false`. Confirm `demo_app/spec/reactors/ar_map_reactor_not_fail_spec.rb`
873
+ passes unchanged and that `demo:ar` prints no deprecation line.
874
+ - [X] T074 [P] [US4] Documentation:
875
+ - rewrite `documentation/data_pipelines.md` §"fail_fast" as §"Atomic maps (`atomic`)",
876
+ with the deprecation note for `fail_fast`;
877
+ - the `README.md` mentions (~lines 902, 908);
878
+ - `demo_app/documentation/data_pipelines.md`, kept in sync;
879
+ - `CHANGELOG.md` Deprecations: `fail_fast` → `atomic`, removal no earlier than the next
880
+ MAJOR.
881
+
882
+ **Checkpoint**: all four stories work independently.
883
+
884
+ ---
885
+
886
+ ## Phase 7: Polish & Cross-Cutting Concerns
887
+
888
+ - [X] T075 Register the aggregate `demo:map_execution_undo` in `demo_app/lib/tasks/demo_reactors.rake`
889
+ (`desc "Distributed map rollback, composed fan-out, default batch size"`, depending on
890
+ `[:environment, :flush_redis, :distributed_map_rollback, :composed_fan_out, :default_batch_size]`),
891
+ and add `:map_execution_undo` to `demo:all`.
892
+ - [X] T076 [P] Sweep for leftover `fail_fast` (FR-024, G4): run
893
+ `grep -rn "fail_fast\|failFast" lib gui/src documentation README.md demo_app/app demo_app/documentation demo_app/lib`.
894
+ Rewrite every hit to `atomic` except three:
895
+ - the deprecated DSL alias and its warning in `lib/ruby_reactor/dsl/map_builder.rb`;
896
+ - `Map::Helpers.normalize_arguments`;
897
+ - the CHANGELOG deprecation entry.
898
+
899
+ Known hits: the comments at `gui/src/components/StepInspector.tsx:590` and
900
+ `gui/src/components/DagVisualizer.tsx:385`. Rebuild the GUI bundle if any `.tsx` changed.
901
+ - [X] T077 [P] Update `specs/future_improvements.md`:
902
+ - delete §"Rollback fan-out for very large maps" (Rollback follow-ups);
903
+ - add a follow-up for "a synchronous caller's final save racing a worker resume", with a
904
+ pointer from R-13's out-of-scope note.
905
+ - [X] T078 [P] Audit `rescue StandardError` / bare `rescue` sites on the rollback path for
906
+ swallowing `RollbackHandedOff` (it is an `Error::Base`):
907
+ - `grep -rn "rescue StandardError\|rescue =>" lib/ruby_reactor/{executor,step,map,reactor.rb,worker.rb}`;
908
+ - add explicit re-raises where one sits between `MapStep#undo` and `Executor`/`Reactor#undo`;
909
+ - list the sites checked in the PR description.
910
+ - [X] T079 Run the 007 evidence harness:
911
+ `bundle exec ruby specs/007-execution-flow-analysis/evidence/run.rb | tail -1`. Update the
912
+ expected sequences in `specs/007-execution-flow-analysis/evidence/probes/*.rb` only for fan-out
913
+ map rollback scenarios whose element order legitimately changes (R-08), each with a comment
914
+ citing 009 R-08. Everything else must match T003.
915
+ - [X] T080 Run `bundle exec rubocop` and fix offenses in the changed files, without
916
+ `--disable-pending-cops`.
917
+ - [X] T081 Run the full suite: `bundle exec rspec`, and `bundle exec rspec --tag slow` for
918
+ `map_scale_spec.rb`. All green. Rerun flaky failures alone before investigating.
919
+ - [X] T082 Run the demo specs locally:
920
+ `cd demo_app && REDIS_URL=redis://localhost:6780/5 RAILS_ENV=test bundle exec rspec spec/reactors`.
921
+ All green.
922
+ - [X] T083 Docker acceptance (Constitution VI.4), in an isolated compose project per quickstart §5
923
+ (`-p rr_009` plus an override with unique `container_name`s and `ports: !reset []`):
924
+ `bin/rails db:prepare && bin/rails demo:map_execution_undo`. The output matches quickstart §5.
925
+ Ask before stopping another worktree's containers.
926
+ - [ ] T084 SC-002 benchmark (quickstart §5): with `demo-sidekiq` at `-c 10`, run
927
+ `bin/rails "demo:map_rollback_benchmark[10000]"`. Record both times and the ratio (at least 5×)
928
+ in the PR description. If it falls short, profile before changing the batch mechanism.
929
+ - [X] T085 Walk quickstart.md §1–§4 end to end and confirm every "Expected" row. Fix the
930
+ quickstart if a command or path changed during implementation.
931
+ - [X] T086 Constitution re-check against `plan.md` §Constitution Check:
932
+ - `README.md` and every `documentation/` file listed in R-17 updated;
933
+ - CHANGELOG complete (Features, Bug Fixes, Deprecations);
934
+ - demo reactor, rake task and matcher-only spec for each story, with one reactor per file;
935
+ - no **new** `Sidekiq::Testing.inline!` outside T041. Existing uses in `map_recovery_spec`,
936
+ `map_batch_size_spec` and the renamed `atomic_inline_spec` may remain. Check with
937
+ `git diff main -- spec | grep '^+.*inline!'`, which must show only T041.
938
+
939
+ ---
940
+
941
+ ## Dependencies & Execution Order
942
+
943
+ ### Phase Dependencies
944
+
945
+ - **Setup (Phase 1)**: none.
946
+ - **Foundational (Phase 2)**: after Setup. Blocks every story. T010 and T015/T016 matter most: the
947
+ owner-resume path and save-before-release are what US1's resume and US2 stand on.
948
+ - **US1 (Phase 3)**: after Foundational.
949
+ - **US2 (Phase 4)**:
950
+ - after Foundational;
951
+ - its specs (T052, T053) and T055 are independent of US1 in behavior;
952
+ - T054 edits `map_step.rb` after T037, and T056 edits `map/sweeper.rb` after T040 (the same-file
953
+ chains below);
954
+ - T057 (rollback hand-off through a composed child) needs US1 T029, T030 and T037;
955
+ - without US1, T053(a)–(d) pass with serial in-process map rollback, but (e)–(f) need US1.
956
+ - **US3 (Phase 5)**:
957
+ - after Foundational;
958
+ - T064 can be written any time after Foundational;
959
+ - T065 edits `map_step.rb` and `dispatcher.rb` after T037 / T035;
960
+ - T064(d) needs US1 (pending otherwise).
961
+ - **US4 (Phase 6)**:
962
+ - after Foundational (T012 did the internal rename);
963
+ - T069–T070 are independent;
964
+ - T071 edits `map_builder.rb` after T065;
965
+ - T072 edits `map_rollback_spec.rb` and `map_rollback_fixtures.rb` after T023.
966
+ - **Polish (Phase 7)**: after the desired stories.
967
+
968
+ ### Within Phases
969
+
970
+ - Spec tasks first, confirmed failing.
971
+ - US1 order:
972
+ 1. T025–T028 (signal, context field, manager);
973
+ 2. T029–T031 (executor incl. `undo_all` and the handshake, then the worker; T019 green);
974
+ 3. T033–T036 (storage, element rollback, dispatch, workers);
975
+ 4. T037 (MapStep);
976
+ 5. T038–T041;
977
+ 6. T042–T044 (test surface, API, GUI).
978
+ - Tasks touching the same file are sequential even across stories. [P] never overrides this list:
979
+ - `map_step.rb`: T012 → T013 → T014 → T016 → T037 → T054 → T065;
980
+ - `result_handler.rb`: T012 → T013;
981
+ - `executor.rb`: T010 → T029 → T030 → T057;
982
+ - `reactor.rb`: T039;
983
+ - `map/sweeper.rb`: T040 → T056;
984
+ - `map/dispatcher.rb`: T012 → T035 → T065;
985
+ - `map/element_executor.rb`: T012 → T038;
986
+ - `dsl/map_builder.rb`: T012 → T065 → T071;
987
+ - `storage/redis_adapter.rb`: T011 → T033;
988
+ - `spec/ruby_reactor/rollback/distributed_map_rollback_spec.rb`: T020 → T021 → T041;
989
+ - `spec/ruby_reactor/rollback/map_rollback_spec.rb`: T008 → T023 → T072;
990
+ - `spec/map/map_compose_fan_out_spec.rb`: T052 → T053;
991
+ - `spec/support/map_rollback_fixtures.rb`: T001 → T072;
992
+ - `demo_reactors.rake`: T047 → T049 → T060 → T067 → T075;
993
+ - `README.md`: T050 → T062 → T068 → T074;
994
+ - `documentation/data_pipelines.md`: T050 → T068 → T074;
995
+ - `documentation/background_and_async.md`: T050 → T062;
996
+ - `CHANGELOG.md`: T051 → T063 → T068 → T074;
997
+ - `demo_app/documentation/data_pipelines.md`: T068 → T074;
998
+ - `specs/future_improvements.md`: T062 → T077.
999
+
1000
+ ### Parallel Opportunities
1001
+
1002
+ - Setup: T002 and T003 alongside T001.
1003
+ - Foundational specs T004–T008 are all [P]. Implementation T009 is [P] beside T010–T012; T013
1004
+ follows T012.
1005
+ - US1 specs T019, T020, T022, T023 and T024 are [P]; T021 follows T020. T025, T027, T032, T036,
1006
+ T042 and T044 are [P] once their inputs exist. Demo and docs tasks T046, T050 and T051 are [P].
1007
+ - US2's T052 comes first, then T053.
1008
+ - Once Foundational is done, the **spec** tasks of US3 (T064) and US4 (T069) can be written
1009
+ alongside US1. Their implementation tasks follow the same-file chains above.
1010
+
1011
+ ---
1012
+
1013
+ ## Parallel Example: User Story 1
1014
+
1015
+ ```bash
1016
+ # Specs first, together:
1017
+ Task: "resume_rollback stub-construct spec in spec/ruby_reactor/executor/resume_rollback_spec.rb" # T019
1018
+ Task: "distributed rollback spec in spec/ruby_reactor/rollback/distributed_map_rollback_spec.rb" # T020
1019
+ Task: "recovery/fault-injection spec in spec/ruby_reactor/rollback/map_rollback_recovery_spec.rb" # T022
1020
+ Task: "both-path 008 coverage in spec/ruby_reactor/rollback/map_rollback_spec.rb" # T023
1021
+
1022
+ # Independent building blocks, together:
1023
+ Task: "RollbackHandedOff in lib/ruby_reactor/error/rollback_handed_off.rb" # T025
1024
+ Task: "context rollback field in lib/ruby_reactor/context.rb + redis_reactor_scan.rb" # T027
1025
+ Task: "routers + MapElementRollbackWorker for sidekiq and active_job" # T036
1026
+ Task: "be_rolling_back matcher + TestSubject in lib/ruby_reactor/rspec/" # T042
1027
+ ```
1028
+
1029
+ ---
1030
+
1031
+ ## Implementation Strategy
1032
+
1033
+ ### MVP First (User Story 1 Only)
1034
+
1035
+ 1. Phase 1 Setup.
1036
+ 2. Phase 2 Foundational: owner resume, save-before-release, metadata, rename internals.
1037
+ 3. Phase 3 US1: distributed rollback.
1038
+ 4. **Stop and validate**: T045, the demo spec T048, and quickstart §1.
1039
+
1040
+ ### Incremental Delivery
1041
+
1042
+ 1. Setup + Foundational: root-level maps resume through the owner, and the latent sweeper bug is
1043
+ fixed.
1044
+ 2. Add US1: large maps roll back distributed. This is the MVP.
1045
+ 3. Add US2: composed fan-out works; rollback through the root.
1046
+ 4. Add US3: default batch size 50.
1047
+ 5. Add US4: `atomic`.
1048
+ 6. Polish: Docker acceptance and the SC-002 benchmark.
1049
+
1050
+ ---
1051
+
1052
+ ## Notes
1053
+
1054
+ - `[P]` means different files and no dependency on an incomplete task.
1055
+ - Commit after each task or logical group, with conventional commit messages: `feat:` for the
1056
+ stories, `fix:` for T007/T010.
1057
+ - If a spec cannot be expressed with the shipped RSpec surface in `demo_app/spec/reactors/`, add
1058
+ the matcher to `lib/ruby_reactor/rspec/` (as T042 does). Never hand-roll scaffolding there.
1059
+ - Record any design deviation found while implementing in research.md, under the decision it
1060
+ changes.