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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/CHANGELOG.md +114 -1
  5. data/CLAUDE.md +5 -0
  6. data/README.md +131 -16
  7. data/lib/ruby_reactor/adapters/active_job/router.rb +21 -6
  8. data/lib/ruby_reactor/adapters/active_job/step_worker.rb +20 -0
  9. data/lib/ruby_reactor/adapters/active_job/worker.rb +6 -1
  10. data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -6
  11. data/lib/ruby_reactor/adapters/sidekiq/step_worker.rb +17 -0
  12. data/lib/ruby_reactor/adapters/sidekiq/worker.rb +5 -2
  13. data/lib/ruby_reactor/async_waiter.rb +113 -0
  14. data/lib/ruby_reactor/configuration.rb +30 -1
  15. data/lib/ruby_reactor/context.rb +21 -0
  16. data/lib/ruby_reactor/context_serializer.rb +2 -0
  17. data/lib/ruby_reactor/dsl/async_macros.rb +176 -0
  18. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +66 -0
  19. data/lib/ruby_reactor/dsl/compose_builder.rb +23 -4
  20. data/lib/ruby_reactor/dsl/interrupt_builder.rb +3 -2
  21. data/lib/ruby_reactor/dsl/interrupt_step_config.rb +7 -1
  22. data/lib/ruby_reactor/dsl/lockable.rb +1 -1
  23. data/lib/ruby_reactor/dsl/map_builder.rb +1 -2
  24. data/lib/ruby_reactor/dsl/reactor.rb +29 -10
  25. data/lib/ruby_reactor/dsl/step_builder.rb +30 -11
  26. data/lib/ruby_reactor/error/async_result_pending.rb +21 -0
  27. data/lib/ruby_reactor/error/async_wait_timeout_error.rb +10 -0
  28. data/lib/ruby_reactor/error/deprecated_dsl_error.rb +11 -0
  29. data/lib/ruby_reactor/executor/async_step_dispatch.rb +110 -0
  30. data/lib/ruby_reactor/executor/ordered_lock_support.rb +2 -2
  31. data/lib/ruby_reactor/executor/result_handler.rb +12 -1
  32. data/lib/ruby_reactor/executor/retry_manager.rb +9 -5
  33. data/lib/ruby_reactor/executor/step_executor.rb +58 -11
  34. data/lib/ruby_reactor/executor.rb +162 -8
  35. data/lib/ruby_reactor/lock.rb +19 -0
  36. data/lib/ruby_reactor/map/element_executor.rb +6 -1
  37. data/lib/ruby_reactor/map/helpers.rb +1 -1
  38. data/lib/ruby_reactor/map/result_enumerator.rb +5 -1
  39. data/lib/ruby_reactor/map/result_summary.rb +63 -0
  40. data/lib/ruby_reactor/open_telemetry.rb +1 -1
  41. data/lib/ruby_reactor/reactor.rb +25 -2
  42. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +2 -1
  43. data/lib/ruby_reactor/rspec/step_executor_patch.rb +2 -2
  44. data/lib/ruby_reactor/rspec/test_subject.rb +66 -11
  45. data/lib/ruby_reactor/semaphore.rb +10 -0
  46. data/lib/ruby_reactor/step/async_reactor_step.rb +207 -0
  47. data/lib/ruby_reactor/step/compose_step.rb +1 -1
  48. data/lib/ruby_reactor/step/map_step.rb +1 -1
  49. data/lib/ruby_reactor/step_sweeper.rb +72 -0
  50. data/lib/ruby_reactor/step_worker.rb +260 -0
  51. data/lib/ruby_reactor/storage/adapter.rb +22 -1
  52. data/lib/ruby_reactor/storage/redis_adapter.rb +17 -13
  53. data/lib/ruby_reactor/storage/redis_locking.rb +7 -0
  54. data/lib/ruby_reactor/storage/redis_pub_sub.rb +31 -0
  55. data/lib/ruby_reactor/storage/redis_step_results.rb +49 -0
  56. data/lib/ruby_reactor/sweeper.rb +7 -1
  57. data/lib/ruby_reactor/sweeper_job.rb +1 -0
  58. data/lib/ruby_reactor/template/result.rb +151 -5
  59. data/lib/ruby_reactor/version.rb +1 -1
  60. data/lib/ruby_reactor/web/api.rb +94 -16
  61. data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +1 -0
  62. data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +22 -0
  63. data/lib/ruby_reactor/web/public/index.html +2 -2
  64. data/lib/ruby_reactor/worker.rb +53 -3
  65. data/lib/ruby_reactor.rb +24 -3
  66. data/specs/001-background-async-steps/checklists/requirements.md +39 -0
  67. data/specs/001-background-async-steps/contracts/public-dsl.md +154 -0
  68. data/specs/001-background-async-steps/data-model.md +117 -0
  69. data/specs/001-background-async-steps/plan.md +168 -0
  70. data/specs/001-background-async-steps/quickstart.md +102 -0
  71. data/specs/001-background-async-steps/research.md +150 -0
  72. data/specs/001-background-async-steps/spec.md +146 -0
  73. data/specs/001-background-async-steps/tasks.md +271 -0
  74. data/specs/active_job.md +1 -1
  75. metadata +27 -3
  76. data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +0 -1
  77. 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
- def async(async = true)
89
- @async = async
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
- def build
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, :async, :retry_config
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 / AsyncResult) complete on resume regardless of chain
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::AsyncResult, RubyReactor::InterruptResult, RetryQueuedResult
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
- @compensation_manager.add_to_undo_stack({ step: step_config, arguments: resolved_arguments, result: result })
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::AsyncResult
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
- is_async = reactor_class.async? || step_config.async? ||
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 AsyncResult, we are truly async.
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::AsyncResult)
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::AsyncResult)
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
- resolved_arguments = resolve_arguments(step_config)
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.async? && !@context.inline_async_execution
80
- handle_async_step(step_config)
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::AsyncResult)
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
- def handle_async_step(step_config)
201
- # Step-level async: hand off execution to worker
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