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.
Files changed (35) 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 +59 -2
  5. data/CLAUDE.md +1 -1
  6. data/README.md +38 -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 +21 -1
  10. data/lib/ruby_reactor/executor.rb +62 -10
  11. data/lib/ruby_reactor/interrupt_claims.rb +81 -0
  12. data/lib/ruby_reactor/map/step_rollback.rb +121 -0
  13. data/lib/ruby_reactor/reactor.rb +210 -95
  14. data/lib/ruby_reactor/rspec/matchers.rb +49 -0
  15. data/lib/ruby_reactor/rspec/test_subject.rb +17 -6
  16. data/lib/ruby_reactor/step/map_step.rb +12 -0
  17. data/lib/ruby_reactor/storage/adapter.rb +17 -0
  18. data/lib/ruby_reactor/storage/redis_adapter.rb +42 -0
  19. data/lib/ruby_reactor/version.rb +1 -1
  20. data/lib/ruby_reactor/web/api.rb +17 -0
  21. data/lib/ruby_reactor/web/public/assets/{index-C6ejszCQ.js → index-DxjQlW7Q.js} +8 -8
  22. data/lib/ruby_reactor/web/public/index.html +1 -1
  23. data/lib/ruby_reactor/worker.rb +73 -6
  24. data/specs/010-rollback-follow-ups/checklists/requirements.md +47 -0
  25. data/specs/010-rollback-follow-ups/contracts/api-surface.md +123 -0
  26. data/specs/010-rollback-follow-ups/contracts/resume-protocol.md +132 -0
  27. data/specs/010-rollback-follow-ups/data-model.md +139 -0
  28. data/specs/010-rollback-follow-ups/plan.md +273 -0
  29. data/specs/010-rollback-follow-ups/pr-description.md +110 -0
  30. data/specs/010-rollback-follow-ups/quickstart.md +157 -0
  31. data/specs/010-rollback-follow-ups/research.md +440 -0
  32. data/specs/010-rollback-follow-ups/spec.md +489 -0
  33. data/specs/010-rollback-follow-ups/tasks.md +1022 -0
  34. data/specs/future_improvements.md +17 -2
  35. metadata +14 -2
@@ -5,7 +5,7 @@
5
5
  <link rel="icon" type="image/svg+xml" href="./vite.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>ui</title>
8
- <script type="module" crossorigin src="./assets/index-C6ejszCQ.js"></script>
8
+ <script type="module" crossorigin src="./assets/index-DxjQlW7Q.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="./assets/index-DM5darhy.css">
10
10
  </head>
11
11
  <body>
@@ -8,7 +8,13 @@ module RubyReactor
8
8
  # `Adapters::ActiveJob::Compat` on ActiveJob::Base) — nothing here references
9
9
  # a specific backend.
10
10
  module Worker
11
- TERMINAL_STATUSES = %w[completed failed cancelled skipped aborted].freeze
11
+ TERMINAL_STATUSES = %w[completed failed cancelled skipped halted aborted].freeze
12
+
13
+ # Seconds a Worker waits for the run's lock before it reads the run (010
14
+ # R-02). The usual holder is the caller that enqueued this job, a few
15
+ # milliseconds from its final save and release; a snooze would cost a whole
16
+ # scheduled-set poll instead. Same bound as `Map::Collector::COLLECT_LOCK_WAIT`.
17
+ CONTEXT_LOCK_WAIT = 2
12
18
 
13
19
  # Use the error's `retry_after_seconds` hint when available
14
20
  # (RateLimit::ExceededError carries the time until the bucket rolls);
@@ -81,16 +87,58 @@ module RubyReactor
81
87
  # Identity-only payload: storage is the source of truth. Rehydrate the live
82
88
  # context from storage by id, then resume. A nil read means the context was
83
89
  # swept, expired, or already terminal-and-collected — nothing to resume.
90
+ #
91
+ # Lock, then load (010 R-02, J-2): the run's `async:` lock is taken BEFORE
92
+ # the read, so this job sees the last save of whoever held it — a caller
93
+ # that just handed off, a manual undo, another worker — and its own later
94
+ # save can never replace newer progress. The executor re-enters the lock.
84
95
  def perform(context_id, reactor_class_name = nil, snooze_count = 0)
85
96
  # Normalize so a nil/omitted name resolves to the same storage key the
86
97
  # enqueue path wrote (always via reactor_storage_name). Without this a
87
98
  # nil here builds "reactor::context:<id>" and misses the stored
88
99
  # "reactor:AnonymousReactor:context:<id>", silently no-op'ing.
89
100
  reactor_class_name ||= RubyReactor.reactor_storage_name(nil)
