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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/CHANGELOG.md +81 -0
- data/CLAUDE.md +1 -1
- data/README.md +42 -3
- data/lib/ruby_reactor/dsl/map_builder.rb +20 -1
- data/lib/ruby_reactor/error/recorded_failure.rb +18 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +27 -1
- data/lib/ruby_reactor/executor/result_handler.rb +16 -0
- data/lib/ruby_reactor/executor/retry_manager.rb +2 -2
- data/lib/ruby_reactor/executor/step_executor.rb +20 -0
- data/lib/ruby_reactor/executor.rb +62 -10
- data/lib/ruby_reactor/interrupt_claims.rb +81 -0
- data/lib/ruby_reactor/map/step_rollback.rb +121 -0
- data/lib/ruby_reactor/reactor.rb +270 -105
- data/lib/ruby_reactor/rspec/matchers.rb +64 -16
- data/lib/ruby_reactor/rspec/test_subject.rb +25 -24
- data/lib/ruby_reactor/step/compose_step.rb +10 -0
- data/lib/ruby_reactor/step/map_step.rb +12 -0
- data/lib/ruby_reactor/storage/adapter.rb +17 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +42 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +17 -0
- data/lib/ruby_reactor/web/public/assets/{index-C6ejszCQ.js → index-DxjQlW7Q.js} +8 -8
- data/lib/ruby_reactor/web/public/index.html +1 -1
- data/lib/ruby_reactor/worker.rb +73 -6
- data/specs/010-interrupt-in-composed-child/checklists/requirements.md +43 -0
- data/specs/010-interrupt-in-composed-child/contracts/api-surface.md +75 -0
- data/specs/010-interrupt-in-composed-child/data-model.md +40 -0
- data/specs/010-interrupt-in-composed-child/plan.md +149 -0
- data/specs/010-interrupt-in-composed-child/quickstart.md +64 -0
- data/specs/010-interrupt-in-composed-child/research.md +207 -0
- data/specs/010-interrupt-in-composed-child/spec.md +267 -0
- data/specs/010-interrupt-in-composed-child/tasks.md +315 -0
- data/specs/010-rollback-follow-ups/checklists/requirements.md +47 -0
- data/specs/010-rollback-follow-ups/contracts/api-surface.md +123 -0
- data/specs/010-rollback-follow-ups/contracts/resume-protocol.md +132 -0
- data/specs/010-rollback-follow-ups/data-model.md +139 -0
- data/specs/010-rollback-follow-ups/plan.md +273 -0
- data/specs/010-rollback-follow-ups/pr-description.md +110 -0
- data/specs/010-rollback-follow-ups/quickstart.md +157 -0
- data/specs/010-rollback-follow-ups/research.md +440 -0
- data/specs/010-rollback-follow-ups/spec.md +489 -0
- data/specs/010-rollback-follow-ups/tasks.md +1022 -0
- data/specs/future_improvements.md +17 -14
- 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).
|