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,585 @@
1
+ # Research: Distributed Map Rollback and Bounded Fan-out
2
+
3
+ **Feature**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md) | **Date**: 2026-09-30, revised
4
+ the same day after `/speckit-analyze` (R-02, R-04, R-05, R-06, R-07, R-09, R-12, R-15, R-16)
5
+
6
+ Each decision gives the choice, why, and what was rejected. File references are to the code as of
7
+ `21a4c59f` (008 merged).
8
+
9
+ ## Current behavior (verified in code)
10
+
11
+ | Question | Answer | Where |
12
+ | --- | --- | --- |
13
+ | Does a fan-out map's rollback run inline, loading every element? | Yes. `MapStep#compensate` reads the whole element-context index (`LRANGE 0 -1`), loads **every** row, deserializes all of them, sorts by index, then undoes each one serially in the process that detected the failure. `batch_size` plays no part. | `step/map_step.rb` `#compensate`, `#completed_elements` |
14
+ | Where does a fan-out map's failure get rolled back? | In the **collector** job: `Map::Helpers#resume_parent_execution` runs `handle_step_failure` for the map step there, holding only the `map_collect:` lock, never the parent's `async:` lock. | `map/helpers.rb`, `map/collector.rb` |
15
+ | What does `fan_out` without `batch_size` do? | Batch size falls back to `source.size`: one batch, every element enqueued at once. | `MapStep#dispatch_async_map`, `Dispatcher.dispatch_batch` |
16
+ | What is the forward back pressure exactly? | `batch_size` jobs are enqueued per throw. When the element at position `k*B - 1` finishes, the next `B` are enqueued. The trigger depends only on position, so other elements of earlier batches may still be running. A slow element never holds back later batches, and outstanding jobs are not bounded, only each throw is. | `ElementExecutor.trigger_next_batch_if_needed` |
17
+ | Why does a fan-out map inside a composed child hang? | `prepare_async_execution` stores the child under its own id. The collector loads that blob, whose `root_context` is nil, and resumes the child as a standalone run. Nothing ever resumes the root. | `map/helpers.rb` `#resume_parked_aware` (ponytail note) |
18
+ | What does `fail_fast` mean? | On (the default): the first element failure fails the map, stops dispatch, and rolls back every completed element. Off: the map completes with per-element `Result`s, failed elements roll themselves back, succeeded ones are kept. That is atomic versus partial. | `MapStep#execute_inline_map`, `ElementExecutor.handle_result`, `Dispatcher.dispatch_batch` |
19
+ | Does the map sweeper keep `batch_size` / `fail_fast` when it re-dispatches? | No. `requeue_index` reads `map_meta["batch_size"]` and `map_meta["fail_fast"]`, but `initialize_map_operation` never stores them. A re-dispatched element therefore loses `atomic` and stops triggering batches. This is a latent bug, fixed by R-10. | `storage/redis_adapter.rb#initialize_map_operation`, `Dispatcher.requeue_index` |
20
+
21
+ ---
22
+
23
+ ## R-01: Rollback mirrors execution mode
24
+
25
+ **Decision**: A map rolls back where its elements ran:
26
+
27
+ - A fan-out map dispatches one rollback job per completed element (R-02 to R-09).
28
+ - An inline map rolls back in the executing process, but reads element states in chunks of
29
+ `Map::ROLLBACK_CHUNK` (100) from the tail of the element index, never all at once.
30
+
31
+ **Rationale**:
32
+
33
+ - This is the user's rule ("rollback each map reactor the same way we execute them").
34
+ - An inline map is already bounded by one process's execution, so its rollback is not the scaling
35
+ problem. What makes it grow is the all-at-once read, and chunking removes that.
36
+
37
+ **Alternatives rejected**:
38
+
39
+ - Distribute inline maps' rollback too. That adds a hand-off to runs that never had one (a
40
+ synchronous `Reactor.run` would return a `DispatchResult` from a failure).
41
+ - Leave inline rollback unchanged. That keeps the O(n) load that FR-012 removes.
42
+
43
+ ## R-02: Back pressure is the forward batch mechanism, reused
44
+
45
+ **Decision**: Rollback jobs are dispatched exactly as forward element jobs are:
46
+
47
+ - Claim `B` positions with one atomic offset increment and enqueue them.
48
+ - When the job at position `k*B - 1` settles, the next batch is claimed.
49
+ - `B` is the map's effective batch size (R-10).
50
+
51
+ The spec bound (FR-002, FR-018, SC-001, SC-004) is "no throw enqueues more than `B`; the next
52
+ throw fires when the previous throw's last position reports". There is **no** bound on outstanding
53
+ jobs: the trigger fires on position alone, so a lagging element in batch `k` does not stop batches
54
+ `k+1`, `k+2` and so on. An earlier draft claimed "at most two batches outstanding". That only holds
55
+ when jobs finish in queue order, and it was dropped after `/speckit-analyze` (A1). The tests assert
56
+ the per-throw burst (`QueueProbe` `max_burst`), never queue depth.
57
+
58
+ **Rationale**:
59
+
60
+ - "Same way we execute them."
61
+ - One mechanism, one bound to document.
62
+ - The user's framing is per throw ("max amount of elements to fan out in a single throw").
63
+
64
+ **Alternatives rejected**:
65
+
66
+ - **Sliding window** (each settled job enqueues exactly one more; a strict `≤ B` outstanding). For
67
+ a database source the forward path would fetch one record per job (`offset(i).limit(1)`)
68
+ instead of one query per batch. It would also make forward and rollback diverge unless both
69
+ changed, and changing forward is out of scope.
70
+ - **Per-batch settle counter** (next batch only when the whole batch settled). This would bound
71
+ outstanding jobs at `B`, but it costs one more counter per map, stalls throughput on one slow
72
+ element, and diverges from forward. It can be revisited if unbounded lag ever matters in
73
+ practice.
74
+
75
+ ## R-03: Map completion resumes the owner run; `MapStep#run` adopts the settled outcome
76
+
77
+ **Decision**: The collector stops writing any context:
78
+
79
+ - **Collector**: once every index has a result slot, it claims `map:<id>:owner_signalled`
80
+ (`SET NX`) and enqueues the **owner's** `Worker`. The owner is the top-level run,
81
+ `context.root_context || context`, whose id and class are now stored in the map metadata
82
+ (`owner_context_id`, `owner_reactor_class_name`).
83
+ - **Owner Worker**: resumes as it does for any hand-off. The map step is not in
84
+ `intermediate_results`, so `MapStep#run` runs again, at any composition depth
85
+ (root → `ComposeStep#run` → child `resume_execution` → `MapStep#run`).
86
+ - **Re-entry**: `context.map_operations[step]` is set, so `MapStep#run` adopts instead of
87
+ dispatching:
88
+ - settled with an atomic failure: return the failing element's `Failure`, carrying its
89
+ `rollback_failures` (moved from `Collector.handle_failure`);
90
+ - settled otherwise: return `Success(collect(ResultEnumerator))` (moved from
91
+ `Collector.apply_collect_block`), and record `started` on the map reference (moved from
92
+ `Helpers#record_elements_started`);
93
+ - not settled (an early or duplicate resume, or a sweeper re-enqueue): return the
94
+ `DispatchResult` again, without dispatching anything.
95
+ - **Failure path**: an adopted `Failure` goes through the executor's normal failure handling.
96
+ `handle_step_failure` compensates the map (R-04 distributes it) and the run rolls back as any
97
+ step failure does.
98
+ - **Deleted**: `Map::Helpers#resume_parent_execution`, `#resume_parked_aware`, `#store_parent`,
99
+ `#record_elements_started`, and the `inline_async_execution = true` workaround.
100
+ - **`prepare_async_execution`**: stores the owner tree (`root_context || context`) under the
101
+ owner id before dispatch, as `handle_background_handoff` already does, so a fast resume finds a
102
+ consistent root. It also keeps storing the child snapshot, which the dispatcher reads to resolve
103
+ the source.
104
+
105
+ **Rationale**:
106
+
107
+ - Fixes "Fan-out map inside a composed child" by the direction `future_improvements.md` already
108
+ records.
109
+ - The same change removes the collector's failure-branch write, which that file lists as a writer
110
+ outside a controlled execution.
111
+ - It gives forward and rollback one mechanism: settle, signal the owner once, and the construct
112
+ adopts on re-entry.
113
+ - Adoption is idempotent, so a spurious resume (the general sweeper re-enqueues any `running`
114
+ context with no `async:` lock) no longer risks dispatching a map twice.
115
+
116
+ **Alternatives rejected**:
117
+
118
+ - **Collector writes the root blob.** This breaks the single-writer rule: the collector never holds
119
+ the root's `async:` lock.
120
+ - **Collector resumes the root inline.** Same lock problem, and the collector keeps a second copy
121
+ of the executor's failure path.
122
+ - **Keep the root-level path, special-case only composed children.** That leaves two completion
123
+ paths and the collector's failure-branch write.
124
+
125
+ **Cost**: One extra job hop (collector → owner Worker) on every fan-out map completion. The owner
126
+ Worker contends on the `async:` lock if the dispatching worker still holds it, and snoozes
127
+ uncapped until it is released (existing behavior for that lock).
128
+
129
+ **Implementation notes** (found while implementing):
130
+
131
+ - `ComposeStep#compensate` / `#undo` now link the stored child to its parent and root, as
132
+ `#run` does. Unlinked, a fan-out map inside the child recorded the child as its owner during
133
+ rollback, and the rollback resumed the child's standalone row instead of the root.
134
+ - The `DispatchResult` a fan-out map returns carries the owner's `execution_id`, not the composed
135
+ child's: it is what the caller of `Reactor.run` holds and looks the run up by.
136
+ - **An interrupt inside a composed child is unsupported on main, fan-out or not**: `ComposeStep`
137
+ fails the step (`NoMethodError` on `InterruptResult#success?`). US2-AS2 (T052(b)) is out of reach
138
+ without that separate capability; its example is `pending` with the reason.
139
+
140
+ ## R-04: Rollback can hand off and resume (`Error::RollbackHandedOff`)
141
+
142
+ **Decision**: A construct whose rollback is distributed (`MapStep#compensate` / `#undo` on a
143
+ fan-out map) starts the map rollback (R-06), then raises `Error::RollbackHandedOff`.
144
+
145
+ **How the signal unwinds**:
146
+
147
+ - `CompensationManager#rollback_completed_steps` pops an entry only after its undo returns, so the
148
+ map entry stays on the undo stack. Entries already undone are gone.
149
+ - A **composed child's** executor rescues the signal, records its own `rollback` state (below) on
150
+ the child context, and re-raises. The root embeds the child, so the root's save persists it.
151
+ - **`Executor#undo_all`** does the same at its own level. This is the path manual undo and
152
+ `ComposeStep#compensate` take into a child: they call the child executor's `undo_all`, never its
153
+ `execute` / `resume_execution`.
154
+ - On a hand-off it writes `@context.rollback["failures"]` from its compensation manager and
155
+ re-raises.
156
+ - On entry, when `@context.rollback` is present, it restores those failures first, and clears
157
+ them when it finishes.
158
+ - Without this, a child's rollback failures collected before the hand-off would be lost, because
159
+ the child executor is rebuilt on resume (`/speckit-analyze` G2).
160
+ - The **top-level** executor (`execute` / `resume_execution` / `resume_rollback`) rescues the
161
+ signal before `rescue Error::Rescuable` and `rescue Exception`. It records the run's `rollback`
162
+ state, sets status `rolling_back`, and **saves inside the rescue body, while it still holds the
163
+ context lock** (R-13). It returns a `DispatchResult` (`job_id: "map_rollback:<map_id>"`).
164
+ - **`Reactor#undo`** (manual) rescues it the same way (R-12).
165
+
166
+ **`Error::Rescuable` does not match `RollbackHandedOff`**: it is a control signal, not a failure.
167
+ This lets it pass through the `rescue Error::Rescuable` sites between the raise and the handler:
168
+
169
+ - `CompensationManager#compensate_step_body` and `#undo_step` (they wrap `call_undo`, which is
170
+ where `MapStep#undo` runs);
171
+ - `StepExecutor#safe_execute_step_sync` (a child's rollback inside `ComposeStep#run`);
172
+ - `Executor#aborting_on_interruption`, which must not mark the run `aborted` on it.
173
+
174
+ **`rollback` state on the context** (data-model.md): `trigger` (`failure` | `undo`), `step` (the
175
+ failing step, or nil), `compensated`, `failure` (the triggering `Failure`, serialized), and
176
+ `failures` (rollback failures collected so far).
177
+
178
+ **Resuming**: `Worker#perform` sees `rolling_back` and calls `Executor#resume_rollback`, which runs
179
+ under the context lock:
180
+
181
+ 1. Restores `rollback_failures` from the saved state.
182
+ 2. If `compensated` is false, compensates the failing step. Only constructs can hand off, and they
183
+ read their state from the context, so arguments are `{}`. A map re-enters and adopts; a compose
184
+ continues its child's rollback.
185
+ 3. Runs `rollback_completed_steps`.
186
+ 4. Finalizes:
187
+ - `failure` trigger: rebuild the `StepFailureError` from the saved failure and call
188
+ `result_handler.handle_execution_error`. This is the code
189
+ `Helpers#resume_parent_execution` has today, moved into the executor, so the final `Failure`,
190
+ the `CompensationError` rule and the `rollback_failures` shape are unchanged.
191
+ - `undo` trigger: status `cancelled`, reason "Undo triggered".
192
+ - Either way, `rollback` is cleared.
193
+
194
+ **Rationale**:
195
+
196
+ - The undo stack already makes rollback resumable: entries pop only when done (008 R-16). What was
197
+ missing is a signal that stops the loop cleanly, and a place to store the trigger.
198
+ - Raising matches how parks unwind (`ExecutionParked`) through the same stack depth.
199
+
200
+ **Alternatives rejected**:
201
+
202
+ - **Return a sentinel result from `#undo`.** It would have to be plumbed through
203
+ `rollback_completed_steps`, `handle_step_failure`, three `ResultHandler` branches,
204
+ `ComposeStep#compensate` and `Reactor#undo`, and every intermediate caller would have to learn
205
+ it.
206
+ - **Wait in place** (the coordinating job polls until element rollbacks finish). This holds a worker
207
+ for the whole rollback, hits job timeouts and shutdown (`Sidekiq::Shutdown` is an interruption,
208
+ so the run would be marked `aborted`), and still needs resumption after a crash.
209
+ - **Subclass `ExecutionParked` and snooze-poll.** Every poll reloads the parent blob. The trigger
210
+ plus sweeper is how the forward path already works.
211
+
212
+ **Implementation notes** (found while implementing):
213
+
214
+ - **The final Failure is captured at the hand-off, not rebuilt at resume.** The `ResultHandler`
215
+ the signal passes through sets `RollbackHandedOff#failure` to the exact Failure the run will end
216
+ with (`failure_for` of the `StepFailureError` it would raise, minus rollback failures), and the
217
+ executor saves it in `rollback["failure"]`. `resume_rollback` adds the rollback failures. Rebuilding
218
+ a `StepFailureError` from the saved Failure loses `step_arguments`, `validation_errors` and the
219
+ code location; capturing keeps every field, so I-7 holds exactly.
220
+ - `rollback["compensation_error"]`: the failing step's compensate returned a Failure before the
221
+ rollback handed off, so the resume ends with that `CompensationError`, as inline.
222
+ - **A third trigger, `step`.** When a composed child hands off from inside `ComposeStep#run` (the
223
+ child's own step failed), the root has no rollback of its own yet. The root records
224
+ `{trigger: "step"}` and, on resume, runs forward again: `ComposeStep#run` re-enters the child,
225
+ which is `rolling_back` and finishes its own rollback (`resume_rollback` at the child level), and
226
+ the child's Failure then rolls the root back exactly as the inline path does. T057's plan
227
+ (compensate the compose step with `compensated: false`) would report the child's rollback
228
+ failures as a `CompensationError`, which diverges from the inline Failure (I-7).
229
+ - `Executor#resume_execution` delegates to `resume_rollback` for a `rolling_back` context, which
230
+ is how a composed child re-enters; `Worker` routes a `rolling_back` row there directly.
231
+
232
+ ## R-05: Exactly-once owner resume; recovery
233
+
234
+ **Decision**:
235
+
236
+ - **Owner resume**: a two-flag handshake, so the owner is enqueued only after its hand-off is
237
+ saved, exactly once, whichever side finishes last.
238
+ - **Hand-off side**: the top-level handler (`Executor` rescue, or `Reactor#undo`) saves the
239
+ context, then sets `map:<id>:rollback:handed_off` (`SET`). It then checks
240
+ `count_map_rollback_outcomes == total`. If the map has settled and it claims
241
+ `map:<id>:rollback:signalled` (`SET NX`), it enqueues the owner's `Worker`.
242
+ - **Job side**: the element rollback job that stores an outcome checks settled. If the map has
243
+ settled **and** `handed_off` is set **and** it claims `signalled`, it enqueues the owner's
244
+ `Worker`.
245
+ - **Why it is exactly once**: whichever side observes both conditions second enqueues, and the
246
+ claim makes it once.
247
+ - **Why the Worker never sees a stale blob**: it is enqueued only after `handed_off`, which is set
248
+ after the save. The owner's `async:` lock still serializes it against the handler's release.
249
+ - **Inline test mode** (`Sidekiq::Testing.inline!`): element jobs run inside dispatch, so every
250
+ outcome is stored before `MapStep#undo` returns and before any `handed_off` flag exists. No job
251
+ enqueues the owner. `#undo` checks settled right after dispatching and aggregates synchronously;
252
+ there is no hand-off. This decides `/speckit-analyze` U3 in the design instead of leaving it to
253
+ implementation.
254
+ - **Recovery**:
255
+ - **General `RubyReactor::Sweeper`**: treats `rolling_back` like `running`. With no `async:` lock
256
+ held, it re-enqueues the owner. On re-entry an unsettled map rollback raises the hand-off again,
257
+ so this is harmless.
258
+ - **`Map::Sweeper`**: gains a rollback pass. For each map with rollback metadata, it
259
+ re-dispatches positions below the claimed offset that have no outcome and no live element lock.
260
+ It also claims positions never dispatched when their batch trigger was lost.
261
+
262
+ **Rationale**: This mirrors the forward map's recovery split, where elements are recovered by
263
+ `Map::Sweeper` and the owner by `RubyReactor::Sweeper`. The owner-side check makes duplicate
264
+ triggers harmless.
265
+
266
+ ## R-06: The rollback work list is the element-context index, read by position
267
+
268
+ **Decision**:
269
+
270
+ - **Work items**: positions in the existing element-context list (`map_element_contexts_key`),
271
+ counted from the tail. `total = LLEN` is fixed when the rollback starts
272
+ (`HSETNX rollback:metadata`, so a second start finds the first: FR-027).
273
+ - **Coverage**: the list holds every element that **started**, because `ElementExecutor` registers
274
+ an element before running it. So failed and halted elements get a job too, and it reports
275
+ `not_needed` without undoing anything. Skipped elements never registered and get no job. Spec
276
+ US1-AS2, FR-007 and the edge cases say this since `/speckit-analyze` I1.
277
+ - **Rejected alternative**: a separate forward list of completed ids. It would miss a context
278
+ that completed but crashed before pushing its id, and a sweeper re-dispatch would then leave
279
+ its side effects un-undone. A no-op job per failed element is cheap by comparison.
280
+ - **Dispatch**: claims positions with `INCRBY rollback:offset B` and reads them with one
281
+ `LRANGE` per batch.
282
+ - **Outcomes**: stored in `rollback:results` (a hash, position → outcome), so they are
283
+ idempotent.
284
+ - **Forward registration is deduplicated at the source**: `ElementExecutor` registers the context
285
+ id only for a fresh context, not on a parked or retried re-entry (`serialized_context` present),
286
+ which re-registers the same id today.
287
+ - **Leftover duplicates** (payloads from before the upgrade, or a sweeper re-dispatch that made a
288
+ second context for the same index) are safe:
289
+ - each job takes the element's `map_element:<map>:<index>` lock. If it is contended, the job
290
+ requeues itself instead of reporting (R-07), so two jobs for the same index (a duplicate id,
291
+ or a superseded context) serialize rather than produce a false `element_in_flight`;
292
+ - an element whose undo stack is already empty reports `undone` with nothing to do.
293
+
294
+ **Rationale**: No new forward write. `LRANGE` by position gives bounded reads and atomic claims.
295
+ Superseded contexts from a sweeper re-dispatch stay in the list and are rolled back if they
296
+ completed, as they are today.
297
+
298
+ **Alternatives rejected**:
299
+
300
+ - **Index → id hash written by the forward path.** It loses superseded ids, so a context that
301
+ completed twice (a lost result followed by a re-dispatch) would be rolled back once.
302
+ - **Scanning the forward results hash.** It holds result values, not context ids, and can be large.
303
+
304
+ **Implementation note**: tail positions are anchored at the `total` the rollback started with
305
+ (head index `total - 1 - position`), so an id a late duplicate appends after the start never
306
+ shifts them.
307
+
308
+ ## R-07: Element rollback checkpoints after every undone entry
309
+
310
+ **Decision**:
311
+
312
+ - The element rollback job (`Map::ElementRollback`) takes the element's `map_element:` lock
313
+ (`wait: ELEMENT_LOCK_WAIT`).
314
+ - **If the lock is held**, the job does not report yet. It requeues itself with the same
315
+ arguments through `perform_map_element_rollback_in(Worker.snooze_delay(config, nil),
316
+ attempt: attempt + 1, ...)`. The position stays outstanding, so back pressure and settle
317
+ counting are unaffected.
318
+ - **Only after `lock_snooze_max_attempts`** does it report `element_in_flight`. This is today's
319
+ outcome for a genuinely live forward duplicate.
320
+ - **Why**: the lock is shared by every context of one index. Without the requeue, a concurrent
321
+ duplicate id or superseded context in the same batch would report a false rollback failure,
322
+ and a superseded completed context would never be undone (`/speckit-analyze` U2).
323
+ - **Inline path**: in-process, one element at a time, so it never contends with itself. It keeps
324
+ today's single wait-then-report.
325
+ - It then runs `rollback_completed_steps` with a per-entry callback that saves the element context
326
+ after each pop. A redelivery resumes after the last saved entry.
327
+ - The same class serves the inline path (R-01), called in-process.
328
+
329
+ **Rationale**: Spec FR-006 as revised. Exactly-once per step would need idempotent user undos or
330
+ two-phase records. At-least-once for the one entry in flight is the standard background-job
331
+ guarantee, and it is documented.
332
+
333
+ **Implementation notes**: only a job's first attempt waits `ELEMENT_LOCK_WAIT` for the lock (the
334
+ settle gap); a requeued attempt tries once, since its requeue delay is the spacing, so a held lock
335
+ never blocks a worker for `2 s × lock_snooze_max_attempts`. An element saves after each undone entry
336
+ with `Executor#checkpoint!`, which stores the row without publishing a completion signal per entry.
337
+
338
+ ## R-08: Dispatch order
339
+
340
+ **Decision**: Positions are counted from the tail of the element index, so the newest-started
341
+ element goes first:
342
+
343
+ - For an inline map this is exactly highest index first, today's order.
344
+ - For a fan-out map it follows start order, which tracks index order batch by batch.
345
+
346
+ Completion order across elements is not guaranteed.
347
+
348
+ **Rationale**: Elements are independent and ran in parallel forward. Strict order across parallel
349
+ jobs would need serialization, which defeats FR-001.
350
+
351
+ ## R-09: Reporting unavailable elements at bounded memory
352
+
353
+ **Decision**:
354
+
355
+ - An element rollback job whose context row is gone reports `context_unavailable`. Its index is
356
+ unknown, because the row held it.
357
+ - Every job adds its element index to `rollback:indexes` (a set).
358
+ - At aggregation, the owner checks `0...started` (from the map reference) against that set in
359
+ pipelined `SISMEMBER` chunks of 1,000. It reports each index that was started but never seen,
360
+ which covers an expired index list, as 008's `report_unavailable` does.
361
+ - **Each expired element is reported once, as in 008**:
362
+ - **`started` known**: the named, started-but-unseen entries are the report, and nil-index
363
+ `context_unavailable` outcomes are **not** added. They describe the same elements.
364
+ - **`started` unknown** (a failed fan-out map that skipped indexes): one unnamed entry per nil-index
365
+ outcome.
366
+
367
+ This matches `report_unavailable` (`map_step.rb`), which reports one form or the other, never
368
+ both, and keeps the inline/fan-out oracle (I-7) exact (`/speckit-analyze` I2).
369
+ - Aggregation reads outcomes with `HSCAN` in chunks and keeps only failures.
370
+ - **Shape**: outcomes are stored as JSON, so aggregation re-symbolizes each failure entry: symbol
371
+ keys, and `step`, `kind`, `reason` and `map_step` values symbolized back. Everything else is
372
+ unchanged: `key`, `message`, and `element_index` (an Integer or nil). The final `rollback_failures`
373
+ are then equal to the inline path's (FR-005, `/speckit-analyze` U1).
374
+
375
+ **Rationale**: Parity with 008 (FR-007) without loading every index at once. Pipelined `SISMEMBER`
376
+ works on any Redis. `SMISMEMBER` would need 6.2, and the gem does not manage Redis.
377
+
378
+ **Implementation notes**: an outcome's `failures` are stored serialized with
379
+ `ContextSerializer.serialize_value`, so symbols round-trip exactly and no manual re-symbolizing is
380
+ needed. Outcomes are first-writer-wins (`HSETNX`): a duplicate delivery, which finds the element
381
+ already undone, must not replace a `failed` outcome with its own `undone`, and only the job that
382
+ stored the outcome claims the next throw.
383
+
384
+ ## R-10: Default batch size, stored with the map
385
+
386
+ **Decision**:
387
+
388
+ - **Constant**: `RubyReactor::Map::DEFAULT_BATCH_SIZE = 50`. There is no configuration setting;
389
+ the per-map `batch_size` is the override (spec Assumptions).
390
+ - **Applied** in `MapStep#dispatch_async_map` and the `Dispatcher` fallback.
391
+ - **Stored in map metadata**: the effective `batch_size` and `atomic`, which fixes the
392
+ sweeper re-dispatch losing both. The rollback reads `batch_size` from the metadata.
393
+
394
+ **Rationale**: The forward and rollback paths and the sweeper need one source of truth. A constant
395
+ is enough until someone needs a different default everywhere (YAGNI).
396
+
397
+ ## R-11: `atomic` replaces `fail_fast`
398
+
399
+ **Decision**:
400
+
401
+ - **DSL**: `MapBuilder#atomic(enabled = true)`, on by default.
402
+ - **`fail_fast(enabled = true)`** sets the same flag and prints one definition-time deprecation
403
+ warning per call site, through the existing `warn_definition` helper. That helper is extracted
404
+ from `StepBuilder` into `Dsl::DefinitionWarnings`, a module both builders include.
405
+ - **Both declared** on one map: `Error::ValidationError` at definition.
406
+ - **Internals** are renamed (`MapStep` input, metadata, job payload key) to `atomic`.
407
+ - **Legacy payloads**: job payloads enqueued before the upgrade carry `fail_fast`. One
408
+ normalization in `Map::Helpers.normalize_arguments`, used by `ElementExecutor`, `Dispatcher`,
409
+ `Collector` and `RetryManager`, maps `fail_fast` to `atomic` (FR-023). It is removed at the
410
+ next MAJOR.
411
+
412
+ **Rationale**:
413
+
414
+ - MINOR: nothing that works today breaks.
415
+ - One normalization point instead of reading both keys everywhere.
416
+
417
+ **Alternatives rejected**:
418
+
419
+ - **Remove `fail_fast` and raise `DeprecatedDslError`**, like map `async`. That is breaking, and
420
+ the semantics did not change, which is what justified the hard removal of `async`.
421
+ - **Keep `fail_fast` internally.** Future readers would see two names for one flag.
422
+ - **`all_or_none` / `all_or_nothing`**: long, and the opt-out (`all_or_none false`) reads awkwardly.
423
+ `atomic` is one word and is the usual saga/transaction term.
424
+ - **`allow_partial`** (inverted, off by default): reads well, but the `fail_fast` alias and the
425
+ legacy payload mapping would have to negate the value, which is easy to get wrong.
426
+
427
+ ## R-12: Manual undo holds the run's context lock
428
+
429
+ **Decision**: `Reactor.undo(id)` / `#undo` acquire `async:<root id>` (`wait:` `undo_lock_wait`
430
+ constant, 5s) around the whole undo.
431
+
432
+ - If the lock is held (a live run or rollback), it raises the existing `Lock::AcquisitionError`.
433
+ There is no new error class; the caller retries.
434
+ - A run already in `rolling_back` raises `Error::ValidationError` ("rollback already in
435
+ progress", FR-027).
436
+ - **`Reactor.cancel` / `#cancel` gets the same guard**: on a `rolling_back` run it raises
437
+ `Error::ValidationError` ("rollback in progress; cannot cancel"). `cancelled` is terminal, so the
438
+ owner `Worker` returns early and the sweeper skips the run. The steps before the map would never
439
+ be undone and nothing would recover them (`/speckit-analyze` G1, Constitution II). `cancel`
440
+ otherwise stays as is.
441
+ - If the undo hands off, `#undo` records `rollback: {trigger: "undo"}`, sets `rolling_back`, saves,
442
+ and returns without cancelling. `Reactor.undo` skips its `cancel` call in that case, and
443
+ `resume_rollback` applies `cancelled` when the rollback finishes.
444
+
445
+ **Rationale**:
446
+
447
+ - Without the lock, element jobs can finish and resume the owner before the caller's process has
448
+ saved the undo stack it already popped, and steps after the map would be undone twice.
449
+ - This is a narrow part of `future_improvements.md` §"External actors send requests". It takes
450
+ the lock for undo only. It adds only the `rolling_back` status guard to `cancel`, and leaves
451
+ `continue` alone.
452
+
453
+ ## R-13: `resume_execution` saves before releasing the context lock
454
+
455
+ **Decision**: In `Executor#resume_execution`'s `ensure`, `save_context` moves before
456
+ `@acquired_context_lock&.release`. The rollback hand-off also saves in its rescue body (R-04).
457
+
458
+ **Rationale**:
459
+
460
+ - Today the lock is released and then the blob saved. An owner Worker (R-03, R-05) can take the
461
+ lock in that gap, load the pre-save blob and continue from stale state, and the late save then
462
+ overwrites its progress.
463
+ - The collector's current resume has the same window. R-03 routes every map completion through it,
464
+ so the ordering becomes load-bearing.
465
+ - A regression spec pins it.
466
+
467
+ **Out of scope**: the synchronous caller's final save racing a worker (`Reactor.run` with a fan-out
468
+ map never holds `async:`). This is `future_improvements.md` §"Fenced context writes"; unchanged.
469
+ Tracked as the follow-up "A synchronous caller's final save races a worker resume" in
470
+ `specs/future_improvements.md` §"Rollback follow-ups (008)".
471
+
472
+ ## R-14: A map step's undo record carries no arguments
473
+
474
+ **Decision**:
475
+
476
+ - `MapStep` declares `rollback_arguments` → `{}`, a `StepConfig` hook with default identity, used
477
+ where `ResultHandler` pushes the undo entry. A map's rollback reads its declaration
478
+ (`element_class`) and the map records, never the resolved `source`.
479
+ - This holds for both modes.
480
+
481
+ **Rationale**: The executor, not the collector, now records a fan-out map's completion (R-03). The
482
+ normal push would store the resolved `source` (a whole array) in the parent blob, which 008 R-03
483
+ deliberately avoided. Inline maps stored it too, needlessly.
484
+
485
+ ## R-15: Observability
486
+
487
+ **Decision**:
488
+
489
+ - **Status `rolling_back`** is added in these places:
490
+ - `Worker::TERMINAL_STATUSES` excludes it;
491
+ - `RedisReactorScan` known statuses;
492
+ - the general sweeper;
493
+ - the dashboard, everywhere statuses are listed (`/speckit-analyze` C4):
494
+ - `gui/src/lib/reactors.ts` `STATUS_GROUPS.running`, so it counts and filters with the live
495
+ runs;
496
+ - `gui/src/components/StatusBadge.tsx`: amber with an undo icon;
497
+ - the status filter options in `LiveView.tsx` and `ReactorClassInstances.tsx`;
498
+ - the status colour in `ReactorDetail.tsx`.
499
+ - **Map reference hydration** (`Web::API.hydrate_map_ref`) adds
500
+ `rollback: { total, settled, outstanding, failed }` (`outstanding = total - settled`) from the
501
+ rollback records. The GUI step inspector shows "Rolling back settled/total (outstanding
502
+ outstanding, failed failed)" **only while the run's status is `rolling_back`**. The records
503
+ outlive the run by the durability TTL, so the API field alone would otherwise show a finished
504
+ rollback as in progress (`/speckit-analyze` A3).
505
+ - **Structured log lines**, key=value, with reactor, map step and element index:
506
+ - `event=ruby_reactor.map.rollback.started`, with `total` and `batch_size`;
507
+ - `event=ruby_reactor.map.rollback.element`, with `index`, `outcome` and `failures`;
508
+ - `event=ruby_reactor.map.rollback.completed`, with `total` and `failed`.
509
+ - **Element undo middleware events** (`start_undo` …) fire inside the element job with the
510
+ element's context, as they do in the inline path.
511
+
512
+ **Rationale**: Constitution IV and spec FR-009 / FR-011. There is no new middleware event type; the
513
+ existing per-step undo events already cover each element.
514
+
515
+ ## R-16: Testing strategy
516
+
517
+ **Decision**: Real Redis, Sidekiq fake mode plus `drain_async_jobs`.
518
+
519
+ - **One new `inline!` spec** (T041). Its subject is inline-mode support itself: a spec edge case,
520
+ and R-05's claim that no hand-off happens. It cannot be written in fake mode. This deviates from
521
+ Constitution III, so it is justified in plan.md Complexity Tracking.
522
+ - **Existing specs** that already use `inline!` (`map_recovery_spec`, `map_batch_size_spec`,
523
+ `fail_fast_spec`) are extended where needed, but no new `inline!` block is added to them.
524
+ - **Step-wise drain helper**: `pending_async_jobs` is the step-wise helper. Its ActiveJob
525
+ `PendingJob` exposes `job_class` while the Sidekiq one exposes `worker_class`. The shipped
526
+ surface gains `worker_class` as an alias on the ActiveJob struct, so step-wise drains work under
527
+ `for_each_async_backend` (`/speckit-analyze` U4).
528
+
529
+ - **Oracle**: the same reactor run with an inline map and with a fan-out map must end with the same
530
+ final `Failure` (message, step, `rollback_failures` set) and the same set of undone elements.
531
+ Specs share one fixture reactor with a `fan_out` toggle.
532
+ - **Back pressure**: drain one job at a time and assert no single job enqueues more than `B`
533
+ element rollback jobs (`max_burst`). Queue depth is not asserted (R-02).
534
+ - **Fault injection**:
535
+ - raise an interruption from an element's second undo, then redeliver the job: the first undo
536
+ runs once and the second runs again;
537
+ - hold an element's `map_element:` lock while its rollback job runs: the job requeues, then
538
+ completes once the lock is free, with no `element_in_flight` (R-07);
539
+ - drop a rollback job, then run `Map::Sweeper.run_once`;
540
+ - drop the owner resume, then run `RubyReactor::Sweeper.run_once`.
541
+ - **Scale** (`:slow` tag): 10,000 elements, no row loaded twice per job; the peak number of element
542
+ contexts in memory per job is 1.
543
+ - **SC-002** (5× with 10 workers) needs real concurrency. It is measured by a benchmark rake task in
544
+ the demo app against Docker Sidekiq at concurrency 10 (quickstart §5), not in RSpec.
545
+ - **Baseline**: the inline-map rollback of the same elements. That is today's serial algorithm,
546
+ with chunked reads that don't change the per-element work. The spec's SC-002 now names this
547
+ baseline explicitly.
548
+
549
+ ## R-17: Documentation impact
550
+
551
+ - **README.md**: the map section (~lines 880–930: `fan_out`, `batch_size`, the `fail_fast`
552
+ mentions, the rollback paragraph, "`batch_size` is optional"); the status list; the Durability
553
+ section's statuses.
554
+ - **documentation/data_pipelines.md**:
555
+ - "`fan_out` Without `batch_size`" (default 50);
556
+ - Back Pressure (applies to rollback);
557
+ - the `fail_fast` section, rewritten as `atomic` with a deprecation note;
558
+ - Rollback (distributed, `rolling_back`, at-least-once window).
559
+ - **documentation/background_and_async.md**: the fan-out maps section (owner resume, rollback
560
+ hand-off, the new worker class, sweeper coverage).
561
+ - **documentation/composition.md**: compose plus fan-out is supported, and how rollback travels.
562
+ - **documentation/core_concepts.md**: the rollback table map row; the `rolling_back` status.
563
+ - **documentation/testing.md**: the `be_rolling_back` matcher.
564
+ - **demo_app/documentation/data_pipelines.md**: kept in sync.
565
+ - **CHANGELOG.md**:
566
+ - Features: distributed map rollback, default batch size, `atomic`;
567
+ - Bug Fixes: composed-child fan-out, sweeper re-dispatch losing `batch_size` / `atomic`;
568
+ - Deprecations: `fail_fast`.
569
+ - **specs/future_improvements.md**:
570
+ - remove "Fan-out map inside a composed child" and "Rollback fan-out for very large maps";
571
+ - mark the collector failure-branch writer as fixed.
572
+
573
+ ## R-18: SemVer
574
+
575
+ **Decision**: MINOR. Nothing is marked `!`.
576
+
577
+ The observable changes, each noted in CHANGELOG:
578
+
579
+ - fan-out maps over 50 elements without a declared `batch_size` are now back-pressured;
580
+ - the new status value `rolling_back`;
581
+ - one extra job per fan-out map completion;
582
+ - `fail_fast` warns.
583
+
584
+ **Rationale**: No working reactor needs a code change (spec SC-006). Consumers that filter on
585
+ status strings see one new value, which is additive.