ruby_reactor 0.8.6 → 0.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/CHANGELOG.md +81 -0
- data/CLAUDE.md +1 -1
- data/README.md +42 -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 +27 -1
- data/lib/ruby_reactor/executor/result_handler.rb +16 -0
- data/lib/ruby_reactor/executor/retry_manager.rb +2 -2
- data/lib/ruby_reactor/executor/step_executor.rb +20 -0
- 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 +270 -105
- data/lib/ruby_reactor/rspec/matchers.rb +64 -16
- data/lib/ruby_reactor/rspec/test_subject.rb +25 -24
- data/lib/ruby_reactor/step/compose_step.rb +10 -0
- 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-interrupt-in-composed-child/checklists/requirements.md +43 -0
- data/specs/010-interrupt-in-composed-child/contracts/api-surface.md +75 -0
- data/specs/010-interrupt-in-composed-child/data-model.md +40 -0
- data/specs/010-interrupt-in-composed-child/plan.md +149 -0
- data/specs/010-interrupt-in-composed-child/quickstart.md +64 -0
- data/specs/010-interrupt-in-composed-child/research.md +207 -0
- data/specs/010-interrupt-in-composed-child/spec.md +267 -0
- data/specs/010-interrupt-in-composed-child/tasks.md +315 -0
- 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 -14
- metadata +22 -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,43 @@
|
|
|
1
|
+
# Specification Quality Checklist: Interrupt Inside a Composed Child
|
|
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 product is a Ruby library, so its public DSL and API (`compose`, `interrupt`, `continue`,
|
|
35
|
+
`be_paused_at`, `ValidationError`) are the user-facing surface and are named, as in specs
|
|
36
|
+
006–009. Internal classes from the input (`ComposeStep#run`, `handle_execution_result`) stay in
|
|
37
|
+
the Input line and Context table only, as the observed defect; the requirements do not prescribe
|
|
38
|
+
them.
|
|
39
|
+
- Defaults chosen instead of clarification markers (see Assumptions): path given as an array, bare
|
|
40
|
+
leaf name not matched to a nested interrupt, web dashboard display and map-element interrupts out
|
|
41
|
+
of scope.
|
|
42
|
+
- FR-017 is a small adjacent guard: once a compose propagates the pause, a compose inside a map
|
|
43
|
+
element would hand the map an unhandled pause result, so it must fail clearly.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Public API Contract: Interrupt Inside a Composed Child
|
|
2
|
+
|
|
3
|
+
Every addition is backward compatible. Calls that do not use a path behave as before.
|
|
4
|
+
|
|
5
|
+
## `Reactor.run` / `Reactor#run`
|
|
6
|
+
|
|
7
|
+
When a composed child reaches an interrupt, the run returns an `InterruptResult` with:
|
|
8
|
+
|
|
9
|
+
- `paused?` → `true`, `status` → `:paused`;
|
|
10
|
+
- `execution_id` → **the root's** id;
|
|
11
|
+
- `correlation_id` → the child interrupt's correlation id, or `nil`.
|
|
12
|
+
|
|
13
|
+
`Reactor.find(id).result` rebuilds an `InterruptResult` with the root's id, and without
|
|
14
|
+
`correlation_id` (unchanged).
|
|
15
|
+
|
|
16
|
+
## `Reactor.continue(id:, payload:, step_name:)`, `Reactor#continue(payload:, step_name:)`
|
|
17
|
+
|
|
18
|
+
`step_name` takes either form:
|
|
19
|
+
|
|
20
|
+
| `step_name` | Meaning |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `:approve` / `"approve"` | A step of the root (unchanged). |
|
|
23
|
+
| `[:fulfil, :approve]` / `["fulfil", "approve"]` | The interrupt `:approve` inside compose `:fulfil`. |
|
|
24
|
+
| `[:order, :fulfil, :approve]` | Two composes deep. |
|
|
25
|
+
| `[:approve]` | Same as `:approve`. |
|
|
26
|
+
|
|
27
|
+
Errors:
|
|
28
|
+
|
|
29
|
+
- `RubyReactor::Error::ValidationError` when the name or path is not a pending interrupt
|
|
30
|
+
(`"Cannot resume: expected step '<current>' or ready steps <ready_interrupt_steps> but got
|
|
31
|
+
'<name>'"`). Nothing changes and the run stays `paused`. A bare name must now be a pending
|
|
32
|
+
interrupt: naming any other ready step is refused, where it used to be accepted.
|
|
33
|
+
- `RubyReactor::Error::ValidationError` when the reactor loaded is a composed child: "Cannot
|
|
34
|
+
resume: <Class> is a composed child; continue its root run".
|
|
35
|
+
- Every existing error keeps its meaning and is driven by the nested interrupt's own declaration:
|
|
36
|
+
not paused, cancelled, invalid payload (`InputValidationError` from the class method),
|
|
37
|
+
`max_attempts` exhaustion (`Failure`, run rolled back from the root and `failed`), and
|
|
38
|
+
lock/semaphore `AcquisitionError` (run stays paused).
|
|
39
|
+
|
|
40
|
+
`resume: :background` on the nested interrupt enqueues **the root's** worker.
|
|
41
|
+
|
|
42
|
+
## `Reactor.continue_by_correlation_id(correlation_id:, payload:, step_name:)`
|
|
43
|
+
|
|
44
|
+
Called on the **root** class with the path. Called on the child class, it raises the
|
|
45
|
+
composed-child `ValidationError` above.
|
|
46
|
+
|
|
47
|
+
## `Reactor.interrupt_key(step_name)` (new, public)
|
|
48
|
+
|
|
49
|
+
Normalizes a step name the way `continue` reads it. `:a`, `"a"` and `[:a]` all give `:a`;
|
|
50
|
+
`["f", "a"]` gives `[:f, :a]`.
|
|
51
|
+
|
|
52
|
+
## `Reactor#ready_interrupt_steps` (new, public)
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
Order.find(id).ready_interrupt_steps # => [[:fulfil, :approve]]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Returns `[]` unless the run is `paused`. Root-level interrupts come back as Symbols, nested ones as
|
|
59
|
+
Arrays. A root can be paused at a nested interrupt and have one of its own ready at the same
|
|
60
|
+
time, so the list can mix both: `[:audit, [:fulfil, :approve]]`.
|
|
61
|
+
|
|
62
|
+
## RSpec surface (`require "ruby_reactor/rspec"`)
|
|
63
|
+
|
|
64
|
+
| API | Change |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `subject.ready_interrupt_steps` | Delegates to `Reactor#ready_interrupt_steps`, so it includes paths. |
|
|
67
|
+
| `be_paused_at([:fulfil, :approve])` | Matches a path. `be_paused_at(:a, :b)` still means two root interrupts. Failure messages print entries with `inspect`. |
|
|
68
|
+
| `have_ready_interrupts(:audit, [:fulfil, :approve])` | Exact set, paths included. |
|
|
69
|
+
| `subject.resume(payload:, step: [:fulfil, :approve])` | Resumes a nested interrupt. With one pending interrupt, `step` may be omitted. |
|
|
70
|
+
|
|
71
|
+
## Map elements
|
|
72
|
+
|
|
73
|
+
An `interrupt` reached inside a map element, directly or through a compose, fails that element
|
|
74
|
+
with `Failure("interrupt :<name> is not supported inside a map element")`. Before this feature it
|
|
75
|
+
crashed with `NoMethodError`.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Data Model: Interrupt Inside a Composed Child
|
|
2
|
+
|
|
3
|
+
No new storage keys or records. One new `private_data` flag; everything else is derived from state
|
|
4
|
+
that is already stored.
|
|
5
|
+
|
|
6
|
+
## Root context (stored row, unchanged shape)
|
|
7
|
+
|
|
8
|
+
| Field | While paused inside a child | Notes |
|
|
9
|
+
| --- | --- | --- |
|
|
10
|
+
| `status` | `paused` | Set by `update_context_status` on the root's `InterruptResult`. |
|
|
11
|
+
| `current_step` | the compose step name (e.g. `:fulfil`) | Set by the `ResultHandler` interrupt arm (R-03). |
|
|
12
|
+
| `composed_contexts[:fulfil]` | `{ name:, type: :composed, context: <child> }` | Child embedded as today. |
|
|
13
|
+
| `undo_stack` | ends with the compose step, `result: Success(nil)` | Partial-run entry (R-04). It is replaced, not duplicated, when the compose completes. |
|
|
14
|
+
| `private_data[:interrupt_attempts]` | keyed by `:"fulfil.approve"` (a Symbol) for a nested interrupt | Root-level interrupts keep their own name as the key (R-06). |
|
|
15
|
+
|
|
16
|
+
## Child context (embedded in the root, and its own observability row)
|
|
17
|
+
|
|
18
|
+
| Field | While paused | Notes |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
| `status` | `paused` | As today, set by the child's executor. |
|
|
21
|
+
| `current_step` | the interrupt (e.g. `:approve`), or the next compose one level down | |
|
|
22
|
+
| `intermediate_results[:approve]` | the payload, once `continue` accepted it | Written to the embedded child, never the observability row. |
|
|
23
|
+
| `private_data[:composed]` | `true` | **New.** Set when the compose step creates the child; refuses a direct `continue` (R-07). |
|
|
24
|
+
|
|
25
|
+
## Step path (value object, not stored)
|
|
26
|
+
|
|
27
|
+
`Array` of two or more step names: the compose steps from the root, then the interrupt. It is
|
|
28
|
+
normalized to Symbols on input. A one-element array is the same as the bare name.
|
|
29
|
+
|
|
30
|
+
## State transitions (root)
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
running ──child reaches interrupt──▶ paused
|
|
34
|
+
paused ──continue(path, valid payload)──▶ running ──▶ completed | failed | paused (next interrupt)
|
|
35
|
+
paused ──continue(path, payload, resume: :background)──▶ running (worker) ──▶ …
|
|
36
|
+
paused ──continue(path, invalid payload, attempts exhausted)──▶ undo ──▶ failed
|
|
37
|
+
paused ──undo──▶ cancelled paused ──cancel──▶ cancelled
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The child mirrors `paused → running → completed` inside the root and is never resumed on its own.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Implementation Plan: Interrupt Inside a Composed Child
|
|
2
|
+
|
|
3
|
+
**Branch**: `interrupt_inside_composed_child` | **Date**: 2026-10-08 | **Spec**: [spec.md](spec.md)
|
|
4
|
+
|
|
5
|
+
**Input**: Feature specification from `specs/010-interrupt-in-composed-child/spec.md`
|
|
6
|
+
|
|
7
|
+
## Summary
|
|
8
|
+
|
|
9
|
+
A composed child already pauses itself correctly. Its parent then crashes on the
|
|
10
|
+
`InterruptResult`. The fix is mostly letting that result through, plus three gaps it exposes:
|
|
11
|
+
|
|
12
|
+
1. **Pass the pause up (US1)**: `InterruptResult` arms in `ComposeStep#handle_execution_result`,
|
|
13
|
+
`RetryManager#handle_retry_result` and `ResultHandler#handle_step_result` (R-01).
|
|
14
|
+
- The compose re-stamps the result with its own context id, so the top level reports the
|
|
15
|
+
root's id (R-02).
|
|
16
|
+
- The result-handler arm sets the root's `current_step` to the compose step, which is the
|
|
17
|
+
resume cursor that `with_step` would otherwise clear (R-03).
|
|
18
|
+
2. **Name and resume (US2)**: a nested interrupt is an Array path. `Reactor#ready_interrupt_steps`
|
|
19
|
+
(new, public) derives the pending paths from stored state (R-05).
|
|
20
|
+
- `Reactor#continue` resolves `(target context, step config, leaf)` once and uses them for
|
|
21
|
+
validation, attempts, background resume and `set_result` (R-06).
|
|
22
|
+
- The resume itself is unchanged: the root re-runs the compose step, and `ComposeStep#run`
|
|
23
|
+
re-enters the admitted child.
|
|
24
|
+
3. **Undo while paused (US3)**: the pausing compose joins the undo stack as a partial-run entry,
|
|
25
|
+
the same entry an aborted compose gets. `add_to_undo_stack` replaces it when the compose later
|
|
26
|
+
completes (R-04).
|
|
27
|
+
4. **Test surface (US4)**: `TestSubject#ready_interrupt_steps` delegates to the reactor, and
|
|
28
|
+
`be_paused_at`/`have_ready_interrupts`/`resume` accept paths.
|
|
29
|
+
5. **Guards**:
|
|
30
|
+
- a composed child refuses a direct `continue` (`private_data[:composed]`, R-07);
|
|
31
|
+
- an interrupt inside a map element fails cleanly instead of `NoMethodError` (R-08).
|
|
32
|
+
|
|
33
|
+
## Technical Context
|
|
34
|
+
|
|
35
|
+
**Language/Version**: Ruby >= 3.0
|
|
36
|
+
|
|
37
|
+
**Primary Dependencies**: Sidekiq and ActiveJob adapters, Redis, dry-validation. No new
|
|
38
|
+
dependencies.
|
|
39
|
+
|
|
40
|
+
**Storage**: Redis through `Storage::RedisAdapter`. No new keys. One new `private_data[:composed]`
|
|
41
|
+
flag on composed child contexts (see [data-model.md](data-model.md)).
|
|
42
|
+
|
|
43
|
+
**Testing**: RSpec against real Redis (`redis://localhost:6780`), `for_each_async_backend` and
|
|
44
|
+
`drain_async_jobs` for the worker paths. Demo specs use the shipped matchers only.
|
|
45
|
+
|
|
46
|
+
**Target Platform**: Any Ruby process with Redis; workers via Sidekiq or ActiveJob.
|
|
47
|
+
|
|
48
|
+
**Project Type**: Library (gem), with `demo_app/` as the integration example.
|
|
49
|
+
|
|
50
|
+
**Performance Goals**: None new. `ready_interrupt_steps` builds one dependency graph per paused
|
|
51
|
+
level, and only on `continue` or in tests.
|
|
52
|
+
|
|
53
|
+
**Constraints**: Single-writer context rule: only the root run is stored and resumed as the
|
|
54
|
+
owner, and a pause is written to the embedded child, never to its observability row.
|
|
55
|
+
|
|
56
|
+
**Scale/Scope**: About 8 lib files touched, each change a few lines, except the `Reactor#continue`
|
|
57
|
+
target resolution. 1 new gem spec file, 1 un-pended example, 2 demo reactors, 1 rake task, 1 demo
|
|
58
|
+
spec, 4 doc files.
|
|
59
|
+
|
|
60
|
+
## Constitution Check
|
|
61
|
+
|
|
62
|
+
*Gate re-checked after Phase 1. No violations.*
|
|
63
|
+
|
|
64
|
+
| Principle | Status | Notes |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| I. Gem-First | PASS | All in `lib/`; no host coupling. |
|
|
67
|
+
| II. Saga Integrity | PASS | Closes a gap: the "interrupts first-class" clause now holds inside a compose, and undo reaches a paused child's completed steps (R-04). |
|
|
68
|
+
| III. Test-First, Real Infra | PASS | Specs written failing first, on real Redis, across async backends. No `inline!` for orchestration. |
|
|
69
|
+
| IV. Observability | PASS | Paused root status and the embedded child are visible in the dashboard as today. A refused `continue` names the pending path. |
|
|
70
|
+
| V. Simplicity & SemVer | PASS | Pass-through arms; the path is derived, not stored; one public method added. MINOR (R-10), with a CHANGELOG entry. |
|
|
71
|
+
| VI. Demo-App Proof | PASS (planned) | `ManagerApprovalReactor` (child) and `ComposedApprovalReactor` (root) in `demo_app/app/reactors/`; `demo:composed_interrupt`; `demo_app/spec/reactors/composed_approval_reactor_spec.rb` using `be_paused_at([...])`, `resume(step: [...])`, `be_success`, `have_run_step`. Docker acceptance run. |
|
|
72
|
+
|
|
73
|
+
- [x] Documentation impact identified (carried into tasks.md as a required task):
|
|
74
|
+
- **`documentation/composition.md`**: replace "An `interrupt` inside a composed child is not
|
|
75
|
+
supported yet…" (rule 6) with the nested-pause behavior and a pointer to interrupts.md.
|
|
76
|
+
- **`documentation/interrupts.md`**: new section "Interrupts inside composed reactors" covering
|
|
77
|
+
the path form, correlation id on the root class, undo/cancel, a child that refuses a direct
|
|
78
|
+
`continue`, and map elements unsupported.
|
|
79
|
+
- **`README.md`**: one line in the *Interrupts (Pause & Resume)* section linking to it.
|
|
80
|
+
- **`CHANGELOG.md`**: *Unreleased → Features*.
|
|
81
|
+
- **`specs/future_improvements.md`**: drop the "Interrupt inside a composed child" entry.
|
|
82
|
+
|
|
83
|
+
## Project Structure
|
|
84
|
+
|
|
85
|
+
### Documentation (this feature)
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
specs/010-interrupt-in-composed-child/
|
|
89
|
+
├── plan.md
|
|
90
|
+
├── research.md
|
|
91
|
+
├── data-model.md
|
|
92
|
+
├── quickstart.md
|
|
93
|
+
├── contracts/api-surface.md
|
|
94
|
+
├── checklists/requirements.md
|
|
95
|
+
└── tasks.md # /speckit-tasks
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Source Code (repository root)
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
lib/ruby_reactor/
|
|
102
|
+
├── step/compose_step.rb # R-01/R-02 pass-through + re-stamp; R-07 mark child composed
|
|
103
|
+
├── executor/retry_manager.rb # R-01 InterruptResult arm
|
|
104
|
+
├── executor/result_handler.rb # R-01/R-03/R-04 arm: cursor + partial-run undo entry
|
|
105
|
+
├── executor/compensation_manager.rb # R-04 replace top entry for the same step
|
|
106
|
+
├── executor/step_executor.rb # R-08 interrupt inside a map element → Failure
|
|
107
|
+
├── reactor.rb # R-05 ready_interrupt_steps; R-06 path in continue; R-07 guard
|
|
108
|
+
└── rspec/
|
|
109
|
+
├── test_subject.rb # delegate ready_interrupt_steps; resume(step: path)
|
|
110
|
+
└── matchers.rb # be_paused_at / have_ready_interrupts accept paths
|
|
111
|
+
|
|
112
|
+
spec/
|
|
113
|
+
├── ruby_reactor/interrupt_in_compose_spec.rb # new
|
|
114
|
+
└── map/map_compose_fan_out_spec.rb # un-pend; add a child fixture with an interrupt after the map
|
|
115
|
+
|
|
116
|
+
demo_app/
|
|
117
|
+
├── app/reactors/manager_approval_reactor.rb # child: reserve → interrupt :wait_for_manager → confirm
|
|
118
|
+
├── app/reactors/composed_approval_reactor.rb # root: charge → compose :approval → ship
|
|
119
|
+
├── lib/tasks/demo_reactors.rake # demo:composed_interrupt
|
|
120
|
+
└── spec/reactors/composed_approval_reactor_spec.rb
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Structure Decision**: Existing gem layout. No new lib files: each change sits where the
|
|
124
|
+
`InterruptResult` is already handled for root-level pauses.
|
|
125
|
+
|
|
126
|
+
## Implementation Order
|
|
127
|
+
|
|
128
|
+
Test-first within each step:
|
|
129
|
+
|
|
130
|
+
1. **US1**: pass-through arms, re-stamp, cursor. Spec: root pauses at depth 1 and 2, sync and
|
|
131
|
+
after a fan-out map (un-pend FR-015).
|
|
132
|
+
2. **US2**: `ready_interrupt_steps`, target resolution in `continue`, composed-child guard. Specs:
|
|
133
|
+
- resume by id and by correlation id;
|
|
134
|
+
- wrong path;
|
|
135
|
+
- background resume;
|
|
136
|
+
- `max_attempts`;
|
|
137
|
+
- two interrupts in sequence;
|
|
138
|
+
- a root interrupt ready alongside.
|
|
139
|
+
3. **US3**: partial-run undo entry and its replacement. Specs: undo while paused (plain child, and
|
|
140
|
+
a child with a fan-out map), cancel then continue, and an undo after completion that undoes the
|
|
141
|
+
compose once.
|
|
142
|
+
4. **US4**: test subject and matchers.
|
|
143
|
+
5. **Guard R-08**: map element interrupt, inline and fan-out.
|
|
144
|
+
6. **Demo** reactors, rake task, demo spec, Docker acceptance run.
|
|
145
|
+
7. **Docs, CHANGELOG, `future_improvements.md`**, then full `rspec` and `rubocop`.
|
|
146
|
+
|
|
147
|
+
## Complexity Tracking
|
|
148
|
+
|
|
149
|
+
No violations to justify.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Quickstart: Validating Interrupt Inside a Composed Child
|
|
2
|
+
|
|
3
|
+
## Prerequisites
|
|
4
|
+
|
|
5
|
+
- Test Redis on `localhost:6780` (`docker start ruby_reactor_redis_test` if the container exists).
|
|
6
|
+
- `bundle install` at the repo root and in `demo_app/`.
|
|
7
|
+
|
|
8
|
+
## 1. Gem specs
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
bundle exec rspec spec/ruby_reactor/interrupt_in_compose_spec.rb # new: US1–US4, edge cases
|
|
12
|
+
bundle exec rspec spec/map/map_compose_fan_out_spec.rb # FR-015: the former `pending` example
|
|
13
|
+
bundle exec rspec spec/ruby_reactor/interrupt_spec.rb spec/ruby_reactor/multiple_interrupts_spec.rb \
|
|
14
|
+
spec/ruby_reactor/interrupt_undo_spec.rb spec/ruby_reactor/interrupt_background_resume_spec.rb \
|
|
15
|
+
spec/integration/interrupt_validation_spec.rb spec/integration/interrupt_max_attempts_spec.rb \
|
|
16
|
+
spec/compose_spec.rb # no regression
|
|
17
|
+
bundle exec rspec && bundle exec rubocop # full gate
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Expected:
|
|
21
|
+
|
|
22
|
+
| Scenario | Outcome |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| Root composing a child with `c1`, `interrupt :approve`, `c2` | `paused`; `execution_id` is the root's; `c2` and later root steps not run |
|
|
25
|
+
| `Root.continue(id:, payload:, step_name: [:c, :approve])` | `completed`; each step ran once; same result as an uninterrupted run |
|
|
26
|
+
| Two composes deep | `ready_interrupt_steps == [[:c, :c, :approve]]` (path through both composes) |
|
|
27
|
+
| `continue` with `:approve` or a wrong path | `ValidationError` listing `[[:c, :approve]]`; still `paused` |
|
|
28
|
+
| `Child.continue(id: <child id>, …)` | `ValidationError` "is a composed child" |
|
|
29
|
+
| `Root.undo(id)` while paused | `undo:child.c1` before `undo:r1`, each once; `cancelled` |
|
|
30
|
+
| Invalid payload, `max_attempts: 1` | rolled back from the root; `failed` |
|
|
31
|
+
| `resume: :background` on the child interrupt | root's worker runs the remainder; the child is never enqueued |
|
|
32
|
+
| `interrupt` inside a map element (direct or via compose) | element fails with "not supported inside a map element" |
|
|
33
|
+
|
|
34
|
+
## 2. Demo app (Constitution VI)
|
|
35
|
+
|
|
36
|
+
Fast local loop (test Redis DB 5, no Docker):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
cd demo_app
|
|
40
|
+
REDIS_URL=redis://localhost:6780/5 RAILS_ENV=test bundle exec rspec spec/reactors/composed_approval_reactor_spec.rb
|
|
41
|
+
REDIS_URL=redis://localhost:6780/5 bin/rails demo:composed_interrupt
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The rake task prints the pause (status, root id, pending path `[:approval, :wait_for_manager]`),
|
|
45
|
+
then the outcome of an approved resume (`completed`) and of a rejected one (`undo` → `cancelled`,
|
|
46
|
+
with the child's step undone).
|
|
47
|
+
|
|
48
|
+
Acceptance run (isolated compose project; see the per-worktree note in the repo memory):
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
docker compose -p rr_interrupt_compose -f docker-compose.yml -f <override.yml> up -d --build demo-redis demo-sidekiq
|
|
52
|
+
docker compose -p rr_interrupt_compose -f docker-compose.yml -f <override.yml> \
|
|
53
|
+
run --rm --no-deps demo-app bash -c "bin/rails db:prepare && bin/rails demo:composed_interrupt"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 3. Docs check
|
|
57
|
+
|
|
58
|
+
- `documentation/composition.md`: the "not supported yet" sentence is gone, and the nested pause
|
|
59
|
+
is described.
|
|
60
|
+
- `documentation/interrupts.md`: has a "Interrupts inside composed reactors" section with the path
|
|
61
|
+
form, undo and correlation behavior.
|
|
62
|
+
- `README.md` Interrupts section: one line pointing to it.
|
|
63
|
+
- `CHANGELOG.md` *Unreleased → Features*: the entry is there.
|
|
64
|
+
- `specs/future_improvements.md`: the "Interrupt inside a composed child" entry is removed.
|