ruby_reactor 0.8.6 → 0.8.7
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 +24 -0
- data/CLAUDE.md +1 -1
- data/README.md +4 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +6 -0
- 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/reactor.rb +72 -22
- data/lib/ruby_reactor/rspec/matchers.rb +15 -16
- data/lib/ruby_reactor/rspec/test_subject.rb +8 -18
- data/lib/ruby_reactor/step/compose_step.rb +10 -0
- data/lib/ruby_reactor/version.rb +1 -1
- 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/future_improvements.md +0 -12
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a0d1aa7711547d66d5a7f19acf5e0b13c7e208a9660d3f1714eb8df2e7eff004
|
|
4
|
+
data.tar.gz: a0b52b6139f80b5133f9585f39ff5fe227e46b34dfb9d1b8767bf4f068e5678f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5457e6cf85bb33a4bd12f00161d810a6dc491a590c3cefaaf2c5c967987d78e783e199c77bdff77d2b2745f569098c9492d07f4aebb0a7ad74f023bc5274f525
|
|
7
|
+
data.tar.gz: 58ad78fe9e8e1c1e6b8b7106c12c384534aae6db6747860202cfb38dd75e3922f35c88b65f91320cfaef9fb121b6b0cae63dce4f42c677a5ae7ee4c6b0ff91b6
|
data/.specify/feature.json
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -241,6 +241,23 @@ Every breaking or shape-changing item of the rollback work, with what to change.
|
|
|
241
241
|
|
|
242
242
|
### Features
|
|
243
243
|
|
|
244
|
+
* **An `interrupt` inside a composed child pauses the top-level run.** Before, the compose step
|
|
245
|
+
failed with `NoMethodError` and the run rolled back. Now the top-level run is stored `paused`, at
|
|
246
|
+
any compose depth and after a `fan_out` map in the child, and the paused result carries its id.
|
|
247
|
+
Resume it on the top-level class by the interrupt's step path, an Array of the compose step names
|
|
248
|
+
then the interrupt (`continue(id:, payload:, step_name: [:approval, :wait_for_manager])`), by id
|
|
249
|
+
or by the child interrupt's correlation id. Payload validation, `max_attempts`,
|
|
250
|
+
`resume: :background` and the resume guards apply as for a top-level interrupt. A resume
|
|
251
|
+
contended on the child's own `with_lock`/`with_semaphore` fails and rolls back the run. Only
|
|
252
|
+
contention on the top-level reactor's lock leaves it paused. `Reactor.undo`
|
|
253
|
+
of the paused run rolls back the child's completed steps, then the parent's. A composed child
|
|
254
|
+
refuses a `continue` of its own. New `Reactor#ready_interrupt_steps` lists the pending
|
|
255
|
+
interrupts (Symbols, and paths for nested ones). `be_paused_at`, `have_ready_interrupts` and
|
|
256
|
+
`TestSubject#resume(step:)` accept paths. A `continue` must now name a pending interrupt: naming
|
|
257
|
+
another ready step, which used to store the payload as that step's result, raises
|
|
258
|
+
`ValidationError`. An `interrupt` inside a `map` element now fails that element with "not
|
|
259
|
+
supported inside a map element" instead of `NoMethodError`.
|
|
260
|
+
See [Interrupts inside composed reactors](documentation/interrupts.md#interrupts-inside-composed-reactors).
|
|
244
261
|
* **`fan_out` without `batch_size` is back-pressured: at most 50 element jobs per throw**
|
|
245
262
|
(`RubyReactor::Map::DEFAULT_BATCH_SIZE`), forward and rollback. Before, every element was
|
|
246
263
|
enqueued at once. A map of more than 50 elements with no declared `batch_size` now runs in
|
|
@@ -444,6 +461,13 @@ Every breaking or shape-changing item of the rollback work, with what to change.
|
|
|
444
461
|
* Docs: a park keeps each level's lock without a second `:lock_acquired` only while the gap stays
|
|
445
462
|
within the lock's `ttl`; a lapsed lock is acquired again.
|
|
446
463
|
|
|
464
|
+
## [0.8.7](https://github.com/arturictus/ruby_reactor/compare/v0.8.6...v0.8.7) (2026-10-08)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
### Features
|
|
468
|
+
|
|
469
|
+
* add support for interrupts inside composed children ([#71](https://github.com/arturictus/ruby_reactor/issues/71)) ([7be968e](https://github.com/arturictus/ruby_reactor/commit/7be968eb5dbfc6458e9419d8c6fddbf9d89cc80e))
|
|
470
|
+
|
|
447
471
|
## [0.8.6](https://github.com/arturictus/ruby_reactor/compare/v0.8.5...v0.8.6) (2026-10-08)
|
|
448
472
|
|
|
449
473
|
|
data/CLAUDE.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!-- SPECKIT START -->
|
|
2
2
|
For additional context about technologies to be used, project structure,
|
|
3
3
|
shell commands, and other important information, read the current plan
|
|
4
|
-
at specs/
|
|
4
|
+
at specs/010-interrupt-in-composed-child/plan.md
|
|
5
5
|
<!-- SPECKIT END -->
|
data/README.md
CHANGED
|
@@ -704,6 +704,10 @@ ApprovalReactor.continue_by_correlation_id(
|
|
|
704
704
|
)
|
|
705
705
|
```
|
|
706
706
|
|
|
707
|
+
An interrupt inside a `compose`d child pauses the top-level run; resume it there by the step path,
|
|
708
|
+
e.g. `step_name: [:approval, :wait_for_manager]`. See
|
|
709
|
+
[Interrupts inside composed reactors](documentation/interrupts.md#interrupts-inside-composed-reactors).
|
|
710
|
+
|
|
707
711
|
### Locks, Semaphores & Ordered Locks
|
|
708
712
|
|
|
709
713
|
Coordinate across processes with Redis-backed primitives:
|
|
@@ -41,7 +41,13 @@ module RubyReactor
|
|
|
41
41
|
@rollback_failures.replace(Array(ContextSerializer.deserialize_value(list)))
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
# A step that paused (a compose whose child hit an interrupt) is already
|
|
45
|
+
# on top as a partial run; its re-run replaces that entry, so the step is
|
|
46
|
+
# undone once (010 R-04).
|
|
47
|
+
# ponytail: assumes the paused step's entry is on top when it re-runs,
|
|
48
|
+
# which holds because a run stops at a pause and resumes at that step.
|
|
44
49
|
def add_to_undo_stack(step_info)
|
|
50
|
+
undo_stack.pop if undo_stack.last && undo_stack.last[:step].name == step_info[:step].name
|
|
45
51
|
@context.undo_stack << step_info
|
|
46
52
|
end
|
|
47
53
|
|
|
@@ -27,6 +27,8 @@ module RubyReactor
|
|
|
27
27
|
handle_retries_exhausted(step_config, result, resolved_arguments)
|
|
28
28
|
when RubyReactor::Failure
|
|
29
29
|
handle_failure(step_config, result, resolved_arguments)
|
|
30
|
+
when RubyReactor::InterruptResult
|
|
31
|
+
handle_interrupted(step_config, resolved_arguments)
|
|
30
32
|
else
|
|
31
33
|
handle_unknown_result(step_config, result, resolved_arguments)
|
|
32
34
|
end
|
|
@@ -148,6 +150,20 @@ module RubyReactor
|
|
|
148
150
|
result
|
|
149
151
|
end
|
|
150
152
|
|
|
153
|
+
# A composed child paused at an interrupt (010): the step stays
|
|
154
|
+
# incomplete, and this run pauses on it. `with_step` cleared
|
|
155
|
+
# `current_step` on the way out; it is the run's resume cursor. The
|
|
156
|
+
# child's completed steps are only undone through this step, so it joins
|
|
157
|
+
# the undo stack as a partial run, as an interrupted compose does (R-04).
|
|
158
|
+
def handle_interrupted(step_config, resolved_arguments)
|
|
159
|
+
@context.current_step = step_config.name
|
|
160
|
+
return unless step_config.undoes_partial_run?
|
|
161
|
+
|
|
162
|
+
@compensation_manager.add_to_undo_stack({ step: step_config,
|
|
163
|
+
arguments: step_config.rollback_arguments(resolved_arguments),
|
|
164
|
+
result: RubyReactor.Success(nil) })
|
|
165
|
+
end
|
|
166
|
+
|
|
151
167
|
# A step returned `RubyReactor.Skipped(...)`: the run continues exactly as
|
|
152
168
|
# for a Success (value, hand-off, period mark), but the step is NOT pushed
|
|
153
169
|
# for undo — it had nothing to do, so there is nothing to revert (008 R-19).
|
|
@@ -108,8 +108,8 @@ module RubyReactor
|
|
|
108
108
|
result
|
|
109
109
|
when RubyReactor::Failure
|
|
110
110
|
handle_failure_result(step_config, reactor_class, result)
|
|
111
|
-
when RetryQueuedResult, RubyReactor::DispatchResult
|
|
112
|
-
# Pass through async results
|
|
111
|
+
when RetryQueuedResult, RubyReactor::DispatchResult, RubyReactor::InterruptResult
|
|
112
|
+
# Pass through async results, and a composed child's pause
|
|
113
113
|
result
|
|
114
114
|
else
|
|
115
115
|
clear_retry_state
|
|
@@ -431,6 +431,15 @@ module RubyReactor
|
|
|
431
431
|
return RubyReactor.Success(result)
|
|
432
432
|
end
|
|
433
433
|
|
|
434
|
+
# A map element cannot pause: nothing would resume it. The step fails
|
|
435
|
+
# like any other, rolling back what the element completed (010 R-08).
|
|
436
|
+
if inside_map_element?
|
|
437
|
+
failure = RubyReactor::Failure("interrupt :#{step_config.name} is not supported inside a map element",
|
|
438
|
+
step_name: step_config.name, reactor_name: @reactor_class.name,
|
|
439
|
+
retryable: false)
|
|
440
|
+
return @result_handler.handle_step_result(step_config, failure, {})
|
|
441
|
+
end
|
|
442
|
+
|
|
434
443
|
# We are pausing
|
|
435
444
|
correlation_id = nil
|
|
436
445
|
correlation_id = step_config.correlation_id_block.call(@context) if step_config.correlation_id_block
|
|
@@ -445,6 +454,17 @@ module RubyReactor
|
|
|
445
454
|
)
|
|
446
455
|
end
|
|
447
456
|
|
|
457
|
+
# This run, or one it is composed into, is a map element.
|
|
458
|
+
def inside_map_element?
|
|
459
|
+
context = @context
|
|
460
|
+
while context
|
|
461
|
+
return true if context.map_metadata
|
|
462
|
+
|
|
463
|
+
context = context.parent_context
|
|
464
|
+
end
|
|
465
|
+
false
|
|
466
|
+
end
|
|
467
|
+
|
|
448
468
|
def configuration
|
|
449
469
|
RubyReactor::Configuration.instance
|
|
450
470
|
end
|
data/lib/ruby_reactor/reactor.rb
CHANGED
|
@@ -38,6 +38,14 @@ module RubyReactor
|
|
|
38
38
|
find(context_id)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
# How a resume names an interrupt: a Symbol for one of the reactor's own,
|
|
42
|
+
# or its step path from the root (an Array: the compose step names, then
|
|
43
|
+
# the interrupt) for one inside a composed child (010).
|
|
44
|
+
def self.interrupt_key(step_name)
|
|
45
|
+
path = Array(step_name).map(&:to_sym)
|
|
46
|
+
path.one? ? path.first : path
|
|
47
|
+
end
|
|
48
|
+
|
|
41
49
|
def self.continue(id:, payload:, step_name:, idempotency_key: nil)
|
|
42
50
|
reactor = find(id)
|
|
43
51
|
result = reactor.continue(payload: payload, step_name: step_name, idempotency_key: idempotency_key)
|
|
@@ -154,6 +162,11 @@ module RubyReactor
|
|
|
154
162
|
raise Error::ValidationError, "Cannot resume: context does not have a current step (was it interrupted?)"
|
|
155
163
|
end
|
|
156
164
|
|
|
165
|
+
# A composed child pauses only inside its root, which owns the run (010 R-07).
|
|
166
|
+
if @context.private_data[:composed] || @context.private_data["composed"]
|
|
167
|
+
raise Error::ValidationError, "Cannot resume: #{self.class.name} is a composed child; continue its root run"
|
|
168
|
+
end
|
|
169
|
+
|
|
157
170
|
if @context.cancelled
|
|
158
171
|
raise Error::ValidationError,
|
|
159
172
|
"Cannot resume: reactor has been cancelled (Reason: #{@context.cancellation_reason})"
|
|
@@ -167,19 +180,17 @@ module RubyReactor
|
|
|
167
180
|
"Cannot resume: the reactor is #{@context.status}, not paused at an interrupt"
|
|
168
181
|
end
|
|
169
182
|
|
|
170
|
-
|
|
183
|
+
path = Array(self.class.interrupt_key(step_name))
|
|
184
|
+
target_context, step_config = resolve_interrupt_target!(path)
|
|
171
185
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
end
|
|
186
|
+
failure = validate_continue_payload(payload, step_config, path)
|
|
187
|
+
return failure if failure
|
|
175
188
|
|
|
176
|
-
|
|
177
|
-
@context.set_result(target_step, payload)
|
|
189
|
+
target_context.set_result(path.last, payload) # the context that paused (010 R-06)
|
|
178
190
|
|
|
179
191
|
# `interrupt :x, resume: :background` — payload is validated and stored
|
|
180
192
|
# (above, in this process); the remaining work goes to a worker instead
|
|
181
193
|
# of running inline in the delivering process.
|
|
182
|
-
step_config = self.class.steps[step_name.to_sym]
|
|
183
194
|
if step_config.respond_to?(:background_resume?) && step_config.background_resume?
|
|
184
195
|
return @result = enqueue_background_resume
|
|
185
196
|
end
|
|
@@ -244,6 +255,15 @@ module RubyReactor
|
|
|
244
255
|
save_context
|
|
245
256
|
end
|
|
246
257
|
|
|
258
|
+
# The interrupts this paused run can be resumed at (010 R-05): a Symbol
|
|
259
|
+
# for one of this reactor's own, and the step path from here (the compose
|
|
260
|
+
# step names, then the interrupt) for one inside a composed child.
|
|
261
|
+
def ready_interrupt_steps
|
|
262
|
+
return [] unless @context.status.to_s == "paused"
|
|
263
|
+
|
|
264
|
+
pending_interrupts(self.class, @context, [])
|
|
265
|
+
end
|
|
266
|
+
|
|
247
267
|
def validate!
|
|
248
268
|
# Validate reactor configuration
|
|
249
269
|
validate_steps!
|
|
@@ -434,39 +454,69 @@ module RubyReactor
|
|
|
434
454
|
@undo_trace = executor.undo_trace
|
|
435
455
|
end
|
|
436
456
|
|
|
437
|
-
|
|
438
|
-
|
|
457
|
+
# The context that paused at the interrupt `path` names, and that
|
|
458
|
+
# interrupt's config. A one-name path is a step of this reactor; a longer
|
|
459
|
+
# one names compose steps from here down, then the interrupt (010 R-05).
|
|
460
|
+
def resolve_interrupt_target!(path)
|
|
461
|
+
pending = ready_interrupt_steps
|
|
462
|
+
key = path.one? ? path.first : path
|
|
463
|
+
unless pending.include?(key)
|
|
464
|
+
raise Error::ValidationError,
|
|
465
|
+
"Cannot resume: expected step '#{@context.current_step}' " \
|
|
466
|
+
"or ready steps #{pending.inspect} but got '#{key}'"
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
target = path[0...-1].reduce(@context) { |context, name| paused_child(context, name) }
|
|
470
|
+
[target, target.reactor_class.steps[path.last]]
|
|
471
|
+
end
|
|
439
472
|
|
|
440
|
-
|
|
441
|
-
graph_manager = Executor::GraphManager.new(
|
|
473
|
+
def pending_interrupts(reactor_class, context, prefix)
|
|
474
|
+
graph_manager = Executor::GraphManager.new(reactor_class, DependencyGraph.new, context)
|
|
442
475
|
graph_manager.build_and_validate!
|
|
443
476
|
graph_manager.mark_completed_steps_from_context
|
|
444
|
-
ready_steps = graph_manager.dependency_graph.ready_steps.map(&:name).map(&:to_s)
|
|
445
477
|
|
|
446
|
-
|
|
478
|
+
# The interrupt a context paused at stays pending even once a resume
|
|
479
|
+
# stored its payload: that resume was contended and never ran.
|
|
480
|
+
ready = graph_manager.dependency_graph.ready_steps
|
|
481
|
+
paused_at = reactor_class.steps[context.current_step&.to_sym]
|
|
482
|
+
ready += [paused_at] if paused_at&.interrupt? && ready.none? { |step| step.name == paused_at.name }
|
|
483
|
+
|
|
484
|
+
ready.flat_map do |step_config|
|
|
485
|
+
name = step_config.name.to_sym
|
|
486
|
+
if step_config.respond_to?(:interrupt?) && step_config.interrupt?
|
|
487
|
+
[prefix.empty? ? name : prefix + [name]]
|
|
488
|
+
elsif (child = paused_child(context, name))
|
|
489
|
+
pending_interrupts(child.reactor_class, child, prefix + [name])
|
|
490
|
+
else
|
|
491
|
+
[]
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
end
|
|
447
495
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
496
|
+
def paused_child(context, step_name)
|
|
497
|
+
entry = context.composed_contexts[step_name] || context.composed_contexts[step_name.to_s]
|
|
498
|
+
child = entry.is_a?(Hash) && (entry[:context] || entry["context"])
|
|
499
|
+
child if child.is_a?(Context) && child.status.to_s == "paused"
|
|
451
500
|
end
|
|
452
501
|
|
|
453
|
-
def validate_continue_payload(payload,
|
|
454
|
-
step_config = self.class.steps[step_name.to_sym]
|
|
502
|
+
def validate_continue_payload(payload, step_config, path)
|
|
455
503
|
return unless step_config&.validation_schema
|
|
456
504
|
|
|
505
|
+
# A nested interrupt counts apart from a root step of the same name.
|
|
506
|
+
step_name = path.one? ? path.first : path.join(".").to_sym
|
|
507
|
+
|
|
457
508
|
validation = step_config.validation_schema.call(payload)
|
|
458
509
|
|
|
459
510
|
return unless validation.failure?
|
|
460
511
|
|
|
461
512
|
# Track attempts
|
|
462
|
-
step_key = step_name.to_sym
|
|
463
513
|
@context.private_data[:interrupt_attempts] ||= {}
|
|
464
|
-
@context.private_data[:interrupt_attempts][
|
|
465
|
-
@context.private_data[:interrupt_attempts][
|
|
514
|
+
@context.private_data[:interrupt_attempts][step_name] ||= 0
|
|
515
|
+
@context.private_data[:interrupt_attempts][step_name] += 1
|
|
466
516
|
|
|
467
517
|
save_context # Persist the attempt count
|
|
468
518
|
|
|
469
|
-
current_attempts = @context.private_data[:interrupt_attempts][
|
|
519
|
+
current_attempts = @context.private_data[:interrupt_attempts][step_name]
|
|
470
520
|
max_attempts = step_config.max_attempts
|
|
471
521
|
|
|
472
522
|
if max_attempts != :infinity && current_attempts >= max_attempts
|
|
@@ -210,50 +210,49 @@ module RubyReactor
|
|
|
210
210
|
# Matcher to check if reactor is paused at a specific interrupt step
|
|
211
211
|
# Works with both single and multiple concurrent interrupts
|
|
212
212
|
matcher :be_paused_at do |*step_names|
|
|
213
|
+
# A step inside a composed child is named by its path, e.g. `[:fulfil, :approve]`.
|
|
214
|
+
expected = -> { step_names.map { |name| RubyReactor::Reactor.interrupt_key(name).inspect }.join(", ") }
|
|
215
|
+
|
|
213
216
|
match do |subject|
|
|
214
217
|
subject.ensure_executed!
|
|
215
218
|
return false unless subject.paused?
|
|
216
219
|
|
|
217
220
|
ready_steps = subject.ready_interrupt_steps
|
|
218
|
-
step_names.all? { |name| ready_steps.include?(name
|
|
221
|
+
step_names.all? { |name| ready_steps.include?(RubyReactor::Reactor.interrupt_key(name)) }
|
|
219
222
|
end
|
|
220
223
|
|
|
221
224
|
failure_message do |subject|
|
|
222
225
|
if subject.paused?
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
"expected reactor to be paused at :#{step_names.first}, " \
|
|
226
|
-
"but ready interrupt steps are: #{ready_steps.inspect}"
|
|
227
|
-
else
|
|
228
|
-
"expected reactor to be paused at #{step_names.map { |s| ":#{s}" }.join(", ")}, " \
|
|
229
|
-
"but ready interrupt steps are: #{ready_steps.inspect}"
|
|
230
|
-
end
|
|
226
|
+
"expected reactor to be paused at #{expected.call}, " \
|
|
227
|
+
"but ready interrupt steps are: #{subject.ready_interrupt_steps.inspect}"
|
|
231
228
|
else
|
|
232
|
-
"expected reactor to be paused at #{
|
|
229
|
+
"expected reactor to be paused at #{expected.call}, " \
|
|
233
230
|
"but status was #{subject.reactor_instance.context.status}"
|
|
234
231
|
end
|
|
235
232
|
end
|
|
236
233
|
|
|
237
234
|
failure_message_when_negated do |_subject|
|
|
238
|
-
"expected reactor not to be paused at #{
|
|
235
|
+
"expected reactor not to be paused at #{expected.call}, but it is"
|
|
239
236
|
end
|
|
240
237
|
end
|
|
241
238
|
|
|
242
239
|
# Matcher to check the exact set of ready interrupt steps
|
|
243
240
|
matcher :have_ready_interrupts do |*expected_steps|
|
|
241
|
+
# A step inside a composed child is named by its path, e.g. `[:fulfil, :approve]`.
|
|
242
|
+
expected = -> { expected_steps.map { |name| RubyReactor::Reactor.interrupt_key(name) } }
|
|
243
|
+
|
|
244
244
|
match do |subject|
|
|
245
245
|
subject.ensure_executed!
|
|
246
246
|
return false unless subject.paused?
|
|
247
247
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
actual_steps == expected
|
|
248
|
+
# Symbols and paths (Arrays) do not compare: sort by their inspect.
|
|
249
|
+
subject.ready_interrupt_steps.sort_by(&:inspect) == expected.call.sort_by(&:inspect)
|
|
251
250
|
end
|
|
252
251
|
|
|
253
252
|
failure_message do |subject|
|
|
254
253
|
if subject.paused?
|
|
255
254
|
actual_steps = subject.ready_interrupt_steps
|
|
256
|
-
"expected ready interrupt steps to be #{
|
|
255
|
+
"expected ready interrupt steps to be #{expected.call}, " \
|
|
257
256
|
"but got #{actual_steps.inspect}"
|
|
258
257
|
else
|
|
259
258
|
"expected reactor to be paused with ready interrupt steps, " \
|
|
@@ -263,7 +262,7 @@ module RubyReactor
|
|
|
263
262
|
|
|
264
263
|
failure_message_when_negated do |subject|
|
|
265
264
|
actual_steps = subject.ready_interrupt_steps
|
|
266
|
-
"expected ready interrupt steps not to be #{
|
|
265
|
+
"expected ready interrupt steps not to be #{expected.call}, " \
|
|
267
266
|
"but it was #{actual_steps.inspect}"
|
|
268
267
|
end
|
|
269
268
|
end
|
|
@@ -426,29 +426,19 @@ module RubyReactor
|
|
|
426
426
|
# Get all ready interrupt steps (steps that can be resumed)
|
|
427
427
|
# This is useful when multiple interrupts are waiting concurrently.
|
|
428
428
|
#
|
|
429
|
-
# @return [Array<Symbol
|
|
429
|
+
# @return [Array<Symbol, Array<Symbol>>] the reactor's own interrupts by
|
|
430
|
+
# name, and one inside a composed child by its step path
|
|
431
|
+
# (e.g. `[:fulfil, :approve]`)
|
|
430
432
|
def ready_interrupt_steps
|
|
431
433
|
ensure_executed!
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
# Build the dependency graph and get ready steps
|
|
435
|
-
graph = RubyReactor::DependencyGraph.new
|
|
436
|
-
graph_manager = RubyReactor::Executor::GraphManager.new(
|
|
437
|
-
@reactor_class, graph, @reactor_instance.context
|
|
438
|
-
)
|
|
439
|
-
graph_manager.build_and_validate!
|
|
440
|
-
graph_manager.mark_completed_steps_from_context
|
|
441
|
-
|
|
442
|
-
# Filter to only interrupt steps (using interrupt? predicate method)
|
|
443
|
-
ready = graph_manager.dependency_graph.ready_steps
|
|
444
|
-
ready.select { |step_config| step_config.respond_to?(:interrupt?) && step_config.interrupt? }
|
|
445
|
-
.map { |step_config| step_config.name.to_sym }
|
|
434
|
+
@reactor_instance.ready_interrupt_steps
|
|
446
435
|
end
|
|
447
436
|
|
|
448
437
|
# Resume a paused reactor with the given payload
|
|
449
438
|
#
|
|
450
439
|
# @param payload [Hash] The data to provide to the interrupt step
|
|
451
|
-
# @param step [Symbol, String, nil] The specific interrupt step to resume
|
|
440
|
+
# @param step [Symbol, String, Array, nil] The specific interrupt step to resume,
|
|
441
|
+
# or the step path of one inside a composed child (e.g. `[:fulfil, :approve]`).
|
|
452
442
|
# Required when multiple interrupts are ready. If not provided and only
|
|
453
443
|
# one interrupt is ready, that step will be used.
|
|
454
444
|
# @return [TestSubject] self for chaining and introspection
|
|
@@ -483,10 +473,10 @@ module RubyReactor
|
|
|
483
473
|
|
|
484
474
|
if step
|
|
485
475
|
# User explicitly specified a step
|
|
486
|
-
step_sym = step
|
|
476
|
+
step_sym = RubyReactor::Reactor.interrupt_key(step)
|
|
487
477
|
unless ready_steps.include?(step_sym)
|
|
488
478
|
raise RubyReactor::Error::ValidationError,
|
|
489
|
-
"Cannot resume: step
|
|
479
|
+
"Cannot resume: step #{step_sym.inspect} is not ready. Ready steps: #{ready_steps.inspect}"
|
|
490
480
|
end
|
|
491
481
|
step_sym
|
|
492
482
|
elsif ready_steps.size == 1
|
|
@@ -74,6 +74,8 @@ module RubyReactor
|
|
|
74
74
|
unless child_context
|
|
75
75
|
composed_inputs = build_composed_inputs(inputs.argument_mappings || {})
|
|
76
76
|
child_context = RubyReactor::Context.new(composed_inputs, inputs.composed_reactor_class)
|
|
77
|
+
# Resumed only through this run, never on its own (010 R-07).
|
|
78
|
+
child_context.private_data[:composed] = true
|
|
77
79
|
end
|
|
78
80
|
|
|
79
81
|
link_contexts(child_context, context)
|
|
@@ -120,6 +122,14 @@ module RubyReactor
|
|
|
120
122
|
# the metadata the direct and async paths do propagate.
|
|
121
123
|
return result if result.is_a?(RubyReactor::Failure)
|
|
122
124
|
|
|
125
|
+
# The child paused at an interrupt: so does this run. Each compose
|
|
126
|
+
# level re-stamps its own id, so the top-level result names the root.
|
|
127
|
+
if result.is_a?(RubyReactor::InterruptResult)
|
|
128
|
+
return RubyReactor::InterruptResult.new(execution_id: context.context_id,
|
|
129
|
+
correlation_id: result.correlation_id,
|
|
130
|
+
intermediate_results: context.intermediate_results)
|
|
131
|
+
end
|
|
132
|
+
|
|
123
133
|
result.success? ? RubyReactor.Success(result.value) : RubyReactor.Failure(result.error)
|
|
124
134
|
end
|
|
125
135
|
end
|
data/lib/ruby_reactor/version.rb
CHANGED
|
@@ -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.
|