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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/demo-app-e2e-verify/SKILL.md +226 -0
  3. data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
  4. data/.release-please-manifest.json +1 -1
  5. data/.specify/feature.json +1 -1
  6. data/.specify/memory/constitution.md +79 -12
  7. data/.specify/templates/tasks-template.md +7 -0
  8. data/CHANGELOG.md +125 -1
  9. data/CLAUDE.md +5 -0
  10. data/README.md +155 -25
  11. data/lib/ruby_reactor/adapters/active_job/router.rb +21 -6
  12. data/lib/ruby_reactor/adapters/active_job/step_worker.rb +20 -0
  13. data/lib/ruby_reactor/adapters/active_job/worker.rb +6 -1
  14. data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -6
  15. data/lib/ruby_reactor/adapters/sidekiq/step_worker.rb +17 -0
  16. data/lib/ruby_reactor/adapters/sidekiq/worker.rb +5 -2
  17. data/lib/ruby_reactor/async_waiter.rb +113 -0
  18. data/lib/ruby_reactor/configuration.rb +30 -1
  19. data/lib/ruby_reactor/context.rb +28 -0
  20. data/lib/ruby_reactor/context_serializer.rb +15 -0
  21. data/lib/ruby_reactor/dsl/async_macros.rb +176 -0
  22. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +66 -0
  23. data/lib/ruby_reactor/dsl/compose_builder.rb +23 -4
  24. data/lib/ruby_reactor/dsl/interrupt_builder.rb +3 -2
  25. data/lib/ruby_reactor/dsl/interrupt_step_config.rb +7 -1
  26. data/lib/ruby_reactor/dsl/lockable.rb +3 -3
  27. data/lib/ruby_reactor/dsl/map_builder.rb +1 -2
  28. data/lib/ruby_reactor/dsl/reactor.rb +29 -10
  29. data/lib/ruby_reactor/dsl/step_builder.rb +30 -11
  30. data/lib/ruby_reactor/dsl/template_helpers.rb +11 -3
  31. data/lib/ruby_reactor/error/async_result_pending.rb +21 -0
  32. data/lib/ruby_reactor/error/async_wait_timeout_error.rb +10 -0
  33. data/lib/ruby_reactor/error/deprecated_dsl_error.rb +11 -0
  34. data/lib/ruby_reactor/executor/async_step_dispatch.rb +110 -0
  35. data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
  36. data/lib/ruby_reactor/executor/ordered_lock_support.rb +11 -11
  37. data/lib/ruby_reactor/executor/result_handler.rb +47 -11
  38. data/lib/ruby_reactor/executor/retry_manager.rb +13 -6
  39. data/lib/ruby_reactor/executor/step_executor.rb +77 -24
  40. data/lib/ruby_reactor/executor.rb +179 -23
  41. data/lib/ruby_reactor/lock.rb +19 -0
  42. data/lib/ruby_reactor/map/element_executor.rb +13 -2
  43. data/lib/ruby_reactor/map/helpers.rb +10 -8
  44. data/lib/ruby_reactor/map/result_enumerator.rb +7 -1
  45. data/lib/ruby_reactor/map/result_summary.rb +63 -0
  46. data/lib/ruby_reactor/map/sweeper.rb +1 -1
  47. data/lib/ruby_reactor/open_telemetry.rb +8 -5
  48. data/lib/ruby_reactor/ordered_lock.rb +3 -3
  49. data/lib/ruby_reactor/reactor.rb +25 -2
  50. data/lib/ruby_reactor/rspec/matchers.rb +61 -11
  51. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +2 -1
  52. data/lib/ruby_reactor/rspec/step_executor_patch.rb +2 -2
  53. data/lib/ruby_reactor/rspec/test_subject.rb +74 -19
  54. data/lib/ruby_reactor/semaphore.rb +10 -0
  55. data/lib/ruby_reactor/step/async_reactor_step.rb +207 -0
  56. data/lib/ruby_reactor/step/compose_step.rb +1 -1
  57. data/lib/ruby_reactor/step/map_step.rb +6 -2
  58. data/lib/ruby_reactor/step.rb +10 -4
  59. data/lib/ruby_reactor/step_signals.rb +33 -0
  60. data/lib/ruby_reactor/step_sweeper.rb +72 -0
  61. data/lib/ruby_reactor/step_worker.rb +260 -0
  62. data/lib/ruby_reactor/storage/adapter.rb +26 -1
  63. data/lib/ruby_reactor/storage/redis_adapter.rb +4 -71
  64. data/lib/ruby_reactor/storage/redis_locking.rb +7 -0
  65. data/lib/ruby_reactor/storage/redis_pub_sub.rb +31 -0
  66. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
  67. data/lib/ruby_reactor/storage/redis_step_results.rb +49 -0
  68. data/lib/ruby_reactor/sweeper.rb +7 -1
  69. data/lib/ruby_reactor/sweeper_job.rb +1 -0
  70. data/lib/ruby_reactor/template/result.rb +151 -5
  71. data/lib/ruby_reactor/version.rb +1 -1
  72. data/lib/ruby_reactor/web/api.rb +117 -22
  73. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
  74. data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
  75. data/lib/ruby_reactor/web/public/index.html +2 -2
  76. data/lib/ruby_reactor/worker.rb +53 -3
  77. data/lib/ruby_reactor.rb +80 -10
  78. data/specs/active_job.md +1 -1
  79. metadata +23 -3
  80. data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +0 -1
  81. data/lib/ruby_reactor/web/public/assets/index-D7IBZvos.js +0 -21
