ruby_reactor 0.8.7 → 0.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/CHANGELOG.md +59 -2
- data/CLAUDE.md +1 -1
- data/README.md +38 -3
- data/lib/ruby_reactor/dsl/map_builder.rb +20 -1
- data/lib/ruby_reactor/error/recorded_failure.rb +18 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +21 -1
- data/lib/ruby_reactor/executor.rb +62 -10
- data/lib/ruby_reactor/interrupt_claims.rb +81 -0
- data/lib/ruby_reactor/map/step_rollback.rb +121 -0
- data/lib/ruby_reactor/reactor.rb +210 -95
- data/lib/ruby_reactor/rspec/matchers.rb +49 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +17 -6
- data/lib/ruby_reactor/step/map_step.rb +12 -0
- data/lib/ruby_reactor/storage/adapter.rb +17 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +42 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +17 -0
- data/lib/ruby_reactor/web/public/assets/{index-C6ejszCQ.js → index-DxjQlW7Q.js} +8 -8
- data/lib/ruby_reactor/web/public/index.html +1 -1
- data/lib/ruby_reactor/worker.rb +73 -6
- data/specs/010-rollback-follow-ups/checklists/requirements.md +47 -0
- data/specs/010-rollback-follow-ups/contracts/api-surface.md +123 -0
- data/specs/010-rollback-follow-ups/contracts/resume-protocol.md +132 -0
- data/specs/010-rollback-follow-ups/data-model.md +139 -0
- data/specs/010-rollback-follow-ups/plan.md +273 -0
- data/specs/010-rollback-follow-ups/pr-description.md +110 -0
- data/specs/010-rollback-follow-ups/quickstart.md +157 -0
- data/specs/010-rollback-follow-ups/research.md +440 -0
- data/specs/010-rollback-follow-ups/spec.md +489 -0
- data/specs/010-rollback-follow-ups/tasks.md +1022 -0
- data/specs/future_improvements.md +17 -2
- metadata +14 -2
|
@@ -0,0 +1,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).
|