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,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.
@@ -0,0 +1,440 @@
1
+ # Research: Rollback and Resume Follow-ups
2
+
3
+ **Feature**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md) | **Date**: 2026-10-08
4
+
5
+ Each decision names the code it rests on. Line numbers are as of `5ca7d1d1`.
6
+
7
+ ## R-01 Caller-process runs hold the run's liveness lock (US1, US2)
8
+
9
+ **Decision**: `Executor#execute` takes the run's `async:<id>` liveness lock, as
10
+ `resume_execution` already does (`executor.rb:666` `acquire_context_lock`). Conditions:
11
+
12
+ - root executor only;
13
+ - not inside a worker (`!inline_async_execution`);
14
+ - not in inline job-testing mode.
15
+
16
+ The lock is auto-extended, with `wait: 0`. `execute` saves the context **before** releasing the
17
+ lock, the same order 009 R-13 gave `resume_execution`. `RubyReactor::Sweeper` is unchanged: it
18
+ already skips a run whose lock is held (`sweeper.rb:54`).
19
+
20
+ **Rationale**:
21
+
22
+ - One signal serves every reader that already asks "is this run live?": the reactor sweeper, the
23
+ map sweeper's owner check (`map/sweeper.rb:136`), manual undo (009 R-12), and now the Worker
24
+ (R-02).
25
+ - The lock's auto-extender is the heartbeat. A step that runs longer than `context_lock_ttl` stays
26
+ live (FR-002). A killed process stops extending, and the lock lapses within one
27
+ `context_lock_ttl` (60s by default, FR-003).
28
+ - A `SIGTERM`-style interruption is rescued, so the run is marked `aborted` and its lock is
29
+ released; the sweeper never re-enqueues an `aborted` run.
30
+
31
+ **Alternatives considered**:
32
+
33
+ - *A heartbeat timestamp on the context.* It needs blob writes or a new key, plus staleness rules in
34
+ the sweeper. The lock already is a renewed heartbeat with a TTL.
35
+ - *A separate "caller-process" liveness key.* That gives two liveness signals for one run, and the
36
+ map sweeper, undo and Worker would all have to read both.
37
+ - *Marking caller-process runs so the sweeper skips them until a timestamp goes stale.* A long step
38
+ either looks dead (re-run) or delays crash recovery. It cannot satisfy both FR-002 and FR-003.
39
+
40
+ **Cost**: one `SET NX`, one release, and one extender thread per synchronous run. Map elements and
41
+ composed children are not root executors or run in a worker, so they take nothing new.
42
+
43
+ ## R-02 The Worker takes the liveness lock before it loads (US2)
44
+
45
+ **Decision**: `Worker#perform` acquires `async:<id>` **before** it reads the context:
46
+
47
+ - Owner: a fresh UUID. Wait: up to `Worker::CONTEXT_LOCK_WAIT = 2` seconds, polling with 0.1s
48
+ sleeps, as `Lock#acquire` does. No BLPOP: the adapter shares one client per process.
49
+ - It then loads the context and hands the owner to the executor (R-03), which re-enters the lock.
50
+ - If the lock is still held after the wait, the worker snoozes as for `ContextLockContention`:
51
+ uncapped, nothing loaded, nothing written.
52
+ - It releases the lock in `ensure` on every exit (`aborted` return, deserialization failure,
53
+ terminal status).
54
+ - Inline job-testing mode skips it, as `acquire_context_lock` does.
55
+
56
+ **Rationale**: with R-01, a caller's final save happens while it holds the lock. Holding the lock
57
+ before loading guarantees the Worker reads the caller's final state, so its own later save cannot
58
+ replace anything newer (FR-005). This is part 2 ("lock, then load") of the "Fenced context writes"
59
+ proposal, applied only to the Worker. The 2s wait covers the usual case: a caller that enqueued the
60
+ hand-off and is milliseconds from releasing. A snooze would cost a whole Sidekiq scheduled-set poll
61
+ (about 5s) on every hand-off from a caller process.
62
+
63
+ It also fixes two places that enqueue the owner's Worker while still holding the lock: the rollback
64
+ hand-off handshake (`executor.rb:377`) and `Reactor#undo`'s hand-off. Those Workers now wait instead
65
+ of contending once and snoozing.
66
+
67
+ **Alternatives considered**:
68
+
69
+ - *Peek at `lock_held?` before loading, and snooze if held.* It leaves a check-then-read gap, and
70
+ every `background` or fan-out hand-off from a caller process would pay a full snooze.
71
+ - *The full fence* (versioned, owner-checked writes). Out of scope (spec Assumptions); it would
72
+ also close the paused-holder case.
73
+ - *Moving the map's `composed_contexts` reference before dispatch*, so the caller's final save adds
74
+ nothing the Worker would miss. That fixes this one write, not the race.
75
+
76
+ ## R-03 The executor accepts an already-held context lock
77
+
78
+ **Decision**: `Executor#context_lock_owner=` (an internal writer). When it is set,
79
+ `acquire_context_lock` acquires with that owner. `Lock` is re-entrant by owner (the Lua count), so
80
+ the acquire succeeds, and the executor's release only decrements the count. The outer holder (the
81
+ Worker or `continue`) releases last.
82
+
83
+ **Rationale**: the smallest change that lets "lock, then load" (R-02, R-05) reuse
84
+ `resume_execution` and `resume_rollback` unchanged.
85
+
86
+ ## R-04 A resume is claimed per interrupt, atomically (US4, US5)
87
+
88
+ **Decision**: accepting a resume means writing a **claim**: `SET NX` of
89
+ `reactor:<Class>:context:<id>:resume:<step>`, holding the serialized payload, with TTL
90
+ `context_ttl`.
91
+
92
+ - The first `SET NX` wins. A loser gets `Error::ValidationError` ("interrupt :x was already
93
+ resumed") and writes nothing else (FR-015).
94
+ - The claim key is never deleted. It expires with the run and remains the dedupe marker, even after
95
+ the payload has been applied.
96
+ - The claim is the payload's only storage until it is applied. Only the execution that owns the
97
+ run's lock applies it: it copies every claimed payload of an interrupt not yet in
98
+ `intermediate_results` into the context, at the start of `resume_execution`, right after the
99
+ context lock (R-05, R-06).
100
+
101
+ **Rationale**:
102
+
103
+ - `SET NX` is atomic in every execution mode. The per-run context lock is not: inline job-testing
104
+ mode skips it (`executor.rb:673`), which is exactly 008's accepted gap (FR-016).
105
+ - Claiming per interrupt, not per run, lets different interrupts be accepted concurrently (US5),
106
+ while the same interrupt is accepted once (US4).
107
+ - `continue` no longer writes the blob before it holds the lock. Today it saves `running` and the
108
+ payload from an unlocked snapshot (`reactor.rb:177-190`), the "`Reactor#continue`" writer listed
109
+ in "Fenced context writes". Applying the payload under the lock also gives US2's guarantee to
110
+ resumes.
111
+
112
+ **Alternatives considered**:
113
+
114
+ - *A compare-and-set of the status inside the blob* (Lua plus cjson). It serializes every resume of
115
+ the run, which blocks US5. It is still a write from outside the owning execution.
116
+ - *Relying on the context lock.* It is skipped in inline mode.
117
+ - *Deleting the claim once it is applied.* A late duplicate could then re-claim between the delete
118
+ and its own readiness check, reading a stale snapshot.
119
+
120
+ ## R-05 The `continue` flow (US3, US4, US5)
121
+
122
+ **Decision**: `Reactor#continue(payload:, step_name:)` runs these steps:
123
+
124
+ 1. **Snapshot checks (unlocked)**:
125
+ - Reject if `cancelled`, or if the status is not `paused` or `running`. That rejects
126
+ finished, `aborted`, `rolling_back` and failed runs, as 008 FR-032 does today.
127
+ - Reject if the step is not ready (`validate_continue_step!`, unchanged), or if it already has
128
+ a result ("already resumed").
129
+ 2. **Validate the payload in this process.** An invalid payload counts an attempt (R-08) and
130
+ returns its failure as today. Nothing is claimed, stored or enqueued (FR-008).
131
+ 3. **Claim** (R-04). If the claim is lost, raise "already resumed".
132
+ 4. **Try to own the run.** Only a `paused` run is resumed here. A `running` run belongs to
133
+ another execution (a resume, its first run in the caller's process or in a worker, a wait on
134
+ background work), so the resume joins it through a Worker without trying the lock: the resume
135
+ never runs someone else's execution in the calling process. For a paused run, take
136
+ `async:<id>` with `wait: 0` (skipped in inline mode).
137
+ - **Contended**, or the run turned `running` by the reload: hand off. Enqueue the run's Worker
138
+ and return a `DispatchResult` (FR-017, FR-019). The Worker takes the lock when it frees and
139
+ applies the claim (R-06).
140
+ - Implementation note: the `current_step` check ("was it interrupted?") applies to paused runs
141
+ only; a running run may not have one yet.
142
+ 5. **Owned**: reload the run under the lock ("lock, then load").
143
+ - If it has finished or been cancelled since step 1, release the lock and raise as step 1
144
+ would.
145
+ 6. **`resume: :background` interrupt**: apply the claims, mark the run `running`, save, release,
146
+ enqueue the Worker, and return a `DispatchResult` (same outward behavior as today).
147
+ 7. **Otherwise**: `Executor#resume_execution` with the held owner (R-03). It applies the claims,
148
+ marks the run `running`, and runs the steps.
149
+ - If it cannot take the reactor-level `with_lock` or `with_semaphore`, it raises
150
+ `Lock::AcquisitionError` or `Semaphore::AcquisitionError`. By then it has saved the run as
151
+ `running` with the claims applied, before raising (its `ensure`).
152
+ - `continue` rescues those errors: it releases the context lock, **then** enqueues the Worker,
153
+ logs `ruby_reactor.resume.deferred` (FR-014), and returns a `DispatchResult` (FR-009). Release
154
+ first, so the Worker does not wait; a crash between the release and the enqueue leaves a
155
+ `running` run with no lock, which the sweeper recovers.
156
+
157
+ `reopen_paused` and the `past_gates?` branch are deleted: a contended resume no longer reverts to
158
+ `paused`. Hand-off paths keep today's `check_for_inline_completion`, so inline job-testing mode
159
+ returns the finished result as it does for `resume: :background`.
160
+
161
+ **Rationale**:
162
+
163
+ - One acceptance point: a valid claim. One application point: the lock owner, at resume start.
164
+ - Every path that cannot run now (contended context lock, contended reactor lock or semaphore,
165
+ background resume) becomes the same hand-off.
166
+ - The Worker never re-validates (FR-011): the payload stored in the claim was validated in step 2.
167
+
168
+ **Alternatives considered**:
169
+
170
+ - *Picking up claims inside the executing resume* when it reaches the claimed interrupt (US5's
171
+ "the executing resume picks it up"). That needs a storage read at every interrupt step. The
172
+ backstop Worker already guarantees FR-018, at the cost of one job hop and a transient `paused`.
173
+ Add the pick-up only if that latency matters (marked `ponytail:` in code).
174
+ - *Returning a retryable error for US5.* That is the behavior the webhook problem in US3 asks to
175
+ remove.
176
+
177
+ ## R-06 What the Worker does, by status
178
+
179
+ **Decision**: after loading under the lock (R-02):
180
+
181
+ | Stored status | Worker action |
182
+ | --- | --- |
183
+ | terminal (`completed`, `failed`, `cancelled`, `skipped`, `aborted`, `halted`) | return. Today only `aborted` returns early; FR-013 extends it. |
184
+ | `paused` | resume only if a claim exists for an interrupt without a result; otherwise return. A stray Worker never advances a paused run. |
185
+ | `rolling_back` | `resume_rollback`, as today |
186
+ | `running` | `resume_execution`, as today, which now applies claims first |
187
+
188
+ **Rationale**: the backstop Worker of R-05 can arrive in any state. A paused run must advance only
189
+ with a payload: steps after the paused interrupt in the same ready set (`step_executor.rb:32-50`)
190
+ would otherwise run without one.
191
+
192
+ ## R-07 The snooze limit applies only before admission (US3)
193
+
194
+ **Decision**: in `Worker#handle_snooze`, reactor-level contention is uncapped once
195
+ `context.admitted?`. That covers a lock or semaphore after a genuine resume, a background resume, or
196
+ a re-entry after a hand-off. At `lock_snooze_max_attempts` the worker logs one
197
+ `event="ruby_reactor.resume.waiting"` warning, and keeps snoozing.
198
+
199
+ **Rationale**:
200
+
201
+ - `escalate_snooze` (`worker.rb:228`) marks the run `failed` **without rolling back**. That is
202
+ harmless before admission (nothing ran) and a saga violation after it: the completed steps keep
203
+ their side effects (Constitution II).
204
+ - The same defect already exists for `resume: :background` interrupts and for a parked run whose
205
+ lock reattach lapsed; this fixes them too.
206
+ - FR-010 asks the deferred resume to wait for the lock.
207
+ - A lock always expires by its TTL. A leaked semaphore slot does not, so the warning gives the
208
+ operator a signal.
209
+
210
+ **Alternatives considered**:
211
+
212
+ - *Escalate with a full rollback.* That fails a run the user accepted because of an unrelated
213
+ holder, and rolls back without the reactor-level lock it ran under.
214
+ - *Keep the cap.* An accepted resume could then end `failed` with nothing undone.
215
+
216
+ ## R-08 Interrupt attempts move to an atomic counter
217
+
218
+ **Decision**: `validate_continue_payload` counts an invalid payload with
219
+ `INCR reactor:<Class>:context:<id>:resume_attempts:<step>` (TTL `context_ttl`), instead of updating
220
+ `private_data[:interrupt_attempts]` and saving the snapshot. When the limit is reached it calls
221
+ `Reactor#undo(failure: ...)`, which ends the run `failed` with the same `failure_reason` as today,
222
+ inside the undo's lock-then-load (R-09).
223
+
224
+ **Rationale**: once a `running` run accepts resumes (US5), an unlocked blob save from `continue`
225
+ would overwrite the executing resume's progress. The counter is the only blob write before the claim.
226
+
227
+ **Migration**: a run paused across the upgrade starts counting from 0. It can take at most
228
+ `max_attempts - 1` extra invalid attempts. Recorded in CHANGELOG.
229
+
230
+ ## R-09 `Reactor#undo` reloads under its lock
231
+
232
+ **Decision**: after `acquire_undo_lock`, `Reactor#undo` reloads the context, then undoes. It takes
233
+ an internal `failure:` keyword: when given, the run ends `failed` with that reason instead of
234
+ `cancelled` (R-08), saved under the same lock. The reason also rides the `rollback` state
235
+ (`failure_reason`), so an undo that hands off at a fan-out map still ends `failed` when the worker
236
+ finishes it (`Executor#finish_undo`).
237
+
238
+ **Rationale**: "lock, then load", as R-02. A Worker that saved between `Reactor.undo`'s `find` and
239
+ its lock would otherwise be overwritten by an undo working from a stale undo stack.
240
+
241
+ ## R-10 An aborted run records an unfinished `compensate` (US6)
242
+
243
+ **Decision**:
244
+
245
+ - `CompensationManager#handle_step_failure` keeps the failing step's `rollback_arguments` on
246
+ `@pending`.
247
+ - `Executor#mark_aborted` writes `context.rollback` when `@pending` exists and is not `compensated`:
248
+
249
+ ```ruby
250
+ { "trigger" => "failure", "step" => name, "compensated" => false,
251
+ "arguments" => serialized_arguments, "error" => { "class" => ..., "message" => ... } }
252
+ ```
253
+
254
+ Composed children and inline map elements are aborted too: each child executor's own
255
+ `rescue Exception` marks its own context, which is embedded in, or stored beside, the root.
256
+ - `Reactor#undo` merges that record instead of overwriting it. It keeps `compensated: false`, the
257
+ step, the arguments and the error.
258
+ - `Executor#undo_all` first runs `compensate_pending!`: if `rollback` names a step that is not
259
+ `compensated` **and** carries `arguments`, it calls
260
+ `CompensationManager#compensate(step_config, error, arguments)`, then sets `compensated` to true,
261
+ then replays the undo stack.
262
+ - That compensate is reported as any compensation is: execution-trace entry, `:failed_compensation`
263
+ event, rollback failure. Either way, the undo stack is still replayed (FR-024).
264
+ - **The error passed in**: an original `String` reason is passed as that string. An exception is
265
+ passed as `Error::RecordedFailure` (a `StandardError` with the original `message` and
266
+ `original_class`). The original object cannot be rebuilt.
267
+
268
+ **Rationale**:
269
+
270
+ - `undo_all` is the one entry point manual undo, a composed child's undo (`ComposeStep#undo`) and
271
+ an inline element's rollback (`Map::ElementRollback`) all use, so FR-025 ("any depth") comes free.
272
+ - The `arguments` key tells this record apart from 009's hand-off state. That state uses the same
273
+ `step` and `compensated` keys and is finished by `finish_rollback`, which needs no arguments: it
274
+ adopts a map's or compose's settled state.
275
+
276
+ **Not changed**: manual undo still saves only at its end. An undo interrupted again re-runs the
277
+ whole remaining undo, `compensate` included; that is at-least-once, as documented. A worker run
278
+ still stays `running` and is redelivered (008 R-08).
279
+
280
+ ## R-11 Showing the outstanding `compensate` (FR-026)
281
+
282
+ **Decision**:
283
+
284
+ - `Web::API` adds `pending_compensation: { step: }` to an `aborted` run whose `rollback` names a
285
+ step that is not `compensated`.
286
+ - `ReactorDetail.tsx` shows one line on such a run: "Compensation of step `x` did not finish; run
287
+ undo to complete it."
288
+ - The interrupts and core-concepts documentation say the same.
289
+
290
+ ## R-12 The `undo_all` DSL (US7)
291
+
292
+ **Decision**: `MapBuilder#undo_all(&block)`:
293
+
294
+ - It raises `Error::ValidationError` with no block, or when declared twice (FR-027). That is the
295
+ definition error `MapBuilder#build` already raises for `fail_fast` plus `atomic`.
296
+ - The block is stored as the map step argument `undo_all_block` (a `Template::Value`), like
297
+ `collect_block`.
298
+ - At rollback it is read from the **static declaration**
299
+ (`context.reactor_class.steps[name].arguments[:undo_all_block]`), because a map's undo record
300
+ carries no arguments (009 R-14).
301
+ - The block receives one argument, a lazy `Enumerable` of completed elements' results. Its outcome:
302
+ - a `RubyReactor::Failure` return or a raise is a failure;
303
+ - any other return value is success.
304
+
305
+ ## R-13 Rolling back through `undo_all`
306
+
307
+ **Decision**: when the block is declared, `MapStep#compensate` (and its alias `undo`) calls
308
+ `bulk_rollback` instead of `distributed_rollback` or `inline_rollback`.
309
+
310
+ 1. **Fan-out map**:
311
+ - Every index has settled before any map rollback (009 R-04), and its elements ran in workers,
312
+ so none is `aborted`.
313
+ - Results come from the forward result slots, through `Map::ResultEnumerator` (strict index
314
+ order), keeping only successful values: no `_error`, `_halt` or `_skipped`.
315
+ - Indexes with no slot (expired) are reported before the call, as `context_unavailable`
316
+ entries, in chunks.
317
+ - No rollback records and no element rollback jobs (FR-029).
318
+ 2. **Inline map**:
319
+ - The element index, read in `Map::ROLLBACK_CHUNK` chunks.
320
+ - **Pass 1** replays each `aborted` element through `Map::ElementRollback`, as today. Only an
321
+ interrupted run leaves one, the newest started. Expired contexts are reported as today.
322
+ - **Pass 2** is the lazy enumerator: it deserializes one `completed` element at a time and
323
+ yields its result (`returns` or the last step's result, as `Reactor#reconstruct_success_result`).
324
+ 3. **No completed element** (the slot count, or pass 1's count, is 0): the block is not called
325
+ (FR-031).
326
+ 4. **The call**:
327
+ - Log `ruby_reactor.map.rollback.undo_all.started` (fields `reactor`, `map_step`, `count`)
328
+ before it and `.completed` (adding `failed`) after it (FR-034).
329
+ - Append an execution-trace entry `{ type: :undo_all, step:, count:, result: }`.
330
+ - A failure adds one rollback failure `{ step: <map step>, kind: :undo_all, reason:, message: }`
331
+ (FR-032).
332
+ 5. **What is returned**: `Success` when there are no failures, otherwise the map's usual
333
+ `Failure("map :x rollback incomplete", rollback_failures:)`. The executor continues with the
334
+ steps before the map, as today.
335
+
336
+ **At least once** (FR-033): the map's undo entry leaves the undo stack only after `undo` returns
337
+ (008 R-16). An execution killed mid-call re-runs it on recovery. Duplicate triggers are already
338
+ rejected by 009 FR-027.
339
+
340
+ **Alternatives considered**:
341
+
342
+ - *Read element contexts for fan-out too.* That deserializes 10,000 blobs; the result slots already
343
+ hold the values.
344
+ - *A single pass over an inline map, replaying aborted elements as the enumerator meets them.* A
345
+ block that stops iterating early would skip them.
346
+
347
+ ## R-14 Inline job-testing mode
348
+
349
+ - The context lock stays skipped in inline mode (R-01, R-02, R-05), as today, because a nested
350
+ re-entry would contend with itself.
351
+ - US4 holds there through the claim (R-04). One justified `inline!` spec covers it, like 009 T041.
352
+ - US5's overlap (a resume arriving **while** another executes) needs real concurrency and the lock,
353
+ so its specs run in fake mode with threads. Sequential resumes in inline mode produce the same
354
+ outcomes as before.
355
+
356
+ ## R-15 Observability
357
+
358
+ New structured log lines (key=value):
359
+
360
+ - `event="ruby_reactor.resume.deferred"`: `reactor`, `context_id`, `step`,
361
+ `reason=lock|semaphore|run_busy|background`, `key`. One per hand-off from `continue` (FR-014).
362
+ - `event="ruby_reactor.resume.waiting"`, at warn level: `reactor`, `context_id`, `error` (the
363
+ contention message, which names the key), `snooze_count`. Logged once, when an admitted run
364
+ passes the snooze limit (R-07).
365
+ - `event="ruby_reactor.map.rollback.undo_all.started"` and `.completed`: `reactor`, `context_id`,
366
+ `map_step`, `count`, `failed` (FR-034).
367
+
368
+ Middleware events are unchanged. A re-run compensate fires `:start_compensation` and
369
+ `:complete_compensation` or `:failed_compensation`. A hand-off fires no `:failed_reactor`, because
370
+ `continue` returns.
371
+
372
+ ## R-16 SemVer: MINOR
373
+
374
+ - **Features**: `undo_all`.
375
+ - **Bug fixes**:
376
+ - the sweeper and caller-process runs;
377
+ - lost progress after a hand-off;
378
+ - lost resumes on lock contention;
379
+ - double resumes;
380
+ - concurrent interrupts;
381
+ - manual undo re-running a cut-off `compensate`;
382
+ - background or deferred resumes no longer escalated to `failed` without rollback.
383
+ - **Migration notes**:
384
+ - `continue` returns a `DispatchResult` where it raised `Lock::AcquisitionError` or
385
+ `Semaphore::AcquisitionError`. A `rescue` of those simply stops firing.
386
+ - A second resume of the same interrupt raises "already resumed".
387
+ - Interrupt attempt counts restart for runs paused across the upgrade.
388
+ - A synchronous `Reactor.run` takes the run's liveness lock.
389
+
390
+ ## R-17 Documentation impact
391
+
392
+ - **README.md**:
393
+ - "Durability & Recovery": caller-process liveness.
394
+ - "Interrupts (Pause & Resume)": acceptance, hand-off on contention, several interrupts at once,
395
+ "already resumed".
396
+ - "Locks, Semaphores & Ordered Locks": a contended resume is deferred, not raised.
397
+ - "Map & Parallel Execution": `undo_all`.
398
+ - **documentation/**:
399
+ - `interrupts.md`: resuming, concurrency, the attempt counter, aborted runs and `compensate`.
400
+ - `locks_and_semaphores.md`: line 136, plus the snooze limit before admission.
401
+ - `background_and_async.md`: the Worker's lock-then-load, sweeper liveness of caller-process runs.
402
+ - `data_pipelines.md`: `undo_all`.
403
+ - `core_concepts.md`: aborted runs (line 343).
404
+ - `testing.md`: the new matchers and the `resume(process_jobs:)` option.
405
+ - **demo_app/documentation/data_pipelines.md**: kept in sync with `undo_all`.
406
+ - **CHANGELOG.md**: per R-16.
407
+ - **specs/future_improvements.md**:
408
+ - remove the seven items;
409
+ - in "Fenced context writes", mark the `Reactor#continue` and synchronous `Reactor.run` writers
410
+ fixed, and part 2 done for the Worker and `continue`.
411
+
412
+ ## R-18 Demo app (Constitution VI)
413
+
414
+ One reactor per file:
415
+
416
+ | Reactor | Shows | Rake task |
417
+ | --- | --- | --- |
418
+ | `BulkRefundDemoReactor` (with `BulkRefundChargeReactor` as its element) | a fan-out map with `undo_all`; a later step fails; one bulk refund call | `demo:map_undo_all` |
419
+ | `ContendedApprovalDemoReactor` | `with_lock`; a resume while another holder has the lock returns a hand-off; it finishes once the lock is released | `demo:contended_resume` |
420
+ | `DualApprovalDemoReactor` | two ready interrupts (`finance` as a background resume, `legal` inline); `legal` is resumed while `finance`'s resume is pending; both are applied once | `demo:concurrent_interrupts` |
421
+
422
+ - The three tasks are grouped as `demo:rollback_follow_ups`, which is added to `demo:all`.
423
+ - Specs use the shipped surface plus additions to `lib/ruby_reactor/rspec/`:
424
+ - `TestSubject#resume(payload:, step:, process_jobs: nil)`. It accepts a `running` run with a
425
+ ready interrupt, mirroring `continue`, and `process_jobs: false` leaves the hand-off pending.
426
+ - Matcher `be_resume_deferred`: the last resume returned a `DispatchResult` and the run is
427
+ `running`.
428
+ - Matcher `have_run_undo_all(:step).with_elements(n)`: reads the `:undo_all` trace entry.
429
+ - Plus the existing `hold_lock`, `be_paused_at`, `have_ready_interrupts` and
430
+ `have_rollback_failure`.
431
+ - US1, US2, US4 and US6 are internal fixes, with no new public surface to demo. They are covered by
432
+ gem specs.
433
+
434
+ ## R-19 Deliberately not done
435
+
436
+ - **The full "Fenced context writes" proposal**: versioned writes, cancel requests, the other
437
+ unlocked writers.
438
+ - **Picking up claims inside an executing resume** (R-05). The backstop Worker covers FR-018.
439
+ - **Per-entry checkpoints during manual undo** (R-10, not changed).
440
+ - **A configurable Worker lock wait.** The constant 2 matches `Collector::COLLECT_LOCK_WAIT`.