ruby_reactor 0.6.0 → 0.7.1
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/.claude/skills/demo-app-e2e-verify/SKILL.md +226 -0
- data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +79 -12
- data/.specify/templates/tasks-template.md +7 -0
- data/CHANGELOG.md +125 -1
- data/CLAUDE.md +5 -0
- data/README.md +155 -25
- data/lib/ruby_reactor/adapters/active_job/router.rb +21 -6
- data/lib/ruby_reactor/adapters/active_job/step_worker.rb +20 -0
- data/lib/ruby_reactor/adapters/active_job/worker.rb +6 -1
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -6
- data/lib/ruby_reactor/adapters/sidekiq/step_worker.rb +17 -0
- data/lib/ruby_reactor/adapters/sidekiq/worker.rb +5 -2
- data/lib/ruby_reactor/async_waiter.rb +113 -0
- data/lib/ruby_reactor/configuration.rb +30 -1
- data/lib/ruby_reactor/context.rb +28 -0
- data/lib/ruby_reactor/context_serializer.rb +15 -0
- data/lib/ruby_reactor/dsl/async_macros.rb +176 -0
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +66 -0
- data/lib/ruby_reactor/dsl/compose_builder.rb +23 -4
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +3 -2
- data/lib/ruby_reactor/dsl/interrupt_step_config.rb +7 -1
- data/lib/ruby_reactor/dsl/lockable.rb +3 -3
- data/lib/ruby_reactor/dsl/map_builder.rb +1 -2
- data/lib/ruby_reactor/dsl/reactor.rb +29 -10
- data/lib/ruby_reactor/dsl/step_builder.rb +30 -11
- data/lib/ruby_reactor/dsl/template_helpers.rb +11 -3
- data/lib/ruby_reactor/error/async_result_pending.rb +21 -0
- data/lib/ruby_reactor/error/async_wait_timeout_error.rb +10 -0
- data/lib/ruby_reactor/error/deprecated_dsl_error.rb +11 -0
- data/lib/ruby_reactor/executor/async_step_dispatch.rb +110 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +11 -11
- data/lib/ruby_reactor/executor/result_handler.rb +47 -11
- data/lib/ruby_reactor/executor/retry_manager.rb +13 -6
- data/lib/ruby_reactor/executor/step_executor.rb +77 -24
- data/lib/ruby_reactor/executor.rb +179 -23
- data/lib/ruby_reactor/lock.rb +19 -0
- data/lib/ruby_reactor/map/element_executor.rb +13 -2
- data/lib/ruby_reactor/map/helpers.rb +10 -8
- data/lib/ruby_reactor/map/result_enumerator.rb +7 -1
- data/lib/ruby_reactor/map/result_summary.rb +63 -0
- data/lib/ruby_reactor/map/sweeper.rb +1 -1
- data/lib/ruby_reactor/open_telemetry.rb +8 -5
- data/lib/ruby_reactor/ordered_lock.rb +3 -3
- data/lib/ruby_reactor/reactor.rb +25 -2
- data/lib/ruby_reactor/rspec/matchers.rb +61 -11
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +2 -1
- data/lib/ruby_reactor/rspec/step_executor_patch.rb +2 -2
- data/lib/ruby_reactor/rspec/test_subject.rb +74 -19
- data/lib/ruby_reactor/semaphore.rb +10 -0
- data/lib/ruby_reactor/step/async_reactor_step.rb +207 -0
- data/lib/ruby_reactor/step/compose_step.rb +1 -1
- data/lib/ruby_reactor/step/map_step.rb +6 -2
- data/lib/ruby_reactor/step.rb +10 -4
- data/lib/ruby_reactor/step_signals.rb +33 -0
- data/lib/ruby_reactor/step_sweeper.rb +72 -0
- data/lib/ruby_reactor/step_worker.rb +260 -0
- data/lib/ruby_reactor/storage/adapter.rb +26 -1
- data/lib/ruby_reactor/storage/redis_adapter.rb +4 -71
- data/lib/ruby_reactor/storage/redis_locking.rb +7 -0
- data/lib/ruby_reactor/storage/redis_pub_sub.rb +31 -0
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
- data/lib/ruby_reactor/storage/redis_step_results.rb +49 -0
- data/lib/ruby_reactor/sweeper.rb +7 -1
- data/lib/ruby_reactor/sweeper_job.rb +1 -0
- data/lib/ruby_reactor/template/result.rb +151 -5
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +117 -22
- data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
- data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor/worker.rb +53 -3
- data/lib/ruby_reactor.rb +80 -10
- data/specs/active_job.md +1 -1
- metadata +23 -3
- data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +0 -1
- data/lib/ruby_reactor/web/public/assets/index-D7IBZvos.js +0 -21
|
@@ -96,7 +96,13 @@ module RubyReactor
|
|
|
96
96
|
|
|
97
97
|
def wrap_result(result)
|
|
98
98
|
if result.is_a?(Hash) && result.key?("_error")
|
|
99
|
-
|
|
99
|
+
# `backtrace: []` is load-bearing: without it Failure falls back to
|
|
100
|
+
# `caller`, so a bare-message element failure would carry the stack of
|
|
101
|
+
# whoever happened to materialize the enumerator (the JSON encoder, in
|
|
102
|
+
# the dashboard's case) instead of the stack of the step that failed.
|
|
103
|
+
RubyReactor::Failure.new(result["_error"], backtrace: [])
|
|
104
|
+
elsif result.is_a?(Hash) && result.key?("_halt")
|
|
105
|
+
RubyReactor::Halt.new(reason: result["reason"])
|
|
100
106
|
else
|
|
101
107
|
RubyReactor::Success.new(ContextSerializer.deserialize_value(result))
|
|
102
108
|
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Map
|
|
5
|
+
# A map's result set is unbounded, so the API describes it instead of
|
|
6
|
+
# inlining it. Inlining meant the JSON encoder materialized the lazy
|
|
7
|
+
# ResultEnumerator itself — one storage read per element — and rendered every
|
|
8
|
+
# element failure as an opaque blob with no count of how many there were.
|
|
9
|
+
class ResultSummary
|
|
10
|
+
# How many failed elements are described in full. The rest are only
|
|
11
|
+
# counted — a map failing 50k elements must not produce a 50k-entry
|
|
12
|
+
# response.
|
|
13
|
+
SAMPLE_LIMIT = 25
|
|
14
|
+
|
|
15
|
+
# Sampled failures are a list, not a detail view, and the same summary is
|
|
16
|
+
# echoed in intermediate_results, the execution trace and the undo stack —
|
|
17
|
+
# full traces on each would triple a large payload.
|
|
18
|
+
BACKTRACE_FRAMES = 10
|
|
19
|
+
|
|
20
|
+
# ponytail: one windowed read of the whole map; paginate if maps grow past
|
|
21
|
+
# what fits in a single response.
|
|
22
|
+
def self.build(enumerator)
|
|
23
|
+
total = enumerator.count
|
|
24
|
+
results = RubyReactor.configuration.storage_adapter.retrieve_map_results_batch(
|
|
25
|
+
enumerator.map_id, enumerator.reactor_class_name, offset: 0, limit: total
|
|
26
|
+
)
|
|
27
|
+
# Slots are index-keyed and the window starts at 0, so a full read means
|
|
28
|
+
# position == element index. A short read means the map is still filling
|
|
29
|
+
# in and gaps would shift positions — then indices are omitted.
|
|
30
|
+
failures = collect_failures(results, indexed: results.size == total)
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
"_type" => "map_results",
|
|
34
|
+
"total" => total,
|
|
35
|
+
"succeeded" => total - failures[:count],
|
|
36
|
+
"failed" => failures[:count],
|
|
37
|
+
"failures" => failures[:sample],
|
|
38
|
+
"failures_truncated" => failures[:count] > failures[:sample].size
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.collect_failures(results, indexed:)
|
|
43
|
+
count = 0
|
|
44
|
+
sample = []
|
|
45
|
+
|
|
46
|
+
results.each_with_index do |raw, position|
|
|
47
|
+
next unless raw.is_a?(Hash) && raw.key?("_error")
|
|
48
|
+
|
|
49
|
+
count += 1
|
|
50
|
+
next if sample.size >= SAMPLE_LIMIT
|
|
51
|
+
|
|
52
|
+
entry = ContextSerializer.simplify_for_api(Failure.new(raw["_error"], backtrace: []))
|
|
53
|
+
entry["backtrace"] = Array(entry["backtrace"]).first(BACKTRACE_FRAMES)
|
|
54
|
+
entry["index"] = position if indexed
|
|
55
|
+
sample << entry
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
{ count: count, sample: sample }
|
|
59
|
+
end
|
|
60
|
+
private_class_method :collect_failures
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -92,7 +92,7 @@ module RubyReactor
|
|
|
92
92
|
|
|
93
93
|
results = data["intermediate_results"] || {}
|
|
94
94
|
status = data["status"].to_s
|
|
95
|
-
results.key?(meta["step_name"].to_s) || %w[completed failed skipped].include?(status)
|
|
95
|
+
results.key?(meta["step_name"].to_s) || %w[completed failed halted skipped].include?(status)
|
|
96
96
|
end
|
|
97
97
|
|
|
98
98
|
def retrigger_collector(meta)
|
|
@@ -147,7 +147,7 @@ module RubyReactor
|
|
|
147
147
|
span = @step_spans.delete(step_name)
|
|
148
148
|
return unless span
|
|
149
149
|
|
|
150
|
-
if result.is_a?(RubyReactor::
|
|
150
|
+
if result.is_a?(RubyReactor::DispatchResult)
|
|
151
151
|
# The step was handed off to a background worker; the run block did not
|
|
152
152
|
# execute here. Rename the span so it is not confused with the real
|
|
153
153
|
# execution span emitted later under the resumed reactor span.
|
|
@@ -502,9 +502,9 @@ module RubyReactor
|
|
|
502
502
|
|
|
503
503
|
case result
|
|
504
504
|
when RubyReactor::Success
|
|
505
|
-
if result.
|
|
506
|
-
span.set_attribute("reactor.status", "
|
|
507
|
-
span.set_attribute("reactor.
|
|
505
|
+
if result.halted?
|
|
506
|
+
span.set_attribute("reactor.status", "halted")
|
|
507
|
+
span.set_attribute("reactor.halt_reason", result.reason.to_s)
|
|
508
508
|
span.status = ::OpenTelemetry::Trace::Status.ok
|
|
509
509
|
else
|
|
510
510
|
span.set_attribute("reactor.status", "completed")
|
|
@@ -578,7 +578,10 @@ module RubyReactor
|
|
|
578
578
|
|
|
579
579
|
case result
|
|
580
580
|
when RubyReactor::Success
|
|
581
|
-
if result.
|
|
581
|
+
if result.halted?
|
|
582
|
+
span.set_attribute("step.status", "halted")
|
|
583
|
+
span.set_attribute("step.halt_reason", result.reason.to_s)
|
|
584
|
+
elsif result.skipped?
|
|
582
585
|
span.set_attribute("step.status", "skipped")
|
|
583
586
|
span.set_attribute("step.skipped_reason", result.reason.to_s)
|
|
584
587
|
else
|
|
@@ -62,11 +62,11 @@ module RubyReactor
|
|
|
62
62
|
# via the stored context status.
|
|
63
63
|
# - `:skip_chain_failed` — only in strict mode: an earlier nonce in this
|
|
64
64
|
# sequence terminated with a Failure, so this run is short-circuited
|
|
65
|
-
# with `
|
|
65
|
+
# with `Halt(reason: :ordered_lock_chain_failed)` without executing.
|
|
66
66
|
# - `:stale_batch` — this run's epoch no longer matches the key's current
|
|
67
67
|
# generation: its batch fully drained and the numbering was reused by a
|
|
68
68
|
# newer batch. The run is short-circuited with
|
|
69
|
-
# `
|
|
69
|
+
# `Halt(reason: :ordered_lock_stale_batch)` and must not participate.
|
|
70
70
|
# - `:poison_advance` is collapsed to `:go` from the caller's perspective.
|
|
71
71
|
def check!
|
|
72
72
|
raise ArgumentError, "OrderedLock#check! requires a nonce" unless @nonce
|
|
@@ -111,7 +111,7 @@ module RubyReactor
|
|
|
111
111
|
#
|
|
112
112
|
# `failed:` records this nonce as the chain-failure marker (only the FIRST
|
|
113
113
|
# failure sticks). In strict mode the marker causes subsequent nonces to
|
|
114
|
-
# short-circuit with
|
|
114
|
+
# short-circuit with Halt.
|
|
115
115
|
def advance!(failed: false)
|
|
116
116
|
raise ArgumentError, "OrderedLock#advance! requires a nonce" unless @nonce
|
|
117
117
|
|
data/lib/ruby_reactor/reactor.rb
CHANGED
|
@@ -118,7 +118,7 @@ module RubyReactor
|
|
|
118
118
|
RubyReactor.reactor_storage_name(self.class),
|
|
119
119
|
intermediate_results: @context.intermediate_results)
|
|
120
120
|
|
|
121
|
-
# Even if it's an
|
|
121
|
+
# Even if it's an DispatchResult, it might have finished inline (e.g. Sidekiq::Testing.inline!)
|
|
122
122
|
# Check storage to see if it's already finished or paused (interrupted).
|
|
123
123
|
begin
|
|
124
124
|
reloaded = self.class.find(@context.context_id)
|
|
@@ -167,6 +167,14 @@ module RubyReactor
|
|
|
167
167
|
target_step = step_name
|
|
168
168
|
@context.set_result(target_step, payload)
|
|
169
169
|
|
|
170
|
+
# `interrupt :x, resume: :background` — payload is validated and stored
|
|
171
|
+
# (above, in this process); the remaining work goes to a worker instead
|
|
172
|
+
# of running inline in the delivering process.
|
|
173
|
+
step_config = self.class.steps[step_name.to_sym]
|
|
174
|
+
if step_config.respond_to?(:background_resume?) && step_config.background_resume?
|
|
175
|
+
return @result = enqueue_background_resume
|
|
176
|
+
end
|
|
177
|
+
|
|
170
178
|
# Resume execution
|
|
171
179
|
executor = Executor.new(self.class, {}, @context)
|
|
172
180
|
@result = executor.resume_execution
|
|
@@ -311,6 +319,21 @@ module RubyReactor
|
|
|
311
319
|
save_context
|
|
312
320
|
end
|
|
313
321
|
|
|
322
|
+
# Mirror of perform_async_run for the interrupt-resume path: persist the
|
|
323
|
+
# context (now carrying the validated payload) BEFORE enqueue — the job
|
|
324
|
+
# payload is identity-only (F2) — then hand the remainder to a worker.
|
|
325
|
+
def enqueue_background_resume
|
|
326
|
+
@context.status = :running
|
|
327
|
+
Executor.middlewares_for(self.class).on(:before_async_enqueue, @context)
|
|
328
|
+
save_context
|
|
329
|
+
|
|
330
|
+
@result = configuration.async_router.perform_async(@context.context_id,
|
|
331
|
+
RubyReactor.reactor_storage_name(self.class),
|
|
332
|
+
intermediate_results: @context.intermediate_results)
|
|
333
|
+
|
|
334
|
+
check_for_inline_completion || @result
|
|
335
|
+
end
|
|
336
|
+
|
|
314
337
|
def perform_async_run
|
|
315
338
|
@context.status = :running
|
|
316
339
|
# Persist BEFORE enqueue — the job payload is identity-only (F2).
|
|
@@ -324,7 +347,7 @@ module RubyReactor
|
|
|
324
347
|
end
|
|
325
348
|
|
|
326
349
|
def check_for_inline_completion
|
|
327
|
-
# Even if it's an
|
|
350
|
+
# Even if it's an DispatchResult, it might have finished inline (e.g. Sidekiq::Testing.inline!)
|
|
328
351
|
# Check storage to see if it's already finished or paused (interrupted).
|
|
329
352
|
reloaded = self.class.find(@context.context_id)
|
|
330
353
|
if reloaded.finished? || reloaded.context.status.to_s == "paused"
|
|
@@ -264,18 +264,18 @@ module RubyReactor
|
|
|
264
264
|
RubyReactor.configuration.storage_adapter
|
|
265
265
|
end
|
|
266
266
|
|
|
267
|
-
# Distinguishes `RubyReactor::
|
|
268
|
-
# any object with a `
|
|
267
|
+
# Distinguishes `RubyReactor::Halt` (a clean halt) from a plain
|
|
268
|
+
# `Success`. Works on any object with a `halted?` predicate.
|
|
269
269
|
#
|
|
270
270
|
# Examples:
|
|
271
|
-
# expect(result).to
|
|
272
|
-
# expect(result).to
|
|
273
|
-
# expect(result).to
|
|
274
|
-
::RSpec::Matchers.define :
|
|
271
|
+
# expect(result).to be_halted
|
|
272
|
+
# expect(result).to be_halted.because(:period)
|
|
273
|
+
# expect(result).to be_halted.at_step(:second)
|
|
274
|
+
::RSpec::Matchers.define :be_halted do
|
|
275
275
|
match do |subject|
|
|
276
276
|
subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
|
|
277
277
|
actual = subject.respond_to?(:result) ? subject.result : subject
|
|
278
|
-
next false unless actual.respond_to?(:
|
|
278
|
+
next false unless actual.respond_to?(:halted?) && actual.halted?
|
|
279
279
|
next false if @expected_reason && actual.reason != @expected_reason
|
|
280
280
|
next false if @expected_step && actual.step_name != @expected_step
|
|
281
281
|
|
|
@@ -292,12 +292,62 @@ module RubyReactor
|
|
|
292
292
|
|
|
293
293
|
failure_message do |subject|
|
|
294
294
|
actual = subject.respond_to?(:result) ? subject.result : subject
|
|
295
|
-
if !actual.respond_to?(:
|
|
296
|
-
"expected result to be
|
|
295
|
+
if !actual.respond_to?(:halted?) || !actual.halted?
|
|
296
|
+
"expected result to be Halt, got #{actual.class}"
|
|
297
297
|
elsif @expected_reason && actual.reason != @expected_reason
|
|
298
|
-
"expected
|
|
298
|
+
"expected Halt reason #{@expected_reason.inspect}, got #{actual.reason.inspect}"
|
|
299
299
|
else
|
|
300
|
-
"expected
|
|
300
|
+
"expected Halt at_step #{@expected_step.inspect}, got #{actual.step_name.inspect}"
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
failure_message_when_negated do
|
|
305
|
+
"expected result not to be Halt"
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Asserts that a STEP was skipped — either the subject itself is a
|
|
310
|
+
# `RubyReactor::Skipped` result, or the execution trace records a
|
|
311
|
+
# `:skipped` entry (optionally for a specific step via `.at_step`). A
|
|
312
|
+
# halted run never satisfies this — halting and skipping are distinct.
|
|
313
|
+
#
|
|
314
|
+
# Examples:
|
|
315
|
+
# expect(result).to be_skipped
|
|
316
|
+
# expect(subject).to be_skipped.at_step(:maybe_sync)
|
|
317
|
+
::RSpec::Matchers.define :be_skipped do
|
|
318
|
+
match do |subject|
|
|
319
|
+
subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
|
|
320
|
+
actual = subject.respond_to?(:result) ? subject.result : subject
|
|
321
|
+
next false if actual.respond_to?(:halted?) && actual.halted?
|
|
322
|
+
|
|
323
|
+
entries = skipped_trace_entries(subject)
|
|
324
|
+
next entries.any? { |e| e[:step].to_s == @expected_step.to_s } if @expected_step
|
|
325
|
+
next true if entries.any?
|
|
326
|
+
|
|
327
|
+
actual.respond_to?(:skipped?) && actual.skipped?
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
chain :at_step do |step|
|
|
331
|
+
@expected_step = step
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def skipped_trace_entries(subject)
|
|
335
|
+
trace = if subject.respond_to?(:reactor_instance)
|
|
336
|
+
subject.reactor_instance.context.execution_trace
|
|
337
|
+
elsif subject.respond_to?(:execution_trace)
|
|
338
|
+
subject.execution_trace
|
|
339
|
+
else
|
|
340
|
+
[]
|
|
341
|
+
end
|
|
342
|
+
trace.select { |e| e[:type].to_s == "skipped" }
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
failure_message do |subject|
|
|
346
|
+
actual = subject.respond_to?(:result) ? subject.result : subject
|
|
347
|
+
if @expected_step
|
|
348
|
+
"expected a skipped step named #{@expected_step.inspect}, found none in the execution trace"
|
|
349
|
+
else
|
|
350
|
+
"expected result to be Skipped, got #{actual.class}"
|
|
301
351
|
end
|
|
302
352
|
end
|
|
303
353
|
|
|
@@ -38,7 +38,8 @@ module RubyReactor
|
|
|
38
38
|
@worker_classes ||= [
|
|
39
39
|
RubyReactor::Adapters::Sidekiq::Worker,
|
|
40
40
|
RubyReactor::Adapters::Sidekiq::MapElementWorker,
|
|
41
|
-
RubyReactor::Adapters::Sidekiq::MapCollectorWorker
|
|
41
|
+
RubyReactor::Adapters::Sidekiq::MapCollectorWorker,
|
|
42
|
+
RubyReactor::Adapters::Sidekiq::StepWorker
|
|
42
43
|
]
|
|
43
44
|
end
|
|
44
45
|
|
|
@@ -14,7 +14,7 @@ module RubyReactor
|
|
|
14
14
|
result = super
|
|
15
15
|
|
|
16
16
|
# 2. Add test-specific logic for inline async execution
|
|
17
|
-
# Only interfere if we got an
|
|
17
|
+
# Only interfere if we got an DispatchResult and we are in a testing environment that supports inline execution
|
|
18
18
|
if should_check_inline_completion?(result)
|
|
19
19
|
# Check if it finished or paused inline (e.g. Sidekiq::Testing.inline!)
|
|
20
20
|
refresh_context_from_storage
|
|
@@ -75,7 +75,7 @@ module RubyReactor
|
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
def should_check_inline_completion?(result)
|
|
78
|
-
return false unless result.is_a?(RubyReactor::
|
|
78
|
+
return false unless result.is_a?(RubyReactor::DispatchResult) || result.is_a?(RubyReactor::RetryQueuedResult)
|
|
79
79
|
return true if defined?(Sidekiq::Testing) && Sidekiq::Testing.inline?
|
|
80
80
|
|
|
81
81
|
false
|
|
@@ -130,6 +130,56 @@ module RubyReactor
|
|
|
130
130
|
elements[index]
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
+
# The durable record for a dispatched `async_step`, or nil if the step was
|
|
134
|
+
# never dispatched (e.g. under `async: false`, where it ran inline and its
|
|
135
|
+
# value is an ordinary `step_result`). Mirrors `#composed` / `#map` in
|
|
136
|
+
# reading the reference off `composed_contexts`.
|
|
137
|
+
#
|
|
138
|
+
# subject.async_step(:send_email) # => the raw record hash
|
|
139
|
+
# subject.async_step(:send_email, :status) # => "dispatched" / "completed"
|
|
140
|
+
def async_step(step_name, key = nil)
|
|
141
|
+
ensure_executed!
|
|
142
|
+
|
|
143
|
+
entry = composed_entry(step_name)
|
|
144
|
+
return nil unless entry && entry[:type] == :async_step_ref
|
|
145
|
+
|
|
146
|
+
record = RubyReactor.configuration.storage_adapter.retrieve_step_result(
|
|
147
|
+
entry[:context_id] || @reactor_instance.context.context_id,
|
|
148
|
+
step_name,
|
|
149
|
+
RubyReactor.reactor_storage_name(@reactor_instance.class)
|
|
150
|
+
)
|
|
151
|
+
key && record ? record[key.to_s] : record
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The child execution a dispatched `async_reactor` created, as its own
|
|
155
|
+
# TestSubject — the same drill-down `#composed` gives for an inline child.
|
|
156
|
+
def async_reactor(step_name)
|
|
157
|
+
ensure_executed!
|
|
158
|
+
|
|
159
|
+
entry = composed_entry(step_name)
|
|
160
|
+
return nil unless entry && entry[:type] == :async_reactor_ref
|
|
161
|
+
|
|
162
|
+
child_class = RubyReactor::Context.resolve_reactor_class(entry[:reactor_class_name])
|
|
163
|
+
return nil unless child_class
|
|
164
|
+
|
|
165
|
+
child_instance = child_class.find(entry[:execution_id])
|
|
166
|
+
self.class.new(
|
|
167
|
+
reactor_class: child_instance.class,
|
|
168
|
+
inputs: child_instance.context.inputs,
|
|
169
|
+
context: child_instance.context,
|
|
170
|
+
async: @async,
|
|
171
|
+
process_jobs: @process_jobs
|
|
172
|
+
).tap do |s|
|
|
173
|
+
s.instance_variable_set(:@executed, true)
|
|
174
|
+
s.instance_variable_set(:@reactor_instance, child_instance)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def composed_entry(step_name)
|
|
179
|
+
contexts = @reactor_instance.context.composed_contexts
|
|
180
|
+
contexts[step_name] || contexts[step_name.to_s] || contexts[step_name.to_sym]
|
|
181
|
+
end
|
|
182
|
+
|
|
133
183
|
private
|
|
134
184
|
|
|
135
185
|
def traverse_composed(step_name)
|
|
@@ -250,8 +300,8 @@ module RubyReactor
|
|
|
250
300
|
end
|
|
251
301
|
end
|
|
252
302
|
RubyReactor::Success.new(val)
|
|
253
|
-
when "skipped"
|
|
254
|
-
|
|
303
|
+
when "halted", "skipped" # "skipped" is the legacy name for a halt
|
|
304
|
+
halted_result(ctx)
|
|
255
305
|
when "running"
|
|
256
306
|
# Try to determine if it is truly running or if we just missed the completion
|
|
257
307
|
if @process_jobs && AsyncTestHelpers.active?
|
|
@@ -276,14 +326,14 @@ module RubyReactor
|
|
|
276
326
|
end
|
|
277
327
|
|
|
278
328
|
# A clean halt: either a `with_period` gate or a step returning
|
|
279
|
-
# `RubyReactor.
|
|
280
|
-
#
|
|
329
|
+
# `RubyReactor.Halt(...)`. The sync run already produced the exact
|
|
330
|
+
# Halt (reason/step intact) — surface it. For async runs the worker
|
|
281
331
|
# swallows the return value, so rebuild from the trace.
|
|
282
|
-
def
|
|
283
|
-
return @run_result if @run_result.is_a?(RubyReactor::
|
|
332
|
+
def halted_result(ctx)
|
|
333
|
+
return @run_result if @run_result.is_a?(RubyReactor::Halt)
|
|
284
334
|
|
|
285
|
-
entry = ctx.execution_trace.reverse.find { |t| t[:type].to_s == "
|
|
286
|
-
RubyReactor::
|
|
335
|
+
entry = ctx.execution_trace.reverse.find { |t| t[:type].to_s == "halt" }
|
|
336
|
+
RubyReactor::Halt.new(reason: entry&.dig(:reason), step_name: entry&.dig(:step))
|
|
287
337
|
end
|
|
288
338
|
|
|
289
339
|
def success?
|
|
@@ -441,7 +491,8 @@ module RubyReactor
|
|
|
441
491
|
end
|
|
442
492
|
|
|
443
493
|
def prepare_execution_class
|
|
444
|
-
# Even if no interceptors, we might need to subclass to
|
|
494
|
+
# Even if no interceptors, we might need to subclass to force the whole
|
|
495
|
+
# reactor to run in-process.
|
|
445
496
|
return @reactor_class if @interceptors.empty? && @async != false
|
|
446
497
|
|
|
447
498
|
interceptors = @interceptors
|
|
@@ -454,7 +505,7 @@ module RubyReactor
|
|
|
454
505
|
@input_validations = superclass.input_validations.dup
|
|
455
506
|
@middlewares = superclass.middlewares.dup
|
|
456
507
|
@return_step = superclass.return_step
|
|
457
|
-
@
|
|
508
|
+
@background_handoff = superclass.background_handoff
|
|
458
509
|
@retry_defaults = superclass.instance_variable_get(:@retry_defaults)
|
|
459
510
|
|
|
460
511
|
# 2. Add Name Handling with Unique Registry Entry
|
|
@@ -464,15 +515,16 @@ module RubyReactor
|
|
|
464
515
|
define_singleton_method(:name) { unique_name }
|
|
465
516
|
RubyReactor::Registry.register(unique_name, self)
|
|
466
517
|
|
|
467
|
-
# 3.
|
|
518
|
+
# 3. `async: false` / `run_async(false)` means "run this reactor's full
|
|
519
|
+
# logic here, in one process". Under the new DSL that is three things:
|
|
520
|
+
# suppress the `background` hand-off, and run `async_step` /
|
|
521
|
+
# `async_reactor` units inline instead of dispatching them.
|
|
468
522
|
if force_sync
|
|
523
|
+
@background_handoff = nil
|
|
469
524
|
@steps.each do |name, config|
|
|
470
|
-
next unless config.
|
|
525
|
+
next unless config.respond_to?(:async_dispatch?) && config.async_dispatch?
|
|
471
526
|
|
|
472
|
-
|
|
473
|
-
new_config = config.clone
|
|
474
|
-
new_config.instance_variable_set(:@async, false)
|
|
475
|
-
@steps[name] = new_config
|
|
527
|
+
@steps[name] = config.clone.tap { |c| c.instance_variable_set(:@async_dispatch, nil) }
|
|
476
528
|
end
|
|
477
529
|
end
|
|
478
530
|
end
|
|
@@ -503,7 +555,8 @@ module RubyReactor
|
|
|
503
555
|
|
|
504
556
|
if nested_interceptors.any?
|
|
505
557
|
apply_nested_interceptors(step_config, nested_interceptors)
|
|
506
|
-
|
|
558
|
+
# A mocked inner step only takes effect if the child runs here.
|
|
559
|
+
step_config.instance_variable_set(:@async_dispatch, nil)
|
|
507
560
|
end
|
|
508
561
|
|
|
509
562
|
# Apply direct interceptors (mocks/failures on this step)
|
|
@@ -551,7 +604,7 @@ module RubyReactor
|
|
|
551
604
|
@input_validations = superclass.input_validations.dup
|
|
552
605
|
@middlewares = superclass.middlewares.dup
|
|
553
606
|
@return_step = superclass.return_step
|
|
554
|
-
@
|
|
607
|
+
@background_handoff = superclass.background_handoff
|
|
555
608
|
@retry_defaults = superclass.instance_variable_get(:@retry_defaults)
|
|
556
609
|
end
|
|
557
610
|
|
|
@@ -606,7 +659,9 @@ module RubyReactor
|
|
|
606
659
|
end
|
|
607
660
|
|
|
608
661
|
step_config.instance_variable_set(:@run_block, wrapper_impl)
|
|
609
|
-
|
|
662
|
+
# The mock replaces the step's body, so it must run where the spec can
|
|
663
|
+
# observe it rather than being dispatched to a worker.
|
|
664
|
+
step_config.instance_variable_set(:@async_dispatch, nil)
|
|
610
665
|
end
|
|
611
666
|
end
|
|
612
667
|
# rubocop:enable Metrics/ClassLength
|
|
@@ -40,6 +40,16 @@ module RubyReactor
|
|
|
40
40
|
release
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
# Park support: re-adopt a token that stayed checked out across a parked
|
|
44
|
+
# gap. Verifies the token is still in the held set — a reset/expiry in
|
|
45
|
+
# between means the slot was lost and the caller must acquire fresh.
|
|
46
|
+
def reattach(token) # rubocop:disable Naming/PredicateMethod
|
|
47
|
+
return false unless token && adapter.semaphore_held?(@key, token)
|
|
48
|
+
|
|
49
|
+
@token = token
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
43
53
|
private
|
|
44
54
|
|
|
45
55
|
def ensure_initialized
|