ruby_reactor 0.8.4 → 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 (104) 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 +254 -0
  6. data/CLAUDE.md +1 -1
  7. data/README.md +66 -15
  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/async_macros.rb +30 -1
  14. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
  15. data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
  16. data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
  17. data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
  18. data/lib/ruby_reactor/dsl/map_builder.rb +27 -9
  19. data/lib/ruby_reactor/dsl/step_builder.rb +93 -34
  20. data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
  21. data/lib/ruby_reactor/error/rescuable.rb +33 -0
  22. data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
  23. data/lib/ruby_reactor/executor/compensation_manager.rb +63 -35
  24. data/lib/ruby_reactor/executor/result_handler.rb +85 -63
  25. data/lib/ruby_reactor/executor/retry_manager.rb +4 -3
  26. data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
  27. data/lib/ruby_reactor/executor/step_executor.rb +61 -49
  28. data/lib/ruby_reactor/executor.rb +221 -7
  29. data/lib/ruby_reactor/map/collector.rb +31 -89
  30. data/lib/ruby_reactor/map/dispatcher.rb +65 -8
  31. data/lib/ruby_reactor/map/element_executor.rb +21 -11
  32. data/lib/ruby_reactor/map/element_rollback.rb +158 -0
  33. data/lib/ruby_reactor/map/helpers.rb +11 -125
  34. data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
  35. data/lib/ruby_reactor/map/step_rollback.rb +136 -0
  36. data/lib/ruby_reactor/map/sweeper.rb +66 -8
  37. data/lib/ruby_reactor/map.rb +17 -0
  38. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
  39. data/lib/ruby_reactor/reactor.rb +65 -2
  40. data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
  41. data/lib/ruby_reactor/rspec/matchers.rb +59 -24
  42. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
  43. data/lib/ruby_reactor/rspec/test_subject.rb +13 -3
  44. data/lib/ruby_reactor/rspec.rb +3 -6
  45. data/lib/ruby_reactor/step/compose_step.rb +10 -1
  46. data/lib/ruby_reactor/step/map_step.rb +144 -36
  47. data/lib/ruby_reactor/step.rb +7 -0
  48. data/lib/ruby_reactor/step_worker.rb +46 -22
  49. data/lib/ruby_reactor/storage/adapter.rb +18 -0
  50. data/lib/ruby_reactor/storage/redis_adapter.rb +27 -2
  51. data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
  52. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  53. data/lib/ruby_reactor/sweeper.rb +3 -1
  54. data/lib/ruby_reactor/version.rb +1 -1
  55. data/lib/ruby_reactor/web/api.rb +11 -1
  56. data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-C6ejszCQ.js} +12 -12
  57. data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
  58. data/lib/ruby_reactor/web/public/index.html +2 -2
  59. data/lib/ruby_reactor/worker.rb +7 -3
  60. data/lib/ruby_reactor.rb +17 -6
  61. data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
  62. data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
  63. data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
  64. data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
  65. data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
  66. data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
  67. data/specs/007-execution-flow-analysis/data-model.md +83 -0
  68. data/specs/007-execution-flow-analysis/evidence/harness.rb +231 -0
  69. data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
  70. data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
  71. data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
  72. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +235 -0
  73. data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
  74. data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
  75. data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
  76. data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
  77. data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
  78. data/specs/007-execution-flow-analysis/plan.md +127 -0
  79. data/specs/007-execution-flow-analysis/quickstart.md +51 -0
  80. data/specs/007-execution-flow-analysis/research.md +202 -0
  81. data/specs/007-execution-flow-analysis/spec.md +270 -0
  82. data/specs/007-execution-flow-analysis/tasks.md +257 -0
  83. data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
  84. data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
  85. data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
  86. data/specs/008-rollback-reliability/data-model.md +139 -0
  87. data/specs/008-rollback-reliability/plan.md +233 -0
  88. data/specs/008-rollback-reliability/quickstart.md +105 -0
  89. data/specs/008-rollback-reliability/research.md +653 -0
  90. data/specs/008-rollback-reliability/spec.md +561 -0
  91. data/specs/008-rollback-reliability/tasks.md +1110 -0
  92. data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
  93. data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
  94. data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
  95. data/specs/009-distributed-map-undo/data-model.md +151 -0
  96. data/specs/009-distributed-map-undo/plan.md +323 -0
  97. data/specs/009-distributed-map-undo/pr-description.md +78 -0
  98. data/specs/009-distributed-map-undo/quickstart.md +154 -0
  99. data/specs/009-distributed-map-undo/research.md +585 -0
  100. data/specs/009-distributed-map-undo/spec.md +360 -0
  101. data/specs/009-distributed-map-undo/tasks.md +1060 -0
  102. data/specs/future_improvements.md +64 -28
  103. metadata +54 -3
  104. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +0 -1
