ruby_reactor 0.8.6 → 0.8.8

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/CHANGELOG.md +81 -0
  5. data/CLAUDE.md +1 -1
  6. data/README.md +42 -3
  7. data/lib/ruby_reactor/dsl/map_builder.rb +20 -1
  8. data/lib/ruby_reactor/error/recorded_failure.rb +18 -0
  9. data/lib/ruby_reactor/executor/compensation_manager.rb +27 -1
  10. data/lib/ruby_reactor/executor/result_handler.rb +16 -0
  11. data/lib/ruby_reactor/executor/retry_manager.rb +2 -2
  12. data/lib/ruby_reactor/executor/step_executor.rb +20 -0
  13. data/lib/ruby_reactor/executor.rb +62 -10
  14. data/lib/ruby_reactor/interrupt_claims.rb +81 -0
  15. data/lib/ruby_reactor/map/step_rollback.rb +121 -0
  16. data/lib/ruby_reactor/reactor.rb +270 -105
  17. data/lib/ruby_reactor/rspec/matchers.rb +64 -16
  18. data/lib/ruby_reactor/rspec/test_subject.rb +25 -24
  19. data/lib/ruby_reactor/step/compose_step.rb +10 -0
  20. data/lib/ruby_reactor/step/map_step.rb +12 -0
  21. data/lib/ruby_reactor/storage/adapter.rb +17 -0
  22. data/lib/ruby_reactor/storage/redis_adapter.rb +42 -0
  23. data/lib/ruby_reactor/version.rb +1 -1
  24. data/lib/ruby_reactor/web/api.rb +17 -0
  25. data/lib/ruby_reactor/web/public/assets/{index-C6ejszCQ.js → index-DxjQlW7Q.js} +8 -8
  26. data/lib/ruby_reactor/web/public/index.html +1 -1
  27. data/lib/ruby_reactor/worker.rb +73 -6
  28. data/specs/010-interrupt-in-composed-child/checklists/requirements.md +43 -0
  29. data/specs/010-interrupt-in-composed-child/contracts/api-surface.md +75 -0
  30. data/specs/010-interrupt-in-composed-child/data-model.md +40 -0
  31. data/specs/010-interrupt-in-composed-child/plan.md +149 -0
  32. data/specs/010-interrupt-in-composed-child/quickstart.md +64 -0
  33. data/specs/010-interrupt-in-composed-child/research.md +207 -0
  34. data/specs/010-interrupt-in-composed-child/spec.md +267 -0
  35. data/specs/010-interrupt-in-composed-child/tasks.md +315 -0
  36. data/specs/010-rollback-follow-ups/checklists/requirements.md +47 -0
  37. data/specs/010-rollback-follow-ups/contracts/api-surface.md +123 -0
  38. data/specs/010-rollback-follow-ups/contracts/resume-protocol.md +132 -0
  39. data/specs/010-rollback-follow-ups/data-model.md +139 -0
  40. data/specs/010-rollback-follow-ups/plan.md +273 -0
  41. data/specs/010-rollback-follow-ups/pr-description.md +110 -0
  42. data/specs/010-rollback-follow-ups/quickstart.md +157 -0
  43. data/specs/010-rollback-follow-ups/research.md +440 -0
  44. data/specs/010-rollback-follow-ups/spec.md +489 -0
  45. data/specs/010-rollback-follow-ups/tasks.md +1022 -0
  46. data/specs/future_improvements.md +17 -14
  47. metadata +22 -2