101
+ lock = lock_run(context_id, reactor_class_name, snooze_count)
102
+ return if lock == :snoozed
103
+
104
+ begin
105
+ perform_locked(context_id, reactor_class_name, snooze_count, lock&.owner)
106
+ ensure
107
+ lock&.release
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ # nil in inline job-testing mode, where a nested re-entry would contend
114
+ # with itself (as `Executor#acquire_context_lock`). Another live holder
115
+ # after the wait: snooze, uncapped, having read and written nothing.
116
+ def lock_run(context_id, reactor_class_name, snooze_count)
117
+ return nil if inline_testing_mode?
118
+
119
+ lock = RubyReactor::Lock.new("async:#{context_id}", owner: SecureRandom.uuid,
120
+ ttl: RubyReactor.configuration.context_lock_ttl,
121
+ wait: CONTEXT_LOCK_WAIT, auto_extend: true)
122
+ lock.acquire
123
+ lock
124
+ rescue RubyReactor::Lock::AcquisitionError => e
125
+ contention = RubyReactor::Lock::ContextLockContention.new(e.message, context_lock_key: "async:#{context_id}")
126
+ handle_snooze(context_id, reactor_class_name, nil, snooze_count, contention)
127
+ :snoozed
128
+ end
129
+
130
+ def inline_testing_mode?
131
+ defined?(::Sidekiq::Testing) && ::Sidekiq::Testing.respond_to?(:inline?) && ::Sidekiq::Testing.inline?
132
+ end
133
+
134
+ def perform_locked(context_id, reactor_class_name, snooze_count, lock_owner)
90
135
  data = RubyReactor.configuration.storage_adapter.retrieve_context(context_id, reactor_class_name)
91
136
  return if data.nil?
92
- # An aborted run is never resumed forward: only a manual undo applies (008 R-08).
93
- return if (data["status"] || data[:status]).to_s == "aborted"
137
+
138
+ status = (data["status"] || data[:status]).to_s
139
+ # Finished, cancelled or aborted: nothing to resume (an aborted run takes
140
+ # only a manual undo, 008 R-08). A stray or duplicate job does nothing.
141
+ return if TERMINAL_STATUSES.include?(status)
94
142
 
95
143
  begin
96
144
  context = ContextSerializer.deserialize_hash(data)
@@ -115,6 +163,10 @@ module RubyReactor
115
163
  return
116
164
  end
117
165
 
166
+ # A paused run advances only with a claimed payload (010 R-06): a stray
167
+ # job must never run the steps that follow the interrupt without one.
168
+ return if status == "paused" && InterruptClaims.unapplied(context).empty?
169
+
118
170
  # Mark that we're executing inline to prevent nested async calls
119
171
  context.inline_async_execution = true
120
172
 
@@ -122,7 +174,8 @@ module RubyReactor
122
174
  # Resume execution from the failed step — or, for a run whose rollback
123
175
  # handed off at a fan-out map, finish that rollback (009 R-04).
124
176
  executor = Executor.new(context.reactor_class, {}, context)
125
- rolling_back = (data["status"] || data[:status]).to_s == "rolling_back"
177
+ executor.context_lock_owner = lock_owner
178
+ rolling_back = status == "rolling_back"
126
179
  rolling_back ? executor.resume_rollback : executor.resume_execution
127
180
  # No explicit save here: resume_execution's ensure block already persists
128
181
  # the final root state (`save_context unless skip_context_persist?`), and
@@ -155,8 +208,6 @@ module RubyReactor
155
208
  end
156
209
  end
157
210
 
158
- private
159
-
160
211
  # If reactor_class_name is provided, use it to get the reactor class.
161
212
  # This handles cases where the class can't be found via const_get.
162
213
  def resolve_reactor_class!(context, reactor_class_name)
@@ -203,6 +254,16 @@ module RubyReactor
203
254
  error.is_a?(RubyReactor::Lock::ContextLockContention) ||
204
255
  error.is_a?(RubyReactor::Error::ExecutionParked))
205
256
 
257
+ # Escalation marks the run `failed` WITHOUT rolling back: harmless before
258
+ # admission, when nothing ran, but a run already admitted (a resume,
259
+ # deferred or `resume: :background`, or a re-entry after a hand-off) has
260
+ # completed steps whose effects would be stranded. It keeps waiting
261
+ # instead, and says so once (010 R-07, J-8).
262
+ if capped && context&.admitted?
263
+ warn_still_waiting(context, snooze_count, error) if max != :infinity && snooze_count == max
264
+ capped = false
265
+ end
266
+
206
267
  if capped && max != :infinity && snooze_count >= max
207
268
  escalate_snooze(context, snooze_count, error)
208
269
  return