@@ -10,13 +10,14 @@ module RubyReactor
10
10
  base.instance_variable_set(:@return_step, nil)
11
11
  base.instance_variable_set(:@middlewares, [])
12
12
  base.instance_variable_set(:@input_validations, {})
13
- base.instance_variable_set(:@async, false)
13
+ base.instance_variable_set(:@background_handoff, nil)
14
14
  base.instance_variable_set(:@retry_defaults, { max_attempts: 3, backoff: :exponential, base_delay: 1 })
15
15
  end
16
16
 
17
17
  module ClassMethods
18
18
  include RubyReactor::Dsl::TemplateHelpers
19
19
  include RubyReactor::Dsl::ValidationHelpers
20
+ include RubyReactor::Dsl::AsyncMacros
20
21
 
21
22
  require_relative "interrupt_builder"
22
23
  require_relative "interrupt_step_config"
@@ -41,12 +42,17 @@ module RubyReactor
41
42
  @input_validations ||= {}
42
43
  end
43
44
 
44
- def async(async = true)
45
- @async = async
46
- end
47
-
48
- def async?
49
- @async ||= false
45
+ # Whole-reactor `async true` is gone: it named the same idea as
46
+ # `background`'s cut point with a different word, right next to the
47
+ # new `async_step`/`async_reactor` macros whose names mean something
48
+ # else entirely. `async?` (the reader) lives in `AsyncMacros`, driven
49
+ # off `background_handoff`.
50
+ def async(*)
51
+ raise RubyReactor::Error::DeprecatedDslError,
52
+ "`async true` on a reactor has been removed: it named the same idea as `background`'s cut " \
53
+ "point with a different word, and read confusingly next to the `async_step`/`async_reactor` " \
54
+ "step macros. Use `background all: true` instead — identical behavior, including validating " \
55
+ "inputs inside the worker."
50
56
  end
51
57
 
52
58
  def retry_defaults(**kwargs)
@@ -138,8 +144,18 @@ module RubyReactor
138
144
  step_config
139
145
  end
140
146
 
141
- def interrupt(name, &block)
142
- builder = RubyReactor::Dsl::InterruptBuilder.new(name, self)
147
+ # `resume: :background` — after `continue` validates and stores the
148
+ # payload, the remaining work is enqueued to a worker instead of
149
+ # running inline in the delivering process (webhook, admin UI).
150
+ def interrupt(name, resume: :inline, &block)
151
+ unless %i[inline background].include?(resume)
152
+ raise RubyReactor::Error::ValidationError,
153
+ "interrupt :#{name} has invalid `resume: #{resume.inspect}` — " \
154
+ "use `:inline` (default, resume runs in the calling process) or " \
155
+ "`:background` (resume is enqueued to a worker)."
156
+ end
157
+
158
+ builder = RubyReactor::Dsl::InterruptBuilder.new(name, self, resume: resume)
143
159
  builder.instance_eval(&block) if block_given?
144
160
 
145
161
  step_config = builder.build
@@ -148,7 +164,10 @@ module RubyReactor
148
164
  end
149
165
 
150
166
  def returns(step_name = nil)
151
- @return_step = step_name if step_name
167
+ if step_name
168
+ reject_async_return_step!(step_name)
169
+ @return_step = step_name
170
+ end
152
171
  @return_step
153
172
  end
154
173
 
@@ -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
@@ -3,6 +3,8 @@
3
3
  module RubyReactor
4
4
  module Dsl
5
5
  module TemplateHelpers
