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
|
@@ -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-
|
|
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>
|
data/lib/ruby_reactor/worker.rb
CHANGED
|
@@ -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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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) |
|