ruby_reactor 0.8.7 → 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 +59 -2
- data/CLAUDE.md +1 -1
- data/README.md +38 -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 +21 -1
- 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 +210 -95
- data/lib/ruby_reactor/rspec/matchers.rb +49 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +17 -6
- 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-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 -2
- metadata +14 -2
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# Implementation Plan: Rollback and Resume Follow-ups
|
|
2
|
+
|
|
3
|
+
**Branch**: `rollback_follow_ups` | **Date**: 2026-10-08 | **Spec**: [spec.md](spec.md)
|
|
4
|
+
|
|
5
|
+
**Input**: Feature specification from `specs/010-rollback-follow-ups/spec.md`
|
|
6
|
+
|
|
7
|
+
## Summary
|
|
8
|
+
|
|
9
|
+
Seven follow-ups from 008, resolved with three shared mechanisms.
|
|
10
|
+
|
|
11
|
+
1. **Every live run holds its liveness lock, and readers lock, then load** (US1, US2):
|
|
12
|
+
- A synchronous `Executor#execute` takes the run's `async:<id>` lock, auto-extended, and saves
|
|
13
|
+
before releasing it (R-01). The sweeper already skips locked runs, so a live caller-process run
|
|
14
|
+
is never re-enqueued. A killed one lapses within `context_lock_ttl`.
|
|
15
|
+
- `Worker#perform` takes the same lock **before** reading the run, waiting up to 2s, and hands
|
|
16
|
+
the owner to the executor, which re-enters the lock (R-02, R-03). A Worker resumed by a fan-out
|
|
17
|
+
map's completion can no longer race the caller's final save, in either order.
|
|
18
|
+
2. **A resume is claimed per interrupt, and applied only by the run's lock owner** (US3, US4, US5):
|
|
19
|
+
- `continue` validates the payload in the calling process, then claims the interrupt with
|
|
20
|
+
`SET NX` (R-04). The claim holds the payload.
|
|
21
|
+
- It then tries to own the run: lock, reload, resume (R-05). Any path that cannot run now (the
|
|
22
|
+
run is busy, the reactor's lock or semaphore is held, a background resume) becomes one hand-off.
|
|
23
|
+
The Worker applies the claims under the lock, and `continue` returns a `DispatchResult`.
|
|
24
|
+
- The claim makes "exactly one resume per interrupt" hold in every mode, including inline
|
|
25
|
+
testing. Claiming per interrupt lets different interrupts be accepted while another resume
|
|
26
|
+
executes.
|
|
27
|
+
- Supporting changes:
|
|
28
|
+
- after admission, the snooze limit no longer escalates a run to `failed` without rollback
|
|
29
|
+
(R-07);
|
|
30
|
+
- interrupt attempts move to an atomic counter (R-08);
|
|
31
|
+
- `Reactor#undo` reloads under its lock (R-09).
|
|
32
|
+
3. **Rollback state records what was cut off** (US6):
|
|
33
|
+
- An `aborted` run records its failing step's unfinished `compensate` (step, arguments, error) in
|
|
34
|
+
009's `context.rollback` (R-10).
|
|
35
|
+
- `Executor#undo_all` runs it before the undo stack. Manual undo, composed children and inline
|
|
36
|
+
elements all go through `undo_all`, so every depth is covered.
|
|
37
|
+
- The dashboard shows the outstanding `compensate` (R-11).
|
|
38
|
+
|
|
39
|
+
Plus one new DSL method, **`map ... undo_all { |completed_results| }`** (US7). When it is declared,
|
|
40
|
+
the map's rollback calls the block once with a lazy, index-ordered enumerable of completed
|
|
41
|
+
results, instead of replaying each element (R-12, R-13):
|
|
42
|
+
|
|
43
|
+
- a fan-out map reads its forward result slots and dispatches no rollback jobs;
|
|
44
|
+
- an inline map reads element contexts one at a time, and still replays an `aborted` element
|
|
45
|
+
itself.
|
|
46
|
+
|
|
47
|
+
## Technical Context
|
|
48
|
+
|
|
49
|
+
**Language/Version**: Ruby >= 3.0
|
|
50
|
+
|
|
51
|
+
**Primary Dependencies**:
|
|
52
|
+
|
|
53
|
+
- Sidekiq and ActiveJob adapters, Redis, dry-validation.
|
|
54
|
+
- Optional OpenTelemetry middleware.
|
|
55
|
+
- GUI: React/Vite (`gui/`, built into `lib/ruby_reactor/web/public`).
|
|
56
|
+
|
|
57
|
+
No new dependencies.
|
|
58
|
+
|
|
59
|
+
**Storage**: Redis through `Storage::RedisAdapter` (see [data-model.md](data-model.md)).
|
|
60
|
+
|
|
61
|
+
- New keys:
|
|
62
|
+
- `reactor:<Class>:context:<id>:resume:<step>`: the claim (`SET NX`, TTL `context_ttl`);
|
|
63
|
+
- `reactor:<Class>:context:<id>:resume_attempts:<step>`: the attempt counter (`INCR`).
|
|
64
|
+
- `lock:async:<id>` gains three holders: `execute`, `Worker`, `continue`.
|
|
65
|
+
- The `context.rollback` hash gains `arguments` and `error` for aborted runs.
|
|
66
|
+
- The map `StepConfig` gains `undo_all_block`.
|
|
67
|
+
- No migration. The one compatibility note: attempt counts restart for runs paused across the
|
|
68
|
+
upgrade (R-08).
|
|
69
|
+
|
|
70
|
+
**Testing**: RSpec against real Redis (`redis://localhost:6780`), with Sidekiq fake mode plus
|
|
71
|
+
`drain_async_jobs`.
|
|
72
|
+
|
|
73
|
+
- **Concurrency specs** (US1, US4, US5): threads, a barrier or latch, real Redis.
|
|
74
|
+
- **Interleaving spec** (US2): a public middleware hook drains jobs between the hand-off and the
|
|
75
|
+
caller's final save.
|
|
76
|
+
- **Crash spec** (US1, FR-003): `fork` plus `SIGKILL`, tagged `:fork`.
|
|
77
|
+
- **Inline mode**: one justified `Sidekiq::Testing.inline!` spec for US4 (FR-016).
|
|
78
|
+
- **Scale**: `:slow` tag, 10,000 elements for `undo_all` (SC-007).
|
|
79
|
+
- **Demo specs**: the shipped matchers only, plus the new `be_resume_deferred` and
|
|
80
|
+
`have_run_undo_all`.
|
|
81
|
+
|
|
82
|
+
**Target Platform**: Ruby gem (MRI), Sidekiq/ActiveJob workers, Rails demo app in Docker.
|
|
83
|
+
|
|
84
|
+
**Project Type**: Library (gem) with a bundled dashboard.
|
|
85
|
+
|
|
86
|
+
**Performance Goals**:
|
|
87
|
+
|
|
88
|
+
- **Synchronous run**: one lock acquire and release, and one extender thread, per run.
|
|
89
|
+
- **Worker**: one extra lock round-trip, and usually no wait. It waits at most 2s, while a caller
|
|
90
|
+
finishes its hand-off.
|
|
91
|
+
- **`continue`**: one `SET NX`, plus one `MGET` per resume for reactors that declare interrupts.
|
|
92
|
+
- **`undo_all` over 10,000 elements**: one call. Memory is bounded by the enumerator's chunk
|
|
93
|
+
(1,000 result slots, or one element context).
|
|
94
|
+
|
|
95
|
+
**Constraints**:
|
|
96
|
+
|
|
97
|
+
- **Single writer**: `continue` writes the run's context only while holding its lock (J-5).
|
|
98
|
+
- **Save before release** for every holder (J-3).
|
|
99
|
+
- A claimed payload is never validated again (J-7).
|
|
100
|
+
- An admitted run is never escalated to `failed` by snoozes (J-8).
|
|
101
|
+
- Inline testing mode takes no context lock, as today (R-14).
|
|
102
|
+
- Existing outcomes are unchanged for:
|
|
103
|
+
- uncontended resumes;
|
|
104
|
+
- background resumes;
|
|
105
|
+
- maps without `undo_all`;
|
|
106
|
+
- aborted runs whose `compensate` had finished.
|
|
107
|
+
|
|
108
|
+
**Scale/Scope**:
|
|
109
|
+
|
|
110
|
+
- About 14 library files touched. 2 new: `interrupt_claims.rb` and `error/recorded_failure.rb`.
|
|
111
|
+
- About 10 new spec files and about 8 updated.
|
|
112
|
+
- Demo: 4 reactor files, 3 rake tasks plus the `demo:rollback_follow_ups` group, 3 specs.
|
|
113
|
+
- 6 documentation files, the README, CHANGELOG and `future_improvements.md`.
|
|
114
|
+
- GUI: `ReactorDetail.tsx` (its API response is untyped, so `lib/reactors.ts` needs no change).
|
|
115
|
+
|
|
116
|
+
## Constitution Check
|
|
117
|
+
|
|
118
|
+
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
|
119
|
+
|
|
120
|
+
| Principle | Status | Note |
|
|
121
|
+
| --- | --- | --- |
|
|
122
|
+
| I. Gem-First Design | PASS | Everything is in `lib/` behind the existing DSL and `Reactor` API. One DSL method (`undo_all`). The Worker change is in the backend-agnostic `Worker` module, so Sidekiq and ActiveJob both get it. No host coupling. |
|
|
123
|
+
| II. Saga Pattern Integrity | PASS (strengthened) | Closes four ways a run lost work or effects: a duplicate forward run (US1), overwritten progress (US2), a lost or double resume (US3–US5), and a `compensate` never finished after an interruption (US6). R-07 removes the snooze escalation that marked runs `failed` without rollback. `undo_all` keeps step-level order (P-6), and an `aborted` inline element still replays its own undos. |
|
|
124
|
+
| III. Test-First with Real Infrastructure | PASS with one justified deviation | Each story's specs are written first and fail first, against real Redis. Concurrency is shown with threads, not mocks. The US2 interleaving uses a public middleware hook, not stubs. One new `inline!` spec, for FR-016, justified in Complexity Tracking. |
|
|
125
|
+
| IV. Observability by Default | PASS | New key=value log lines: `resume.deferred`, `resume.waiting`, `map.rollback.undo_all.started` / `.completed`. A `:undo_all` trace entry. Dashboard: `pending_compensation` on aborted runs, and a "Resume accepted" API message. A re-run `compensate` fires the standard compensation middleware events. A hand-off never fires `:failed_reactor`. |
|
|
126
|
+
| V. Simplicity & SemVer | PASS with notes | Three mechanisms, each serving several stories (Complexity Tracking). Code deleted: `reopen_paused` and the `past_gates?` contention branch in `continue`, and the blob write of attempts. MINOR (R-16), with CHANGELOG migration notes: `continue` returns a hand-off where it raised; "already resumed"; attempt counts restart; a synchronous run holds the liveness lock. No new config knobs. |
|
|
127
|
+
| VI. Demo-App Proof of Feature | PASS (planned) | One reactor per file, all class-based steps (R-18): `BulkRefundDemoReactor` with `BulkRefundChargeReactor` (`undo_all`, fan-out, a later failure), `ContendedApprovalDemoReactor` (resume while locked), and `DualApprovalDemoReactor` (two interrupts at once). Rake: `demo:map_undo_all`, `demo:contended_resume`, `demo:concurrent_interrupts`, grouped as `demo:rollback_follow_ups` in `demo:all`. Specs use the shipped matchers. `TestSubject#resume(process_jobs:)`, `be_resume_deferred` and `have_run_undo_all` are added to `lib/ruby_reactor/rspec/`. No new Docker service or environment variable. |
|
|
128
|
+
|
|
129
|
+
- [x] Documentation impact identified (research R-17):
|
|
130
|
+
- **README.md**: "Durability & Recovery", "Interrupts (Pause & Resume)", "Locks, Semaphores &
|
|
131
|
+
Ordered Locks" and "Map & Parallel Execution".
|
|
132
|
+
- **documentation/**: `interrupts.md`, `locks_and_semaphores.md`, `background_and_async.md`,
|
|
133
|
+
`data_pipelines.md`, `core_concepts.md`, `testing.md`.
|
|
134
|
+
- **demo_app/documentation/data_pipelines.md**.
|
|
135
|
+
- **CHANGELOG.md**.
|
|
136
|
+
- **specs/future_improvements.md**: remove the seven items, and update "Fenced context writes".
|
|
137
|
+
- Carried into tasks.md as a required task per user story.
|
|
138
|
+
|
|
139
|
+
**Post-design re-check (after Phase 1)**: PASS.
|
|
140
|
+
|
|
141
|
+
- New public surface:
|
|
142
|
+
- one DSL method;
|
|
143
|
+
- one changed return contract for `continue`, and one new rejection;
|
|
144
|
+
- two log events, plus the `undo_all` pair;
|
|
145
|
+
- one API field;
|
|
146
|
+
- three RSpec additions.
|
|
147
|
+
- New internals:
|
|
148
|
+
- one small module (`InterruptClaims`) used by three callers;
|
|
149
|
+
- one error class;
|
|
150
|
+
- three storage methods.
|
|
151
|
+
- The spec was revised for what the design fixed (see spec Status): FR-010, FR-017, FR-024, US6-AS3.
|
|
152
|
+
|
|
153
|
+
## Project Structure
|
|
154
|
+
|
|
155
|
+
### Documentation (this feature)
|
|
156
|
+
|
|
157
|
+
```text
|
|
158
|
+
specs/010-rollback-follow-ups/
|
|
159
|
+
├── plan.md # This file
|
|
160
|
+
├── research.md # Phase 0: R-01..R-19
|
|
161
|
+
├── data-model.md # Phase 1: lock holders, claim, counter, rollback record, undo_all
|
|
162
|
+
├── quickstart.md # Phase 1: validation guide
|
|
163
|
+
├── contracts/
|
|
164
|
+
│ ├── api-surface.md # continue outcomes, undo on aborted, undo_all, logs, RSpec additions
|
|
165
|
+
│ └── resume-protocol.md # Invariants J-1..J-9, sequences P-1..P-6
|
|
166
|
+
├── checklists/requirements.md
|
|
167
|
+
└── tasks.md # Phase 2 (/speckit-tasks)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Source Code (repository root)
|
|
171
|
+
|
|
172
|
+
```text
|
|
173
|
+
lib/ruby_reactor/
|
|
174
|
+
├── interrupt_claims.rb # new: claim!(ctx, step, payload) (SET NX), unapplied(ctx) (MGET of
|
|
175
|
+
│ # interrupts without a result), apply!(ctx) → set_result (R-04, R-06)
|
|
176
|
+
├── error/recorded_failure.rb # new: StandardError carrying the original message + original_class (R-10)
|
|
177
|
+
├── executor.rb # execute: acquire_context_lock (root, caller process); save before
|
|
178
|
+
│ # releasing it (R-01). context_lock_owner= for re-entry (R-03).
|
|
179
|
+
│ # resume_execution: InterruptClaims.apply! after the lock (R-04).
|
|
180
|
+
│ # mark_aborted: record the unfinished compensate in `rollback` (R-10).
|
|
181
|
+
│ # undo_all: compensate_pending! before the stack (R-10)
|
|
182
|
+
├── executor/compensation_manager.rb # @pending keeps rollback_arguments (R-10)
|
|
183
|
+
├── reactor.rb # continue: validate → claim → lock → reload → resume, or hand off;
|
|
184
|
+
│ # delete reopen_paused (R-05). validate_continue_payload: attempt
|
|
185
|
+
│ # counter (R-08). undo: reload under lock, merge the aborted record,
|
|
186
|
+
│ # failure: kwarg (R-09, R-10)
|
|
187
|
+
├── worker.rb # perform: lock, then load (R-02); status table (R-06); release in
|
|
188
|
+
│ # ensure; handle_snooze: uncapped once admitted, one
|
|
189
|
+
│ # resume.waiting warning (R-07)
|
|
190
|
+
├── storage/redis_adapter.rb # claim_interrupt_resume, retrieve_interrupt_resumes,
|
|
191
|
+
│ # increment_interrupt_attempts
|
|
192
|
+
├── dsl/map_builder.rb # undo_all(&block): once, block required → undo_all_block (R-12)
|
|
193
|
+
├── step/map_step.rb # compensate/undo: bulk_rollback when undo_all is declared (R-13)
|
|
194
|
+
├── map/step_rollback.rb # bulk_rollback: fan-out result slots / inline two passes; trace,
|
|
195
|
+
│ # logs, failure entry (R-13)
|
|
196
|
+
├── web/api.rb # pending_compensation on aborted; continue → "Resume accepted" (R-11)
|
|
197
|
+
└── rspec/{test_subject,matchers}.rb # resume(process_jobs:), running runs; be_resume_deferred;
|
|
198
|
+
# have_run_undo_all (R-18)
|
|
199
|
+
|
|
200
|
+
gui/src/components/ReactorDetail.tsx # outstanding-compensate line on aborted runs
|
|
201
|
+
gui/src/lib/reactors.ts # pending_compensation type
|
|
202
|
+
lib/ruby_reactor/web/public/ # rebuilt bundle
|
|
203
|
+
|
|
204
|
+
spec/
|
|
205
|
+
├── ruby_reactor/caller_process_liveness_spec.rb # new: US1 (latch, long step, fork + SIGKILL)
|
|
206
|
+
├── ruby_reactor/executor/caller_save_race_spec.rb # new: US2 interleaving (middleware drain)
|
|
207
|
+
├── ruby_reactor/worker_lock_then_load_spec.rb # new: R-02, R-06 status table
|
|
208
|
+
├── ruby_reactor/worker_snooze_admitted_spec.rb # new: R-07
|
|
209
|
+
├── ruby_reactor/interrupts/contended_resume_spec.rb # new: US3
|
|
210
|
+
├── ruby_reactor/interrupts/resume_claim_spec.rb # new: US4, including the one inline! example
|
|
211
|
+
├── ruby_reactor/interrupts/concurrent_interrupts_spec.rb # new: US5
|
|
212
|
+
├── ruby_reactor/dsl/map_undo_all_dsl_spec.rb # new: R-12
|
|
213
|
+
├── map/map_undo_all_spec.rb # new: US7 (fan-out, inline, atomic, undo, :slow)
|
|
214
|
+
├── ruby_reactor/rollback/aborted_execution_spec.rb # updated: US6 at top level, compose, inline element
|
|
215
|
+
├── ruby_reactor/rollback/resume_guard_spec.rb # updated: contention defers instead of raising
|
|
216
|
+
├── ruby_reactor/sweeper_spec.rb # updated: caller-process runs skipped
|
|
217
|
+
├── ruby_reactor/context_lock_spec.rb # updated: execute holds the lock; re-entry by owner
|
|
218
|
+
├── ruby_reactor/interrupt_background_resume_spec.rb # updated: claim-based background resume
|
|
219
|
+
├── integration/interrupt_max_attempts_spec.rb # updated: counter record; failure under lock
|
|
220
|
+
├── ruby_reactor/web/*_spec.rb # updated: pending_compensation, continue message
|
|
221
|
+
└── ruby_reactor/rspec/*_spec.rb # new or updated: resume(process_jobs:), new matchers
|
|
222
|
+
|
|
223
|
+
demo_app/
|
|
224
|
+
├── app/reactors/bulk_refund_demo_reactor.rb # fan-out map with undo_all; a later step fails
|
|
225
|
+
├── app/reactors/bulk_refund_charge_reactor.rb # element reactor (class-based charge step)
|
|
226
|
+
├── app/reactors/contended_approval_demo_reactor.rb # with_lock + interrupt
|
|
227
|
+
├── app/reactors/dual_approval_demo_reactor.rb # finance (resume: :background) + legal interrupts
|
|
228
|
+
├── lib/tasks/demo_reactors.rake # demo:map_undo_all, :contended_resume, :concurrent_interrupts,
|
|
229
|
+
│ # :rollback_follow_ups (in demo:all)
|
|
230
|
+
└── spec/reactors/{bulk_refund,contended_approval,dual_approval}_demo_reactor_spec.rb
|
|
231
|
+
|
|
232
|
+
README.md, documentation/*.md, demo_app/documentation/data_pipelines.md, CHANGELOG.md,
|
|
233
|
+
specs/future_improvements.md # per R-17
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
**Structure Decision**: single gem project, existing `lib/ruby_reactor` layout. New interrupt specs
|
|
237
|
+
go in a new `spec/ruby_reactor/interrupts/` folder. Rollback specs stay in
|
|
238
|
+
`spec/ruby_reactor/rollback/`, and map specs in `spec/map/`.
|
|
239
|
+
|
|
240
|
+
## Delivery Order
|
|
241
|
+
|
|
242
|
+
The stories are independently testable. tasks.md is the authoritative order.
|
|
243
|
+
|
|
244
|
+
1. **Foundation** (blocks US2, US3 and US5):
|
|
245
|
+
- `Executor#context_lock_owner=` (R-03);
|
|
246
|
+
- `Worker#perform` lock, then load, plus the status table (R-02, R-06);
|
|
247
|
+
- `Reactor#undo` reload under its lock (R-09).
|
|
248
|
+
2. **US1 (P1)**: `execute` takes and releases the liveness lock, saving first (R-01). Specs: latch,
|
|
249
|
+
long step, fork kill.
|
|
250
|
+
3. **US2 (P1)**: the interleaving spec, which passes with foundation plus US1. The inline-`continue`
|
|
251
|
+
variant lands with US3.
|
|
252
|
+
4. **Claims foundation** (blocks US3–US5): storage methods, `InterruptClaims`, and `apply!` in
|
|
253
|
+
`resume_execution` (R-04).
|
|
254
|
+
5. **US3 (P1)**: the `continue` flow with contention hand-off (R-05), the uncapped snooze after
|
|
255
|
+
admission (R-07), and the `resume_guard_spec` update.
|
|
256
|
+
6. **US4 (P2)**: claim race specs, including the one `inline!` example.
|
|
257
|
+
7. **US5 (P2)**: accept on `running` (R-05 step 1), the attempt counter (R-08), and the concurrency
|
|
258
|
+
specs.
|
|
259
|
+
8. **US6 (P2)**: `@pending` arguments, the `mark_aborted` record, `undo_all`'s
|
|
260
|
+
`compensate_pending!`, the `Reactor#undo` merge (R-10), and the API and GUI (R-11).
|
|
261
|
+
9. **US7 (P3)**: the DSL (R-12), `bulk_rollback` (R-13), and the matchers.
|
|
262
|
+
10. **Demo, docs, CHANGELOG** per story. Close with the Docker acceptance run of
|
|
263
|
+
`demo:rollback_follow_ups`.
|
|
264
|
+
|
|
265
|
+
## Complexity Tracking
|
|
266
|
+
|
|
267
|
+
| Addition | Why needed | Simpler alternative rejected because |
|
|
268
|
+
| --- | --- | --- |
|
|
269
|
+
| Resume claim record per interrupt (`SET NX`) | FR-015 and FR-016: exactly one resume per interrupt **in every mode**. FR-017: different interrupts accepted concurrently. Also stores the payload until the lock owner applies it, so `continue` never writes the blob unlocked (J-5). | **The context lock** is skipped in inline mode. **A status compare-and-set in the blob** serializes all resumes of the run, which blocks US5, and still writes from outside the owning execution. |
|
|
270
|
+
| The Worker takes the liveness lock before loading (wait ≤ 2s) | FR-005: closes the caller-save race in both orders. It also fixes the Workers enqueued by the rollback hand-off handshake and by `Reactor#undo` while holding the lock. | **Peek-then-snooze** leaves a check-then-read gap and adds a full Sidekiq poll (about 5s) to every hand-off from a caller process. **Full fenced writes** are out of scope (spec Assumptions). |
|
|
271
|
+
| Uncapped snooze once admitted (R-07) | FR-010. Escalation marks a run with completed work `failed` **without rollback**, a Constitution II violation that also hits `resume: :background` today. | **Escalation with rollback** fails a run the user accepted, because of an unrelated holder. **Keeping the cap** strands an accepted resume. A one-time warning keeps leaked semaphores visible. |
|
|
272
|
+
| One new `Sidekiq::Testing.inline!` spec, a deviation from Constitution III | FR-016 requires US4 to hold in inline job-testing mode, the mode that skips the run lock. That is the original 008 gap. | **Fake mode plus drain** cannot show it: the context lock is taken there, so the race under test never opens. Scoped to one example. |
|
|
273
|
+
| Attempt counter moved to its own key (R-08) | Once `continue` accepts resumes of a `running` run (US5), the old blob save of the attempt count, from an unlocked snapshot, would overwrite the executing resume's progress. | **Counting under the run's lock** would make an invalid-payload response wait behind, or contend with, a live resume. **Not counting while running** makes `max_attempts` depend on timing. |
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# 010 Rollback and Resume Follow-ups
|
|
2
|
+
|
|
3
|
+
Closes the seven "Rollback follow-ups (008)" items from `specs/future_improvements.md`.
|
|
4
|
+
Spec: `specs/010-rollback-follow-ups/spec.md` · Plan: `plan.md` · Decisions: `research.md` (R-01..R-19).
|
|
5
|
+
|
|
6
|
+
## What changes
|
|
7
|
+
|
|
8
|
+
| Story | Fix |
|
|
9
|
+
| --- | --- |
|
|
10
|
+
| US1 (P1): the sweeper re-ran live caller-process runs | `Executor#execute` holds the run's `async:<id>` liveness lock, auto-extended, and saves before releasing it (R-01). The sweeper is unchanged: it already skips locked runs. A killed process is recovered once the lock lapses. |
|
|
11
|
+
| US2 (P1): a caller's final save overwrote a worker's progress | With US1's lock, plus `Worker#perform` takes the run's lock **before** it reads the run (waiting up to 2s), and hands its owner to the executor, which re-enters the lock (R-02, R-03). |
|
|
12
|
+
| US3 (P1): a resume contended on the reactor's lock/semaphore raised and was lost | `continue` validates the payload, claims the interrupt, then either owns the run (lock, then reload, then resume inline) or hands the resume to a worker and returns a `DispatchResult` (R-05). After admission, `lock_snooze_max_attempts` no longer escalates a run to `failed` without rollback; it warns once with `ruby_reactor.resume.waiting` (R-07). |
|
|
13
|
+
| US4 (P2): two resumes of one interrupt could both run | Per-interrupt claim, `SET NX` on `reactor:<Class>:context:<id>:resume:<step>`, in every mode, including `Sidekiq::Testing.inline!` (R-04). The loser raises "already resumed" and writes nothing. |
|
|
14
|
+
| US5 (P2): a resume for another interrupt was rejected while the run executed | A `running` run accepts a claim for a ready, unexecuted interrupt and hands it to a Worker. The Worker applies claims under the run's lock at resume start, and resumes a `paused` run only when it has an unapplied claim (R-06). Attempt counts move to their own `INCR` record, so `continue` never writes the run unlocked (R-08). `Reactor#undo` reloads under its lock (R-09). |
|
|
15
|
+
| US6 (P2): a cut-off `compensate` of the failing step was never re-run | `mark_aborted` records the failing step, its arguments and its reason in `context.rollback`. `Executor#undo_all` re-runs it before the undo stack, so this works at any depth (composed child, inline map element). The dashboard API adds `pending_compensation`, and `ReactorDetail` shows it (R-10, R-11). |
|
|
16
|
+
| US7 (P3): map-level `undo_all` | `map ... undo_all { |completed_results| }` is called once per rollback with a lazy, index-ordered Enumerable. A fan-out map reads its result slots 1,000 at a time and dispatches no rollback jobs. An inline map reads element contexts one at a time, and `aborted` elements still replay their own steps (R-12, R-13). |
|
|
17
|
+
|
|
18
|
+
New RSpec surface:
|
|
19
|
+
|
|
20
|
+
- `TestSubject#resume(process_jobs:)`, which also accepts `running` subjects;
|
|
21
|
+
- `be_resume_deferred`;
|
|
22
|
+
- `have_run_undo_all(:map).with_elements(n)`.
|
|
23
|
+
|
|
24
|
+
Demo (Constitution VI):
|
|
25
|
+
|
|
26
|
+
- `BulkRefundDemoReactor` with `BulkRefundChargeReactor`, `ContendedApprovalDemoReactor` and
|
|
27
|
+
`DualApprovalDemoReactor`;
|
|
28
|
+
- rake `demo:map_undo_all`, `demo:contended_resume` and `demo:concurrent_interrupts`, grouped as
|
|
29
|
+
`demo:rollback_follow_ups` in `demo:all`;
|
|
30
|
+
- matcher-only specs.
|
|
31
|
+
|
|
32
|
+
Docs:
|
|
33
|
+
|
|
34
|
+
- README: Durability, Interrupts, Locks, Map;
|
|
35
|
+
- `documentation/`: `interrupts.md`, `locks_and_semaphores.md`, `background_and_async.md`,
|
|
36
|
+
`data_pipelines.md`, `core_concepts.md`, `testing.md`;
|
|
37
|
+
- `demo_app/documentation/data_pipelines.md`;
|
|
38
|
+
- CHANGELOG;
|
|
39
|
+
- `specs/future_improvements.md`.
|
|
40
|
+
|
|
41
|
+
## Migration notes (also in CHANGELOG)
|
|
42
|
+
|
|
43
|
+
1. `continue` returns a `DispatchResult` where it raised `Lock::AcquisitionError` /
|
|
44
|
+
`Semaphore::AcquisitionError`; a `rescue` of those simply stops firing.
|
|
45
|
+
2. A second resume of the same interrupt raises `ValidationError` "already resumed".
|
|
46
|
+
3. A resume for another ready interrupt of a `running` run is accepted (`DispatchResult`).
|
|
47
|
+
4. Interrupt attempt counts restart for runs paused across the upgrade.
|
|
48
|
+
5. A synchronous `Reactor.run` holds the run's liveness lock; `Reactor.undo` of such a live run
|
|
49
|
+
waits, then raises `Lock::AcquisitionError`.
|
|
50
|
+
|
|
51
|
+
## Verification
|
|
52
|
+
|
|
53
|
+
- **Baseline (T002)**: `bundle exec rspec` on `5ca7d1d1`: 1479 examples, 0 failures, 2 pending.
|
|
54
|
+
- **After (T072)**: 1570 examples, 0 failures, 2 pending (the same two pending: the 009
|
|
55
|
+
interrupt-inside-a-composed-child examples). Run on a dedicated test Redis DB
|
|
56
|
+
(`RUBY_REACTOR_TEST_REDIS_URL=redis://localhost:6780/11`).
|
|
57
|
+
- **Tagged (T073)**: `--tag slow` (10,000-element `undo_all`: one call, memory flat across the
|
|
58
|
+
enumeration, about 0.16s for the call) and `--tag fork` (a `SIGKILL`ed caller process is
|
|
59
|
+
recovered): both pass.
|
|
60
|
+
- **`bundle exec rubocop`**: no offenses. **GUI**: `vitest` 69 tests pass; the bundle is rebuilt
|
|
61
|
+
into `lib/ruby_reactor/web/public`.
|
|
62
|
+
- **Demo app (T075)**: 152 examples, 0 failures (local, test Redis DB 5).
|
|
63
|
+
- **Docker acceptance (T076)**: in an isolated compose project, `bin/rails demo:rollback_follow_ups`:
|
|
64
|
+
- `demo:map_undo_all`: bulk refund called once with 12 charges, 0 per-element refunds, run
|
|
65
|
+
`failed`. ✅
|
|
66
|
+
- `demo:contended_resume`: resume accepted (`DispatchResult`) while the lock was held, run
|
|
67
|
+
`completed` after release. ✅
|
|
68
|
+
- `demo:concurrent_interrupts`: finance accepted (background), legal accepted while running,
|
|
69
|
+
`completed` with both approvals. ✅
|
|
70
|
+
|
|
71
|
+
### `demo:all`: failures that are already on `main`
|
|
72
|
+
|
|
73
|
+
`demo:all` runs every demo against one Redis that is flushed once, with one Sidekiq process. On
|
|
74
|
+
`main` (run the same way, in a separate compose project) it already fails:
|
|
75
|
+
|
|
76
|
+
- `distributed_map_rollback` (70 charges for 40 orders);
|
|
77
|
+
- `composed_fan_out`, happy path and rollback (8 shipped instead of 5);
|
|
78
|
+
- `default_batch_size`;
|
|
79
|
+
- one `async_step` unit wait;
|
|
80
|
+
- `AsyncReactorDemoReactor(user_7)` (async wait timeout).
|
|
81
|
+
|
|
82
|
+
On this branch the same set fails, minus `default_batch_size` and the `async_step` unit. Each of
|
|
83
|
+
those demos passes when run alone (checked `demo:map_execution_undo` and
|
|
84
|
+
`demo:async_reactor_demo` on this branch). The three new tasks wait up to 120s, so they pass
|
|
85
|
+
inside `demo:all` too. The `demo:all` interference is out of scope here and worth its own issue.
|
|
86
|
+
|
|
87
|
+
## Regression proofs (red before green)
|
|
88
|
+
|
|
89
|
+
- **US1 (T014)**: with `Executor#execute`'s liveness lock removed, 5 of the 7 examples in
|
|
90
|
+
`caller_process_liveness_spec.rb` fail.
|
|
91
|
+
- **US2 (T018)**: with the same line removed, the owner Worker started by the map's completion
|
|
92
|
+
writes the run four times while the caller is still inside `execute`. With it, the Worker
|
|
93
|
+
snoozes without reading or writing, and the run finishes once.
|
|
94
|
+
- **US4 (T032)**: with the claim disabled, the 200-iteration race accepts both resumes.
|
|
95
|
+
- **US6 (T046)**: with `compensate_pending!` disabled, 5 of the 9 examples fail.
|
|
96
|
+
- **The inline payload (found by the demo suite)**: with the caller's payload not applied as
|
|
97
|
+
given, a string-keyed payload reached the step symbolized (`WebhookInterruptReactor` was
|
|
98
|
+
rejected). Guarded by `interrupt_claims_spec.rb` ("as given, string keys included").
|
|
99
|
+
|
|
100
|
+
## Deviations from tasks.md
|
|
101
|
+
|
|
102
|
+
- **Spec placement**:
|
|
103
|
+
- T015's example lives in `caller_process_liveness_spec.rb`;
|
|
104
|
+
- T046 and T047 in a new `rollback/aborted_compensate_spec.rb`.
|
|
105
|
+
- **No `lib/reactors.ts` type for `pending_compensation`** (T054): the detail response is
|
|
106
|
+
untyped.
|
|
107
|
+
- **`continue` on a `running` run always hands off.** It never tries the lock or runs another
|
|
108
|
+
execution's run inline (research R-05, updated).
|
|
109
|
+
- **The `ruby_reactor.resume.waiting` line carries `error`** (whose message names the key), not a
|
|
110
|
+
separate `key` field (contracts updated).
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Quickstart: Validate the Rollback and Resume Follow-ups
|
|
2
|
+
|
|
3
|
+
A guide to proving each user story end to end. The behavior is specified in
|
|
4
|
+
[contracts/](contracts/), and the records in [data-model.md](data-model.md).
|
|
5
|
+
|
|
6
|
+
## 0. Prerequisites
|
|
7
|
+
|
|
8
|
+
- Ruby >= 3.0 and `bundle install`.
|
|
9
|
+
- Test Redis reachable at `redis://localhost:6780` (`docker start ruby_reactor_redis_test`, or
|
|
10
|
+
`docker run -d --name rr-test-redis -p 6780:6379 redis:7-alpine`).
|
|
11
|
+
- If other suites share the test Redis, run the new specs alone first (known flake under load).
|
|
12
|
+
- The concurrency specs use threads and real Redis in Sidekiq fake mode. Only the US4 spec marked
|
|
13
|
+
`inline` uses `Sidekiq::Testing.inline!` (R-14).
|
|
14
|
+
|
|
15
|
+
## 1. US1: the sweeper leaves live caller-process runs alone (P1)
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bundle exec rspec spec/ruby_reactor/caller_process_liveness_spec.rb
|
|
19
|
+
bundle exec rspec spec/ruby_reactor/sweeper_spec.rb
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
| Scenario | Observable outcome |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| Synchronous run blocked in a step (latch), `Sweeper.run_once` called meanwhile | Returns 0 for that run, `be_locked` on `async:<id>`, and the step's counter is 1 after the latch opens |
|
|
25
|
+
| Same, `context_lock_ttl = 2` (the lock extends at most once a second), the step blocks 4s, sweeping every 0.5s | Never re-enqueued (the auto-extender renews the lock) |
|
|
26
|
+
| A forked child runs the reactor, and is `SIGKILL`ed mid-step | Once its lock lapses (`context_lock_ttl = 2`), `Sweeper.run_once` re-enqueues it once (spec tagged `:fork`) |
|
|
27
|
+
| Synchronous run hands off at a fan-out map and returns | Swept as before (lock released after the final save) |
|
|
28
|
+
|
|
29
|
+
## 2. US2: no lost progress from the caller's last save (P1)
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
bundle exec rspec spec/ruby_reactor/executor/caller_save_race_spec.rb
|
|
33
|
+
bundle exec rspec spec/ruby_reactor/worker_lock_then_load_spec.rb
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
| Scenario | Observable outcome |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| A middleware on the map step's `:complete_step` drains jobs while the caller still holds the lock | The owner Worker waits or snoozes and loads nothing. After a second drain: `be_success`, and each step after the map ran once (`have_run_step`) |
|
|
39
|
+
| Same, with the fix reverted (regression proof, run once while writing the spec) | The final save overwrites the Worker's progress, and the run is stuck `running` |
|
|
40
|
+
| Worker started while another owner holds `async:<id>` for longer than 2s | Snoozes as `ContextLockContention`, nothing written; runs after release on fresh state |
|
|
41
|
+
| Inline `continue` reaching a fan-out map | Same as the first row |
|
|
42
|
+
|
|
43
|
+
## 3. US3: contended resume is accepted (P1)
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
bundle exec rspec spec/ruby_reactor/interrupts/contended_resume_spec.rb
|
|
47
|
+
bundle exec rspec spec/ruby_reactor/rollback/resume_guard_spec.rb # updated: no longer raises
|
|
48
|
+
bundle exec rspec spec/ruby_reactor/worker_snooze_admitted_spec.rb
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
| Scenario | Observable outcome |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `hold_lock(<reactor lock key>)`, then `continue` with a valid payload | Returns `DispatchResult`, the run is `running`, a `ruby_reactor.resume.deferred reason=lock` line is logged, and one Worker job is enqueued |
|
|
54
|
+
| Release the lock, drain | `be_success`; the interrupt's result equals the payload |
|
|
55
|
+
| Lock held across 25 snoozes (`lock_snooze_max_attempts = 20`) | Never `failed`; one `ruby_reactor.resume.waiting` warning; resumes after release |
|
|
56
|
+
| Invalid payload while the lock is held | Validation failure returned (the class method raises `InputValidationError`); `be_paused`; no job enqueued; no claim stored |
|
|
57
|
+
| A second `continue` for the same interrupt while deferred | Raises `ValidationError` "already resumed" |
|
|
58
|
+
| Semaphore with no free slot | Same as the first three rows |
|
|
59
|
+
| `Reactor.cancel` before draining | The Worker loads `cancelled` and does nothing |
|
|
60
|
+
|
|
61
|
+
## 4. US4: exactly one of two simultaneous resumes (P2)
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
bundle exec rspec spec/ruby_reactor/interrupts/resume_claim_spec.rb
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
| Scenario | Observable outcome |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| 200 iterations: two threads released by a barrier call `continue` for the same interrupt | Exactly one returns, one raises "already resumed"; the steps after the interrupt run once per run; the stored result is the winner's payload |
|
|
70
|
+
| Same, inside `Sidekiq::Testing.inline!` (the one justified inline spec) | Same |
|
|
71
|
+
| `resume: :background` interrupt | Exactly one Worker job is enqueued |
|
|
72
|
+
|
|
73
|
+
## 5. US5: several interrupts resumed at once (P2)
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
bundle exec rspec spec/ruby_reactor/interrupts/concurrent_interrupts_spec.rb
|
|
77
|
+
bundle exec rspec spec/ruby_reactor/multiple_interrupts_spec.rb # existing, unchanged outcomes
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
| Scenario | Observable outcome |
|
|
81
|
+
| --- | --- |
|
|
82
|
+
| Paused at A and B. Thread 1 resumes A, and a step after A blocks on a latch. Thread 2 resumes B | Thread 2 gets `DispatchResult` (`reason=run_busy`). The latch opens, the run pauses at B, then a drain lets the Worker apply B: `be_success`, both results, each applied once |
|
|
83
|
+
| 2 to 5 ready interrupts all resumed from threads | All accepted; final `be_success`; each interrupt result applied once |
|
|
84
|
+
| Resume of B with an invalid payload while A runs | Validation failure; no claim; B stays ready |
|
|
85
|
+
| A's resume fails and rolls back after B was accepted | The Worker loads `failed` and returns; B's result never applied |
|
|
86
|
+
| The run is executing its first run in a worker, and B's dependencies are complete | Accepted; the run does not stay paused at B |
|
|
87
|
+
|
|
88
|
+
## 6. US6: manual undo finishes a cut-off `compensate` (P2)
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
bundle exec rspec spec/ruby_reactor/rollback/aborted_compensate_spec.rb
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
| Scenario | Observable outcome |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| Step X fails; X's `compensate` raises `Interrupt` part-way (caller process) | The run is `aborted`, and `rollback` carries `step: X`, `compensated: false` and the arguments; the API shows `pending_compensation` |
|
|
97
|
+
| `Reactor.undo(id)` | X's `compensate` runs again with the same arguments and a `RecordedFailure` reason (or the original string), before every undo; `cancelled` |
|
|
98
|
+
| Interruption after X's `compensate` returned (during the undo stack) | Manual undo does not re-run `compensate`, and replays only the remaining entries |
|
|
99
|
+
| Re-run `compensate` returns `Failure` | A `:failed_compensation` event and trace entry; the undo stack is still replayed |
|
|
100
|
+
| X inside a composed child; X inside an inline map element | Re-run before that child's or element's undos |
|
|
101
|
+
|
|
102
|
+
## 7. US7: `undo_all` (P3)
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
bundle exec rspec spec/map/map_undo_all_spec.rb
|
|
106
|
+
bundle exec rspec spec/ruby_reactor/dsl/map_undo_all_dsl_spec.rb
|
|
107
|
+
bundle exec rspec spec/map/map_undo_all_spec.rb --tag slow # 10,000 elements (SC-007)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
| Scenario | Observable outcome |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| Fan-out map of 20 elements with `undo_all`, a later step fails | One call with 20 results in index order; no element undo ran; no rollback jobs enqueued; the steps before the map undone after the call; `have_run_undo_all(:map).with_elements(20)` |
|
|
113
|
+
| Atomic fan-out map, element 7 fails | Called with the completed results only; element 7 rolled itself back |
|
|
114
|
+
| Inline map, same reactor | Same outcome |
|
|
115
|
+
| The block raises | `have_rollback_failure(:map)` with `kind: :undo_all`; the steps before the map are still undone |
|
|
116
|
+
| No element completed | Not called |
|
|
117
|
+
| `Reactor.undo(id)` of a completed run | Called once |
|
|
118
|
+
| Declared twice or without a block | `ValidationError` at class load |
|
|
119
|
+
| 10,000 completed elements (slow) | One call; zero per-element undos; the process's memory is flat across the enumeration (measured as in 009 SC-001) |
|
|
120
|
+
|
|
121
|
+
## 8. Full suite and style
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
bundle exec rspec
|
|
125
|
+
bundle exec rubocop
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## 9. Demo app (Constitution VI)
|
|
129
|
+
|
|
130
|
+
Locally, without Docker, against test Redis DB 5:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
cd demo_app
|
|
134
|
+
REDIS_URL=redis://localhost:6780/5 RAILS_ENV=test bundle exec rspec \
|
|
135
|
+
spec/reactors/bulk_refund_demo_reactor_spec.rb \
|
|
136
|
+
spec/reactors/contended_approval_demo_reactor_spec.rb \
|
|
137
|
+
spec/reactors/dual_approval_demo_reactor_spec.rb
|
|
138
|
+
REDIS_URL=redis://localhost:6780/5 bin/rails demo:rollback_follow_ups
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Docker acceptance**: use an isolated compose project, because container names are fixed and
|
|
142
|
+
collide across worktrees.
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
docker compose -p rr_rollback_follow_ups -f docker-compose.yml -f <override.yml> up -d --build demo-redis demo-sidekiq
|
|
146
|
+
docker compose -p rr_rollback_follow_ups -f docker-compose.yml -f <override.yml> run --rm --no-deps demo-app \
|
|
147
|
+
bash -c "bin/rails db:prepare && bin/rails demo:rollback_follow_ups"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Expected rake output, per task:
|
|
151
|
+
|
|
152
|
+
- **`demo:map_undo_all`**: "bulk refund called once with N charges", then the run `failed` with
|
|
153
|
+
the steps before the map undone.
|
|
154
|
+
- **`demo:contended_resume`**: "resume accepted (DispatchResult) while the lock is held", then
|
|
155
|
+
`completed` after release.
|
|
156
|
+
- **`demo:concurrent_interrupts`**: "finance accepted (background); legal accepted while running",
|
|
157
|
+
then `completed`, with both approvals in the result.
|