6
+ include RubyReactor::StepSignals
7
+
6
8
  def input(name, path = nil)
7
9
  RubyReactor::Template::Input.new(name, path)
8
10
  end
@@ -19,7 +21,7 @@ module RubyReactor
19
21
  RubyReactor::Template::Element.new(map_name, path)
20
22
  end
21
23
 
22
- # Make Success, Failure, and Skipped available in DSL contexts
24
+ # Make Success, Failure, Halt, and Skipped available in DSL contexts
23
25
  # rubocop:disable Naming/MethodName
24
26
  def Success(value = nil)
25
27
  # rubocop:enable Naming/MethodName
@@ -33,9 +35,15 @@ module RubyReactor
33
35
  end
34
36
 
35
37
  # rubocop:disable Naming/MethodName
36
- def Skipped(reason: nil, **kwargs)
38
+ def Halt(reason: nil, **kwargs)
39
+ # rubocop:enable Naming/MethodName
40
+ RubyReactor.Halt(reason: reason, **kwargs)
41
+ end
42
+
43
+ # rubocop:disable Naming/MethodName
44
+ def Skipped(...)
37
45
  # rubocop:enable Naming/MethodName
38
- RubyReactor.Skipped(reason: reason, **kwargs)
46
+ RubyReactor.Skipped(...)
39
47
  end
40
48
  end
41
49
  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
@@ -55,33 +55,44 @@ module RubyReactor
55
55
  @context.middlewares || RubyReactor::MiddlewareRunner.new([])
56
56
  end
57
57
 
58
+ # Ensure we have a value to log (if it's a Success/Failure object, get the value or error)
59
+ def loggable_value(result)
60
+ if result.respond_to?(:value)
61
+ result.value
62
+ elsif result.respond_to?(:error)
63
+ result.error
64
+ else
65
+ result
66
+ end
67
+ end
68
+
69
+ def skipped_result?(result)
70
+ result.respond_to?(:skipped?) && result.skipped?
71
+ end
72
+
58
73
  def compensate_step(step_config, error, arguments)
59
74
  middlewares.on(:start_compensation, step_config.name, error, arguments, @context)
60
75
  begin
61
- compensate_result = if step_config.compensate_block
62
- step_config.compensate_block.call(error, arguments, @context)
63
- elsif step_config.has_impl?
64
- step_config.impl.compensate(error, arguments, @context)
65
- else
66
- RubyReactor.Success() # Default compensation
67
- end
68
-
69
- # Ensure we have a value to log
70
- logged_result = if compensate_result.respond_to?(:value)
71
- compensate_result.value
72
- elsif compensate_result.respond_to?(:error)
73
- compensate_result.error
74
- else
75
- compensate_result
76
- end
77
-
78
- @context.execution_trace << {
79
- type: :compensate,
80
- step: step_config.name,
81
- timestamp: Time.now,
82
- result: logged_result,
83
- arguments: arguments
84
- }
76
+ compensate_result = catch(StepSignals::TAG) do
77
+ if step_config.compensate_block
78
+ step_config.compensate_block.call(error, arguments, @context)
79
+ elsif step_config.has_impl?
80
+ step_config.impl.compensate(error, arguments, @context)
81
+ else
82
+ RubyReactor.Skipped() # Default: nothing defined, rollback continues
83
+ end
84
+ end
85
+
86
+ @context.append_execution_trace(
87
+ {
88
+ type: :compensate,
89
+ step: step_config.name,
90
+ timestamp: Time.now,
91
+ result: loggable_value(compensate_result),
92
+ arguments: arguments,
93
+ skipped: skipped_result?(compensate_result)
94
+ }
95
+ )
85
96
  @undo_trace << { type: :compensation, step: step_config.name, error: error, arguments: arguments }
86
97
 
87
98
  if compensate_result.is_a?(RubyReactor::Failure)
@@ -100,25 +111,26 @@ module RubyReactor
100
111
  def undo_step(step_config, result, arguments)
101
112
  middlewares.on(:start_undo, step_config.name, result, arguments, @context)
102
113
  begin