@@ -0,0 +1,489 @@
1
+ # Feature Specification: Rollback and Resume Follow-ups
2
+
3
+ **Feature Branch**: `rollback_follow_ups`
4
+
5
+ **Created**: 2026-10-08
6
+
7
+ **Status**: Draft, revised 2026-10-08 by /speckit-plan:
8
+
9
+ - **FR-010**: no snooze limit after admission (research R-07).
10
+ - **FR-017**: acceptance is keyed on the interrupt, not on why the run is `running` (R-04, R-05).
11
+ - **FR-024** and **US6-AS3**: how manual undo reports a failure (R-10).
12
+
13
+ **Input**: User description: "Rollback follow-ups (008), raised while implementing
14
+ specs/008-rollback-reliability; none blocks it. (1) The recovery sweep re-enqueues runs still
15
+ executing in the caller's process. (2) A map-level `undo_all { |completed_results| ... }` that
16
+ replaces the per-element undo replay. (3) A synchronous caller's final save races a worker resume
17
+ after a fan-out map hand-off. (4) A resume for a second pending interrupt while the first is
18
+ executing is rejected. (5) An interrupted `compensate` of the failing step is not re-run by manual
19
+ undo. (6) A resume contended on the reactor's lock or semaphore raises and is lost if the caller
20
+ does not retry. (7) Two resumes in the same instant both pass the `paused` check." Full text:
21
+ `specs/future_improvements.md`, §"Rollback follow-ups (008)".
22
+
23
+ ## Context
24
+
25
+ What happens today, and what this feature changes:
26
+
27
+ | Area | Today | After this feature |
28
+ | --- | --- | --- |
29
+ | Recovery sweep vs a run in the caller's process | The sweep treats any `running` run without a live worker as crashed. A run still executing in the caller's process looks crashed, so the sweep enqueues a worker that runs it forward a second time. | A run executing in the caller's process is recognised as live while that process is alive, however long its steps take. It is recovered only once its process is gone. |
30
+ | Caller's last save after a fan-out hand-off | A synchronous run that hands off at a fan-out map saves once more on its way out. If the map finished fast and a worker already resumed the run and saved newer progress, that last save overwrites it. | A save from the caller's process never overwrites progress a worker saved after it. |
31
+ | Resume while the reactor's lock or semaphore is held | `continue` raises the acquisition error and leaves the run `paused`. A webhook that does not retry loses the resume. | The payload is validated at once. A valid payload is accepted, stored, and handed to a worker that waits for the lock. `continue` returns a hand-off result instead of raising. |
32
+ | Two resumes of the same interrupt at the same instant | Both can read `paused` and both run, in modes that skip the per-run lock (inline job testing). | Exactly one is accepted; the other is rejected, in every execution mode. |
33
+ | Resume of a second pending interrupt while the first resume executes | Rejected: "the reactor is running". | Accepted and applied once, if that interrupt has not executed and has no accepted payload yet. |
34
+ | Manual undo after the failing step's `compensate` was cut off | Undoes the completed steps but never re-runs that `compensate`; the step can stay half-compensated. | Re-runs the cut-off `compensate` first, then undoes the completed steps. |
35
+ | Rolling back a map | Always replays each element's own step undos. | A map can declare `undo_all`, called once with the completed elements' results instead. |
36
+
37
+ ## User Scenarios & Testing *(mandatory)*
38
+
39
+ ### User Story 1 - The recovery sweep never runs a live caller-process run twice (Priority: P1)
40
+
41
+ A developer runs a reactor synchronously (`Reactor.run`, or an inline `continue`) in a web request
42
+ or a script. The host also runs the recovery sweep on a schedule. Today, if a sweep happens while
43
+ the run is still executing in the caller's process, the sweep decides the run has crashed and
44
+ enqueues a worker that runs it forward again. Steps execute twice; side effects (charges, emails)
45
+ happen twice. After this feature the sweep leaves such a run alone while its process is alive, even
46
+ if one of its steps takes longer than the sweep interval. If the caller's process dies, the run is
47
+ still recovered, as today.
48
+
49
+ **Why this priority**: Duplicate forward execution breaks the one guarantee users rely on most: a
50
+ step's side effect happens once per run. It needs nothing unusual to trigger, only a sweep schedule
51
+ and a slow synchronous step.
52
+
53
+ **Independent Test**: Start a synchronous run whose step blocks for longer than the liveness timeout.
54
+ Run the sweep several times while it blocks. Verify the sweep enqueues nothing for that run and the
55
+ step runs once. Then kill a caller process mid-run and verify the next sweep after the liveness
56
+ timeout recovers the run.
57
+
58
+ **Acceptance Scenarios**:
59
+
60
+ 1. **Given** a synchronous run executing a step in the caller's process, **When** the recovery sweep
61
+ runs, **Then** it does not enqueue that run and the step runs once.
62
+ 2. **Given** a synchronous run whose step takes longer than the liveness timeout, **When** sweeps run
63
+ throughout, **Then** none enqueues that run.
64
+ 3. **Given** an inline `continue` executing the steps after an interrupt in the caller's process,
65
+ **When** the sweep runs, **Then** it does not enqueue that run.
66
+ 4. **Given** a synchronous run whose caller process was killed mid-step (no clean-up ran), **When** the
67
+ liveness timeout has passed and the sweep runs, **Then** the run is recovered as today.
68
+ 5. **Given** a synchronous run that handed off at a fan-out map and whose caller already returned,
69
+ **When** the sweep runs, **Then** it treats the run as it does today (waiting on background work).
70
+
71
+ ---
72
+
73
+ ### User Story 2 - A caller's last save never overwrites a worker's progress (Priority: P1)
74
+
75
+ A developer runs a reactor synchronously and it reaches a fan-out map. The caller's process hands
76
+ the map to background jobs and returns a hand-off result. If the elements finish fast, the map's
77
+ completion resumes the run in a worker, which runs the steps after the map and saves. Today the
78
+ caller's process saves once more on its way out, and that save can land after the worker's and
79
+ overwrite it: the run loses progress, re-runs steps, or never finishes. After this feature, no save
80
+ from the caller's process replaces progress a worker saved after it.
81
+
82
+ **Why this priority**: Silent loss of saved progress corrupts the run's record and can repeat side
83
+ effects. 009 narrowed the window but did not close it.
84
+
85
+ **Independent Test**: Run a synchronous reactor with a fan-out map followed by steps. Force the
86
+ interleaving in which the worker resumes the run and saves before the caller's process makes its
87
+ last save. Verify the stored run reflects the worker's progress, each step after the map runs once,
88
+ and the run reaches its final state.
89
+
90
+ **Acceptance Scenarios**:
91
+
92
+ 1. **Given** a synchronous run that handed off at a fan-out map, **When** the worker resumed by the map's
93
+ completion saves before the caller's process finishes, **Then** the caller's process does not
94
+ overwrite that progress.
95
+ 2. **Given** the same interleaving, **When** all background work is drained, **Then** every step after
96
+ the map ran once and the run ends `completed`.
97
+ 3. **Given** an inline `continue` that reaches a fan-out map, **When** the same interleaving happens,
98
+ **Then** the same holds.
99
+ 4. **Given** the ordinary interleaving (the caller's process finishes before the map completes),
100
+ **When** the run proceeds, **Then** the behavior is unchanged from today.
101
+
102
+ ---
103
+
104
+ ### User Story 3 - A resume that meets a held lock is accepted, not lost (Priority: P1)
105
+
106
+ A developer pauses a reactor at an interrupt and resumes it from a webhook with `continue`. The
107
+ reactor declares a reactor-level lock or semaphore, and another run holds it when the webhook
108
+ arrives. Today `continue` raises the acquisition error and the run stays `paused`; a webhook sender
109
+ that does not retry loses the resume for good. After this feature, the payload is validated at
110
+ once, in the calling process. An invalid payload is answered with its validation failure, and
111
+ nothing is stored or enqueued. A valid payload is stored and the resume is handed to a worker,
112
+ which waits for the lock without spending retries and resumes the run once it is free. `continue`
113
+ returns a hand-off result. The worker never re-validates the payload, so a resume the caller was
114
+ told was accepted cannot later fail validation.
115
+
116
+ **Why this priority**: A lost resume strands a run in `paused` forever, with nothing to show the
117
+ webhook was ever received.
118
+
119
+ **Independent Test**: Hold a reactor's lock from another run. Call `continue` with a valid payload:
120
+ it returns a hand-off result and does not raise. Release the lock and drain background work: the
121
+ run resumes and finishes with that payload. Repeat with an invalid payload: the validation failure
122
+ comes back and no work is enqueued.
123
+
124
+ **Acceptance Scenarios**:
125
+
126
+ 1. **Given** a paused run whose reactor lock is held by another run, **When** `continue` is called with a
127
+ valid payload, **Then** it returns a hand-off result, does not raise, and the run shows `running`.
128
+ 2. **Given** that hand-off, **When** the lock is released, **Then** the worker resumes the run with the
129
+ accepted payload and the run reaches its final state.
130
+ 3. **Given** the lock stays held for longer than the worker's retry budget would allow, **When** it is
131
+ finally released, **Then** the resume still happens; waiting spent no retries.
132
+ 4. **Given** a paused run whose reactor lock is held, **When** `continue` is called with an invalid
133
+ payload, **Then** the validation failure is returned as today, the payload is not stored, no work is
134
+ enqueued, and the run stays `paused`.
135
+ 5. **Given** a resume handed off on contention, **When** a second `continue` arrives for the same
136
+ interrupt, **Then** it is rejected as for any resume already accepted.
137
+ 6. **Given** the same scenario with a reactor-level semaphore with no free slot, **When** `continue` is
138
+ called with a valid payload, **Then** the behavior matches scenarios 1–3.
139
+
140
+ ---
141
+
142
+ ### User Story 4 - Two resumes of one interrupt at the same instant: exactly one wins (Priority: P2)
143
+
144
+ Two callers (a retried webhook, a double-clicked approval) call `continue` for the same interrupt at
145
+ the same moment. Today both can read `paused` before either records the resume. The per-run lock
146
+ separates them in production, but modes that skip it (inline job testing) run the interrupt and
147
+ everything after it twice. After this feature exactly one resume is accepted, in every execution
148
+ mode, and the other is rejected without changing the stored run.
149
+
150
+ **Why this priority**: The production window is narrow and partly covered today, but when it opens
151
+ it runs downstream steps twice, and test suites that use inline mode cannot catch the problem.
152
+
153
+ **Independent Test**: Release two `continue` calls for the same interrupt from a barrier, many times,
154
+ in each execution mode. Verify that each time one is accepted, one is rejected, the interrupt's
155
+ payload is the accepted one, and the steps after the interrupt run once.
156
+
157
+ **Acceptance Scenarios**:
158
+
159
+ 1. **Given** a run paused at interrupt A, **When** two `continue` calls for A arrive at the same instant,
160
+ **Then** exactly one is accepted and the other is rejected with a clear "already resumed" error.
161
+ 2. **Given** the same race in inline job-testing mode, **When** it happens, **Then** the outcome is the
162
+ same.
163
+ 3. **Given** the rejected call, **When** the run is inspected, **Then** its payload was not stored and
164
+ nothing it did is visible on the run.
165
+ 4. **Given** a background-resume interrupt (`resume: :background`), **When** two resumes for it race,
166
+ **Then** exactly one is enqueued.
167
+
168
+ ---
169
+
170
+ ### User Story 5 - Resuming several pending interrupts at once (Priority: P2)
171
+
172
+ A reactor pauses at two ready interrupts, A and B (for example, two independent approvals). Both
173
+ approvers answer at about the same time. Today the resume for A marks the run `running`, and B's
174
+ resume, arriving while A's executes, is rejected with "the reactor is running"; B's caller has to
175
+ retry. After this feature B's resume is accepted as long as B has not executed and has no accepted
176
+ payload yet. Its payload is validated in the calling process and stored, and B is applied once:
177
+ either by the resume already executing, or right after it. The run never ends paused at an
178
+ interrupt whose resume was accepted.
179
+
180
+ **Why this priority**: The rejection is retryable and the window is short (interrupts have no body).
181
+ But callers that answer in parallel hit it routinely, and a caller that does not retry strands the
182
+ run.
183
+
184
+ **Independent Test**: Pause a run at interrupts A and B. Resume A with a step after A that blocks.
185
+ While it blocks, resume B. Verify that B's resume is accepted, both payloads are applied once, and
186
+ the run finishes without pausing at B.
187
+
188
+ **Acceptance Scenarios**:
189
+
190
+ 1. **Given** a run paused at ready interrupts A and B and A's resume executing, **When** B's resume
191
+ arrives with a valid payload, **Then** it is accepted and B is applied once.
192
+ 2. **Given** that sequence, **When** all work finishes, **Then** the run never pauses at B and ends in
193
+ the state it would reach had A and B been resumed one after another.
194
+ 3. **Given** A's resume executing, **When** a second resume for A arrives, **Then** it is rejected
195
+ ("already resumed").
196
+ 4. **Given** A's resume executing, **When** a resume for B arrives with an invalid payload, **Then** the
197
+ validation failure is returned, nothing is stored, and B stays pending.
198
+ 5. **Given** A's resume executing, **When** a resume arrives for an interrupt whose dependencies are not
199
+ complete, **Then** it is rejected as today.
200
+ 6. **Given** B's resume accepted while A's executes, **When** A's resume fails and the run rolls back,
201
+ **Then** the run ends failed as for any failure. B's payload is not applied, and B's caller can see
202
+ the outcome on the run.
203
+
204
+ ---
205
+
206
+ ### User Story 6 - Manual undo finishes a compensate that was cut off (Priority: P2)
207
+
208
+ A step fails in a run executing in the caller's process, and its `compensate` starts. The process is
209
+ interrupted part-way (a deploy's signal, an exit, out of memory). The run is recorded `aborted`.
210
+ An operator then calls `Reactor.undo(id)`. Today that undoes the steps that completed before the
211
+ failure but never re-runs the failing step's `compensate`, so its side effect stays half-reversed.
212
+ After this feature the aborted run records which step was failing, with what arguments and error,
213
+ and whether its `compensate` finished. Manual undo re-runs a `compensate` that did not finish,
214
+ before the completed steps' undos.
215
+
216
+ **Why this priority**: It breaks saga integrity (Constitution II), but only after a process
217
+ interruption during that exact window, so it is rare.
218
+
219
+ **Independent Test**: Make a step fail and interrupt its `compensate` part-way in the caller's process.
220
+ Verify the run is `aborted`. Call manual undo. Verify the `compensate` ran to completion, with the
221
+ same arguments and failure as the first attempt, before any completed step was undone.
222
+
223
+ **Acceptance Scenarios**:
224
+
225
+ 1. **Given** an aborted run whose failing step's `compensate` was cut off, **When** manual undo runs,
226
+ **Then** that `compensate` runs again first, then the completed steps are undone in reverse order.
227
+ 2. **Given** an aborted run whose failing step's `compensate` finished and the interruption came during
228
+ the undos after it, **When** manual undo runs, **Then** the `compensate` does not run again and only
229
+ the outstanding undos run.
230
+ 3. **Given** the re-run `compensate` fails, **When** manual undo continues, **Then** the completed steps
231
+ are still undone, and the run's execution trace records that compensation failure, attributed to
232
+ the step.
233
+ 4. **Given** the failing step inside a composed child that ran in the same process, **When** the root is
234
+ undone manually, **Then** the child's cut-off `compensate` is re-run before that child's undos.
235
+ 5. **Given** an aborted run with a cut-off `compensate`, **When** an operator inspects the run, **Then**
236
+ its status shows which step's `compensate` is outstanding.
237
+
238
+ ---
239
+
240
+ ### User Story 7 - One bulk undo for a whole map (Priority: P3)
241
+
242
+ A developer maps over 5,000 payments, each element charging a card. On rollback, the payment provider
243
+ offers one bulk refund call; replaying 5,000 per-element refunds is slow and rate-limited. The developer
244
+ declares `undo_all { |completed_results| ... }` on the map. When the map is rolled back, for any
245
+ reason, that block is called once with the results of the elements that completed, instead of each
246
+ element's own undos being replayed. Steps before the map are undone after it returns, as today.
247
+
248
+ **Why this priority**: A new capability, not a fix. Per-element replay already rolls back correctly;
249
+ this one is about efficiency and fitting bulk external APIs.
250
+
251
+ **Independent Test**: Declare `undo_all` on a fan-out map of N elements, fail a step after the map, and
252
+ drain background work. Verify that `undo_all` was called once with N completed results, no element's
253
+ own undo ran, and the steps before the map were undone afterwards.
254
+
255
+ **Acceptance Scenarios**:
256
+
257
+ 1. **Given** a map with `undo_all` whose elements all completed, **When** a later step fails, **Then**
258
+ `undo_all` is called once with every completed element's result, no element undo runs, and the
259
+ steps before the map are undone after it.
260
+ 2. **Given** an atomic map with `undo_all` where one element failed, **When** the map rolls back,
261
+ **Then** `undo_all` receives only the completed elements' results. The failed element rolled itself
262
+ back as today; skipped elements are not included.
263
+ 3. **Given** a fan-out map with `undo_all`, **When** it rolls back, **Then** no element rollback jobs are
264
+ dispatched, and the results are passed without loading every element's state at once.
265
+ 4. **Given** `undo_all` raises or returns a failure, **When** rollback continues, **Then** the steps
266
+ before the map are still undone and the run's final failure lists the bulk undo failure,
267
+ attributed to the map step.
268
+ 5. **Given** no element completed, **When** the map rolls back, **Then** `undo_all` is not called.
269
+ 6. **Given** a finished run with a map declaring `undo_all`, **When** an operator undoes the run
270
+ manually, **Then** `undo_all` is called once for that map.
271
+ 7. **Given** a map without `undo_all`, **When** it rolls back, **Then** behavior is unchanged from
272
+ today.
273
+
274
+ ---
275
+
276
+ ### Edge Cases
277
+
278
+ - **Long synchronous step**: a caller-process step that runs longer than the liveness timeout and
279
+ longer than the sweep interval is never judged dead while its process is alive (US1).
280
+ - **Caller process killed with no clean-up**: the run stays `running`; it is recovered once its
281
+ liveness lapses, as today (US1).
282
+ - **Inline job-testing mode**: background work runs immediately inside the caller's frame. Every story
283
+ produces the same final outcome there; US4 must hold there specifically.
284
+ - **Contended resume, then the run is cancelled**: an operator cancels a run whose resume is waiting
285
+ on the lock. The waiting resume finds the run cancelled and does nothing.
286
+ - **Contended resume on a `resume: :background` interrupt**: already handed to a worker; unchanged.
287
+ - **Run-level contention (another execution of the same run is live)**: still rejected, as today,
288
+ unless US5 applies (a different interrupt that has not executed).
289
+ - **Max attempts on invalid payloads**: an invalid payload still counts toward the interrupt's attempt
290
+ limit, as today; reaching the limit still fails and compensates the run, as today.
291
+ - **Several interrupts accepted while one resume executes**: each is applied once (US5).
292
+ - **Interrupted `compensate` in a worker run**: a worker run is not recorded `aborted`; its job is
293
+ redelivered as today. US6 covers the caller-process `aborted` path only.
294
+ - **Manual undo of an aborted run whose failing step has no `compensate`**: nothing extra runs; the
295
+ undos run as today.
296
+ - **`undo_all` interrupted mid-call** (worker killed): rollback recovery calls it again. Like any undo
297
+ under redelivery, it runs at least once and must tolerate a repeat.
298
+ - **`undo_all` and element still in flight or with expired state**: reported as a rollback failure per
299
+ element index, as today (009 FR-007), never silently left out of the bulk call.
300
+ - **`undo_all` with elements that did not complete but finished some steps** (aborted elements): they
301
+ have no result for the bulk call, so their own completed steps are undone by the per-element
302
+ replay, as today.
303
+ - **Map nested inside a map element with `undo_all`**: the inner map's `undo_all` runs inside the outer
304
+ element's rollback.
305
+
306
+ ## Requirements *(mandatory)*
307
+
308
+ ### Functional Requirements
309
+
310
+ #### Liveness of caller-process runs (US1)
311
+
312
+ - **FR-001**: The recovery sweep MUST NOT enqueue a run while it is executing in the caller's process
313
+ (synchronous `Reactor.run`, inline `continue`, manual undo) and that process is alive.
314
+ - **FR-002**: FR-001 MUST hold however long a single step runs. Liveness MUST be renewed while the run
315
+ executes, not judged from the run's start time.
316
+ - **FR-003**: A caller-process run whose process died without clean-up MUST become recoverable by the
317
+ sweep within one liveness timeout, and MUST then be recovered as today.
318
+ - **FR-004**: A run that handed off to background work and whose caller already returned MUST be swept
319
+ as today.
320
+
321
+ #### No lost progress from the caller's last save (US2)
322
+
323
+ - **FR-005**: A save made by the caller's process MUST NOT replace run state that a worker saved after
324
+ resuming the same run. This covers the save after a hand-off at a fan-out map, from both
325
+ `Reactor.run` and an inline `continue`.
326
+ - **FR-006**: After the race in FR-005, every step after the hand-off MUST run once, and the run MUST
327
+ reach its final state.
328
+ - **FR-007**: When the caller's process finishes before any worker resumes the run, behavior MUST be
329
+ unchanged.
330
+
331
+ #### Resume under lock or semaphore contention (US3)
332
+
333
+ - **FR-008**: `continue` MUST validate the payload in the calling process before anything else. An
334
+ invalid payload MUST return its validation failure as today (the class-level method raises, the
335
+ instance method returns the failure), store no payload, enqueue nothing, and leave the run `paused`.
336
+ Attempt counting is unchanged.
337
+ - **FR-009**: When a valid resume cannot take the reactor-level lock or semaphore, `continue` MUST store
338
+ the payload, mark the run `running`, hand the resume to a background worker, and return a hand-off
339
+ result (`DispatchResult`). It MUST NOT raise the acquisition error.
340
+ - **FR-010**: The deferred resume MUST wait for the lock or semaphore without spending the job's retry
341
+ budget, and without being failed by the background snooze limit: a run that already passed its
342
+ first admission is never marked failed for waiting. It MUST resume the run once the lock or
343
+ semaphore is free.
344
+ - **FR-011**: The deferred resume MUST NOT re-validate the payload. It applies the payload accepted in
345
+ FR-009.
346
+ - **FR-012**: While a deferred resume is pending, another `continue` for the same interrupt MUST be
347
+ rejected, as for any accepted resume.
348
+ - **FR-013**: A deferred resume that finds the run cancelled or finished MUST do nothing.
349
+ - **FR-014**: A resume deferred on contention MUST be logged as one structured entry carrying reactor
350
+ name, run id, interrupt step and the contended lock or semaphore.
351
+
352
+ #### One accepted resume per interrupt (US4)
353
+
354
+ - **FR-015**: Of any number of concurrent resumes for the same interrupt, exactly one MUST be accepted.
355
+ Every other MUST be rejected with an "already resumed" error and MUST NOT change the stored run.
356
+ - **FR-016**: FR-015 MUST hold in every execution mode, including inline job-testing mode and
357
+ background-resume interrupts.
358
+
359
+ #### Concurrent resumes of different interrupts (US5)
360
+
361
+ - **FR-017**: While the run is executing, a resume for an interrupt MUST be accepted if that
362
+ interrupt is ready, has not executed, and has no accepted payload yet. This applies whether the
363
+ run is executing another resume, its first run, or is waiting on background work. Its payload is
364
+ validated per FR-008.
365
+ - **FR-018**: An accepted resume MUST be applied once. The run MUST NOT end paused at an interrupt whose
366
+ resume was accepted, whether the executing resume picks it up or it is applied right after.
367
+ - **FR-019**: The caller of a resume accepted under FR-017 MUST receive a hand-off result, as in FR-009.
368
+ - **FR-020**: A resume for an interrupt that already executed, or already has an accepted payload, MUST
369
+ be rejected ("already resumed"). A resume for an interrupt that is not ready MUST be rejected as
370
+ today. A resume for a run that is finished, cancelled, `aborted` or rolling back MUST be rejected as
371
+ today (008 FR-032).
372
+ - **FR-021**: If the run fails before an accepted payload is applied, the payload MUST NOT be applied,
373
+ and the run's outcome is the failure, as for any failure.
374
+
375
+ #### Re-running a cut-off `compensate` (US6)
376
+
377
+ - **FR-022**: A caller-process run recorded `aborted` during its failing step's `compensate` MUST record
378
+ which step was failing, the arguments it was called with, its failure (message and error class), and
379
+ whether its `compensate` finished.
380
+ - **FR-023**: A manual undo of such a run MUST re-run a `compensate` that did not finish, with the
381
+ recorded arguments and failure, before undoing the completed steps. A `compensate` recorded as
382
+ finished MUST NOT run again.
383
+ - **FR-024**: A re-run `compensate` that fails MUST be reported as a compensation failure attributed to
384
+ the step, the way manual undo reports any rollback failure: an execution-trace entry and the
385
+ compensation-failure middleware event. The completed steps MUST still be undone.
386
+ - **FR-025**: FR-022–FR-024 MUST apply to a failing step at any depth executed in the same process
387
+ (a composed child, an inline map element).
388
+ - **FR-026**: The run's status view and the dashboard MUST show, for an aborted run, which step's
389
+ `compensate` is outstanding.
390
+
391
+ #### Map-level `undo_all` (US7)
392
+
393
+ - **FR-027**: The `map` DSL MUST offer an optional `undo_all` taking a block. It may be declared at most
394
+ once per map; a second declaration, or one without a block, MUST raise a definition error.
395
+ - **FR-028**: When a map declares `undo_all`, every rollback of that map MUST call the block once with
396
+ the results of the elements that completed, in element index order, instead of replaying those
397
+ elements' own undos. This covers compensation after an atomic map failure, undo after a later step
398
+ failed, and a manual undo of the run.
399
+ - **FR-029**: The completed results MUST be readable without loading every element's state at once, for
400
+ inline and fan-out maps. A fan-out map with `undo_all` MUST NOT dispatch element rollback jobs for
401
+ its completed elements.
402
+ - **FR-030**: Element coverage MUST otherwise stay as today (009 FR-007): failed elements roll
403
+ themselves back; skipped elements are excluded; an element in flight or with expired state is
404
+ reported as a rollback failure per element index; an element that did not complete but finished
405
+ some steps is undone by its own per-element replay.
406
+ - **FR-031**: `undo_all` MUST NOT be called when no element completed.
407
+ - **FR-032**: A failure of `undo_all` (raised or returned) MUST be reported as one rollback failure
408
+ attributed to the map step. The steps before the map MUST still be undone afterwards. No new retry
409
+ policy applies.
410
+ - **FR-033**: `undo_all` MUST be called once per map rollback despite duplicate triggers or deliveries.
411
+ If its process dies mid-call, it MAY run again (at least once).
412
+ - **FR-034**: Map rollback through `undo_all` MUST emit structured log entries for start and outcome,
413
+ carrying reactor name, map step name and the number of completed elements passed.
414
+
415
+ #### Delivery
416
+
417
+ - **FR-035**: README and the affected documentation pages (interrupts and resume, background and async,
418
+ data pipelines, durability and recovery) MUST describe the new behaviors: caller-process liveness,
419
+ the contended-resume hand-off and `continue`'s new return value, concurrent interrupt resumes, manual
420
+ undo re-running a cut-off `compensate`, and `undo_all`.
421
+ - **FR-036**: CHANGELOG MUST record `undo_all` under Features, the fixes under Bug Fixes, and a migration
422
+ note for `continue` returning a hand-off result where it used to raise an acquisition error.
423
+ - **FR-037**: The demo app MUST include an example reactor, rake task and spec (Constitution
424
+ Principle VI) for `undo_all`, for a resume contended on the reactor's lock, and for concurrent
425
+ resumes of two interrupts. Any assertion the shipped RSpec matchers cannot express MUST add the
426
+ missing matcher or helper.
427
+
428
+ ### Key Entities
429
+
430
+ - **Caller-process run**: a run executing in the process that called `run`, `continue` or `undo`, not
431
+ in a background worker. It is live while that process is alive and executing it. The sweep may
432
+ recover it only after its liveness lapses.
433
+ - **Accepted resume**: a resume whose payload passed validation and was claimed for its interrupt.
434
+ Exactly one per interrupt; applied once, or dropped if the run fails first.
435
+ - **Deferred resume**: an accepted resume handed to a background worker because the reactor's lock or
436
+ semaphore was held, or because another resume of the run was executing. It waits without spending
437
+ retries and never re-validates.
438
+ - **Outstanding compensation**: on an aborted run, the failing step, its arguments, its failure, and
439
+ whether its `compensate` finished. Manual undo consumes it.
440
+ - **Bulk undo (`undo_all`)**: a map-level rollback block called once with the completed elements'
441
+ results. It replaces the per-element replay for completed elements.
442
+
443
+ ## Success Criteria *(mandatory)*
444
+
445
+ ### Measurable Outcomes
446
+
447
+ - **SC-001**: In 100 synchronous runs with sweeps running throughout, including steps longer than the
448
+ liveness timeout, no step runs twice. In 100% of runs whose caller process was killed, the run is
449
+ recovered within one liveness timeout plus one sweep interval.
450
+ - **SC-002**: In 100 forced interleavings of a worker resume and the caller's last save, no run loses
451
+ saved progress. Every run reaches its final state with each step after the map run once.
452
+ - **SC-003**: 100% of valid resumes delivered while the reactor's lock or semaphore is held are applied
453
+ once after it is released, with no retry by the caller. 0 invalid payloads are stored or enqueued.
454
+ - **SC-004**: In 1,000 trials of two simultaneous resumes for the same interrupt, in every execution
455
+ mode, exactly one is accepted every time, and the steps after the interrupt run once.
456
+ - **SC-005**: For runs paused at 2 to 5 ready interrupts all resumed at the same time, 100% of valid
457
+ resumes are accepted and applied once each. No run ends paused at an accepted interrupt.
458
+ - **SC-006**: For a failing step's `compensate` interrupted at any point, followed by manual undo, 100%
459
+ of runs end with that `compensate` finished (re-run only if it had not finished) and every
460
+ completed step undone.
461
+ - **SC-007**: Rolling back a map of 10,000 completed elements that declares `undo_all` makes exactly
462
+ one bulk call and zero per-element undo calls. The memory of the process running the rollback does
463
+ not grow with the element count.
464
+
465
+ ## Assumptions
466
+
467
+ - **Targeted fixes, not the full fence.** The general "Fenced context writes" proposal in
468
+ `specs/future_improvements.md` stays out of scope. This feature closes the windows named in US1
469
+ and US2. One mechanism (for example, a caller-process run holding the run's liveness lock from its
470
+ start) may cover both; the plan decides.
471
+ - **Crash recovery of caller-process runs is unchanged.** A run whose caller process died is recovered
472
+ as today, by resuming it forward in a worker, once its liveness lapses.
473
+ - **Only the reactor-level lock and semaphore gate a resume.** Resumes skip the rate-limit and period
474
+ gates today; this feature does not change that.
475
+ - **`continue` returning a hand-off result is a MINOR change.** Code that rescued the acquisition error
476
+ to retry keeps working: the rescue simply no longer fires. The CHANGELOG carries a migration note.
477
+ - **A cut-off `compensate` must tolerate being run again**, as an undo must under redelivery. The
478
+ documentation states this.
479
+ - **Worker-path interruptions are unchanged.** A worker run is not marked `aborted`; its job is
480
+ redelivered as today.
481
+ - **`undo_all` does not change element self-rollback.** An element that failed still rolls itself
482
+ back with its own steps' `compensate` and undos; `undo_all` only replaces the replay of completed
483
+ elements.
484
+ - **`undo_all` gets the elements' results only.** The block receives what each completed element
485
+ returned; anything else it needs (an order id, a batch reference) comes from those results.
486
+ - **Inline job-testing mode** must produce the same final outcomes as real background workers for
487
+ every story.
488
+ - **Depends on** 008 (aborted runs, FR-032 resume rule, rollback-failure attribution) and 009
489
+ (distributed map rollback, element coverage rules, save-before-release on the worker path).