@@ -0,0 +1,360 @@
1
+ # Feature Specification: Distributed Map Rollback and Bounded Fan-out
2
+
3
+ **Feature Branch**: `distributed_map_undo`
4
+
5
+ **Created**: 2026-09-30
6
+
7
+ **Status**: Draft, revised 2026-09-30 by /speckit-plan (back-pressure bound, at-least-once window, dispatch order; see research R-02, R-07, R-08) and after /speckit-analyze (started-element coverage, per-throw bound, cancel guard, FR-027)
8
+
9
+ **Input**: User description: "Improvements to maps execution and undo. (1) It is not clear whether
10
+ rollback of a map run in batches executes every element's compensation inline, loading all the
11
+ elements in a single thread. When processing a huge list with a declared batch_size, each map
12
+ element should be rolled back the same way it was executed: using the batch size, each undo in its
13
+ own job, with back pressure. (2) Rename the `fail_fast` configuration; what it really means is
14
+ 'all or none'. (3) `fan_out` without batch_size fans out every element at once; define a maximum
15
+ per throw, i.e. a default batch size (50). (4) Fix 'Fan-out map inside a composed child' from
16
+ specs/future_improvements.md."
17
+
18
+ ## Context
19
+
20
+ What happens today, and what this feature changes:
21
+
22
+ | Area | Today | After this feature |
23
+ | --- | --- | --- |
24
+ | Rolling back a fan-out map | One process loads the state of **every** completed element at once and undoes them one after another. Time and memory grow with the element count (10,000 elements take about a minute in the test suite). | Each started element gets its own rollback job, enqueued one batch at a time, the same way the elements ran. |
25
+ | Rolling back an inline map | Same single pass, all element states loaded at once. | Still in the executing process (it ran there), but element states are read one bounded chunk at a time. |
26
+ | `fan_out` without `batch_size` | Every element is enqueued at once: no back pressure. | A default batch size of 50 applies. |
27
+ | `fail_fast` | Name suggests "stop early"; the real contract is "every element succeeds or none is kept". | Renamed `atomic`; `fail_fast` keeps working with a deprecation warning. |
28
+ | Fan-out map inside a composed child | The root never finishes: the map's completion resumes the child as a standalone run, and nothing resumes the root. | The root resumes and finishes; rollback through the root also works. |
29
+
30
+ ## User Scenarios & Testing *(mandatory)*
31
+
32
+ ### User Story 1 - Roll back a large fan-out map the way it ran (Priority: P1)
33
+
34
+ A developer runs a reactor whose `fan_out` map processes a large collection (for example 10,000
35
+ payments) with a batch size. A later step fails, or the map itself fails under atomic, or an
36
+ operator undoes the run manually. Every completed element must be rolled back. Instead of one
37
+ process loading all 10,000 element states and undoing them serially, the rollback is dispatched
38
+ like the forward run: one rollback job per started element, enqueued one batch at a time, the next
39
+ batch enqueued as the previous batch's last element reports. Only after every element has reported back are the
40
+ steps before the map undone.
41
+
42
+ **Why this priority**: This is the core gap. Today a large map's rollback can exceed job timeouts
43
+ and memory limits in one process, so the saga's compensation promise does not hold at the scale
44
+ fan-out is meant for.
45
+
46
+ **Independent Test**: Run a reactor with a fan-out map of N elements and a declared batch size B,
47
+ then fail a step after the map. Verify that each completed element was rolled back by its own job,
48
+ that no single throw enqueued more than B element rollback jobs, that the steps before the map were
49
+ undone only after the last element rollback reported, and that the run ends failed with every
50
+ element rolled back exactly once.
51
+
52
+ **Acceptance Scenarios**:
53
+
54
+ 1. **Given** a completed fan-out map of 1,000 elements with batch size 50, **When** a step after the
55
+ map fails, **Then** 1,000 element rollback jobs run, no throw enqueuing more than 50, and the
56
+ steps before the map are undone after all 1,000 report back.
57
+ 2. **Given** an atomic fan-out map where element 7 fails while others completed, **When** every
58
+ index has settled, **Then** every element that started gets a rollback job with the same back
59
+ pressure. Only the completed ones undo anything. The failed element's job finds nothing to undo,
60
+ because it already rolled itself back, and reports `not_needed`. Skipped elements never started
61
+ and get no job.
62
+ 3. **Given** a finished run containing a completed fan-out map, **When** an operator triggers a
63
+ manual undo, **Then** the steps after the map are undone, the map's elements are rolled back
64
+ through distributed jobs, and the steps before the map are undone once those report back; the
65
+ run is not shown as finished until then.
66
+ 4. **Given** one element's rollback fails, **When** the other element rollbacks complete, **Then**
67
+ the steps before the map are still undone and the run's final failure lists the element's
68
+ rollback failure attributed to the map step and element index.
69
+ 5. **Given** a worker dies in the middle of an element's rollback, **When** the job is redelivered
70
+ or recovered, **Then** that element's rollback resumes after its last recorded undo (only the step
71
+ whose undo was cut off may run again), and the run still completes its rollback.
72
+ 6. **Given** a completed inline (non-fan-out) map, **When** it is rolled back, **Then** the rollback
73
+ runs in the executing process as today, reading element states in bounded chunks rather than all
74
+ at once.
75
+
76
+ ---
77
+
78
+ ### User Story 2 - Fan-out map inside a composed child finishes (Priority: P2)
79
+
80
+ A developer composes a child reactor into a root, and the child contains a `fan_out` map. Today the
81
+ root stays `running` forever: when the map completes, the child is resumed as if it were its own
82
+ top-level run, finishes in its own record, and nothing resumes the root. After this feature the
83
+ root resumes after the map and runs to completion. If the map or a later step fails, rollback
84
+ travels through the root: the map's elements, the child's earlier steps and the root's earlier
85
+ steps are all undone.
86
+
87
+ **Why this priority**: A silent hang with no error is the worst failure mode for a workflow engine.
88
+ It already exists on main and blocks composing any reactor that uses fan-out.
89
+
90
+ **Independent Test**: Root composes Child; Child runs a fan-out map with batch size 1. Drain all
91
+ background work. The root finishes successfully and returns the expected result. Repeat with a
92
+ pause (interrupt) after the map, and with a failing step after the map in the root.
93
+
94
+ **Acceptance Scenarios**:
95
+
96
+ 1. **Given** a root composing a child with a fan-out map, **When** all element work is processed,
97
+ **Then** the root resumes, runs its steps after the compose step, and finishes `completed`.
98
+ 2. **Given** the same setup with an interrupt after the map in the child, **When** the interrupt is
99
+ resumed, **Then** the root finishes `completed`.
100
+ 3. **Given** the same setup where a root step after the compose step fails, **When** rollback runs,
101
+ **Then** the map's completed elements are rolled back (distributed, per User Story 1), then the
102
+ child's earlier steps, then the root's earlier steps, and the root ends `failed`.
103
+ 4. **Given** the child's map element fails under atomic, **When** every index has settled,
104
+ **Then** the failure is applied to the root's run (not only the child's record) and the root
105
+ rolls back and ends `failed`.
106
+ 5. **Given** compose nested two levels deep with the fan-out map at the deepest level, **When** the
107
+ map completes, **Then** the top-level root resumes and finishes.
108
+
109
+ ---
110
+
111
+ ### User Story 3 - Fan-out never floods the queue by default (Priority: P2)
112
+
113
+ A developer writes `fan_out` without a batch size on a map whose source may be large. Today every
114
+ element is enqueued at once. After this feature no throw enqueues more than 50 element jobs; the
115
+ next throw fires when the previous batch's last element finishes. Declaring a batch size still
116
+ overrides the default.
117
+
118
+ **Why this priority**: One missing option should not be able to enqueue millions of jobs. A safe
119
+ default protects queues and storage without any code change by the developer.
120
+
121
+ **Independent Test**: Run a fan-out map of 500 elements with no declared batch size. Verify that
122
+ no throw enqueued more than 50 element jobs and that all 500 results are collected.
123
+
124
+ **Acceptance Scenarios**:
125
+
126
+ 1. **Given** a fan-out map with no batch size and 500 elements, **When** it runs, **Then** no throw
127
+ enqueues more than 50 element jobs and all 500 outcomes are collected.
128
+ 2. **Given** a fan-out map with no batch size and 20 elements, **When** it runs, **Then** all 20 are
129
+ dispatched at once, as today.
130
+ 3. **Given** a fan-out map with an explicit batch size of 200, **When** it runs, **Then** 200 is used,
131
+ not the default.
132
+ 4. **Given** a fan-out map with no batch size that must be rolled back, **When** rollback runs,
133
+ **Then** it uses the same default of 50 for element rollback jobs.
134
+
135
+ ---
136
+
137
+ ### User Story 4 - The map failure policy says what it means (Priority: P3)
138
+
139
+ A developer reading or writing a map sees `atomic` instead of `fail_fast`. With it on (the
140
+ default), the map succeeds only if every element succeeds; if any element fails, no new element
141
+ starts and every completed element is rolled back. With it off, the map completes with a
142
+ per-element outcome, failed elements roll back themselves and succeeded ones are kept. Existing
143
+ reactors that declare `fail_fast` keep working unchanged and see a deprecation warning pointing to
144
+ the new name.
145
+
146
+ **Why this priority**: A naming fix with no behavior change. It prevents misuse (reading
147
+ `fail_fast false` as "keep going but still roll back on failure") but nothing breaks without it.
148
+
149
+ **Independent Test**: Declare `atomic false` on a map where one element fails: the map
150
+ completes with one failure and the other results. Declare `fail_fast false` on the same map: same
151
+ outcome plus one deprecation warning naming `atomic`.
152
+
153
+ **Acceptance Scenarios**:
154
+
155
+ 1. **Given** a map with no failure policy declared, **When** one element fails, **Then** the map fails
156
+ and every completed element is rolled back (`atomic` is the default).
157
+ 2. **Given** `atomic false`, **When** one element fails, **Then** the map completes with that
158
+ element's failure alongside the other elements' results.
159
+ 3. **Given** an existing reactor declaring `fail_fast true` or `fail_fast false`, **When** the class
160
+ is loaded, **Then** it behaves exactly as `atomic true` / `atomic false` and a single
161
+ deprecation warning names the replacement.
162
+ 4. **Given** a map declaring both `fail_fast` and `atomic`, **When** the class is loaded,
163
+ **Then** a definition error explains that only one may be declared.
164
+ 5. **Given** element jobs enqueued by the previous gem version (carrying the old setting), **When**
165
+ they are processed after an upgrade, **Then** they apply the same policy they were enqueued with.
166
+
167
+ ---
168
+
169
+ ### Edge Cases
170
+
171
+ - **Nothing to undo**: a fan-out map where no element ever started (all were skipped) dispatches
172
+ no rollback jobs, and the parent continues its rollback at once. A map whose started elements all
173
+ failed or halted still dispatches their jobs; each one reports `not_needed` and undoes nothing.
174
+ - **Duplicate trigger**: the same element rollback job delivered twice, two rollback jobs for the
175
+ same element at once, or rollback triggered twice for one run. Each element is undone once
176
+ (FR-006, FR-027).
177
+ - **Element still in flight, or its state expired**: reported per FR-007, never skipped silently.
178
+ - **Cancel during rollback**: cancelling a run that is `rolling_back` is rejected. Cancelling would
179
+ mark the run finished and strand the steps before the map un-undone.
180
+ - **Rollback work lost** (worker killed, job dropped): the existing recovery sweep finds the stalled
181
+ map rollback and re-dispatches the missing element rollbacks, as it does for forward fan-out.
182
+ - **Map nested inside a map element** (runs inline): its rollback runs inside the outer element's
183
+ rollback job, inline.
184
+ - **Batch size larger than the source**: everything is dispatched at once, forward and rollback.
185
+ - **Inline job-testing mode**: distributed rollback produces the same final outcome when background
186
+ jobs run immediately in-process.
187
+ - **Failure while the map is still dispatching** (atomic): rollback starts only after every
188
+ index has settled, as today, so elements that were in flight and completed are included.
189
+ - **Composed child with a fan-out map that is itself undone manually from the root**: the manual
190
+ undo reaches the map through the root and distributes the element rollbacks.
191
+
192
+ ## Requirements *(mandatory)*
193
+
194
+ ### Functional Requirements
195
+
196
+ #### Distributed rollback of fan-out maps
197
+
198
+ - **FR-001**: Rolling back a fan-out map — compensating it after it failed, undoing it after a later
199
+ step failed, or undoing it through a manual undo of the run — MUST roll back each completed
200
+ element in its own background job.
201
+ - **FR-002**: Element rollback jobs MUST use the forward run's back pressure (FR-018):
202
+ - no single throw enqueues more than the effective batch size;
203
+ - the next throw fires when the previous throw's last position has reported.
204
+
205
+ Jobs still running from earlier throws are not counted. A slow element does not hold back later
206
+ throws, as in the forward run.
207
+ - **FR-003**: No job taking part in a map's rollback MUST load the state of more than one element
208
+ at a time. The coordinating execution's memory MUST NOT grow with the element count, except for
209
+ the rollback failures it reports. It MAY read per-element outcome records in bounded chunks.
210
+ - **FR-004**: The steps before the map MUST be undone only after every element rollback has
211
+ reported back, preserving reverse-order saga semantics at the step level.
212
+ - **FR-005**: Element rollback failures MUST be collected into the run's final failure, each
213
+ attributed to the map step and element index, in the same shape as today's rollback failures.
214
+ - **FR-006**: Each completed element MUST be rolled back once despite duplicate delivery or
215
+ duplicate triggers. An element whose rollback was interrupted (worker killed) MUST resume after
216
+ its last recorded undo: a step whose undo completed and was recorded is never undone again; only
217
+ the step whose undo was cut off may run again (at-least-once, as for any background job).
218
+ - **FR-007**: Rollback coverage rules MUST stay as today:
219
+ - only completed elements, or aborted ones with completed steps, undo anything;
220
+ - every element that started gets a rollback job, and one that failed (self-rolled-back) or halted
221
+ reports `not_needed` without undoing;
222
+ - skipped elements, which never started, get no job;
223
+ - an element still in flight, or whose state expired, is reported as a rollback failure once per
224
+ element index, never skipped silently.
225
+ - **FR-008**: Element rollbacks MUST be dispatched newest-started element first (highest index
226
+ first for an inline map; start order approximates index order for a fan-out map). Completion
227
+ order across elements is not guaranteed; within one element, steps MUST be undone in reverse
228
+ order.
229
+ - **FR-009**: While a distributed map rollback is in progress the run MUST NOT be reported as
230
+ finished, and MUST NOT be cancellable. Its status and the dashboard MUST show that rollback is in
231
+ progress, with element rollback counts:
232
+ - total started;
233
+ - settled (reported);
234
+ - outstanding (total minus settled);
235
+ - failed.
236
+
237
+ The dashboard shows these counts only while the run is `rolling_back`.
238
+ - **FR-010**: A stalled map rollback (lost jobs) MUST be recoverable by the same recovery sweep that
239
+ recovers stalled forward fan-out.
240
+ - **FR-011**: Map rollback MUST emit structured log entries for rollback start, each element
241
+ rollback outcome and rollback completion, carrying reactor name, map step name and element index.
242
+
243
+ #### Inline map rollback
244
+
245
+ - **FR-012**: An inline map MUST keep rolling back in the executing process (it ran there), reading
246
+ element states in bounded chunks rather than all at once. Coverage, ordering and failure reporting
247
+ are the same as for fan-out rollback.
248
+
249
+ #### Fan-out map inside a composed child
250
+
251
+ - **FR-013**: A top-level run whose composed child (at any depth) hands off at a fan-out map MUST be
252
+ resumed when the map settles, and MUST run to its final state, with or without an interrupt after
253
+ the map.
254
+ - **FR-014**: A map settling inside a composed child MUST NOT resume the child as a standalone run;
255
+ only the top-level run resumes, and only the execution that owns the top-level run writes its
256
+ state (single-writer rule).
257
+ - **FR-015**: On re-entry after the map settled, the map MUST adopt the settled outcome (results or
258
+ failure) and MUST NOT dispatch its elements again.
259
+ - **FR-016**: A failure of that map, or of any later step in the child or the root, MUST roll back
260
+ through the top-level run: the map's completed elements (per FR-001–FR-008), the child's earlier
261
+ steps, then the root's earlier steps.
262
+
263
+ #### Default fan-out batch size
264
+
265
+ - **FR-017**: A fan-out map without a declared batch size MUST use a default batch size of 50. A
266
+ declared batch size MUST override it, with today's validation (a positive integer).
267
+ - **FR-018**: The effective batch size (declared or default) MUST govern both forward element
268
+ dispatch and element rollback dispatch, with one back-pressure rule: no throw enqueues more than
269
+ the effective batch size, and the next throw fires when the previous throw's last position has
270
+ reported.
271
+ - **FR-019**: A fan-out map with no more elements than its effective batch size MUST dispatch all of
272
+ them at once, as today.
273
+
274
+ #### Failure policy rename
275
+
276
+ - **FR-020**: The map DSL MUST offer `atomic`, on by default, with exactly the semantics of
277
+ today's `fail_fast`:
278
+ - on: any element failure fails the map, no new element starts after it, and every completed
279
+ element is rolled back;
280
+ - off: the map completes with every element's outcome; failed elements roll back themselves and
281
+ succeeded ones are kept.
282
+ - **FR-021**: `fail_fast` MUST keep working as a deprecated alias of `atomic`, emitting one
283
+ deprecation warning per declaration, at class definition, naming the replacement.
284
+ - **FR-022**: Declaring both `fail_fast` and `atomic` on one map MUST raise a definition error.
285
+ - **FR-023**: Work enqueued before an upgrade that carries the old setting MUST keep the policy it
286
+ was enqueued with.
287
+ - **FR-024**: Documentation, dashboard labels, error messages and log fields MUST use the new name.
288
+
289
+ #### Delivery
290
+
291
+ - **FR-025**: README and the affected documentation pages (data pipelines, background and async,
292
+ composition, core concepts) MUST describe distributed rollback, the default batch size and
293
+ `atomic`; CHANGELOG MUST record the default-batch-size behavior change, the deprecation, and
294
+ the composed-child fix.
295
+ - **FR-026**: The demo app MUST include an example reactor, rake task and spec exercising a fan-out
296
+ map's distributed rollback with back pressure and a fan-out map inside a composed child, per
297
+ Constitution Principle VI.
298
+
299
+ #### Single rollback per run
300
+
301
+ - **FR-027**: A run's rollback MUST NOT be started twice:
302
+ - a manual undo of a run that is `rolling_back` is rejected;
303
+ - cancelling a run that is `rolling_back` is rejected;
304
+ - a map whose rollback already exists never dispatches its element rollbacks again.
305
+
306
+ ### Key Entities
307
+
308
+ - **Effective batch size**: the declared batch size of a fan-out map, or 50 when none is declared.
309
+ Caps how many element jobs one throw enqueues, forward and rollback.
310
+ - **Element rollback job**: one unit of background work for one started element. If the element
311
+ completed, the job replays its undo stack. Outcome per element: undone, rollback failed (with its
312
+ failures), not needed (nothing to undo), in flight, unavailable.
313
+ - **Map rollback**: the tracked rollback of one map in one run: which completed elements remain,
314
+ which are outstanding, which reported and with what failures. When every element has reported,
315
+ the run's rollback continues with the steps before the map.
316
+ - **Failure policy** (`atomic`): whether a map requires every element to succeed (on) or
317
+ returns per-element outcomes (off).
318
+ - **Top-level run**: the outermost execution that owns the run state; composed children and their
319
+ maps report to it and never resume on their own.
320
+
321
+ ## Success Criteria *(mandatory)*
322
+
323
+ ### Measurable Outcomes
324
+
325
+ - **SC-001**: Rolling back a fan-out map of 10,000 completed elements never enqueues more than its
326
+ effective batch size of element rollbacks in one throw. No job loads more than one element's
327
+ state, and the coordinating execution's memory does not grow with the element count.
328
+ - **SC-002**: With 10 workers available, rolling back 10,000 completed elements finishes at least 5×
329
+ faster than a serial, in-process rollback of the same elements. That baseline is today's algorithm:
330
+ the inline-map rollback path, which reads in chunks but undoes elements one by one.
331
+ - **SC-003**: In fault-injection runs (duplicate deliveries, a worker killed mid-rollback), 100% of
332
+ completed elements are rolled back, and no step whose undo was recorded is undone again.
333
+ - **SC-004**: A fan-out map of 1,000 elements with no declared batch size never enqueues more than
334
+ 50 element jobs in one throw, and collects all 1,000 outcomes.
335
+ - **SC-005**: A root composing a child with a fan-out map reaches its final state in 100% of runs:
336
+ success, success with an interrupt after the map, and failure with rollback.
337
+ - **SC-006**: Every existing reactor declaring `fail_fast` keeps its behavior after upgrade with zero
338
+ code changes, and shows one deprecation warning per declaration.
339
+
340
+ ## Assumptions
341
+
342
+ - **Rollback mirrors execution mode.** Fan-out maps roll back distributed; inline maps roll back in
343
+ process. A `batch_size` declared on an inline map still has no effect, as today.
344
+ - **50 is a library default, not a global setting.** The per-map `batch_size` is the override. A
345
+ global configuration knob is out of scope until someone needs a different default everywhere.
346
+ - **Order across elements does not matter.** Elements run independently and in parallel forward,
347
+ so their rollbacks may complete in any order. Dispatch is newest-started first (FR-008); within
348
+ an element, reverse step order is kept.
349
+ - **Deprecate, don't remove.** `fail_fast` stays as an alias with a warning, so this ships as a
350
+ MINOR release. Removal is a later MAJOR decision.
351
+ - **The default batch size changes throughput, not results.** Maps above 50 elements without a
352
+ declared batch size become back-pressured; this is a behavior change noted in the CHANGELOG, not
353
+ an API break.
354
+ - **Element rollback failure rules stay as today.** An element's undo that raises is reported as a
355
+ rollback failure for that element, following the same rules as today's inline element rollback;
356
+ this feature adds no new retry policy for undo.
357
+ - **Out of scope**: the map-level `undo_all` override and fenced context writes from
358
+ `specs/future_improvements.md`; the other 008 rollback follow-ups.
359
+ - **Depends on** 008 rollback reliability: element indexing, rollback-failure attribution, the
360
+ settle-before-apply rule for atomic failures, and the map recovery sweep.