103
- undo_result = if step_config.undo_block
104
- step_config.undo_block.call(result.value, arguments, @context)
105
- elsif step_config.has_impl?
106
- step_config.impl.undo(result.value, arguments, @context)
107
- else
108
- RubyReactor.Success()
109
- end
110
-
111
- # Ensure we have a value to log (if it's a Success/Failure object, get the value or error)
112
- logged_result = if undo_result.respond_to?(:value)
113
- undo_result.value
114
- elsif undo_result.respond_to?(:error)
115
- undo_result.error
116
- else
117
- undo_result
118
- end
119
-
120
- @context.execution_trace << { type: :undo, step: step_config.name, timestamp: Time.now, result: logged_result,
121
- arguments: arguments }
114
+ undo_result = catch(StepSignals::TAG) do
115
+ if step_config.undo_block
116
+ step_config.undo_block.call(result.value, arguments, @context)
117
+ elsif step_config.has_impl?
118
+ step_config.impl.undo(result.value, arguments, @context)
119
+ else
120
+ RubyReactor.Skipped() # Default: nothing defined, rollback continues
121
+ end
122
+ end
123
+
124
+ @context.append_execution_trace(
125
+ {
126
+ type: :undo,
127
+ step: step_config.name,
128
+ timestamp: Time.now,
129
+ result: loggable_value(undo_result),
130
+ arguments: arguments,
131
+ skipped: skipped_result?(undo_result)
132
+ }
133
+ )
122
134
 
123
135
  if undo_result.is_a?(RubyReactor::Failure)
124
136
  middlewares.on(:failed_undo, step_config.name, undo_result, @context)
@@ -130,8 +142,9 @@ module RubyReactor
130
142
  rescue StandardError => e
131
143
  middlewares.on(:failed_undo, step_config.name, e, @context)
132
144
  # Log undo failure but don't halt the rollback process
133
- @context.execution_trace << { type: :undo_failure, step: step_config.name, timestamp: Time.now,
134
- error: e.message }
145
+ @context.append_execution_trace(
146
+ { type: :undo_failure, step: step_config.name, timestamp: Time.now, error: e.message }
147
+ )
135
148
  RubyReactor.Failure(e)
136
149
  end
137
150
  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).
@@ -117,14 +117,14 @@ module RubyReactor
117
117
  @ordered_lock_drained_replay == true
118
118
  end
119
119
 
120
- # Terminal Skipped result when the ordered-lock gate short-circuits this
120
+ # Terminal Halt result when the ordered-lock gate short-circuits this
121
121
  # run (stale batch, strict chain failure, or a drained-batch redelivery of
122
122
  # an already-terminal context), or nil to continue. Shared by `execute`
123
123
  # and `resume_execution`.
124
124
  def ordered_lock_short_circuit
125
- return RubyReactor::Skipped.new(reason: :ordered_lock_stale_batch) if ordered_lock_stale_batch?
126
- return RubyReactor::Skipped.new(reason: :ordered_lock_drained_replay) if ordered_lock_drained_replay?
127
- return RubyReactor::Skipped.new(reason: :ordered_lock_chain_failed) if ordered_lock_chain_skip?
125
+ return RubyReactor::Halt.new(reason: :ordered_lock_stale_batch) if ordered_lock_stale_batch?
126
+ return RubyReactor::Halt.new(reason: :ordered_lock_drained_replay) if ordered_lock_drained_replay?
127
+ return RubyReactor::Halt.new(reason: :ordered_lock_chain_failed) if ordered_lock_chain_skip?
128
128
 
129
129
  nil
130
130
  end
@@ -138,12 +138,12 @@ module RubyReactor
138
138
  def short_circuit!(result)
139
139
  @result = result
140
140
 
141
- # A stale-batch or drained-batch-redelivery skip means this run's epoch
141
+ # A stale-batch or drained-batch-redelivery halt means this run's epoch
142
142
  # belongs to a drained generation — typically a slow straggler or a
143
143
  # Sidekiq at-least-once redelivery. If the redelivery is of a job that
144
144
  # ALREADY reached a terminal status, its stored context is the source of
145
- # truth; writing :skipped over a :completed/:failed record would silently
146
- # corrupt the outcome. Return the skip to the worker (so it stops)
145
+ # truth; writing :halted over a :completed/:failed record would silently
146
+ # corrupt the outcome. Return the halt to the worker (so it stops)
147
147
  # without saving. The `@skip_context_persist` flag also suppresses the
148
148
  # ensure-block save in execute / resume_execution, which would otherwise
149
149
  # clobber the stored terminal record with this run's stale in-memory
@@ -169,14 +169,14 @@ module RubyReactor
169
169
  # set when the status was terminal, but re-checking keeps both paths
170
170
  # uniform and self-guarding.
171
171
  def redelivery_of_terminal?(result)
172
- return false unless result.is_a?(RubyReactor::Skipped)
172
+ return false unless result.is_a?(RubyReactor::Halt)
173
173
  return false unless %i[ordered_lock_stale_batch ordered_lock_drained_replay].include?(result.reason)
