ruby_reactor 0.6.0 → 0.7.0
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 +114 -1
- data/CLAUDE.md +5 -0
- data/README.md +131 -16
- 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 +21 -0
- data/lib/ruby_reactor/context_serializer.rb +2 -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 +1 -1
- 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/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/ordered_lock_support.rb +2 -2
- data/lib/ruby_reactor/executor/result_handler.rb +12 -1
- data/lib/ruby_reactor/executor/retry_manager.rb +9 -5
- data/lib/ruby_reactor/executor/step_executor.rb +58 -11
- data/lib/ruby_reactor/executor.rb +162 -8
- data/lib/ruby_reactor/lock.rb +19 -0
- data/lib/ruby_reactor/map/element_executor.rb +6 -1
- data/lib/ruby_reactor/map/helpers.rb +1 -1
- data/lib/ruby_reactor/map/result_enumerator.rb +5 -1
- data/lib/ruby_reactor/map/result_summary.rb +63 -0
- data/lib/ruby_reactor/open_telemetry.rb +1 -1
- data/lib/ruby_reactor/reactor.rb +25 -2
- 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 +66 -11
- 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 +1 -1
- 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 +22 -1
- data/lib/ruby_reactor/storage/redis_adapter.rb +17 -13
- 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_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 +94 -16
- data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +1 -0
- data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.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 +24 -3
- data/specs/001-background-async-steps/checklists/requirements.md +39 -0
- data/specs/001-background-async-steps/contracts/public-dsl.md +154 -0
- data/specs/001-background-async-steps/data-model.md +117 -0
- data/specs/001-background-async-steps/plan.md +168 -0
- data/specs/001-background-async-steps/quickstart.md +102 -0
- data/specs/001-background-async-steps/research.md +150 -0
- data/specs/001-background-async-steps/spec.md +146 -0
- data/specs/001-background-async-steps/tasks.md +271 -0
- data/specs/active_job.md +1 -1
- metadata +27 -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
|
@@ -24,7 +24,6 @@ module RubyReactor
|
|
|
24
24
|
@validate_args_input = nil
|
|
25
25
|
@args_validator = nil
|
|
26
26
|
@output_validator = nil
|
|
27
|
-
@async = false
|
|
28
27
|
@retry_config = {}
|
|
29
28
|
end
|
|
30
29
|
|
|
@@ -85,8 +84,22 @@ module RubyReactor
|
|
|
85
84
|
end
|
|
86
85
|
end
|
|
87
86
|
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
# The per-step hand-off flag is gone. Only the FIRST flagged step
|
|
88
|
+
# in a reactor ever took effect — every later one was silently ignored —
|
|
89
|
+
# so this must fail at class-definition time rather than surprise someone
|
|
90
|
+
# at run time. Kept as a stub purely to say what to use instead.
|
|
91
|
+
def async(*)
|
|
92
|
+
raise RubyReactor::Error::DeprecatedDslError.new(
|
|
93
|
+
"`async` inside a `step` block has been removed: it was ambiguous (only the first " \
|
|
94
|
+
"flagged step in a reactor ever took effect). Replacements:\n " \
|
|
95
|
+
"* `background after: :#{@name}` — hand every REMAINING step to a worker once " \
|
|
96
|
+
":#{@name} finishes in the calling process (declared on the reactor, not the step);\n " \
|
|
97
|
+
"* `background before: :#{@name}` — hand off starting WITH :#{@name};\n " \
|
|
98
|
+
"* `async_step :#{@name}` — dispatch just this step's work to its own job while the " \
|
|
99
|
+
"reactor keeps running;\n " \
|
|
100
|
+
"* `async_reactor :name, ChildReactor` — dispatch a whole nested reactor independently.",
|
|
101
|
+
step: @name
|
|
102
|
+
)
|
|
90
103
|
end
|
|
91
104
|
|
|
92
105
|
def retries(max_attempts: 3, backoff: :exponential, base_delay: 1)
|
|
@@ -97,8 +110,12 @@ module RubyReactor
|
|
|
97
110
|
}
|
|
98
111
|
end
|
|
99
112
|
|
|
100
|
-
|
|
113
|
+
# `async_dispatch` marks a step whose work is dispatched as an independent
|
|
114
|
+
# unit rather than run inline — `:step` for `async_step`, `:reactor` for
|
|
115
|
+
# `async_reactor`. Nil for an ordinary step.
|
|
116
|
+
def build(async_dispatch: nil)
|
|
101
117
|
step_config = {
|
|
118
|
+
async_dispatch: async_dispatch,
|
|
102
119
|
name: @name,
|
|
103
120
|
impl: @impl,
|
|
104
121
|
arguments: @arguments,
|
|
@@ -110,7 +127,6 @@ module RubyReactor
|
|
|
110
127
|
dependencies: @dependencies,
|
|
111
128
|
args_validator: @args_validator || build_args_validator(@arg_validations, @validate_args_input),
|
|
112
129
|
output_validator: @output_validator,
|
|
113
|
-
async: @async,
|
|
114
130
|
retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
|
|
115
131
|
}
|
|
116
132
|
|
|
@@ -120,9 +136,10 @@ module RubyReactor
|
|
|
120
136
|
|
|
121
137
|
class StepConfig
|
|
122
138
|
attr_reader :name, :impl, :arguments, :run_block, :compensate_block, :undo_block, :conditions, :guards,
|
|
123
|
-
:dependencies, :args_validator, :output_validator, :
|
|
139
|
+
:dependencies, :args_validator, :output_validator, :retry_config, :async_dispatch
|
|
124
140
|
|
|
125
141
|
def initialize(config)
|
|
142
|
+
@async_dispatch = config[:async_dispatch]
|
|
126
143
|
@name = config[:name]
|
|
127
144
|
@impl = config[:impl]
|
|
128
145
|
@arguments = config[:arguments] || {}
|
|
@@ -134,10 +151,16 @@ module RubyReactor
|
|
|
134
151
|
@dependencies = config[:dependencies] || []
|
|
135
152
|
@args_validator = config[:args_validator]
|
|
136
153
|
@output_validator = config[:output_validator]
|
|
137
|
-
@async = config[:async] || false
|
|
138
154
|
@retry_config = { max_attempts: 1 }.merge(config[:retry_config] || {})
|
|
139
155
|
end
|
|
140
156
|
|
|
157
|
+
# True for `async_step` / `async_reactor` — the step's work leaves this
|
|
158
|
+
# process instead of running inline. `RSpec::TestSubject`'s `async: false`
|
|
159
|
+
# clears the marker to run the whole reactor in one process.
|
|
160
|
+
def async_dispatch?
|
|
161
|
+
!@async_dispatch.nil?
|
|
162
|
+
end
|
|
163
|
+
|
|
141
164
|
def has_impl?
|
|
142
165
|
!@impl.nil?
|
|
143
166
|
end
|
|
@@ -146,10 +169,6 @@ module RubyReactor
|
|
|
146
169
|
!@run_block.nil?
|
|
147
170
|
end
|
|
148
171
|
|
|
149
|
-
def async?
|
|
150
|
-
@async
|
|
151
|
-
end
|
|
152
|
-
|
|
153
172
|
def retryable?
|
|
154
173
|
(retry_config[:max_attempts] || 0) > 1
|
|
155
174
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Error
|
|
5
|
+
# Raised inside a WORKER when `result(:name)` references an `async_step` /
|
|
6
|
+
# `async_reactor` that is not yet terminal. Instead of blocking the worker
|
|
7
|
+
# thread for the whole wait (the sync-caller behavior), the executor parks:
|
|
8
|
+
# exclusive lock / semaphore stay HELD (recorded on the context), the job
|
|
9
|
+
# re-enqueues itself via the snooze path, and the wait resumes on
|
|
10
|
+
# redelivery. Bounded by `Configuration#async_park_timeout`, enforced at
|
|
11
|
+
# the wait site before this is raised.
|
|
12
|
+
class AsyncResultPending < Base
|
|
13
|
+
attr_reader :channel
|
|
14
|
+
|
|
15
|
+
def initialize(message, channel: nil)
|
|
16
|
+
super(message)
|
|
17
|
+
@channel = channel
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Error
|
|
5
|
+
# Raised when a notified wait on an `async_step` / `async_reactor`
|
|
6
|
+
# result exceeds `Configuration#async_wait_timeout`. Never an unbounded wait.
|
|
7
|
+
class AsyncWaitTimeoutError < Base
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Error
|
|
5
|
+
# Definition-time error for DSL that has been removed. Subclasses
|
|
6
|
+
# ValidationError so existing `rescue Error::ValidationError` sites keep
|
|
7
|
+
# catching it.
|
|
8
|
+
class DeprecatedDslError < ValidationError
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
class Executor
|
|
5
|
+
# The dispatching half of `async_step`, mixed into StepExecutor. Split out
|
|
6
|
+
# because it is a self-contained concern — write the durable record and the
|
|
7
|
+
# context reference, enqueue, then mark the node graph-complete — and because
|
|
8
|
+
# it is where the structured logging for every hand-off lives.
|
|
9
|
+
module AsyncStepDispatch
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
# Send one step's work off as its own job and KEEP GOING.
|
|
13
|
+
#
|
|
14
|
+
# The ordering below is load-bearing (F2): the durable record and the
|
|
15
|
+
# context reference are written BEFORE the enqueue, so a crash in between
|
|
16
|
+
# can never leave a job with no record — and that same record is what
|
|
17
|
+
# tells a recovery pass the work is already out there, so it
|
|
18
|
+
# re-attaches instead of dispatching a duplicate side effect.
|
|
19
|
+
#
|
|
20
|
+
# Deliberately NOT gated on `inline_async_execution`: that flag stops the
|
|
21
|
+
# `background` hand-off from re-triggering inside a worker, but an
|
|
22
|
+
# `async_step` reached during a worker resume must still get its own job,
|
|
23
|
+
# or the feature silently degrades to inline execution exactly where the
|
|
24
|
+
# spec says it must not.
|
|
25
|
+
def dispatch_async_step(step_config)
|
|
26
|
+
if already_dispatched?(step_config)
|
|
27
|
+
@dependency_graph.complete_step(step_config.name)
|
|
28
|
+
return RubyReactor.Success(nil)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
record_async_step_dispatch(step_config)
|
|
32
|
+
enqueue_async_step(step_config)
|
|
33
|
+
|
|
34
|
+
# Mark complete for SCHEDULING only — no result is recorded, so
|
|
35
|
+
# `result(:name)` still routes through the notified wait. This is what
|
|
36
|
+
# lets unrelated siblings become ready and run while the unit is in
|
|
37
|
+
# flight, instead of the loop returning early on an DispatchResult.
|
|
38
|
+
@dependency_graph.complete_step(step_config.name)
|
|
39
|
+
RubyReactor.Success(nil)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def already_dispatched?(step_config)
|
|
43
|
+
!storage.retrieve_step_result(@context.context_id, step_config.name, async_step_class_name).nil?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def record_async_step_dispatch(step_config)
|
|
47
|
+
root = @context.root_context || @context
|
|
48
|
+
@context.composed_contexts[step_config.name] = {
|
|
49
|
+
name: step_config.name,
|
|
50
|
+
type: :async_step_ref,
|
|
51
|
+
# Carried on the ref so the dashboard can find the Step Result Record
|
|
52
|
+
# from the reference alone, without re-deriving which context owns it.
|
|
53
|
+
context_id: @context.context_id,
|
|
54
|
+
dispatched_at: Time.now
|
|
55
|
+
}
|
|
56
|
+
storage.store_step_result(
|
|
57
|
+
@context.context_id, step_config.name,
|
|
58
|
+
{
|
|
59
|
+
"status" => "dispatched", "dispatched_at" => Time.now.iso8601,
|
|
60
|
+
# The re-dispatch arguments, verbatim. The record's own key names the
|
|
61
|
+
# reactor that OWNS the step, which for a composed child is not the
|
|
62
|
+
# root the worker must load, so recovery cannot re-derive them.
|
|
63
|
+
"root_context_id" => root.context_id,
|
|
64
|
+
"reactor_class_name" => RubyReactor.reactor_storage_name(root.reactor_class),
|
|
65
|
+
"step_context_id" => @context.context_id,
|
|
66
|
+
"step_name" => step_config.name.to_s
|
|
67
|
+
},
|
|
68
|
+
async_step_class_name
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# The worker loads the parent by id, so the parent must be durable
|
|
72
|
+
# before the job exists AND must outlive the dispatched unit — including
|
|
73
|
+
# the fire-and-forget case where this reactor finishes immediately and
|
|
74
|
+
# nothing ever waits.
|
|
75
|
+
checkpoint_root!(root, RubyReactor.reactor_storage_name(root.reactor_class))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def enqueue_async_step(step_config)
|
|
79
|
+
root = @context.root_context || @context
|
|
80
|
+
log_async_event("async_step.dispatched", step_config.name)
|
|
81
|
+
configuration.async_router.perform_step_async(
|
|
82
|
+
root_context_id: root.context_id,
|
|
83
|
+
reactor_class_name: RubyReactor.reactor_storage_name(root.reactor_class),
|
|
84
|
+
step_context_id: @context.context_id,
|
|
85
|
+
step_name: step_config.name
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Step Result Records are namespaced by the reactor that OWNS the step,
|
|
90
|
+
# which for a composed child is the child — the same name the reader's
|
|
91
|
+
# `Template::Result` will look under.
|
|
92
|
+
def async_step_class_name
|
|
93
|
+
RubyReactor.reactor_storage_name(@context.reactor_class || @reactor_class)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def storage
|
|
97
|
+
RubyReactor::Configuration.instance.storage_adapter
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# One machine-parseable line per hand-off / dispatch, carrying the
|
|
101
|
+
# three identifiers needed to correlate it with everything else.
|
|
102
|
+
def log_async_event(event, step_name)
|
|
103
|
+
configuration.logger.info(
|
|
104
|
+
"event=\"ruby_reactor.#{event}\" reactor=#{@reactor_class&.name.inspect} " \
|
|
105
|
+
"step=#{step_name.inspect} execution_id=#{@context.context_id.inspect}"
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -69,7 +69,7 @@ module RubyReactor
|
|
|
69
69
|
#
|
|
70
70
|
# The strict-mode chain-skip only fires on a *fresh* start (no step
|
|
71
71
|
# has run yet on this context). This lets an in-flight run that paused
|
|
72
|
-
# (Interrupt /
|
|
72
|
+
# (Interrupt / DispatchResult) complete on resume regardless of chain
|
|
73
73
|
# failures that landed while it was parked, while still applying
|
|
74
74
|
# strict to a fresh Sidekiq job (which enters via `resume_execution`
|
|
75
75
|
# but has no prior step state).
|
|
@@ -296,7 +296,7 @@ module RubyReactor
|
|
|
296
296
|
|
|
297
297
|
def terminal_for_ordered_lock?(result)
|
|
298
298
|
case result
|
|
299
|
-
when RubyReactor::
|
|
299
|
+
when RubyReactor::DispatchResult, RubyReactor::InterruptResult, RetryQueuedResult
|
|
300
300
|
false
|
|
301
301
|
when RubyReactor::Success, RubyReactor::Failure
|
|
302
302
|
true
|
|
@@ -97,11 +97,22 @@ module RubyReactor
|
|
|
97
97
|
def handle_success(step_config, result, resolved_arguments)
|
|
98
98
|
validate_step_output(step_config, result.value, resolved_arguments)
|
|
99
99
|
@step_results[step_config.name] = result
|
|
100
|
-
|
|
100
|
+
# `async_step` / `async_reactor` dispatches are independent units of
|
|
101
|
+
# work with their own compensation flows — the parent rolling back must
|
|
102
|
+
# not "undo" a dispatch whose unit runs (and may still succeed)
|
|
103
|
+
# elsewhere. They never enter the parent's undo stack.
|
|
104
|
+
unless async_unit?(step_config)
|
|
105
|
+
@compensation_manager.add_to_undo_stack({ step: step_config, arguments: resolved_arguments,
|
|
106
|
+
result: result })
|
|
107
|
+
end
|
|
101
108
|
@context.set_result(step_config.name, result.value)
|
|
102
109
|
@dependency_graph.complete_step(step_config.name)
|
|
103
110
|
end
|
|
104
111
|
|
|
112
|
+
def async_unit?(step_config)
|
|
113
|
+
step_config.respond_to?(:async_dispatch?) && step_config.async_dispatch?
|
|
114
|
+
end
|
|
115
|
+
|
|
105
116
|
def handle_retries_exhausted(step_config, result, resolved_arguments)
|
|
106
117
|
@compensation_manager.handle_step_failure(step_config, result.original_error, resolved_arguments)
|
|
107
118
|
orig_err = result.original_error.is_a?(Exception) ? result.original_error : nil
|
|
@@ -99,7 +99,7 @@ module RubyReactor
|
|
|
99
99
|
result
|
|
100
100
|
when RubyReactor::Failure
|
|
101
101
|
handle_failure_result(step_config, reactor_class, result)
|
|
102
|
-
when RetryQueuedResult, RubyReactor::
|
|
102
|
+
when RetryQueuedResult, RubyReactor::DispatchResult
|
|
103
103
|
# Pass through async results
|
|
104
104
|
result
|
|
105
105
|
else
|
|
@@ -126,8 +126,12 @@ module RubyReactor
|
|
|
126
126
|
@context
|
|
127
127
|
)
|
|
128
128
|
|
|
129
|
-
# Check if we should requeue (async retry)
|
|
130
|
-
|
|
129
|
+
# Check if we should requeue (async retry). The per-step `async` flag is
|
|
130
|
+
# gone: a step relocated by `background` fails inside the worker, where
|
|
131
|
+
# `inline_async_execution` already answers this — and a step failing
|
|
132
|
+
# BEFORE the hand-off point genuinely has no worker to requeue into, so
|
|
133
|
+
# it must retry synchronously.
|
|
134
|
+
is_async = reactor_class.async? ||
|
|
131
135
|
@context.root_context&.reactor_class&.async? ||
|
|
132
136
|
@context.inline_async_execution
|
|
133
137
|
|
|
@@ -142,9 +146,9 @@ module RubyReactor
|
|
|
142
146
|
def handle_async_retry(step_config, reactor_class, result)
|
|
143
147
|
requeue_result = requeue_job_for_step_retry(step_config, result.error, reactor_class)
|
|
144
148
|
|
|
145
|
-
# If it returned an
|
|
149
|
+
# If it returned an DispatchResult, we are truly async.
|
|
146
150
|
# Otherwise, it ran inline and we should return the result of that execution.
|
|
147
|
-
if requeue_result.is_a?(RubyReactor::
|
|
151
|
+
if requeue_result.is_a?(RubyReactor::DispatchResult)
|
|
148
152
|
RetryQueuedResult.new(
|
|
149
153
|
step_config.name,
|
|
150
154
|
@context.retry_context.attempts_for_step(step_config.name),
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module RubyReactor
|
|
4
4
|
class Executor
|
|
5
5
|
class StepExecutor
|
|
6
|
+
include AsyncStepDispatch
|
|
7
|
+
|
|
6
8
|
def initialize(context:, dependency_graph:, reactor_class:, managers:)
|
|
7
9
|
@context = context
|
|
8
10
|
@dependency_graph = dependency_graph
|
|
@@ -30,7 +32,7 @@ module RubyReactor
|
|
|
30
32
|
result = execute_step(step_config)
|
|
31
33
|
|
|
32
34
|
# If step execution was handed off to async, return the async result
|
|
33
|
-
return result if result.is_a?(RubyReactor::
|
|
35
|
+
return result if result.is_a?(RubyReactor::DispatchResult)
|
|
34
36
|
|
|
35
37
|
# If a step returns RetryQueuedResult, we need to stop and return it
|
|
36
38
|
return result if result.is_a?(RetryQueuedResult)
|
|
@@ -62,25 +64,36 @@ module RubyReactor
|
|
|
62
64
|
end
|
|
63
65
|
|
|
64
66
|
def execute_step(step_config)
|
|
65
|
-
# If we're already in inline async execution mode (inside Worker),
|
|
66
|
-
# treat async steps as sync to avoid infinite recursion
|
|
67
|
-
|
|
68
67
|
if @dependency_graph.completed.include?(step_config.name)
|
|
69
68
|
return RubyReactor.Success(@context.get_result(step_config.name))
|
|
70
69
|
end
|
|
71
70
|
|
|
72
|
-
|
|
71
|
+
# Decided BEFORE argument resolution: resolving can block (or park) on
|
|
72
|
+
# an async `result(:name)`, and when the step body is about to be
|
|
73
|
+
# dispatched elsewhere — a `before:` hand-off, or an `async_step`'s
|
|
74
|
+
# own worker — that wait belongs to the process that will actually run
|
|
75
|
+
# it, not this one. (`async_reactor` still resolves here: its resolved
|
|
76
|
+
# values are the child's INPUTS, needed at dispatch.)
|
|
77
|
+
deferred_body = step_config.async_dispatch == :step || handoff_at?(step_config, :before)
|
|
78
|
+
resolved_arguments = deferred_body ? {} : resolve_arguments(step_config)
|
|
73
79
|
|
|
74
80
|
@middlewares.on(:start_step, step_config.name, resolved_arguments, @context)
|
|
75
81
|
completed = false
|
|
76
82
|
begin
|
|
77
83
|
result = if step_config.interrupt?
|
|
78
84
|
handle_interrupt_step(step_config)
|
|
79
|
-
elsif step_config.
|
|
80
|
-
|
|
85
|
+
elsif step_config.async_dispatch == :step
|
|
86
|
+
dispatch_async_step(step_config)
|
|
87
|
+
elsif handoff_at?(step_config, :before)
|
|
88
|
+
# `before: :x` hands off INSTEAD of running :x, leaving its
|
|
89
|
+
# graph node incomplete for the worker to pick up.
|
|
90
|
+
handle_background_handoff(step_config)
|
|
81
91
|
else
|
|
82
92
|
execute_step_with_retry(step_config, resolved_arguments)
|
|
83
93
|
end
|
|
94
|
+
# `after: :x` hands off once :x's result is recorded — the step really
|
|
95
|
+
# did run here, and only what remains moves to the worker.
|
|
96
|
+
result = handle_background_handoff(step_config) if handoff_after?(step_config, result)
|
|
84
97
|
completed = true
|
|
85
98
|
if result.is_a?(RubyReactor::Failure)
|
|
86
99
|
@middlewares.on(:failed_step, step_config.name, result, @context)
|
|
@@ -96,6 +109,37 @@ module RubyReactor
|
|
|
96
109
|
|
|
97
110
|
private
|
|
98
111
|
|
|
112
|
+
# The reactor's single hand-off point, `{ mode: :after|:before, step: }`.
|
|
113
|
+
# Nil for a reactor that never declares `background`.
|
|
114
|
+
def background_handoff
|
|
115
|
+
return @background_handoff if defined?(@background_handoff)
|
|
116
|
+
|
|
117
|
+
@background_handoff =
|
|
118
|
+
(@reactor_class.background_handoff if @reactor_class.respond_to?(:background_handoff))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Hand-off is keyed to REACHING the named step, not to where the
|
|
122
|
+
# declaration sits in the class body. A step whose `where`/guard says it
|
|
123
|
+
# must not run never triggers it — the hand-off only ever relocates work
|
|
124
|
+
# that is actually going to happen. Inside the worker the whole thing is
|
|
125
|
+
# suppressed (`inline_async_execution`) so it cannot re-trigger.
|
|
126
|
+
def handoff_at?(step_config, mode)
|
|
127
|
+
point = background_handoff
|
|
128
|
+
return false unless point && point[:mode] == mode && point[:step] == step_config.name
|
|
129
|
+
return false if @context.inline_async_execution
|
|
130
|
+
|
|
131
|
+
step_config.should_run?(@context)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Post-execution trigger for `after:`. Only a plain continue-Success means
|
|
135
|
+
# the named step actually completed here — a Failure, Skipped, interrupt,
|
|
136
|
+
# queued retry or an already-async result each own the flow instead.
|
|
137
|
+
def handoff_after?(step_config, result)
|
|
138
|
+
return false unless result.is_a?(RubyReactor::Success) && !result.is_a?(RubyReactor::Skipped)
|
|
139
|
+
|
|
140
|
+
handoff_at?(step_config, :after)
|
|
141
|
+
end
|
|
142
|
+
|
|
99
143
|
def reconstruct_failure(data)
|
|
100
144
|
return data if data.is_a?(RubyReactor::Failure)
|
|
101
145
|
return nil unless data.is_a?(Hash)
|
|
@@ -125,7 +169,7 @@ module RubyReactor
|
|
|
125
169
|
safe_execute_step_sync(step_config, resolved_arguments)
|
|
126
170
|
end
|
|
127
171
|
|
|
128
|
-
unless result.is_a?(RetryQueuedResult) || result.is_a?(RubyReactor::
|
|
172
|
+
unless result.is_a?(RetryQueuedResult) || result.is_a?(RubyReactor::DispatchResult)
|
|
129
173
|
@result_handler.handle_step_result(step_config, result, resolved_arguments)
|
|
130
174
|
end
|
|
131
175
|
|
|
@@ -197,9 +241,12 @@ module RubyReactor
|
|
|
197
241
|
end
|
|
198
242
|
end
|
|
199
243
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
244
|
+
# Hand every step not yet executed to a worker job, and return the
|
|
245
|
+
# `DispatchResult` that halts `execute_all_steps` in the calling process.
|
|
246
|
+
# Shared verbatim by both `background` forms — they differ only in WHERE
|
|
247
|
+
# the trigger sits, never in what the hand-off does.
|
|
248
|
+
def handle_background_handoff(step_config)
|
|
249
|
+
log_async_event("background.handoff", step_config.name)
|
|
203
250
|
@context.current_step = step_config.name
|
|
204
251
|
@context.undo_stack = @compensation_manager.undo_stack
|
|
205
252
|
|