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,139 @@
1
+ # Data Model: Rollback and Resume Follow-ups
2
+
3
+ **Feature**: [spec.md](spec.md) | **Research**: [research.md](research.md)
4
+
5
+ All records live in Redis through `Storage::RedisAdapter`. `<Class>` is the reactor's storage name
6
+ (`RubyReactor.reactor_storage_name`), and `<id>` is the run's context id.
7
+
8
+ ## 1. Run liveness lock (changed holders)
9
+
10
+ `lock:async:<id>` is the existing re-entrant lock: Lua owner/count hash, TTL `context_lock_ttl`,
11
+ auto-extended by its holder.
12
+
13
+ | Holder | Since | Wait | Taken | Released |
14
+ | --- | --- | --- | --- | --- |
15
+ | `Executor#resume_execution`, `#resume_rollback` | 004/009 | 0 | after the reactor-level gates' rate-limit check | after the final save (009 R-13) |
16
+ | `Reactor#undo` | 009 R-12 | 5s | before undoing; **now also before reloading** (R-09) | after the save |
17
+ | **`Executor#execute`** (root, caller process) | **010 R-01** | 0 | after input validation, before the reactor-level lock and semaphore | **after the final save** |
18
+ | **`Worker#perform`** | **010 R-02** | 2s, polling | **before the context is read** | in `ensure`, after the executor released its re-entry |
19
+ | **`Reactor#continue`** | **010 R-05** | 0 | after the claim, **before the reload** | after the resume returns, or before the hand-off enqueue |
20
+
21
+ - **Re-entry**: the Worker and `continue` pass their owner to the executor
22
+ (`Executor#context_lock_owner=`). The executor's acquire then raises the count instead of
23
+ contending.
24
+ - **Inline job-testing mode**: no holder takes it (unchanged).
25
+ - **Reader**: `Sweeper#run_once`, which is unchanged. It now sees caller-process runs as live.
26
+
27
+ ## 2. Interrupt resume claim (new)
28
+
29
+ | Field | Value |
30
+ | --- | --- |
31
+ | Key | `reactor:<Class>:context:<id>:resume:<step>` |
32
+ | Value | `ContextSerializer.serialize_value(payload)`, as JSON |
33
+ | Created | `SET NX EX context_ttl`, by `Reactor#continue` after the payload has validated |
34
+ | Read | `MGET` over the reactor's interrupt steps that have no result yet: `Executor#resume_execution` right after the context lock, and `Worker#perform` for a `paused` run |
35
+ | Deleted | never; it expires with TTL |
36
+
37
+ **Lifecycle** of one interrupt:
38
+
39
+ ```text
40
+ absent ──continue (valid payload, SET NX wins)──▶ claimed ──lock owner applies──▶ applied
41
+ │ │ │
42
+ │ second continue: SET NX loses ─▶ "already resumed" │
43
+ └──────────────── continue after apply: the result exists ─▶ "already resumed" ◀──┘
44
+ ```
45
+
46
+ - **Applied** means `context.set_result(step, payload)` by the execution that owns the lock,
47
+ persisted by that execution's next save.
48
+ - An applied claim stays as the dedupe marker until it expires. After that, the step's result in
49
+ `intermediate_results` rejects duplicates.
50
+
51
+ **Validation rules**:
52
+
53
+ - A claim is written only for an interrupt step that is ready, has no result and has passed payload
54
+ validation (FR-008, FR-017, FR-020).
55
+ - The run must be `paused` or `running`, and not `cancelled`.
56
+
57
+ ## 3. Interrupt attempt counter (moved)
58
+
59
+ | Field | Value |
60
+ | --- | --- |
61
+ | Key | `reactor:<Class>:context:<id>:resume_attempts:<step>` |
62
+ | Value | an integer, raised by `INCR` once per invalid payload, with `EXPIRE context_ttl` |
63
+ | Replaces | `context.private_data[:interrupt_attempts][step]`, no longer written |
64
+
65
+ At `step_config.max_attempts`, the run fails through `Reactor#undo(failure:)` (R-08, R-09). The
66
+ `failure_reason` is unchanged: `message`, `step_name`, `errors`, `payload`, `step_arguments`,
67
+ `attempts`, `validation_errors`.
68
+
69
+ ## 4. `context.rollback` on an aborted run (extended)
70
+
71
+ There is no new context attribute. 009 added `rollback`, and 010 adds keys for the aborted case
72
+ (R-10).
73
+
74
+ | Key | Type | Meaning |
75
+ | --- | --- | --- |
76
+ | `trigger` | `"failure"` | a step failure was rolling back when the process was interrupted |
77
+ | `step` | String | the failing step's name, at this context's level |
78
+ | `compensated` | Boolean | whether that step's `compensate` returned. `false` makes manual undo re-run it |
79
+ | `arguments` | serialized Hash | the step's `rollback_arguments`. **Its presence marks the aborted record**; 009 hand-off records do not carry it |
80
+ | `error` | `{ "class", "message" }`, or a String | the failure that `compensate` receives again |
81
+ | `failures` | serialized Array | rollback failures collected before the interruption (009) |
82
+
83
+ **State transitions** (top level; a composed child or inline element behaves the same at its own
84
+ level):
85
+
86
+ ```text
87
+ running ──step fails──▶ (compensate running) ──interrupted──▶ aborted + rollback{compensated:false}
88
+ │
89
+ Reactor.undo: lock, reload, merge record, undo_all
90
+ ▼
91
+ compensate re-run (once) ─▶ compensated:true ─▶ undo stack replayed ─▶ cancelled
92
+ running ──step fails──▶ compensate returns ──interrupted during undo stack──▶ aborted (no record;
93
+ manual undo replays the remaining stack only)
94
+ ```
95
+
96
+ `Web::API` derives `pending_compensation: { step: }` from this record when the status is `aborted`
97
+ and `compensated` is false (R-11).
98
+
99
+ ## 5. Map step declaration: `undo_all` (new)
100
+
101
+ | Field | Where | Value |
102
+ | --- | --- | --- |
103
+ | `undo_all_block` | map `StepConfig#arguments`, source `Template::Value` | the block given to `undo_all`; absent when not declared |
104
+
105
+ **Validation rules** (`MapBuilder`, at class definition): a block is required, and the method may be
106
+ declared once per map. Either violation raises `Error::ValidationError` naming the map (FR-027).
107
+
108
+ ### Bulk undo inputs and outcome
109
+
110
+ | Item | Fan-out map | Inline map |
111
+ | --- | --- | --- |
112
+ | Completed results | forward result slots, in index order, `Success` values only (`Map::ResultEnumerator`) | element contexts with status `completed`, in index order, one deserialized at a time |
113
+ | Replayed per element instead | none (no element is `aborted`) | elements with status `aborted` (pass 1, `Map::ElementRollback`) |
114
+ | Reported unavailable | indexes with no result slot | element contexts that expired, as in 009 |
115
+ | Rollback records or jobs | none | none |
116
+
117
+ Outcome entries:
118
+
119
+ - **Execution trace**: `{ type: :undo_all, step: <map step>, count: <completed>, result: <value or error>, timestamp: }`.
120
+ - **Rollback failure**, on a raise or a returned `Failure`:
121
+ `{ step: <map step>, kind: :undo_all, reason: :raised | :returned_failure, message: }`. It is
122
+ aggregated into the run's final failure like any rollback failure.
123
+
124
+ ## 6. Worker decision by status (R-06)
125
+
126
+ | Stored status | Action |
127
+ | --- | --- |
128
+ | `completed`, `failed`, `cancelled`, `skipped`, `aborted`, `halted` | return (release the lock) |
129
+ | `paused` | resume if any interrupt without a result has a claim; otherwise return |
130
+ | `rolling_back` | `resume_rollback` |
131
+ | `running` | `resume_execution` (applies claims first) |
132
+
133
+ ## 7. Snooze limit (R-07)
134
+
135
+ | Condition | Limited by `lock_snooze_max_attempts`? |
136
+ | --- | --- |
137
+ | Reactor-level lock, semaphore or rate limit before admission (`!context.admitted?`) | yes (unchanged): escalate to `failed` |
138
+ | Reactor-level lock or semaphore once admitted | **no**: one `ruby_reactor.resume.waiting` warning at the limit, then keep snoozing |
139
+ | `ContextLockContention`, `OrderedLock::WaitError`, `ExecutionParked` | no (unchanged) |
@@ -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).