@@ -214,6 +275,12 @@ module RubyReactor
214
275
  self.class.perform_in(delay, context_id, reactor_class_name, snooze_count + 1)
215
276
  end
216
277
 
278
+ def warn_still_waiting(context, snooze_count, error)
279
+ fields = { event: "ruby_reactor.resume.waiting", reactor: RubyReactor.reactor_storage_name(context.reactor_class),
280
+ context_id: context.context_id, error: error.message, snooze_count: snooze_count }
281
+ RubyReactor.configuration.logger.warn(fields.map { |k, v| "#{k}=#{v.inspect}" }.join(" "))
282
+ end
283
+
217
284
  # Instance methods delegate to the module functions above — worker
218
285
  # behavior is unchanged, just relocated so other callers (StepWorker,
219
286
  # T036) can reuse the same logic without a Worker instance.
@@ -0,0 +1,47 @@
1
+ # Specification Quality Checklist: Rollback and Resume Follow-ups
2
+
3
+ **Purpose**: Validate specification completeness and quality before proceeding to planning
4
+ **Created**: 2026-10-08
5
+ **Feature**: [spec.md](../spec.md)
6
+
7
+ ## Content Quality
8
+
9
+ - [x] No implementation details (languages, frameworks, APIs)
10
+ - [x] Focused on user value and business needs
11
+ - [x] Written for non-technical stakeholders
12
+ - [x] All mandatory sections completed
13
+
14
+ ## Requirement Completeness
15
+
16
+ - [x] No [NEEDS CLARIFICATION] markers remain
17
+ - [x] Requirements are testable and unambiguous
18
+ - [x] Success criteria are measurable
19
+ - [x] Success criteria are technology-agnostic (no implementation details)
20
+ - [x] All acceptance scenarios are defined
21
+ - [x] Edge cases are identified
22
+ - [x] Scope is clearly bounded
23
+ - [x] Dependencies and assumptions identified
24
+
25
+ ## Feature Readiness
26
+
27
+ - [x] All functional requirements have clear acceptance criteria
28
+ - [x] User scenarios cover primary flows
29
+ - [x] Feature meets measurable outcomes defined in Success Criteria
30
+ - [x] No implementation details leak into specification
31
+
32
+ ## Notes
33
+
34
+ - The gem's public API (`continue`, `Reactor.run`, `Reactor.undo`, `map`, `undo_all`,
35
+ `DispatchResult`) is the user-facing surface of a library, so naming it is not an implementation
36
+ detail (same convention as specs 008 and 009). Internal classes, storage keys and lock mechanics
37
+ are kept out; the one mechanism mentioned (a liveness lock for caller-process runs) is an example
38
+ in Assumptions, explicitly left to the plan.
39
+ - No clarification markers: each item's direction came from `specs/future_improvements.md`. Defaults
40
+ chosen where it offered two options:
41
+ - US5 accepts a resume for another pending interrupt, rather than returning a retryable error,
42
+ matching US3's hand-off.
43
+ - US1/US2 get targeted fixes; the full fenced-writes proposal is out of scope.
44
+ - `undo_all` leaves partially-run (aborted) elements to per-element replay.
45
+
46
+ `/speckit-clarify` can revisit any of these.
47
+ - Validation pass 1: all items pass.
@@ -0,0 +1,123 @@
1
+ # Contract: Public API Surface
2
+
3
+ Changes to what gem users call, receive, or observe. Internals appear only where a spec pins them.
4
+
5
+ ## 1. `Reactor.continue` / `Reactor#continue` / `Reactor.continue_by_correlation_id`
6
+
7
+ Signatures are unchanged: `continue(id:, payload:, step_name:, idempotency_key: nil)` on the class,
8
+ `continue(payload:, step_name:, idempotency_key: nil)` on an instance.
9
+
10
+ ### Outcomes
11
+
12
+ | Situation | Today | After 010 |
13
+ | --- | --- | --- |
14
+ | Valid payload, run `paused`, gates free | resumes inline and returns its result (`Success`, `Failure`, `InterruptResult` or `DispatchResult`) | unchanged |
15
+ | Valid payload, the reactor's `with_lock` / `with_semaphore` is held by another run | raises `Lock::AcquisitionError` / `Semaphore::AcquisitionError`; the run stays `paused` | **returns `DispatchResult`**; the run is `running`; a Worker resumes it once the lock frees (FR-009, FR-010) |
16
+ | Valid payload, `resume: :background` interrupt | returns `DispatchResult` | unchanged |
17
+ | Valid payload for a ready interrupt, run `running` (another resume, or the run is executing) | raises `ValidationError` "the reactor is running, not paused" | **returns `DispatchResult`**; the payload is applied once the run is free (FR-017–FR-019) |
18
+ | Second resume of the same interrupt (concurrent or later) | may run twice (inline mode) or race | **raises `ValidationError`** "Cannot resume: interrupt :x was already resumed" (FR-015, FR-020) |
19
+ | Invalid payload | the class method raises `InputValidationError`; the instance method returns `Failure(invalid_payload: true)`; one attempt counted | unchanged; the attempt is now counted in its own record (data-model §3) |
20
+ | Invalid payload at `max_attempts` | undoes, then saves `failed` (two unlocked writes) | same outcome, under the run's lock (R-08, R-09); may raise `Lock::AcquisitionError` if another execution holds the run for more than 5s |
21
+ | Interrupt not ready | raises `ValidationError` | unchanged |
22
+ | Run finished, `aborted`, `rolling_back`, or cancelled | raises `ValidationError` | unchanged (008 FR-032) |
23
+
24
+ A `DispatchResult` from a hand-off carries `execution_id: <run id>`. In inline job-testing mode, a
25
+ hand-off that already finished returns the reloaded result, as `resume: :background` does today
26
+ (`check_for_inline_completion`).
27
+
28
+ **Migration** (CHANGELOG): a `rescue Lock::AcquisitionError` / `Semaphore::AcquisitionError` around
29
+ `continue` no longer fires; the resume was accepted. Callers that treat any non-`Failure` result as
30
+ "resumed" need no change.
31
+
32
+ ### Web dashboard API
33
+
34
+ `POST /api/reactors/:id/continue`:
35
+
36
+ - a `DispatchResult` answers `{ success: true, message: "Resume accepted" }`;
37
+ - an "already resumed" rejection answers 422 with the error.
38
+
39
+ ## 2. `Reactor.undo(id)` on an `aborted` run
40
+
41
+ The signature is unchanged.
42
+
43
+ - If the run was interrupted during its failing step's `compensate`, that `compensate` runs again
44
+ first, with the recorded arguments and failure, before the completed steps are undone (FR-022 to
45
+ FR-025).
46
+ - The `reason` passed to the re-run `compensate`:
47
+ - the original `String`, if the failure was a string;
48
+ - an `RubyReactor::Error::RecordedFailure` (`StandardError`) otherwise, whose `message` is the
49
+ original message and whose `original_class` is the original class name.
50
+ - A failing re-run is reported as any compensation failure is: a `:compensate` trace entry, a
51
+ `:failed_compensation` middleware event, and a rollback failure. The undo stack is still replayed.
52
+ The run still ends `cancelled` ("Undo triggered"), as manual undo does today.
53
+
54
+ Dashboard API (`GET /api/reactors/:id`): an `aborted` run whose `compensate` did not finish adds
55
+ `"pending_compensation": { "step": "<name>" }`. The GUI shows it on the run's detail view.
56
+
57
+ ## 3. `map ... undo_all`
58
+
59
+ ```ruby
60
+ map :refunds do
61
+ source input(:payments)
62
+ argument :payment, element(:refunds)
63
+ step :charge, ChargeStep
64
+ returns :charge
65
+ fan_out batch_size: 100
66
+
67
+ # Called once per rollback of this map, with the completed elements' results
68
+ # (a lazy Enumerable, in index order), INSTEAD of each element's own undos.
69
+ undo_all do |completed_results|
70
+ Payments.bulk_refund(completed_results.map { |charge| charge[:id] })
71
+ end
72
+ end
73
+ ```
74
+
75
+ | Rule | Behavior |
76
+ | --- | --- |
77
+ | Declaration | optional; at most once per map; needs a block. Otherwise `Error::ValidationError` at class definition |
78
+ | When it is called | every rollback of the map: an atomic map's failure, a later step's failure, a manual undo |
79
+ | Argument | a lazy `Enumerable` of the results of the elements that completed, in index order; never every element's state at once |
80
+ | Not included | failed elements (they roll themselves back), halted, skipped |
81
+ | Not called | no element completed |
82
+ | Inline map with an `aborted` element (an interrupted run) | that element replays its own undos first; it is not passed |
83
+ | Return | `RubyReactor::Failure` or a raise is a failure; anything else is success |
84
+ | Failure | one rollback failure `{ step: <map>, kind: :undo_all, reason:, message: }`; the steps before the map are still undone |
85
+ | Fan-out | no element rollback jobs, no rollback records |
86
+ | Repetition | once per map rollback; at least once if the executing process dies mid-call |
87
+
88
+ ## 4. Synchronous `Reactor.run`
89
+
90
+ The signature and results are unchanged. The run holds its liveness lock (`async:<id>`) while it
91
+ executes in the caller's process (R-01). Observable effects:
92
+
93
+ - `RubyReactor::Sweeper` / `RubyReactor.sweep_once` no longer re-enqueue it while it executes.
94
+ - `Reactor.undo(id)` of that run waits up to 5s, then raises `Lock::AcquisitionError`, as for a run
95
+ executing in a worker today.
96
+
97
+ ## 5. Worker behavior (background jobs)
98
+
99
+ | Change | Observable effect |
100
+ | --- | --- |
101
+ | It takes the run's liveness lock before it reads the run (waits up to 2s) | a Worker enqueued by a caller still finishing its hand-off starts within about 2s instead of racing it |
102
+ | It returns on terminal statuses, and on a `paused` run without claimed payloads | stray or duplicate Workers do nothing |
103
+ | Admitted runs are not limited by `lock_snooze_max_attempts` | a resumed run waiting on a reactor lock or semaphore is never marked `failed` without rollback; it logs `ruby_reactor.resume.waiting` once at the limit |
104
+
105
+ ## 6. Structured logs (key=value)
106
+
107
+ | Event | Level | Fields |
108
+ | --- | --- | --- |
109
+ | `ruby_reactor.resume.deferred` | info | `reactor`, `context_id`, `step`, `reason` (`lock`, `semaphore`, `run_busy`, `background`), `key` |
110
+ | `ruby_reactor.resume.waiting` | warn | `reactor`, `context_id`, `error` (names the contended key), `snooze_count` |
111
+ | `ruby_reactor.map.rollback.undo_all.started` | info | `reactor`, `context_id`, `map_step`, `count` |
112
+ | `ruby_reactor.map.rollback.undo_all.completed` | info | `reactor`, `context_id`, `map_step`, `count`, `failed` |
113
+
114
+ ## 7. RSpec surface (`lib/ruby_reactor/rspec/`)
115
+
116
+ | Addition | Contract |
117
+ | --- | --- |
118
+ | `TestSubject#resume(payload:, step:, process_jobs: nil)` | Accepts a `paused` **or `running`** run with a ready interrupt, mirroring `continue`. `process_jobs: false` leaves a hand-off pending; `nil` keeps the subject's setting. |
119
+ | `be_resume_deferred` | Passes when the subject's last `resume` returned a `DispatchResult` and the run is `running`. |
120
+ | `have_run_undo_all(:map_step)` / `.with_elements(n)` | Passes when the run's execution trace has an `:undo_all` entry for that map, with a count of `n`. |
121
+
122
+ The existing `hold_lock`, `be_paused_at`, `have_ready_interrupts`, `have_rollback_failure` and
123
+ `be_success` / `be_failure` are unchanged.
@@ -0,0 +1,132 @@
1
+ # Contract: Run Ownership and Resume Protocol
2
+
3
+ The sequences the implementation must follow and the invariants the specs pin. The records are
4
+ defined in [data-model.md](../data-model.md). This extends the invariants I-1 to I-8 of
5
+ `specs/009-distributed-map-undo/contracts/rollback-protocol.md`.
6
+
7
+ ## Invariants
8
+
9
+ - **J-1: a live run holds its lock.** While a run executes anywhere (a worker, the caller's process
10
+ through `execute`, `continue`, or `undo`), its `async:<id>` lock is held and auto-extended.
11
+ Exception: inline job-testing mode, which takes no context lock (R-14). The sweeper re-enqueues
12
+ only `running` and `rolling_back` runs with no lock holder.
13
+ - **J-2: lock, then load.** A Worker, `continue` and `Reactor#undo` read the run only after taking
14
+ its lock. A synchronous `execute` creates the run, so it has nothing older to read.
15
+ - **J-3: save before release** (009 I-6, extended to `execute`). Every holder persists its final
16
+ state before its lock count reaches zero.
17
+ - **J-4: one claim per interrupt.** At most one payload is accepted per interrupt per run (the
18
+ `SET NX` claim). Rejected resumes write nothing.
19
+ - **J-5: claims are applied only by the lock owner.** A claimed payload enters the run's context
20
+ only in the execution holding `async:<id>`, at the start of `resume_execution`. `continue` never
21
+ writes the context without the lock.
22
+ - **J-6: an accepted resume is not stranded.** After a successful claim, either the claiming
23
+ `continue` owns the run and resumes it, or a Worker for the run is enqueued. A Worker that finds
24
+ the run `paused` with an unapplied claim resumes it.
25
+ - **J-7: validated once.** A claimed payload is never validated again (FR-011).
26
+ - **J-8: no escalation after admission.** An admitted run is never marked `failed` by snooze
27
+ escalation (R-07).
28
+ - **J-9: no `compensate` is lost.** An `aborted` run whose failing step's `compensate` had not
29
+ returned keeps a record of it. Manual undo runs it before the undo stack, at each level where it
30
+ was recorded.
31
+
32
+ ## P-1: Synchronous run with a fan-out hand-off (US1, US2)
33
+
34
+ ```text
35
+ caller process element jobs / collector Worker(run)
36
+ ────────────── ──────────────────────── ───────────
37
+ Reactor.run → Executor#execute
38
+ take async:<id> (wait 0, auto-extend) ◀── sweeper sees it held: skip
39
+ steps…; MapStep#run dispatches, returns DispatchResult
40
+ elements run; all settled →
41
+ enqueue Worker(run) ─────────▶ take async:<id> (wait ≤ 2s)
42
+ ensure: save final state (J-3) …blocked…
43
+ release async:<id> ──────────────────────────────────────────────────▶ acquired
44
+ return DispatchResult load (sees caller's final save, J-2)
45
+ resume_execution (re-entrant) → adopt map → …
46
+ save; release
47
+ ```
48
+
49
+ If the caller takes more than 2s to release, the Worker snoozes as a `ContextLockContention`
50
+ (uncapped) and retries. It has loaded and written nothing.
51
+
52
+ ## P-2: Resume while the reactor's lock is held (US3)
53
+
54
+ ```text
55
+ webhook → Reactor.continue(id, payload, :approve)
56
+ find (snapshot) → status paused ✓, :approve ready ✓
57
+ validate payload ✓ (in this process)
58
+ SET NX resume:approve ✓
59
+ take async:<id> ✓ → reload → Executor(owner).resume_execution
60
+ apply claims → status running → with_lock contended → ensure: save → raise Lock::AcquisitionError
61
+ rescue: release async:<id> → enqueue Worker(run) → log resume.deferred(reason=lock)
62
+ return DispatchResult
63
+ Worker(run): lock, then load → running → resume_execution → apply claims (none left) → with_lock
64
+ contended → snooze (uncapped: admitted) … lock freed → acquire → steps run → final save
65
+ ```
66
+
67
+ ## P-3: Two resumes of the same interrupt (US4)
68
+
69
+ ```text
70
+ caller A caller B
71
+ find: paused find: paused
72
+ validate ✓ validate ✓
73
+ SET NX resume:approve ✓ SET NX resume:approve ✗ → raise "already resumed" (nothing written)
74
+ take lock, reload, resume …
75
+ ```
76
+
77
+ The same holds in inline job-testing mode: the claim does not depend on the context lock.
78
+
79
+ ## P-4: Resuming a second interrupt while the first executes (US5)
80
+
81
+ ```text
82
+ caller A (approve_a) caller B (approve_b) Worker(run)
83
+ SET NX resume:approve_a ✓; take async:<id> ✓
84
+ reload; resume_execution: apply claim a
85
+ running; steps after a… find: running; approve_b ready ✓
86
+ validate ✓; SET NX resume:approve_b ✓
87
+ take async:<id> ✗ (held by A)
88
+ enqueue Worker(run); log deferred(run_busy)
89
+ return DispatchResult
90
+ reaches approve_b (no result) → pauses
91
+ save paused; release ───────────────────────────────────────────────────────────▶ take lock; load: paused
92
+ claim b unapplied → resume_execution
93
+ apply claim b → steps → completed
94
+ ```
95
+
96
+ - If A's run reaches its end before B's claim, it pauses at `approve_b`, and the Worker resumes it.
97
+ - If A's run fails and rolls back first, the Worker loads a `failed` run and returns. Claim b is
98
+ never applied (FR-021).
99
+
100
+ ## P-5: Manual undo of an aborted run (US6)
101
+
102
+ ```text
103
+ caller process: Step X fails → handle_step_failure records @pending{step X, args, error}
104
+ compensate(X) … SIGTERM → rescue Exception → mark_aborted:
105
+ status aborted; rollback = {trigger: failure, step: X, compensated: false, arguments, error}
106
+ ensure: save; release async:<id>
107
+ operator: Reactor.undo(id)
108
+ take async:<id> (wait 5s) → reload (J-2)
109
+ rollback merged {trigger: undo, step: X, compensated: false, arguments, error}
110
+ Executor#undo_all → compensate_pending!: compensate(X, recorded error, recorded args)
111
+ → compensated: true
112
+ → rollback_completed_steps (newest first)
113
+ status cancelled ("Undo triggered"); save; release
114
+ ```
115
+
116
+ A composed child or inline map element that was cut off records the same, on its own context. Its
117
+ level's `undo_all` (called through `ComposeStep#undo` or `Map::ElementRollback`) runs it before
118
+ that level's undo stack.
119
+
120
+ ## P-6: Map rollback through `undo_all` (US7)
121
+
122
+ ```text
123
+ executor rollback reaches the map's undo entry (or compensates a failed atomic map)
124
+ MapStep#compensate → undo_all_block declared → bulk_rollback
125
+ fan-out: report indexes with no result slot; enum = result slots (Success values, index order)
126
+ inline: pass 1 → aborted elements → Map::ElementRollback (per-element replay)
127
+ enum = completed element contexts, one at a time, index order
128
+ count == 0 → skip the call
129
+ log undo_all.started → block.call(enum) → trace :undo_all → log undo_all.completed
130
+ failure → rollback failure {step: map, kind: :undo_all}
131
+ return Success / Failure(rollback_failures) → the executor pops the entry → steps before the map
132
+ ```
@@ -0,0 +1,139 @@
1
+ # Data Model: Rollback and Resume Follow-ups
2
+
3
+ **Feature**: [spec.md](spec.md) | **Research**: [research.md](research.md)
4
+
5
+ All records live in Redis through `Storage::RedisAdapter`. `<Class>` is the reactor's storage name
6
+ (`RubyReactor.reactor_storage_name`), and `<id>` is the run's context id.
7
+
8
+ ## 1. Run liveness lock (changed holders)
9
+
10
+ `lock:async:<id>` is the existing re-entrant lock: Lua owner/count hash, TTL `context_lock_ttl`,
11
+ auto-extended by its holder.
12
+
13
+ | Holder | Since | Wait | Taken | Released |
14
+ | --- | --- | --- | --- | --- |
15
+ | `Executor#resume_execution`, `#resume_rollback` | 004/009 | 0 | after the reactor-level gates' rate-limit check | after the final save (009 R-13) |
16
+ | `Reactor#undo` | 009 R-12 | 5s | before undoing; **now also before reloading** (R-09) | after the save |
17
+ | **`Executor#execute`** (root, caller process) | **010 R-01** | 0 | after input validation, before the reactor-level lock and semaphore | **after the final save** |
18
+ | **`Worker#perform`** | **010 R-02** | 2s, polling | **before the context is read** | in `ensure`, after the executor released its re-entry |
19
+ | **`Reactor#continue`** | **010 R-05** | 0 | after the claim, **before the reload** | after the resume returns, or before the hand-off enqueue |
20
+
21
+ - **Re-entry**: the Worker and `continue` pass their owner to the executor
22
+ (`Executor#context_lock_owner=`). The executor's acquire then raises the count instead of
23
+ contending.
24
+ - **Inline job-testing mode**: no holder takes it (unchanged).
25
+ - **Reader**: `Sweeper#run_once`, which is unchanged. It now sees caller-process runs as live.
26
+
27
+ ## 2. Interrupt resume claim (new)
28
+
29
+ | Field | Value |
30
+ | --- | --- |
31
+ | Key | `reactor:<Class>:context:<id>:resume:<step>` |
32
+ | Value | `ContextSerializer.serialize_value(payload)`, as JSON |
33
+ | Created | `SET NX EX context_ttl`, by `Reactor#continue` after the payload has validated |
34
+ | Read | `MGET` over the reactor's interrupt steps that have no result yet: `Executor#resume_execution` right after the context lock, and `Worker#perform` for a `paused` run |
35
+ | Deleted | never; it expires with TTL |
36
+
37
+ **Lifecycle** of one interrupt:
38
+
39
+ ```text
40
+ absent ──continue (valid payload, SET NX wins)──▶ claimed ──lock owner applies──▶ applied
41
+ │ │ │
42
+ │ second continue: SET NX loses ─▶ "already resumed" │
43
+ └──────────────── continue after apply: the result exists ─▶ "already resumed" ◀──┘
44
+ ```
45
+
46
+ - **Applied** means `context.set_result(step, payload)` by the execution that owns the lock,
47
+ persisted by that execution's next save.
48
+ - An applied claim stays as the dedupe marker until it expires. After that, the step's result in
49
+ `intermediate_results` rejects duplicates.
50
+
51
+ **Validation rules**:
52
+
53
+ - A claim is written only for an interrupt step that is ready, has no result and has passed payload
54
+ validation (FR-008, FR-017, FR-020).
55
+ - The run must be `paused` or `running`, and not `cancelled`.
56
+
57
+ ## 3. Interrupt attempt counter (moved)
58
+
59
+ | Field | Value |
60
+ | --- | --- |
61
+ | Key | `reactor:<Class>:context:<id>:resume_attempts:<step>` |
62
+ | Value | an integer, raised by `INCR` once per invalid payload, with `EXPIRE context_ttl` |
63
+ | Replaces | `context.private_data[:interrupt_attempts][step]`, no longer written |
64
+
65
+ At `step_config.max_attempts`, the run fails through `Reactor#undo(failure:)` (R-08, R-09). The
66
+ `failure_reason` is unchanged: `message`, `step_name`, `errors`, `payload`, `step_arguments`,
67
+ `attempts`, `validation_errors`.
68
+
69
+ ## 4. `context.rollback` on an aborted run (extended)
70
+
71
+ There is no new context attribute. 009 added `rollback`, and 010 adds keys for the aborted case
72
+ (R-10).
73
+
74
+ | Key | Type | Meaning |
75
+ | --- | --- | --- |
76
+ | `trigger` | `"failure"` | a step failure was rolling back when the process was interrupted |
77
+ | `step` | String | the failing step's name, at this context's level |
78
+ | `compensated` | Boolean | whether that step's `compensate` returned. `false` makes manual undo re-run it |
79
+ | `arguments` | serialized Hash | the step's `rollback_arguments`. **Its presence marks the aborted record**; 009 hand-off records do not carry it |
80
+ | `error` | `{ "class", "message" }`, or a String | the failure that `compensate` receives again |
81
+ | `failures` | serialized Array | rollback failures collected before the interruption (009) |
82
+
83
+ **State transitions** (top level; a composed child or inline element behaves the same at its own
84
+ level):
85
+
86
+ ```text
87
+ running ──step fails──▶ (compensate running) ──interrupted──▶ aborted + rollback{compensated:false}
88
+ │
89
+ Reactor.undo: lock, reload, merge record, undo_all
90
+ ▼
91
+ compensate re-run (once) ─▶ compensated:true ─▶ undo stack replayed ─▶ cancelled
92
+ running ──step fails──▶ compensate returns ──interrupted during undo stack──▶ aborted (no record;
93
+ manual undo replays the remaining stack only)
94
+ ```
95
+
96
+ `Web::API` derives `pending_compensation: { step: }` from this record when the status is `aborted`
97
+ and `compensated` is false (R-11).
98
+
99
+ ## 5. Map step declaration: `undo_all` (new)
100
+
101
+ | Field | Where | Value |
102
+ | --- | --- | --- |
103
+ | `undo_all_block` | map `StepConfig#arguments`, source `Template::Value` | the block given to `undo_all`; absent when not declared |
104
+
105
+ **Validation rules** (`MapBuilder`, at class definition): a block is required, and the method may be
106
+ declared once per map. Either violation raises `Error::ValidationError` naming the map (FR-027).
107
+
108
+ ### Bulk undo inputs and outcome
109
+
110
+ | Item | Fan-out map | Inline map |
111
+ | --- | --- | --- |
112
+ | Completed results | forward result slots, in index order, `Success` values only (`Map::ResultEnumerator`) | element contexts with status `completed`, in index order, one deserialized at a time |
113
+ | Replayed per element instead | none (no element is `aborted`) | elements with status `aborted` (pass 1, `Map::ElementRollback`) |
114
+ | Reported unavailable | indexes with no result slot | element contexts that expired, as in 009 |
115
+ | Rollback records or jobs | none | none |
116
+
117
+ Outcome entries:
118
+
119
+ - **Execution trace**: `{ type: :undo_all, step: <map step>, count: <completed>, result: <value or error>, timestamp: }`.
120
+ - **Rollback failure**, on a raise or a returned `Failure`:
121
+ `{ step: <map step>, kind: :undo_all, reason: :raised | :returned_failure, message: }`. It is
122
+ aggregated into the run's final failure like any rollback failure.
123
+
124
+ ## 6. Worker decision by status (R-06)
125
+
126
+ | Stored status | Action |
127
+ | --- | --- |
128
+ | `completed`, `failed`, `cancelled`, `skipped`, `aborted`, `halted` | return (release the lock) |
129
+ | `paused` | resume if any interrupt without a result has a claim; otherwise return |
130
+ | `rolling_back` | `resume_rollback` |
131
+ | `running` | `resume_execution` (applies claims first) |
132
+
133
+ ## 7. Snooze limit (R-07)
134
+
135
+ | Condition | Limited by `lock_snooze_max_attempts`? |
136
+ | --- | --- |
137
+ | Reactor-level lock, semaphore or rate limit before admission (`!context.admitted?`) | yes (unchanged): escalate to `failed` |
138
+ | Reactor-level lock or semaphore once admitted | **no**: one `ruby_reactor.resume.waiting` warning at the limit, then keep snoozing |
139
+ | `ContextLockContention`, `OrderedLock::WaitError`, `ExecutionParked` | no (unchanged) |