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,1022 @@
1
+ ---
2
+
3
+ description: "Task list for 010 Rollback and Resume Follow-ups"
4
+ ---
5
+
6
+ # Tasks: Rollback and Resume Follow-ups
7
+
8
+ **Input**: Design documents from `specs/010-rollback-follow-ups/`
9
+
10
+ **Prerequisites**: [plan.md](plan.md), [spec.md](spec.md), [research.md](research.md),
11
+ [data-model.md](data-model.md), [contracts/](contracts/), [quickstart.md](quickstart.md)
12
+
13
+ **Tests**: REQUIRED by Constitution III (test-first, real Redis). In every phase, write the spec tasks
14
+ first and confirm they FAIL on the current code before implementing.
15
+
16
+ **Citations**:
17
+
18
+ - research decisions: `R-nn`;
19
+ - data-model sections: `DM §n`;
20
+ - protocol invariants and sequences: `J-n` / `P-n` (contracts/resume-protocol.md);
21
+ - API contract: `API §n` (contracts/api-surface.md).
22
+
23
+ ## Format: `[ID] [P?] [Story] Description`
24
+
25
+ - **[P]**: can run in parallel (different files, no dependency on an incomplete task)
26
+ - **[Story]**: US1–US7 from spec.md
27
+
28
+ ## Path Conventions
29
+
30
+ Single gem project: `lib/ruby_reactor/`, `spec/`, `demo_app/`, `gui/`, `documentation/`.
31
+
32
+ **Test rules**:
33
+
34
+ - Specs run against the test Redis at `redis://localhost:6780`.
35
+ - Async paths use `for_each_async_backend` (`spec/support/async_backends.rb`) plus
36
+ `drain_async_jobs`. Single-job stepping uses `QueueProbe.next_job` / `QueueProbe.pending`
37
+ (`spec/support/queue_probe.rb`).
38
+ - Concurrency specs use real threads with `ResumeFixtures.latch` / `ResumeFixtures.barrier` (T001),
39
+ never stubs of Redis or of the lock.
40
+ - Add no new `Sidekiq::Testing.inline!`, except the one US4 example (T033), justified in plan.md
41
+ Complexity Tracking.
42
+ - Don't run the gem suite and the demo suite at the same time: they flush the same Redis. If another
43
+ worktree's suite shares the test Redis, rerun failures alone first.
44
+ - Demo specs locally: `cd demo_app && REDIS_URL=redis://localhost:6780/5 RAILS_ENV=test bundle exec rspec …`.
45
+
46
+ ---
47
+
48
+ ## Phase 1: Setup (Shared Infrastructure)
49
+
50
+ **Purpose**: test scaffolding shared by the resume and liveness specs.
51
+
52
+ - [X] T001 Create `spec/support/resume_fixtures.rb`, module `ResumeFixtures`, on top of
53
+ `RollbackRecorder` (`spec/support/rollback_recorder.rb`).
54
+
55
+ Helpers:
56
+
57
+ - `ResumeFixtures.latch`: returns an object with `#wait(timeout = 5)` and `#open!`, backed by a
58
+ `Queue`. It blocks a step body until the spec opens it, raises if the timeout passes, and
59
+ `open!` is idempotent.
60
+ - `ResumeFixtures.barrier(n)`: `#wait` releases all `n` threads together (`Mutex` +
61
+ `ConditionVariable`).
62
+ - `ResumeFixtures.reset!`: clears every latch and the shared counters. Call it from a
63
+ `before` hook that the file registers.
64
+
65
+ Reactors: one constant each, class-based steps, with the `RollbackRecorder` tag set:
66
+
67
+ | Constant | Definition |
68
+ | --- | --- |
69
+ | `LockedApproval` | `with_lock { "resume-fx:locked" }`; `recording_step :a`; `interrupt(:approval) { wait_for :a; validate { required(:ok).filled(:bool) } }`; `recording_step :c, after: :approval` |
70
+ | `SemaphoredApproval` | same, with `with_semaphore(limit: 1) { "resume-fx:sem" }` |
71
+ | `PlainApproval` | `LockedApproval` without the lock |
72
+ | `BackgroundApproval` | `interrupt :approval, resume: :background`, otherwise as `PlainApproval` |
73
+ | `DualApproval` | `recording_step :prep`; `interrupt(:a) { wait_for :prep }`; `interrupt(:b) { wait_for :prep }`; `recording_step :after_a, after: :a` (waits on `ResumeFixtures.latch(:after_a)` when it exists); `recording_step :done` depending on `:after_a` and `:b` (use `argument :x, result(:after_a)` and `argument :y, result(:b)`) |
74
+ | `ManyApprovals.build(n)` | `n` interrupts `:i0..:i(n-1)` after `:prep`, and a final step depending on all of them |
75
+ | `SlowSync` | `recording_step :slow`, which waits on `ResumeFixtures.latch(:slow)` and increments `ResumeFixtures.counter(:slow)` |
76
+ | `SyncFanOut` | `recording_step :a` → `map :m` (fan-out, `batch_size 2`, element `RollbackRecorder` element with one recording step) → `recording_step :b, after: :m` → `recording_step :c, after: :b` |
77
+
78
+ Check the interrupt validation DSL against `lib/ruby_reactor/dsl/interrupt_builder.rb`
79
+ (`validate` / `validate_payload`).
80
+ - [X] T002 [P] Baseline, with no file changes. Run `bundle exec rspec` and record the pass/pending
81
+ counts in a draft PR description (`specs/010-rollback-follow-ups/pr-description.md`). It is the
82
+ regression bar for T078.
83
+
84
+ ---
85
+
86
+ ## Phase 2: Foundational (Blocking Prerequisites)
87
+
88
+ **Purpose**: run ownership (lock, then load) and the per-interrupt resume claim.
89
+
90
+ - Part A blocks US1, US2, US3, US5 and US6.
91
+ - Part B blocks US3, US4 and US5.
92
+ - US7 needs neither.
93
+
94
+ **⚠️ CRITICAL**: complete Part A before any story, and Part B before US3–US5.
95
+
96
+ ### Part A: run ownership (R-02, R-03, R-06, R-09)
97
+
98
+ #### Specs first
99
+
100
+ - [X] T003 [P] Write `spec/ruby_reactor/worker_lock_then_load_spec.rb` (R-02, R-06, J-2),
101
+ `for_each_async_backend`, with a `running` context stored for a simple two-step reactor.
102
+
103
+ Lock, then load:
104
+
105
+ - (a) Spy (`and_call_original`) on `RubyReactor::Lock#acquire` and on
106
+ `storage.retrieve_context`. In `Worker#perform`, the `lock:async:<id>` acquire happens before
107
+ the first `retrieve_context`.
108
+ - (b) `hold_lock("async:<id>", owner: "other")` with `stub_const("RubyReactor::Worker::CONTEXT_LOCK_WAIT", 0.2)`:
109
+ - `perform` calls neither `retrieve_context` nor `store_context`;
110
+ - exactly one snooze is enqueued (`perform_in`) with `snooze_count + 1`;
111
+ - `escalate_snooze` is never called, even with `snooze_count` above `lock_snooze_max_attempts`
112
+ (uncapped).
113
+
114
+ Status table (R-06):
115
+
116
+ - (c) For each stored status `completed`, `failed`, `cancelled`, `aborted`, `halted`:
117
+ `Executor#resume_execution` is not called, and afterwards
118
+ `storage.lock_held?("async:<id>")` is false.
119
+ - (d) For `paused` with no claims: `resume_execution` is not called, and the stored blob is
120
+ unchanged byte for byte.
121
+
122
+ Release and inline mode:
123
+
124
+ - (e) The lock is released after a normal `perform`, and after the deserialization-failure path
125
+ (store a blob with an unknown `schema_version`).
126
+ - (f) Inside `Sidekiq::Testing.inline!` (an existing pattern, assertion only),
127
+ `Lock#acquire` is never called for `async:`. Sidekiq backend only.
128
+
129
+ It must FAIL on current code: (a), (b), (c) and (d) fail.
130
+ - [X] T004 [P] Extend `spec/ruby_reactor/context_lock_spec.rb` (R-03): with owner `"outer"`
131
+ holding `async:<id>` through `RubyReactor::Lock.new("async:<id>", owner: "outer").acquire`, an
132
+ `Executor` given `context_lock_owner = "outer"`:
133
+ - runs `resume_execution` to completion with no `ContextLockContention`;
134
+ - after it returns, the lock is still held (`lock_held?` true);
135
+ - after the outer `release`, the lock is free.
136
+
137
+ It fails on current code (`NoMethodError` on the writer).
138
+ - [X] T005 [P] Write `spec/ruby_reactor/rollback/undo_reload_spec.rb` (R-09).
139
+ - Pause a reactor with three recording steps before an interrupt; `reactor = Klass.find(id)`.
140
+ - Then simulate a newer save by another owner: load the context again, change it, and store it
141
+ with `storage.store_context`. The change: `a`, `b`, `c` on the undo stack (the stale snapshot
142
+ has only `a`, `b`), with `c` set in `intermediate_results`.
143
+ - Call `reactor.undo`: the log shows `undo:c undo:b undo:a`, and the run ends `cancelled`.
144
+ - Also: `reactor.undo(failure: { message: "x" })` ends `failed` with `failure_reason[:message] == "x"`.
145
+
146
+ It fails on current code: `c` is not undone, and the keyword does not exist.
147
+
148
+ #### Implementation
149
+
150
+ - [X] T006 In `lib/ruby_reactor/executor.rb`, add `attr_writer :context_lock_owner` (R-03).
151
+ `acquire_context_lock` already uses `@context_lock_owner ||= SecureRandom.uuid`. Add a comment
152
+ explaining the re-entry: the outer holder releases last. T004 passes.
153
+ - [X] T007 Restructure `Worker#perform` in `lib/ruby_reactor/worker.rb` as "lock, then load"
154
+ (R-02, R-06).
155
+
156
+ Lock:
157
+
158
+ - Add `CONTEXT_LOCK_WAIT = 2` with a comment (it matches `Map::Collector::COLLECT_LOCK_WAIT`;
159
+ the caller is milliseconds from releasing).
160
+ - Unless inline testing mode (reuse the executor's check), take
161
+ `RubyReactor::Lock.new("async:#{context_id}", owner: SecureRandom.uuid, ttl: config.context_lock_ttl, wait: CONTEXT_LOCK_WAIT, auto_extend: true)`.
162
+ - On `Lock::AcquisitionError`: call `handle_snooze(context_id, reactor_class_name, nil, snooze_count, Lock::ContextLockContention.new(e.message, context_lock_key: "async:#{context_id}"))`
163
+ and return. Check that `handle_snooze` never touches `context` on the uncapped path.
164
+
165
+ Load and dispatch by status:
166
+
167
+ - Then `retrieve_context`. Return on `nil`.
168
+ - Return when the status is in `TERMINAL_STATUSES`. Add `"halted"` to the constant.
169
+ - Return when the status is `paused` (Part B refines this in T013).
170
+ - Deserialize as today, and set `executor.context_lock_owner = lock.owner` before calling
171
+ `resume_execution` / `resume_rollback`.
172
+ - Release the lock in an `ensure` around everything after the acquire.
173
+
174
+ T003 passes.
175
+ - [X] T008 In `lib/ruby_reactor/reactor.rb#undo` (R-09):
176
+ - after `acquire_undo_lock`, reload: `@context = self.class.find(@context.context_id).context`;
177
+ - add the keyword `failure: nil`. When it is given, after `undo_all` set `@context.status =
178
+ "failed"` and `@context.failure_reason = failure` instead of leaving it for `cancel`, and save
179
+ before the lock is released;
180
+ - in `self.undo(id)`, keep calling `cancel` only on the non-failure path.
181
+
182
+ Run the existing `spec/ruby_reactor/undo_spec.rb`, `spec/ruby_reactor/interrupt_undo_spec.rb` and
183
+ `spec/ruby_reactor/rollback/distributed_map_rollback_spec.rb` unchanged. T005 passes.
184
+
185
+ ### Part B: resume claims (R-04, DM §2, §3)
186
+
187
+ #### Specs first
188
+
189
+ - [X] T009 [P] Write `spec/ruby_reactor/interrupt_claims_spec.rb`:
190
+
191
+ Storage:
192
+
193
+ - (a) `claim_interrupt_resume(id, klass, :approval, json)` returns true, then false for a second
194
+ claim. `TTL` equals `durability_ttl`.
195
+ - (b) `retrieve_interrupt_resumes(id, klass, %w[a b])` returns only the claimed steps.
196
+ - (c) `increment_interrupt_attempts` returns 1, then 2, with `TTL` set.
197
+
198
+ `RubyReactor::InterruptClaims` (with `ResumeFixtures::DualApproval` paused at `a` and `b`):
199
+
200
+ - (d) `claim!` stores the serialized payload.
201
+ - (e) `unapplied(context)` lists only interrupts without a result.
202
+ - (f) `apply!(context)` sets `intermediate_results[:b]` from a claim and returns `[:b]`.
203
+
204
+ Executor and Worker:
205
+
206
+ - (g) Store a claim for `b` directly, then run `Executor#resume_execution` through a `Worker`
207
+ job (fake mode): `b`'s result equals the claimed payload in the stored context, and the run
208
+ pauses again at `a`.
209
+ - (h) `Worker#perform` on a `paused` run with an unapplied claim resumes it.
210
+
211
+ Fails on current code.
212
+
213
+ #### Implementation
214
+
215
+ - [X] T010 [P] Storage (DM §2, §3):
216
+ - In `lib/ruby_reactor/storage/redis_adapter.rb`:
217
+ - `claim_interrupt_resume(context_id, reactor_class_name, step_name, serialized_payload)`:
218
+ `SET NX EX durability_ttl` on `"#{context_key(context_id, reactor_class_name)}:resume:#{step_name}"`.
219
+ Returns a Boolean; add the `rubocop:disable Naming/PredicateMethod` comment as
220
+ `claim_map_owner_signal` has.
221
+ - `retrieve_interrupt_resumes(context_id, reactor_class_name, step_names)`: one `MGET`, returns
222
+ `{ "step" => serialized }` for the present keys.
223
+ - `increment_interrupt_attempts(context_id, reactor_class_name, step_name)`: `MULTI` with
224
+ `INCR` and `EXPIRE durability_ttl` on `...:resume_attempts:<step>`. Returns the Integer.
225
+ - Declare all three in `lib/ruby_reactor/storage/adapter.rb`, with doc comments.
226
+
227
+ T009 (a)–(c) pass.
228
+ - [X] T011 Create `lib/ruby_reactor/interrupt_claims.rb`, module `RubyReactor::InterruptClaims`
229
+ (R-04), with module functions:
230
+ - `claim!(context, step_name, payload)`: serialize with `ContextSerializer.serialize_value`
231
+ plus `JSON.generate`, then call the storage claim. Returns a Boolean.
232
+ - `unapplied(context)`:
233
+ - interrupt steps of `context.reactor_class.steps` (`config.interrupt?`) with no
234
+ `context.has_result?`;
235
+ - one `retrieve_interrupt_resumes` call;
236
+ - returns `{ step_sym => payload }`;
237
+ - returns `{}` with no storage call when the reactor declares no interrupts.
238
+ - `apply!(context)`: calls `context.set_result` for each unapplied claim; returns the names.
239
+
240
+ Use `RubyReactor.reactor_storage_name(context.reactor_class)` for the class name. T009 (d)–(f)
241
+ pass.
242
+ - [X] T012 In `lib/ruby_reactor/executor.rb#resume_execution`, right after `acquire_context_lock`,
243
+ add `InterruptClaims.apply!(@context) if (@context.root_context || @context).equal?(@context)`.
244
+ It runs before the reactor-level lock or semaphore, so a contended resume saves the applied
245
+ payload (R-05 step 7, J-5). Comment that this is the only place a claim enters a context.
246
+ - [X] T013 In `lib/ruby_reactor/worker.rb`, refine the `paused` branch from T007: return only when
247
+ `InterruptClaims.unapplied(context).empty?`, after deserializing; otherwise call
248
+ `resume_execution` (R-06). T009 (g) and (h) pass.
249
+
250
+ **Checkpoint**: run `bundle exec rspec spec/ruby_reactor spec/map`: no regressions against T002.
251
+
252
+ ---
253
+
254
+ ## Phase 3: User Story 1 - The recovery sweep never runs a live caller-process run twice (Priority: P1) 🎯 MVP
255
+
256
+ **Goal**: a run executing in the caller's process is live to the sweeper while its process is alive,
257
+ and recovered after its liveness lapses (FR-001 to FR-004, R-01, J-1, J-3).
258
+
259
+ **Independent Test**: quickstart §1.
260
+
261
+ ### Tests for User Story 1
262
+
263
+ - [X] T014 [P] [US1] Write `spec/ruby_reactor/caller_process_liveness_spec.rb`, in fake mode:
264
+ - (a) `ResumeFixtures::SlowSync` runs in a thread. Wait until `async:<id>` is held (poll
265
+ `storage.lock_held?`, at most 2s). `RubyReactor::Sweeper.run_once` returns 0 and enqueues no
266
+ `Worker` job. Open the latch: `ResumeFixtures.counter(:slow) == 1`, the run is `completed`, and
267
+ the lock is free.
268
+ - (b) Same with `RubyReactor.configuration.context_lock_ttl = 1` (restore it in `after`). The
269
+ step blocks for 3s while the spec calls `Sweeper.run_once` every 0.5s: always 0.
270
+ - (c) `:fork`-tagged.
271
+ - `pid = fork { RubyReactor.configuration.context_lock_ttl = 1; ResumeFixtures::SlowSync.run({}) }`.
272
+ - Wait until a `running` context of that class exists, then `Process.kill(:KILL, pid)` and
273
+ `Process.wait(pid)`.
274
+ - Within 3s, `Sweeper.run_once` returns 1 for it.
275
+ - redis-client reconnects after fork. If CI lacks `fork`, skip with
276
+ `skip unless Process.respond_to?(:fork)`.
277
+ - (d) `ResumeFixtures::SyncFanOut` returns a `DispatchResult`; afterwards `lock_held?` is
278
+ false.
279
+ - (e) A step raising `Interrupt` leaves the run `aborted` with the lock released, and
280
+ `Sweeper.run_once` returns 0.
281
+ - (f) Order: a spy on `Lock#release` for `async:<id>` and on `storage.store_context` shows the
282
+ last store before the release.
283
+
284
+ (a), (b), (c) and (f) fail on current code.
285
+ - [X] T015 [P] [US1] (Landed in `spec/ruby_reactor/caller_process_liveness_spec.rb`, next to the other US1 examples.) Extend `spec/ruby_reactor/context_lock_spec.rb`: while `SlowSync` blocks in a
286
+ thread, `Reactor.undo(id)` (with `stub_const("RubyReactor::Reactor::UNDO_LOCK_WAIT", 0.2)`) raises
287
+ `RubyReactor::Lock::AcquisitionError`, and the run then completes normally.
288
+
289
+ ### Implementation for User Story 1
290
+
291
+ - [X] T016 [US1] In `lib/ruby_reactor/executor.rb#execute` (R-01):
292
+ - after `input_validator.validate!` and before `reset_held_lock_keys!`, add
293
+ `acquire_context_lock unless @context.inline_async_execution`. The method already limits itself
294
+ to the root executor and to non-inline-testing mode;
295
+ - in `ensure`, after the existing `save_context if persist_context? && !skip_context_persist?`,
296
+ add `@acquired_context_lock&.release; @acquired_context_lock = nil`. Save before release
297
+ (J-3), with a comment citing R-01 and 009 R-13;
298
+ - extend the comment on `acquire_context_lock`, which says "Only the root executor holds it":
299
+ a caller-process run holds it too.
300
+
301
+ T014 and T015 pass. Then run `spec/ruby_reactor/sweeper_spec.rb`,
302
+ `spec/ruby_reactor/sweeper_entrypoints_spec.rb`, `spec/compose_spec.rb`, `spec/map` and
303
+ `spec/ruby_reactor/rollback`.
304
+ - [X] T017 [US1] Documentation (R-17):
305
+ - README "Durability & Recovery": a paragraph saying a synchronous run holds the liveness lock
306
+ while it executes, so the sweeper never re-runs it, and a killed process is recovered after
307
+ `context_lock_ttl`;
308
+ - `documentation/background_and_async.md` (sweeper section): the same;
309
+ - `CHANGELOG.md` Bug Fixes entry.
310
+
311
+ **Checkpoint**: US1 is independently shippable.
312
+
313
+ ---
314
+
315
+ ## Phase 4: User Story 2 - A caller's last save never overwrites a worker's progress (Priority: P1)
316
+
317
+ **Goal**: FR-005 to FR-007 through J-1 to J-3 (T007, T016). P-1.
318
+
319
+ **Independent Test**: quickstart §2.
320
+
321
+ ### Tests for User Story 2
322
+
323
+ - [X] T018 [P] [US2] Write `spec/ruby_reactor/executor/caller_save_race_spec.rb`, in fake mode,
324
+ Sidekiq backend.
325
+ - Register a test middleware on `ResumeFixtures::SyncFanOut` (or a subclass defined in the spec)
326
+ whose `on(:complete_step, name, result, ctx)` runs only for `:m` with a `DispatchResult`. It
327
+ performs queued jobs one at a time (`QueueProbe.next_job.perform!`) until the owner `Worker` job
328
+ has been performed **once**, with `stub_const("RubyReactor::Worker::CONTEXT_LOCK_WAIT", 0.1)`.
329
+ - Assert, inside the hook:
330
+ - that `Worker` perform made no `storage.store_context` call for the run id (a spy counting
331
+ calls between hook entry and exit, by Worker class);
332
+ - a snooze job for the run is pending.
333
+ - After `SyncFanOut.run` returns: `drain_async_jobs`. Then:
334
+ - the run is `completed`;
335
+ - `RollbackRecorder.log` contains `run:b` and `run:c` exactly once each.
336
+ - Variant (FR-005): the same with an inline `continue`. Use a subclass that pauses at an
337
+ interrupt before `:m`, then `continue` drives the run into the fan-out map.
338
+ - Variant (FR-007): no hook. The run completes as before.
339
+
340
+ Regression proof: write it first, confirm it **passes** after T007 and T016, then temporarily
341
+ revert T016's lock in `execute` and see it fail (the overwrite leaves `running`, or runs `b`
342
+ twice). Note that observation in the PR description draft; don't commit the revert.
343
+
344
+ ### Implementation for User Story 2
345
+
346
+ - [X] T019 [US2] No new library code is expected. If T018 fails with T007 and T016 in place, fix
347
+ the save/release order at the failing holder and cite J-3 in a comment.
348
+ - [X] T020 [US2] Documentation:
349
+ - `documentation/background_and_async.md` "Durability" or Worker section: "a Worker takes the
350
+ run's liveness lock before it reads the run (waits up to 2s), so it always sees the last save
351
+ of whoever held it";
352
+ - `CHANGELOG.md` Bug Fixes entry.
353
+
354
+ **Checkpoint**: US1 and US2 together close the caller-process ownership gaps.
355
+
356
+ ---
357
+
358
+ ## Phase 5: User Story 3 - A resume that meets a held lock is accepted, not lost (Priority: P1)
359
+
360
+ **Goal**: FR-008 to FR-014, R-05, R-07, P-2, API §1.
361
+
362
+ **Independent Test**: quickstart §3.
363
+
364
+ ### Tests for User Story 3
365
+
366
+ - [X] T021 [P] [US3] Write `spec/ruby_reactor/interrupts/contended_resume_spec.rb`,
367
+ `for_each_async_backend`, with `ResumeFixtures::LockedApproval` paused.
368
+ - (a) Inside `hold_lock("resume-fx:locked", owner: "another-run")`, `continue(payload: { ok: true })`:
369
+ - returns a `RubyReactor::DispatchResult` with `execution_id == id`;
370
+ - no error is raised;
371
+ - `Klass.find(id).context.status == "running"`;
372
+ - the logger receives one line matching
373
+ `event="ruby_reactor.resume.deferred".*reason="lock".*key="resume-fx:locked"`;
374
+ - one `Worker` job is pending.
375
+ - (b) After the block (lock released): `drain_async_jobs`. Then `completed`, the result of
376
+ `approval` equals `{ ok: true }`, and the log is `run:a run:c`.
377
+ - (c) An invalid payload (`{ ok: "nope" }`) inside `hold_lock`:
378
+ - `Klass.continue` raises `InputValidationError`, and the instance method returns a `Failure`
379
+ with `invalid_payload?`;
380
+ - the run stays `paused`;
381
+ - `QueueProbe.pending` is empty;
382
+ - `retrieve_interrupt_resumes` is empty.
383
+ - (d) After (a), a second `continue` raises `ValidationError` matching `/already resumed/`.
384
+ - (e) The same as (a) and (b) with `SemaphoredApproval`, holding the semaphore's only slot through
385
+ `RubyReactor::Semaphore.new("resume-fx:sem", limit: 1).acquire`.
386
+ - (f) After (a), `Klass.cancel(id:, reason:)` and then drain: the Worker does nothing, the status
387
+ stays `cancelled`, and the log has no `run:c`.
388
+ - (g) `BackgroundApproval`: `continue` returns a `DispatchResult` with status `running`, and the
389
+ log line has `reason="background"`; draining completes it.
390
+ - [X] T022 [P] [US3] Write `spec/ruby_reactor/worker_snooze_admitted_spec.rb` (R-07, J-8),
391
+ `for_each_async_backend`, with `lock_snooze_max_attempts = 2` (restore it after).
392
+ - (a) Run (a) of T021 while holding the lock, then perform the Worker job 4 times
393
+ (`QueueProbe.next_job`):
394
+ - the run is never `failed`;
395
+ - exactly one `ruby_reactor.resume.waiting` warning is logged, carrying `snooze_count=2`;
396
+ - after release and drain, it is `completed`.
397
+ - (b) Unchanged: an async (`background all: true`) reactor's **first** run that contends on its
398
+ lock 3 times is escalated to `failed`, as today.
399
+ - [X] T023 [P] [US3] Update `spec/ruby_reactor/rollback/resume_guard_spec.rb`:
400
+ - The "reactor lock" and "reactor semaphore" contention examples now expect a `DispatchResult`
401
+ and status `running`, and completion after release plus drain. Rename the describe text from
402
+ "contended resume raises" to "contended resume is deferred".
403
+ - `CompensatingResume` now expects the rejection message `/already resumed/` (its interrupt has a
404
+ result and a claim). The log expectation is unchanged.
405
+ - Keep the `aborted` and finished-run rejections unchanged.
406
+
407
+ ### Implementation for User Story 3
408
+
409
+ - [X] T024 [US3] Rewrite `Reactor#continue` in `lib/ruby_reactor/reactor.rb` (R-05; keep each
410
+ method under RuboCop's limits by extracting private helpers).
411
+
412
+ Steps:
413
+
414
+ 1. `ensure_resumable!(step_name)`:
415
+ - raise `ValidationError` as today when there is no `current_step`, when the run is
416
+ `cancelled`, and when the status is not `paused` or `running`;
417
+ - for this phase, `running` still raises the existing message; T039 opens it;
418
+ - `validate_continue_step!(step_name)`;
419
+ - raise `"Cannot resume: interrupt :#{step_name} was already resumed"` when
420
+ `@context.has_result?(step_name)`.
421
+ 2. `if (failure = validate_continue_payload(payload, step_name)) then return failure`
422
+ (unchanged in this phase).
423
+ 3. Claim:
424
+ `raise Error::ValidationError, "Cannot resume: interrupt :#{step_name} was already resumed" unless InterruptClaims.claim!(@context, step_name, payload)`.
425
+ 4. `lock = try_run_lock`, a new private method: a `RubyReactor::Lock` on `async:<id>` with
426
+ `wait: 0` and `auto_extend: true`. It returns `nil` in inline testing mode and `:contended` on
427
+ `AcquisitionError`. On `:contended`, `return hand_off_resume(step_name, reason: :run_busy)`.
428
+ 5. Reload: `@context = self.class.find(@context.context_id).context`. If it is now finished or
429
+ `cancelled`, release and raise as step 1 would.
430
+ 6. If the step's `background_resume?`:
431
+ - `InterruptClaims.apply!(@context)`;
432
+ - status `:running`;
433
+ - `Executor.middlewares_for(self.class).on(:before_async_enqueue, @context)`;
434
+ - `save_context`;
435
+ - release, then `return hand_off_resume(step_name, reason: :background)`.
436
+ 7. Otherwise: `executor = Executor.new(self.class, {}, @context)`, then
437
+ `executor.context_lock_owner = lock&.owner`, then `@result = executor.resume_execution`, and
438
+ copy the context and traces back, as today.
439
+ 8. `rescue Lock::AcquisitionError, Semaphore::AcquisitionError => e` (a `ContextLockContention`
440
+ cannot occur once the lock is owned; let it re-raise if it does):
441
+ - release the run lock **first**;
442
+ - `return hand_off_resume(step_name, reason: e.is_a?(Semaphore::AcquisitionError) ? :semaphore : :lock, key: <the key>)`.
443
+ - The key comes from the reactor's `lock_config` or `semaphore_config` `key_proc` applied to
444
+ `@context.inputs`.
445
+ 9. `ensure`: release the lock if it is still held (idempotent).
446
+
447
+ `hand_off_resume(step_name, reason:, key: nil)` logs
448
+ `event="ruby_reactor.resume.deferred" reactor= context_id= step= reason= key=`, calls
449
+ `configuration.async_router.perform_async(id, RubyReactor.reactor_storage_name(self.class))`, and
450
+ returns `check_for_inline_completion || @result`, where `@result` is the router's
451
+ `DispatchResult`.
452
+
453
+ Delete `reopen_paused`, the `past_gates?` rescue branch and `enqueue_background_resume` (folded
454
+ into step 6). Keep `Executor#past_gates?` only if another caller uses it; grep first. T021
455
+ (a)–(e) and (g) pass.
456
+ - [X] T025 [US3] In `Worker#handle_snooze` (`lib/ruby_reactor/worker.rb`, R-07):
457
+ - set `capped &&= !context&.admitted?`;
458
+ - when an uncapped admitted run reaches `snooze_count == max` (only then), log at warn
459
+ `event="ruby_reactor.resume.waiting" reactor= context_id= key= snooze_count=`. The key comes
460
+ from `error.message` or `error.context_lock_key`; take the reactor-level key from the error
461
+ message if there is no accessor;
462
+ - comment: escalation marks `failed` without rollback, so it applies only before admission (J-8).
463
+
464
+ T022 passes.
465
+ - [X] T026 [US3] In `lib/ruby_reactor/web/api.rb`, the `continue` route: a `DispatchResult`
466
+ answers `{ success: true, message: "Resume accepted" }`, and a `ValidationError` answers 422 with
467
+ its message, as today. Add the example to the existing web API spec under
468
+ `spec/ruby_reactor/web/` (find the file covering `POST .../continue`).
469
+ - [X] T027 [US3] RSpec surface (R-18, API §7), in `lib/ruby_reactor/rspec/test_subject.rb` and
470
+ `lib/ruby_reactor/rspec/matchers.rb`:
471
+ - `TestSubject#resume(payload: {}, step: nil, process_jobs: nil)`:
472
+ - keep the result of `@reactor_instance.continue` in `@last_resume_result`;
473
+ - process jobs only when `process_jobs.nil? ? @process_jobs : process_jobs`.
474
+ - Matcher `be_resume_deferred`: passes when `subject.last_resume_result` is a `DispatchResult`
475
+ **and** the reloaded status is `running`, with a failure message naming the actual result class
476
+ and status. Expose `last_resume_result` as a reader.
477
+ - Specs in `spec/ruby_reactor/rspec/` (new file `resume_deferred_matcher_spec.rb`).
478
+ - [X] T028 [P] [US3] Demo: `demo_app/app/reactors/contended_approval_demo_reactor.rb`, class
479
+ `ContendedApprovalDemoReactor`, with class-based steps:
480
+ - `with_lock { |inputs| "demo:approval:#{inputs[:request_id]}" }`;
481
+ - step `:submit`, then `interrupt(:approve) { wait_for :submit; validate { required(:approved).filled(:bool) } }`,
482
+ then step `:record`.
483
+ - [X] T029 [US3] Demo rake task `demo:contended_resume` in `demo_app/lib/tasks/demo_reactors.rake`,
484
+ with a `desc`, depending on `[:environment, :flush_redis]`. It:
485
+ - runs the reactor to the pause;
486
+ - takes the reactor's lock as "another run" (`RubyReactor::Lock.new(key, owner: "other-run").acquire`);
487
+ - calls `continue`, and prints "resume accepted (DispatchResult) while the lock is held" and the
488
+ status;
489
+ - releases the lock, then waits (poll `find(id)` for up to 30s) and prints the final status and
490
+ the `approve` result.
491
+ - [X] T030 [US3] Demo spec `demo_app/spec/reactors/contended_approval_demo_reactor_spec.rb`,
492
+ `type: :reactor`, shipped surface only:
493
+ - `test_reactor(..., process_jobs: false)`, then `be_paused_at(:approve)`;
494
+ - inside `hold_lock("demo:approval:1")`, `subject.resume(payload: { approved: true }, process_jobs: false)`,
495
+ then `expect(subject).to be_resume_deferred`;
496
+ - after the block, `drain_async_jobs`, a reload, `be_success`, and
497
+ `subject.step_result(:approve)`;
498
+ - an invalid payload inside `hold_lock` raises `InputValidationError`, and `be_paused` holds.
499
+ - [X] T031 [US3] Documentation:
500
+ - `documentation/interrupts.md`, "Resuming Execution": validation first, the claim, the
501
+ hand-off on contention, the `DispatchResult` return, the `resume.deferred` log line;
502
+ - `documentation/locks_and_semaphores.md`:
503
+ - line 136: replace "a contended resume raises and the run stays paused" with the deferral;
504
+ - add the snooze-limit-before-admission rule (R-07) where `lock_snooze_max_attempts` is
505
+ described;
506
+ - README "Interrupts (Pause & Resume)" and "Locks, Semaphores & Ordered Locks": one sentence
507
+ each;
508
+ - `documentation/testing.md`: `resume(process_jobs:)` and `be_resume_deferred`;
509
+ - `CHANGELOG.md`: the Bug Fixes entry, plus a **migration note** that `continue` returns a
510
+ `DispatchResult` where it raised, and that background resumes are no longer escalated to
511
+ `failed`.
512
+
513
+ **Checkpoint**: the US3 specs and demo spec are green; webhook-style resumes are never lost to
514
+ contention.
515
+
516
+ ---
517
+
518
+ ## Phase 6: User Story 4 - Two resumes of one interrupt at the same instant: exactly one wins (Priority: P2)
519
+
520
+ **Goal**: FR-015 and FR-016 through the claim (R-04, J-4, P-3).
521
+
522
+ **Independent Test**: quickstart §4.
523
+
524
+ ### Tests for User Story 4
525
+
526
+ - [X] T032 [P] [US4] Write `spec/ruby_reactor/interrupts/resume_claim_spec.rb`, in fake mode.
527
+
528
+ Race:
529
+
530
+ - (a) 200 iterations. Each one:
531
+ - pauses `ResumeFixtures::PlainApproval`;
532
+ - starts two threads that `barrier.wait` and then call `Klass.continue` with payloads
533
+ `{ ok: true, who: 1 }` and `{ ok: true, who: 2 }`;
534
+ - collects the outcomes.
535
+
536
+ In every iteration:
537
+
538
+ - exactly one returns and one raises `ValidationError` `/already resumed/`;
539
+ - the stored `approval` result equals the winner's payload;
540
+ - `RollbackRecorder` counts `run:c` once.
541
+
542
+ Loser and background resumes:
543
+
544
+ - (b) The loser leaves no trace: the stored claim value is the winner's payload, and the context
545
+ `updated` marker or `execution_trace` has only one resume's entries.
546
+ - (c) With `BackgroundApproval` and two threads, exactly one `Worker` job is enqueued
547
+ (`QueueProbe.enqueued`).
548
+ - (d) Inline mode is covered by T033.
549
+ - [X] T033 [US4] Add to the same file the one justified inline example (plan.md Complexity
550
+ Tracking):
551
+ - `around { |ex| Sidekiq::Testing.inline! { ex.run } }`, Sidekiq backend only;
552
+ - 50 iterations of (a), with the same assertions;
553
+ - a comment citing FR-016, R-14 and why fake mode cannot show it (the context lock is taken
554
+ there).
555
+
556
+ ### Implementation for User Story 4
557
+
558
+ - [X] T034 [US4] No new library code is expected: the claim (T011) and `continue` steps 1 and 3
559
+ (T024) implement it. If (a) fails because the loser wrote the context, make sure no
560
+ `save_context` runs between the claim check and the lock (J-5). Confirm
561
+ `validate_continue_payload` writes nothing on valid payloads.
562
+ - [X] T035 [US4] Documentation:
563
+ - `documentation/interrupts.md`: "A second resume of the same interrupt, concurrent or later,
564
+ raises `ValidationError` ("already resumed"), in every execution mode";
565
+ - `CHANGELOG.md` Bug Fixes entry and migration note.
566
+
567
+ ---
568
+
569
+ ## Phase 7: User Story 5 - Resuming several pending interrupts at once (Priority: P2)
570
+
571
+ **Goal**: FR-017 to FR-021, through acceptance on `running`, the hand-off backstop, and claims
572
+ applied by the lock owner (R-05, R-06, R-08, P-4).
573
+
574
+ **Independent Test**: quickstart §5.
575
+
576
+ ### Tests for User Story 5
577
+
578
+ - [X] T036 [P] [US5] Write `spec/ruby_reactor/interrupts/concurrent_interrupts_spec.rb`, in fake
579
+ mode, with `ResumeFixtures::DualApproval`.
580
+ - (a) Pause at `a` and `b`.
581
+ - Thread 1: `continue(step_name: :a, payload: {...})`, where `after_a` blocks on
582
+ `latch(:after_a)`.
583
+ - Main thread: wait until the status is `running` and `async:<id>` is held, then
584
+ `continue(step_name: :b, payload: {...})`. It returns a `DispatchResult`, and the log line
585
+ has `reason="run_busy"`.
586
+ - Open the latch and join thread 1, whose result is an `InterruptResult` (paused at `b`).
587
+ - `drain_async_jobs`. Then `completed`; the `a` and `b` results are the payloads;
588
+ `RollbackRecorder` counts `run:after_a` and `run:done` once each.
589
+ - (b) `ResumeFixtures::ManyApprovals.build(n)` for n in 2..5: n threads resume all interrupts
590
+ behind a barrier, then drain. Every call returns (no raise), the run is `completed`, and each
591
+ interrupt's result is its payload.
592
+ - (c) While `a`'s resume blocks, an invalid payload for `b`:
593
+ - returns the validation failure;
594
+ - stores no claim;
595
+ - after the latch opens, `a`'s progress is intact (`run:after_a` recorded, the status ends
596
+ paused at `b`);
597
+ - `increment_interrupt_attempts` was called once, and the context blob has no
598
+ `interrupt_attempts`.
599
+ - (d) `a`'s resume fails in `after_a` (use a failing variant) after `b` was accepted:
600
+ - drain; the run is `failed`;
601
+ - the `b` result is never set;
602
+ - the Worker made no `resume_execution` call.
603
+ - (e) An async reactor (`background all: true` variant of `DualApproval`) is enqueued but not yet
604
+ drained. `continue(step_name: :b)` while `running` (with `:prep` already done: enqueue after
605
+ the first pause) returns a `DispatchResult`. Drain: the run does not stay paused at `b`.
606
+ - [X] T037 [P] [US5] Update `spec/integration/interrupt_max_attempts_spec.rb` and
607
+ `spec/integration/interrupt_validation_spec.rb`:
608
+ - attempts are counted by `increment_interrupt_attempts`, not
609
+ `private_data[:interrupt_attempts]`;
610
+ - reaching `max_attempts` ends the run `failed` with the same `failure_reason` keys (DM §3), and
611
+ the completed steps undone;
612
+ - add one example where `max_attempts` is reached while another resume holds the run: with
613
+ `stub_const(...UNDO_LOCK_WAIT, 0.2)`, `continue` raises `Lock::AcquisitionError` and the run is
614
+ not marked `failed` (API §1).
615
+ - [X] T038 [P] [US5] Run the existing `spec/ruby_reactor/multiple_interrupts_spec.rb`,
616
+ `spec/ruby_reactor/interrupt_spec.rb` and `spec/ruby_reactor/async_notification_interrupt_spec.rb`
617
+ unchanged, and record any expectation that encoded "running is rejected". Update only those, each
618
+ citing FR-017.
619
+
620
+ ### Implementation for User Story 5
621
+
622
+ - [X] T039 [US5] In `Reactor#ensure_resumable!` (T024), accept `running` alongside `paused`
623
+ (FR-017). Rejections stay: finished, `aborted`, `rolling_back`, cancelled, not ready, already
624
+ resumed (FR-020). Update the error message for other statuses: "the reactor is <status>; only a
625
+ paused or running run accepts a resume".
626
+ - [X] T040 [US5] Rewrite `Reactor#validate_continue_payload` (R-08):
627
+ - replace the `private_data[:interrupt_attempts]` increment and its `save_context` with
628
+ `current_attempts = configuration.storage_adapter.increment_interrupt_attempts(id, storage_name, step_key)`;
629
+ - at `max_attempts`, call `undo(failure: { message:, step_name:, errors:, payload:, step_arguments:, attempts:, validation_errors: })`
630
+ (T008) instead of `undo` followed by a separate failed save;
631
+ - return the same `Failure`.
632
+
633
+ T036 (c) and T037 pass.
634
+ - [X] T041 [US5] `TestSubject#resume` (`lib/ruby_reactor/rspec/test_subject.rb`): accept a
635
+ `running` subject when `ready_interrupt_steps` includes the step. Keep the `paused?` error for
636
+ other statuses, with the message updated to mirror T039. Spec in `spec/ruby_reactor/rspec/`.
637
+ - [X] T042 [P] [US5] Demo: `demo_app/app/reactors/dual_approval_demo_reactor.rb`, class
638
+ `DualApprovalDemoReactor`, with class-based steps:
639
+ - step `:prepare`;
640
+ - `interrupt :finance, resume: :background` (`wait_for :prepare`);
641
+ - `interrupt(:legal) { wait_for :prepare }`;
642
+ - step `:approve_all` using `result(:finance)` and `result(:legal)`.
643
+ - [X] T043 [US5] Demo rake task `demo:concurrent_interrupts`, with a `desc`:
644
+ - run to the pause, printing the ready interrupts;
645
+ - `continue(:finance)`: print "finance accepted (background)" and the status `running`;
646
+ - immediately `continue(:legal)`: print "legal accepted while running" and the result class;
647
+ - poll up to 30s, then print the final status and `approve_all`'s result.
648
+ - [X] T044 [US5] Demo spec `demo_app/spec/reactors/dual_approval_demo_reactor_spec.rb`, shipped
649
+ surface only:
650
+ - `have_ready_interrupts(:finance, :legal)`;
651
+ - `resume(step: :finance, payload:, process_jobs: false)`, then `be_resume_deferred`;
652
+ - `resume(step: :legal, payload:, process_jobs: false)`, then `be_resume_deferred`;
653
+ - `drain_async_jobs`, then `be_success`, and both results present.
654
+ - [X] T045 [US5] Documentation:
655
+ - `documentation/interrupts.md`, the multiple-interrupts section: resumes for different ready
656
+ interrupts are accepted while the run executes, and applied once. Describe the hand-off and the
657
+ transient `paused` state. Note that in inline job-testing mode, overlapping resumes need real
658
+ workers (R-14);
659
+ - `documentation/interrupts.md`: the attempt counter's new home, and the restart for runs paused
660
+ across the upgrade;
661
+ - README "Interrupts" one-liner;
662
+ - `CHANGELOG.md` entry, plus the migration note on the attempt counts.
663
+
664
+ ---
665
+
666
+ ## Phase 8: User Story 6 - Manual undo finishes a compensate that was cut off (Priority: P2)
667
+
668
+ **Goal**: FR-022 to FR-026 (R-10, R-11, J-9, P-5).
669
+
670
+ **Independent Test**: quickstart §6.
671
+
672
+ ### Tests for User Story 6
673
+
674
+ - [X] T046 [P] [US6] (Landed as `spec/ruby_reactor/rollback/aborted_compensate_spec.rb`, beside `aborted_execution_spec.rb`.) Extend `spec/ruby_reactor/rollback/aborted_execution_spec.rb`, caller process.
675
+
676
+ Fixtures:
677
+
678
+ - `recording_step :a`;
679
+ - `recording_step :x, after: :a, fail: true`. Its `compensate` records `compensate-start:x`,
680
+ raises `SignalException.new("TERM")` the first time it runs (a class-level flag), and the
681
+ second time records `compensate:x(<arguments>, <reason class>/<reason message>)`;
682
+ - the step's arguments include `id: 7`.
683
+
684
+ Examples:
685
+
686
+ - (a) `run` raises `SignalException`:
687
+ - the stored status is `aborted`;
688
+ - `rollback` has `step: "x"`, `compensated: false` and `arguments` including `id: 7`;
689
+ - `error["message"]` is the failure message.
690
+ - (b) `Klass.undo(id)`: the log continues `compensate-start:x compensate:x(... id: 7 ...) undo:a`;
691
+ the status is `cancelled`; the `rollback` field is cleared.
692
+ - (c) The interruption comes in `a`'s undo, after `x`'s compensate returned: `rollback` carries
693
+ no `arguments`, and manual undo logs only `undo:a`, with no `compensate`.
694
+ - (d) The re-run `compensate` returns `Failure("nope")`:
695
+ - `undo:a` still runs;
696
+ - a `:compensate` trace entry records the failure;
697
+ - a test middleware receives `:failed_compensation` for `:x`.
698
+ - (e) A string failure reason (`Failure("plain")`) is passed again as the string `"plain"`. An
699
+ exception reason is passed as `RubyReactor::Error::RecordedFailure`, with `message` and
700
+ `original_class`.
701
+ - (f) Depth: the same failing step inside a composed child (compose step in the root); manual
702
+ undo of the root re-runs `x`'s compensate before the child's `undo:` entries, then the root's.
703
+ - (g) Depth: inside an inline map element (map of 2 elements; element 1 fails at `x`). Manual
704
+ undo re-runs element 1's `compensate:x` before its own undos, and element 0 is undone as
705
+ today.
706
+ - [X] T047 [P] [US6] (Landed in `aborted_compensate_spec.rb`, "the dashboard API".) API spec, in the existing web API spec file under `spec/ruby_reactor/web/`:
707
+ - `GET /api/reactors/:id` for the aborted run of T046 (a) includes
708
+ `"pending_compensation" => { "step" => "x" }`;
709
+ - after manual undo, the key is absent;
710
+ - for an aborted run with no pending compensate, the key is absent.
711
+
712
+ ### Implementation for User Story 6
713
+
714
+ - [X] T048 [P] [US6] Create `lib/ruby_reactor/error/recorded_failure.rb`:
715
+ `RubyReactor::Error::RecordedFailure < StandardError`, with `attr_reader :original_class`,
716
+ initialized as `new(message, original_class:)`. A doc comment says it is the reason a re-run
717
+ `compensate` receives, because the original exception cannot be rebuilt (R-10).
718
+ - [X] T049 [US6] In `lib/ruby_reactor/executor/compensation_manager.rb#handle_step_failure`, keep
719
+ `arguments: step_config.rollback_arguments(arguments)` on `@pending`. Add the helper
720
+ `pending_record` that returns:
721
+ - `{ "trigger" => "failure", "step" => name.to_s, "compensated" => false, "arguments" => ContextSerializer.serialize_value(args), "error" => serialized_error, "failures" => serialize(rollback_failures) }`
722
+ while the pending compensate has not returned;
723
+ - `nil` otherwise.
724
+
725
+ `serialized_error` is the `String` itself, or `{ "class" => e.class.name, "message" => e.message }`.
726
+ - [X] T050 [US6] In `lib/ruby_reactor/executor.rb#mark_aborted`: when it is about to set
727
+ `:aborted`, also `@context.rollback = @compensation_manager.pending_record if @compensation_manager.pending_record`
728
+ (R-10). Check that the `rescue Exception` and `aborting_on_interruption` paths both reach it,
729
+ for the root and for a composed child's own executor.
730
+ - [X] T051 [US6] In `lib/ruby_reactor/executor.rb#undo_all`, before
731
+ `@compensation_manager.rollback_completed_steps`, add the private `compensate_pending!`:
732
+ - if `@context.rollback` has `"arguments"`, a `"step"` naming a step of this reactor, and
733
+ `"compensated" == false`:
734
+ - rebuild the reason: the string, or `Error::RecordedFailure.new(h["message"], original_class: h["class"])`;
735
+ - `@compensation_manager.compensate(step_config, reason, ContextSerializer.deserialize_value(args))`;
736
+ - then set `@context.rollback["compensated"] = true`.
737
+
738
+ It must not run for 009's hand-off states, which carry no `"arguments"`. Comment with J-9.
739
+ - [X] T052 [US6] In `lib/ruby_reactor/reactor.rb#undo` (after T008's reload), merge instead of
740
+ overwrite:
741
+ - `record = @context.rollback if @context.status.to_s == "aborted" && @context.rollback&.key?("arguments")`;
742
+ - `@context.rollback = { "trigger" => "undo", "compensated" => true, "failures" => [] }.merge(record ? record.slice("step", "compensated", "arguments", "error") : {})`.
743
+
744
+ T046 (a)–(g) pass. If (f) or (g) fail, check that `ComposeStep#undo` and `Map::ElementRollback`
745
+ reach the child's `Executor#undo_all` with the child context that carries its own `rollback`.
746
+ - [X] T053 [US6] In `lib/ruby_reactor/web/api.rb`, the detail serialization (R-11): when the status
747
+ is `aborted` and `rollback` is a Hash with `"arguments"` and `"compensated" == false`, add
748
+ `"pending_compensation" => { "step" => rollback["step"] }`. T047 passes.
749
+ - [X] T054 [US6] GUI:
750
+ - `gui/src/lib/reactors.ts`: add `pending_compensation?: { step: string }` to the reactor detail
751
+ type;
752
+ - `gui/src/components/ReactorDetail.tsx`: on `aborted` runs with it, show one amber line:
753
+ "Compensation of step `<step>` did not finish; run undo to complete it.";
754
+ - a vitest example in `gui/src/components/__tests__/`;
755
+ - rebuild with `cd gui && npm run build`, which updates `lib/ruby_reactor/web/public/`. Commit
756
+ the bundle.
757
+ - [X] T055 [US6] Documentation:
758
+ - `documentation/interrupts.md`, the aborted section (around line 172);
759
+ - `documentation/core_concepts.md`, around line 343: manual undo re-runs an unfinished failing
760
+ `compensate` first, at any depth; the `compensate` must tolerate a repeat; the dashboard
761
+ flag;
762
+ - `CHANGELOG.md` Bug Fixes entry.
763
+
764
+ ---
765
+
766
+ ## Phase 9: User Story 7 - One bulk undo for a whole map (Priority: P3)
767
+
768
+ **Goal**: FR-027 to FR-034 (R-12, R-13, P-6, API §3).
769
+
770
+ **Independent Test**: quickstart §7.
771
+
772
+ ### Tests for User Story 7
773
+
774
+ - [X] T056 [P] [US7] Write `spec/ruby_reactor/dsl/map_undo_all_dsl_spec.rb`:
775
+ - `undo_all { |r| }` stores the block (the map step config's `arguments[:undo_all_block]`
776
+ resolves to a Proc);
777
+ - two declarations raise `RubyReactor::Error::ValidationError` naming the map;
778
+ - `undo_all` without a block raises the same;
779
+ - a map without it has no `undo_all_block` argument.
780
+ - [X] T057 [P] [US7] Write `spec/map/map_undo_all_spec.rb`, with fixtures from
781
+ `spec/support/map_rollback_fixtures.rb`: extend `MapRollbackFixtures.parent` with
782
+ `undo_all: nil` (a Proc). The block records `RollbackRecorder.record("undo_all:#{results.to_a.inspect}")`
783
+ and returns its own option result.
784
+
785
+ Fan-out (`for_each_async_backend`, `fan_out: true, batch_size: 5`, 20 elements, `b_fails: true`):
786
+
787
+ - (a) One `undo_all:` entry with 20 results in index order.
788
+ - (b) No `undo:e.*` entries.
789
+ - (c) `QueueProbe.enqueued("RubyReactor::Adapters::Sidekiq::MapElementRollbackWorker")` (or its
790
+ ActiveJob class) is 0 throughout.
791
+ - (d) `undo:a` comes after `undo_all:`.
792
+ - (e) The run is `failed`.
793
+ - (f) The `:undo_all` trace entry has `count: 20`.
794
+ - (g) Log lines `ruby_reactor.map.rollback.undo_all.started` and `.completed`, with `count=20`.
795
+
796
+ Other cases:
797
+
798
+ - (h) Atomic, element 7 fails: called with exactly the elements that have a success slot (the
799
+ ones that completed before or while 7 failed), never index 7 or a skipped index, and the
800
+ element's own compensate ran (`compensate:e.e2[7]`).
801
+ - (i) Inline map (`fan_out: false`): the same as (a), (b), (d), (e) and (f).
802
+ - (j) The block raises:
803
+ - `have_rollback_failure(:m)` with `kind: :undo_all`;
804
+ - `undo:a` still runs;
805
+ - the final `Failure` lists it.
806
+ - (k) The block returns `RubyReactor::Failure("x")`: the same, with `reason: :returned_failure`.
807
+ - (l) Every element halts or is skipped: no `undo_all:` entry, and `undo:a` runs.
808
+ - (m) A completed run, then `Klass.undo(id)`: exactly one `undo_all:` entry.
809
+ - (n) An inline map element 1 interrupted (`ElemInterruptOnce`-style, raising in its forward
810
+ step), so the run is `aborted`. Manual undo replays element 1's completed steps per element,
811
+ and `undo_all` receives element 0's result only.
812
+ - (o) Fan-out with one result slot deleted (`storage` `HDEL`) before rollback: one
813
+ `context_unavailable` failure for that index, and `undo_all` receives the rest.
814
+ - (p) `:slow`: 10,000 elements, fan-out, `batch_size 50`, a block that counts by iterating.
815
+ - Count `== 10,000`.
816
+ - Zero element undos.
817
+ - Memory flat: `GC.stat(:heap_live_slots)` sampled every 1,000 items inside the block grows by
818
+ less than 10% from the first to the last sample. Document the bound in the spec.
819
+
820
+ ### Implementation for User Story 7
821
+
822
+ - [X] T058 [US7] In `lib/ruby_reactor/dsl/map_builder.rb`:
823
+ - add `undo_all(&block)`:
824
+ - raise `RubyReactor::Error::ValidationError.new("map :#{@name} undo_all needs a block", step: @name)`
825
+ without a block;
826
+ - raise `"map :#{@name} declares undo_all twice"` when `@undo_all_block` is already set;
827
+ - store the block in `@undo_all_block`;
828
+ - in `build_step_config`, add `undo_all_block: { source: RubyReactor::Template::Value.new(@undo_all_block) }`
829
+ only when it is set.
830
+
831
+ T056 passes.
832
+ - [X] T059 [US7] In `lib/ruby_reactor/step/map_step.rb`:
833
+ - add `input :undo_all_block, optional: true`, so the declared argument is accepted;
834
+ - in `compensate` (and therefore `undo`), add as the first branch:
835
+ `return bulk_rollback(map_id, step_name, block) if (block = undo_all_block)`;
836
+ - add the private `undo_all_block`, which reads the static declaration
837
+ (`context.reactor_class.steps[context.current_step].arguments[:undo_all_block]&.dig(:source)&.value`,
838
+ in the same style as `element_class`), because the undo record carries no arguments (009 R-14).
839
+ - [X] T060 [US7] In `lib/ruby_reactor/map/step_rollback.rb`, add `bulk_rollback(map_id, step_name, block)`
840
+ (R-13):
841
+
842
+ Collect the results:
843
+
844
+ - **Fan-out** (`dispatched_map_id(step_name)`):
845
+ - `total = metadata["count"]`, from `storage.retrieve_map_metadata`;
846
+ - report missing slots as `rollback_entry(index, :context_unavailable, "context expired")`,
847
+ checked in chunks of 1,000 with a new adapter `map_result_indexes_present(map_id, klass, indexes)`
848
+ (`HMGET` or `HEXISTS` pipeline) declared in `lib/ruby_reactor/storage/adapter.rb`;
849
+ - `enum = RubyReactor::Map::ResultEnumerator.new(map_id, klass, strict_ordering: true).lazy.select(&:success?).map(&:value)`.
850
+ Check `wrap_result` for error, halt and skip markers, and exclude them;
851
+ - `count` comes from a counting pass over the same lazy filter. Prefer a stored count if one
852
+ exists, otherwise count in chunks.
853
+ - **Inline**:
854
+ - pass 1 over `retrieve_map_element_context_ids_from_tail` chunks (`Map::ROLLBACK_CHUNK`): for
855
+ each `aborted` element, call `inline_element_rollback(...)` (per-element replay) and collect
856
+ its failures; count the `completed` elements; report expired elements as `inline_rollback`
857
+ does;
858
+ - pass 2 is a lazy `Enumerator` over the same index in **index order** (from the head) that
859
+ deserializes one context at a time and yields `completed` elements' results. Reuse the
860
+ result reconstruction `RubyReactor::Reactor.new(element_context).result.value`.
861
+
862
+ Call:
863
+
864
+ - when `count.zero?`, skip the call;
865
+ - otherwise `log_rollback("undo_all.started", step_name, count:)`;
866
+ `outcome = block.call(enum)`, with `rescue RubyReactor::Error::Rescuable => e` → a failure;
867
+ - a failure is `outcome.is_a?(RubyReactor::Failure)` or the raise. Add
868
+ `{ step: step_name.to_sym, kind: :undo_all, reason: :raised | :returned_failure, message: }`;
869
+ - `context.append_execution_trace(type: :undo_all, step: step_name, count:, result:, timestamp: Time.now)`;
870
+ - `log_rollback("undo_all.completed", step_name, count:, failed:)`;
871
+ - `finish_rollback(step_name, count, failures)`.
872
+
873
+ T057 passes, except (p) until T061.
874
+ - [X] T061 [US7] Profile T057 (p). If memory grows, reduce what the lazy chain retains:
875
+ `ResultEnumerator`'s `count` memoization is fine, and no `to_a` is allowed. Mark the chunk sizes
876
+ with a `ponytail:` comment naming the bound.
877
+ - [X] T062 [US7] Matcher `have_run_undo_all(step_name)`, with chain `.with_elements(n)`, in
878
+ `lib/ruby_reactor/rspec/matchers.rb`. It reads the subject's execution trace for
879
+ `type: :undo_all, step: step_name`, and `count == n` when chained. Spec in
880
+ `spec/ruby_reactor/rspec/`.
881
+ - [X] T063 [P] [US7] Demo reactors, one per file, class-based steps:
882
+ - `demo_app/app/reactors/bulk_refund_charge_reactor.rb`: `BulkRefundChargeReactor`, an element
883
+ with a `:charge` step returning `{ id:, amount: }`, and an `undo` that prints a per-element
884
+ refund (it never runs here: proof that `undo_all` replaced it);
885
+ - `demo_app/app/reactors/bulk_refund_demo_reactor.rb`: `BulkRefundDemoReactor`, with:
886
+ - `map :charges`: fan-out, `batch_size 5`, `undo_all { |charges| BulkRefundDemoReactor.refunded << charges.map { _1[:id] } }`.
887
+ `refunded` is a class-level array the rake task prints;
888
+ - then a step `:settle` that fails when `inputs[:fail_settle]`.
889
+ - [X] T064 [US7] Demo rake task `demo:map_undo_all`, with a `desc`. It runs with
890
+ `fail_settle: true`, waits for a terminal status (polling up to 60s), and prints:
891
+ - "bulk refund called once with N charges";
892
+ - the final status (`failed`);
893
+ - confirmation that no per-element refund line printed.
894
+ - [X] T065 [US7] Demo spec `demo_app/spec/reactors/bulk_refund_demo_reactor_spec.rb`, shipped
895
+ surface only:
896
+ - `test_reactor(BulkRefundDemoReactor, { payments: 6.times.map { ... }, fail_settle: true })`,
897
+ then `be_failure`, then `have_run_undo_all(:charges).with_elements(6)`;
898
+ - `fail_settle: false`: `be_success`, and `have_run_undo_all(:charges)` is negated.
899
+ - [X] T066 [US7] Documentation:
900
+ - `documentation/data_pipelines.md`: an `undo_all` section, covering contract, coverage,
901
+ at-least-once, the fan-out path that uses no jobs, and aborted inline elements;
902
+ - `demo_app/documentation/data_pipelines.md`: kept in sync;
903
+ - README "Map & Parallel Execution": the example from API §3;
904
+ - `documentation/testing.md`: `have_run_undo_all`;
905
+ - `CHANGELOG.md`: a **Features** entry.
906
+
907
+ ---
908
+
909
+ ## Phase 10: Polish & Cross-Cutting Concerns
910
+
911
+ - [X] T067 Register `demo:rollback_follow_ups` in `demo_app/lib/tasks/demo_reactors.rake`, with a
912
+ `desc`, depending on `[:environment, :flush_redis, :map_undo_all, :contended_resume, :concurrent_interrupts]`.
913
+ Append `:rollback_follow_ups` to `demo:all`'s prerequisites.
914
+ - [X] T068 [P] `specs/future_improvements.md`:
915
+ - remove the seven "Rollback follow-ups (008)" bullets;
916
+ - in "Fenced context writes": strike the `Reactor#continue` and synchronous
917
+ `Reactor.run` / `Executor#execute` rows of "Remaining writers", like the collector row, citing
918
+ 010 R-01, R-04 and R-05. Note that part 2 ("Lock, then load") is done for the `Worker`,
919
+ `continue` and `Reactor#undo`.
920
+ - [X] T069 [P] Grep for stale claims:
921
+ `grep -rn "contended resume raises\|run stays paused\|not paused at an interrupt\|interrupt_attempts" README.md documentation demo_app/documentation lib`.
922
+ Fix every hit that contradicts 010, except the internal `private_data` legacy comment if one is
923
+ kept.
924
+ - [X] T070 [P] Observability check (Constitution IV): every new log line is key=value with
925
+ `reactor` and `context_id` (R-15). Add log-shape examples to
926
+ `spec/ruby_reactor/interrupts/contended_resume_spec.rb` (`resume.deferred`),
927
+ `spec/ruby_reactor/worker_snooze_admitted_spec.rb` (`resume.waiting`) and
928
+ `spec/map/map_undo_all_spec.rb` (`undo_all.*`) if not already covered.
929
+ - [X] T071 Run `bundle exec rubocop` and fix offences. No `--disable-pending-cops`.
930
+ - [X] T072 Run the full gem suite, `bundle exec rspec`, alone on the test Redis. Compare with T002:
931
+ no new failures. Pending examples are allowed only for the documented `future_improvements`
932
+ items.
933
+ - [X] T073 Run the `:slow` and `:fork` tags explicitly:
934
+ `bundle exec rspec --tag slow spec/map/map_undo_all_spec.rb` and
935
+ `bundle exec rspec --tag fork spec/ruby_reactor/caller_process_liveness_spec.rb`.
936
+ - [X] T074 [P] GUI: run `cd gui && npm run lint && npm test -- --run`. Confirm that the committed
937
+ bundle in `lib/ruby_reactor/web/public/` matches a fresh `npm run build`.
938
+ - [X] T075 Demo specs, locally:
939
+ `cd demo_app && REDIS_URL=redis://localhost:6780/5 RAILS_ENV=test bundle exec rspec spec/reactors/{bulk_refund,contended_approval,dual_approval}_demo_reactor_spec.rb`,
940
+ and `REDIS_URL=redis://localhost:6780/5 bin/rails demo:rollback_follow_ups`.
941
+ - [X] T076 Docker acceptance (Constitution VI.4), using an isolated compose project because the
942
+ container names are fixed:
943
+ `docker compose -p rr_rollback_follow_ups -f docker-compose.yml -f <override with unique container_name and ports: !reset []> up -d --build demo-redis demo-sidekiq`,
944
+ then `run --rm --no-deps demo-app bash -c "bin/rails db:prepare && bin/rails demo:rollback_follow_ups"`.
945
+ Check the printed outcomes against quickstart §9, then tear the project down. Ask before
946
+ touching another worktree's containers.
947
+ - [X] T077 Walk through quickstart.md §1–§9 and tick each row. Fix any drift in the quickstart
948
+ itself.
949
+ - [X] T078 Fill in `specs/010-rollback-follow-ups/pr-description.md`: the summary per story, the
950
+ migration notes (R-16), the regression proof from T018, the T002 and T072 counts, and the
951
+ Docker run output.
952
+
953
+ ---
954
+
955
+ ## Dependencies & Execution Order
956
+
957
+ ### Phase dependencies
958
+
959
+ ```text
960
+ Phase 1 Setup ─▶ Phase 2A (T003–T008) ─┬─▶ US1 (Phase 3) ─▶ US2 (Phase 4)
961
+ ├─▶ US6 (Phase 8)
962
+ Phase 2B (T009–T013) ─┴─▶ US3 (Phase 5) ─▶ US4 (Phase 6)
963
+ └────▶ US5 (Phase 7)
964
+ US7 (Phase 9): depends on Phase 1 only
965
+ Phase 10 Polish: after every story
966
+ ```
967
+
968
+ ### Story dependencies
969
+
970
+ - **US1**: Phase 2A only. It is the MVP.
971
+ - **US2**: US1, because T016's lock in `execute` is one half of the fix; T007 is the other.
972
+ - **US3**: Phase 2A and 2B.
973
+ - **US4**: US3, because it needs `continue` steps 1 and 3 from T024.
974
+ - **US5**: US3, because it extends T024 and T027.
975
+ - **US6**: Phase 2A (T008's reload). Independent of US3–US5.
976
+ - **US7**: independent of every other story. It can run in parallel from the start.
977
+
978
+ ### Within each story
979
+
980
+ 1. Specs first; they must fail.
981
+ 2. Library code.
982
+ 3. RSpec surface.
983
+ 4. Demo: reactor, then rake task, then spec.
984
+ 5. Documentation and CHANGELOG.
985
+
986
+ ---
987
+
988
+ ## Parallel Execution Examples
989
+
990
+ - **Phase 2**: T003, T004, T005 and T009 are separate spec files and can run in parallel. T010
991
+ (storage) can run in parallel with T006 and T008. T007 and T013 both touch `worker.rb`, so run
992
+ them in sequence.
993
+ - **After Phase 2**: these streams are independent:
994
+ - US1 (T014–T017), then US2 (T018–T020);
995
+ - US6 (T046–T055);
996
+ - US7 (T056–T066).
997
+ - **US3**: T021, T022 and T023 (specs) together, then T028 (demo reactor) in parallel with T024 to
998
+ T027.
999
+ - **US5**: T036, T037 and T038 together; T042 (demo reactor) in parallel with T039 to T041.
1000
+ - **US6**: T046 and T047 together; T048 in parallel with T049.
1001
+ - **US7**: T056 and T057 together; T063 in parallel with T058 to T062.
1002
+ - **Polish**: T068, T069, T070 and T074 in parallel.
1003
+
1004
+ ## Implementation Strategy
1005
+
1006
+ ### MVP (User Story 1 only)
1007
+
1008
+ 1. Phase 1, then Phase 2A.
1009
+ 2. Phase 3 (US1): the sweeper no longer re-runs live synchronous runs.
1010
+ 3. **Stop and validate**: quickstart §1. Ship as a PATCH if needed (no public API change).
1011
+
1012
+ ### Incremental delivery
1013
+
1014
+ 1. **US1 + US2** (ownership): one PR-sized increment.
1015
+ 2. **Phase 2B + US3**: a contended resume becomes a hand-off. This is the webhook fix and a MINOR
1016
+ API change, with a CHANGELOG migration note.
1017
+ 3. **US4 + US5**: claim semantics, with "already resumed" and concurrent interrupts.
1018
+ 4. **US6**: the cut-off compensate.
1019
+ 5. **US7**: `undo_all`, the only pure feature.
1020
+ 6. **Polish**: the Docker acceptance run and the PR description.
1021
+
1022
+ Each increment keeps the suite green and its docs current (Constitution, Development Workflow).