174
174
 
175
175
  stored_status_terminal?
176
176
  end
177
177
 
178
178
  def stored_status_terminal?
179
- %w[completed failed skipped].include?(stored_context_status)
179
+ %w[completed failed halted skipped].include?(stored_context_status)
180
180
  end
181
181
 
182
182
  def stored_context_status
@@ -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
@@ -14,9 +14,13 @@ module RubyReactor
14
14
 
15
15
  def handle_step_result(step_config, result, resolved_arguments)
16
16
  case result
17
+ when RubyReactor::Halt
18
+ # Important: must come before Skipped and Success — both are Halt's
19
+ # siblings under Success, and Halt takes precedence over either.
20
+ handle_halt(step_config, result)
17
21
  when RubyReactor::Skipped
18
- # Important: must come before the Success branch — Skipped < Success.
19
- handle_skipped(step_config, result)
22
+ # Must come before the Success branch — Skipped < Success.
23
+ handle_skipped(step_config, result, resolved_arguments)
20
24
  when RubyReactor::Success
21
25
  handle_success(step_config, result, resolved_arguments)
22
26
  when RubyReactor::MaxRetriesExhaustedFailure
@@ -78,30 +82,62 @@ module RubyReactor
78
82
  )
79
83
  end
80
84
 
81
- # A step returned `RubyReactor.Skipped(...)`. Halt cleanly: record the
85
+ # A step returned `RubyReactor.Halt(...)`. Halt cleanly: record the
82
86
  # event in the trace, do NOT push to the undo stack (so existing
83
87
  # completed steps stay as-is — no compensation), and stamp the step
84
88
  # name on the result so the caller can see who halted.
85
- def handle_skipped(step_config, result)
89
+ def handle_halt(step_config, result)
86
90
  @step_results[step_config.name] = result
87
91
  result.instance_variable_set(:@step_name, step_config.name) if result.step_name.nil?
88
- @context.execution_trace << {
89
- type: :skipped,
90
- step: step_config.name,
91
- timestamp: Time.now,
92
- reason: result.reason
93
- }
92
+ @context.append_execution_trace(
93
+ {
94
+ type: :halt,
95
+ step: step_config.name,
96
+ timestamp: Time.now,
97
+ reason: result.reason
98
+ }
99
+ )
100
+ result
101
+ end
102
+
103
+ # A step returned `RubyReactor.Skipped(...)`. The reactor continues:
104
+ # validate and record the value exactly like a Success, but do NOT push
105
+ # to the undo stack (nothing happened, so there is nothing to undo).
106
+ def handle_skipped(step_config, result, resolved_arguments)
107
+ validate_step_output(step_config, result.value, resolved_arguments)
108
+ @step_results[step_config.name] = result
109
+ @context.set_result(step_config.name, result.value)
110
+ @dependency_graph.complete_step(step_config.name)
111
+ @context.append_execution_trace(
112
+ {
113
+ type: :skipped,
114
+ step: step_config.name,
115
+ timestamp: Time.now,
116
+ reason: result.reason
117
+ }
118
+ )
94
119
  result
95
120
  end
96
121
 
97
122
  def handle_success(step_config, result, resolved_arguments)
98
123
  validate_step_output(step_config, result.value, resolved_arguments)
99
124
  @step_results[step_config.name] = result
100
- @compensation_manager.add_to_undo_stack({ step: step_config, arguments: resolved_arguments, result: result })
125
+ # `async_step` / `async_reactor` dispatches are independent units of
126
+ # work with their own compensation flows — the parent rolling back must
127
+ # not "undo" a dispatch whose unit runs (and may still succeed)
128
+ # elsewhere. They never enter the parent's undo stack.
129
+ unless async_unit?(step_config)
130
+ @compensation_manager.add_to_undo_stack({ step: step_config, arguments: resolved_arguments,
131
+ result: result })
132
+ end
101
133
  @context.set_result(step_config.name, result.value)
102
134
  @dependency_graph.complete_step(step_config.name)
103
135
  end
104
136
 
137
+ def async_unit?(step_config)
138
+ step_config.respond_to?(:async_dispatch?) && step_config.async_dispatch?
139
+ end
140
+
105
141
  def handle_retries_exhausted(step_config, result, resolved_arguments)
106
142
  @compensation_manager.handle_step_failure(step_config, result.original_error, resolved_arguments)
107
143
  orig_err = result.original_error.is_a?(Exception) ? result.original_error : nil