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
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ # The shared wait core behind `result(:name)` for an `async_step` or an
5
+ # `async_reactor` that has not finished yet. Both callers hand it a pub/sub
6
+ # channel and a callable that returns the terminal value (or nil while the work
7
+ # is still in flight), so there is exactly one implementation of the wait.
8
+ #
9
+ # The contract is "durable record answers, signal only hurries":
10
+ #
11
+ # * the completing side writes its durable outcome FIRST, then publishes;
12
+ # * this side checks the durable target, then blocks until either the signal
13
+ # arrives or a coarse fallback interval elapses, then re-checks.
14
+ #
15
+ # Redis pub/sub is at-most-once and unpersisted, so a dropped signal must never
16
+ # cost correctness — only fallback latency. Every exit path here goes through
17
+ # the durable check, and the whole thing is bounded: it raises rather than
18
+ # hanging (SC-005).
19
+ class AsyncWaiter
20
+ # Latency backstop for a lost signal, not a tuning surface — derived from the
21
+ # timeout rather than configured (Principle V). The clamp guarantees ~10
22
+ # re-checks inside any bound, so a dropped notification costs at most ~10% of
23
+ # the wait, and never re-checks hotter than once a second.
24
+ FALLBACK_BOUNDS = (1.0..5.0)
25
+
26
+ attr_reader :channel, :timeout
27
+
28
+ # @param channel [String] completion-signal channel to listen on
29
+ # @param timeout [Numeric, nil] seconds; defaults to `async_wait_timeout`
30
+ # @yieldreturn [Object, nil] the terminal value, or nil while still pending
31
+ def initialize(channel:, timeout: nil, &terminal_check)
32
+ @channel = channel
33
+ @timeout = timeout || RubyReactor.configuration.async_wait_timeout
34
+ @terminal_check = terminal_check
35
+ @mutex = Mutex.new
36
+ @condition = ConditionVariable.new
37
+ @signalled = false
38
+ end
39
+
40
+ def wait
41
+ deadline = monotonic + @timeout
42
+ subscriber = start_subscriber
43
+
44
+ loop do
45
+ value = @terminal_check.call
46
+ return value unless value.nil?
47
+
48
+ remaining = deadline - monotonic
49
+ raise timeout_error if remaining <= 0
50
+
51
+ block_until_signalled_or([fallback_interval, remaining].min)
52
+ end
53
+ ensure
54
+ # ponytail: killing the thread is enough — `subscribe`'s own ensure closes
55
+ # the dedicated connection on the way out.
56
+ subscriber&.kill
57
+ end
58
+
59
+ private
60
+
61
+ def fallback_interval
62
+ (@timeout / 10.0).clamp(FALLBACK_BOUNDS)
63
+ end
64
+
65
+ # Subscribing happens before the first durable check so a completion landing
66
+ # mid-check still wakes us. The subscription is established asynchronously,
67
+ # so this narrows the race rather than closing it outright — which is fine
68
+ # precisely because the fallback re-check, not the signal, is what makes the
69
+ # wait correct.
70
+ def start_subscriber
71
+ Thread.new do
72
+ RubyReactor.configuration.storage_adapter.subscribe(@channel) do |_message|
73
+ signal!
74
+ true # stop subscribing — completion is one-shot
75
+ end
76
+ rescue StandardError => e
77
+ # A waiter that loses its notification channel degrades to the fallback
78
+ # re-check; it must not take the waiting step down with it.
79
+ RubyReactor.configuration.logger.warn(
80
+ "RubyReactor: async completion subscription to #{@channel} failed (#{e.class}: #{e.message}); " \
81
+ "falling back to periodic re-checks"
82
+ )
83
+ end
84
+ end
85
+
86
+ def signal!
87
+ @mutex.synchronize do
88
+ @signalled = true
89
+ @condition.broadcast
90
+ end
91
+ end
92
+
93
+ def block_until_signalled_or(seconds)
94
+ @mutex.synchronize do
95
+ @condition.wait(@mutex, seconds) unless @signalled
96
+ @signalled = false
97
+ end
98
+ end
99
+
100
+ def timeout_error
101
+ Error::AsyncWaitTimeoutError.new(
102
+ "Timed out after #{@timeout}s waiting for async completion on '#{@channel}'. " \
103
+ "The dispatched work never reached a terminal state within " \
104
+ "`RubyReactor.configuration.async_wait_timeout` — check that a worker is running and " \
105
+ "consuming the queue, or raise the timeout if this unit is legitimately slower."
106
+ )
107
+ end
108
+
109
+ def monotonic
110
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
111
+ end
112
+ end
113
+ end
@@ -10,7 +10,8 @@ module RubyReactor
10
10
  attr_writer :queue_name, :job_retry_count, :logger, :async_router,
11
11
  :lock_snooze_base_delay, :lock_snooze_jitter, :lock_snooze_max_attempts,
12
12
  :middlewares, :context_ttl, :context_lock_ttl, :checkpoint_min_interval,
13
- :sweeper_enabled, :sweeper_interval, :sweeper_limit
13
+ :sweeper_enabled, :sweeper_interval, :sweeper_limit, :async_wait_timeout,
14
+ :async_park_timeout
14
15
 
15
16
  def queue_name
16
17
  @queue_name ||= :default
@@ -89,6 +90,34 @@ module RubyReactor
89
90
  @context_lock_ttl ||= 60
90
91
  end
91
92
 
93
+ # Seconds a step blocks in the notified wait when it reads
94
+ # `result(:name)` for an `async_step` / `async_reactor` that has not finished
95
+ # yet. Never unbounded: on expiry the referencing step fails with an
96
+ # `Error::AsyncWaitTimeoutError`.
97
+ #
98
+ # 30s must comfortably exceed dispatch -> worker pickup -> completion for a
99
+ # small unit under a healthy queue, while staying under the request/job
100
+ # timeouts of typical hosts (Sidekiq's 25s shutdown grace, Puma's 60s) so a
101
+ # stuck wait fails loudly on our terms instead of being killed from outside.
102
+ #
103
+ # The wait's fallback re-check interval is DERIVED from this, not configured:
104
+ # `async_wait_timeout / 10` clamped to 1..5s (see AsyncWaiter).
105
+ def async_wait_timeout
106
+ @async_wait_timeout ||= 30
107
+ end
108
+
109
+ # Total seconds a WORKER-side `result(:name)` wait may stay parked before
110
+ # failing with `Error::AsyncWaitTimeoutError`, measured from the unit's
111
+ # `dispatched_at`. Inside a worker a pending result does not block the
112
+ # thread for `async_wait_timeout` — after a short in-thread grace the job
113
+ # parks (re-enqueues itself, locks kept held) and re-checks on redelivery,
114
+ # so this bound can be generous where the blocking one must stay tight.
115
+ # `:infinity` removes the bound (the context TTL then remains the only
116
+ # backstop), mirroring `lock_snooze_max_attempts`.
117
+ def async_park_timeout
118
+ @async_park_timeout ||= 3600
119
+ end
120
+
92
121
  def job_retry_count
93
122
  @job_retry_count ||= 3
94
123
  end
@@ -2,6 +2,27 @@
2
2
 
3
3
  module RubyReactor
4
4
  class Context
5
+ # `composed_contexts[step_name]` is the one channel for "children of this
6
+ # context, referenced for later drill-down" — the dashboard's
7
+ # `hydrate_composed_contexts` and `RSpec::TestSubject`'s traversal both
8
+ # switch on the `type:` tag. Four tags, all plain data (no serialization
9
+ # change between them):
10
+ #
11
+ # :composed => { name:, type:, context: } — an inline `compose` child
12
+ # :map_ref => { name:, type:, map_id:, element_reactor_class: }
13
+ # :async_step_ref => { name:, type:, dispatched_at: }
14
+ # The reference only. The `async_step`'s actual outcome lives in the Step
15
+ # Result Record bucket, keyed by (context_id, step_name), because a
16
+ # separate worker writes it concurrently with this still-running context.
17
+ # :async_reactor_ref => { name:, type:, execution_id:, reactor_class_name:, dispatched_at: }
18
+ # The child is an ordinary addressable reactor, so its outcome is simply
19
+ # its own context row — no extra storage primitive.
20
+ #
21
+ # Both async refs are written SYNCHRONOUSLY by the dispatching step, before
22
+ # it returns, so there is no cross-process write race on the reference
23
+ # itself (unlike the result).
24
+ COMPOSED_CONTEXT_TYPES = %i[composed map_ref async_step_ref async_reactor_ref].freeze
25
+
5
26
  attr_accessor :inputs, :intermediate_results, :private_data, :current_step, :retry_count, :concurrency_key,
6
27
  :retry_context, :reactor_class, :execution_trace, :inline_async_execution, :undo_stack,
7
28
  :parent_context, :root_context, :composed_contexts, :context_id, :map_operations, :map_metadata,
@@ -82,6 +103,13 @@ module RubyReactor
82
103
  @current_step = old_step
83
104
  end
84
105
 
106
+ # Appends an execution-trace event stamped with whether this process is the
107
+ # worker (`inline_async_execution`). Traces written before this field
108
+ # existed stay unlabeled on deserialize.
109
+ def append_execution_trace(entry)
110
+ @execution_trace << entry.merge(background: !!@inline_async_execution)
111
+ end
112
+
85
113
  def to_h
86
114
  {
87
115
  inputs: @inputs,
@@ -36,6 +36,11 @@ module RubyReactor
36
36
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
37
37
  def serialize_value(value)
38
38
  case value
39
+ when RubyReactor::Halt
40
+ { "_type" => "Halt", "reason" => value.reason, "period_key" => value.period_key,
41
+ "step_name" => value.step_name }
42
+ when RubyReactor::Skipped
43
+ { "_type" => "Skipped", "value" => serialize_value(value.value), "reason" => value.reason }
39
44
  when RubyReactor::Success
40
45
  { "_type" => "Success", "value" => serialize_value(value.value) }
41
46
  when RubyReactor::Failure
@@ -112,6 +117,14 @@ module RubyReactor
112
117
  if value.key?("_type")
113
118
  # Special serialized types (Time, BigDecimal, etc.)
114
119
  case value["_type"]
120
+ when "Halt"
121
+ RubyReactor::Halt.new(
122
+ reason: value["reason"],
123
+ period_key: value["period_key"],
124
+ step_name: value["step_name"]
125
+ )
126
+ when "Skipped"
127
+ RubyReactor::Skipped.new(deserialize_value(value["value"]), reason: value["reason"])
115
128
  when "Success"
116
129
  RubyReactor::Success(deserialize_value(value["value"]))
117
130
  when "Failure"
@@ -185,6 +198,8 @@ module RubyReactor
185
198
  # Simplifies data for public API usage (removes wrappers, flattens types)
186
199
  def simplify_for_api(value)
187
200
  case value
201
+ when RubyReactor::Map::ResultEnumerator
202
+ RubyReactor::Map::ResultSummary.build(value)
188
203
  when Hash
189
204
  simplified = value.each_with_object({}) do |(k, v), hash|
190
205
  hash[k.to_s] = simplify_for_api(v)
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Dsl
5
+ # The three ways a reactor sends work out of the calling process, kept
6
+ # together because they are one design decision seen from three distances:
7
+ #
8
+ # background — the rest of THIS reactor moves to a worker
9
+ # (`all: true` — EVERYTHING, incl. input validation)
10
+ # async_step — ONE step's work becomes its own job
11
+ # async_reactor — a whole nested reactor runs independently
12
+ #
13
+ # Mixed into `Dsl::Reactor::ClassMethods`.
14
+ module AsyncMacros
15
+ # The single, unambiguous cut point between what runs in
16
+ # the calling process and what is handed to a worker. Replaces the
17
+ # per-step `async` flag, where only the first flagged step ever took
18
+ # effect and the rest were silently ignored, and the whole-reactor
19
+ # `async true` flag, which named the same idea with a different word.
20
+ #
21
+ # background after: :second # :second is the LAST step to run here
22
+ # background before: :third # :third is the FIRST step in the worker
23
+ # background all: true # the ENTIRE reactor runs in the worker,
24
+ # # including input validation
25
+ #
26
+ # `after:`/`before:` name one cut point from opposite sides — identical
27
+ # in a linear chain, different in a DAG, where each pins the step it
28
+ # names. `all:` names no step: there is nothing left to pin, everything
29
+ # moves. Triggering is keyed to REACHING the named step (or, for `all:`,
30
+ # to the run starting at all), not to where this declaration sits in
31
+ # the class body.
32
+ def background(after: nil, before: nil, all: false)
33
+ point = validate_background_declaration!(after, before, all)
34
+
35
+ @background_handoff = point
36
+ end
37
+
38
+ # The normalized `{ mode:, step: }` pair — one reader, never a one-sided
39
+ # `background_after`, so no consumer can be accidentally implemented for
40
+ # `after:` only. `step` is `nil` when `mode` is `:all`.
41
+ def background_handoff
42
+ @background_handoff
43
+ end
44
+
45
+ # True only for the whole-reactor hand-off (`background all: true`) —
46
+ # the entire run, including input validation, happens in a worker.
47
+ def async?
48
+ background_handoff&.fetch(:mode, nil) == :all
49
+ end
50
+
51
+ def validate_background_declaration!(after, before, all)
52
+ given = [after, before, all].count { |v| v }
53
+ if given > 1
54
+ raise RubyReactor::Error::ValidationError,
55
+ "`background` takes exactly one of `after:`, `before:`, or `all:`, got more than one " \
56
+ "(after: #{after.inspect}, before: #{before.inspect}, all: #{all.inspect}). Each names a " \
57
+ "different hand-off shape: `after: :x` / `before: :x` pin a cut point around step :x; " \
58
+ "`all: true` sends the whole reactor, including input validation."
59
+ end
60
+
61
+ if given.zero?
62
+ raise RubyReactor::Error::ValidationError,
63
+ "`background` requires one of `after: :step_name` (that step is the last to run in the " \
64
+ "calling process), `before: :step_name` (that step is the first to run in the worker), or " \
65
+ "`all: true` (the entire reactor, including input validation, runs in the worker)."
66
+ end
67
+
68
+ point = if all
69
+ { mode: :all, step: nil }
70
+ else
71
+ { mode: after ? :after : :before, step: (after || before).to_sym }
72
+ end
73
+
74
+ # Re-declaring the SAME point is a no-op — a class body can be
75
+ # evaluated twice (Rails reloading, a spec reopening a fixture class)
76
+ # and that must not be an error. A DIFFERENT second point is the real
77
+ # footgun `background` exists to remove.
78
+ if background_handoff && background_handoff != point
79
+ raise RubyReactor::Error::ValidationError,
80
+ "#{name || "This reactor"} already declares `background " \
81
+ "#{describe_handoff_point(background_handoff)}` and cannot also declare `background " \
82
+ "#{describe_handoff_point(point)}`. A reactor has exactly one hand-off point — a second " \
83
+ "would reintroduce the ambiguity `background` exists to remove."
84
+ end
85
+
86
+ validate_step_handoff_point!(point) unless point[:mode] == :all
87
+
88
+ point
89
+ end
90
+ private :validate_background_declaration!
91
+
92
+ def validate_step_handoff_point!(point)
93
+ step_name = point[:step]
94
+ unless steps.key?(step_name)
95
+ raise RubyReactor::Error::ValidationError,
96
+ "`background` names unknown step :#{step_name}. Known steps: " \
97
+ "#{steps.keys.map { |k| ":#{k}" }.join(", ")}. The step must be defined before the " \
98
+ "`background` declaration."
99
+ end
100
+
101
+ reject_interrupt_handoff_point!(point)
102
+ end
103
+ private :validate_step_handoff_point!
104
+
105
+ def describe_handoff_point(point)
106
+ point[:mode] == :all ? "all: true" : "#{point[:mode]}: :#{point[:step]}"
107
+ end
108
+ private :describe_handoff_point
109
+
110
+ # An interrupt re-enters the reactor from a foreground process, so an
111
+ # edge-triggered hand-off keyed to it either never fires (`after:` — the
112
+ # resume path skips the already-resulted step) or enqueues a worker that
113
+ # instantly pauses and swallows the InterruptResult (`before:`). Both are
114
+ # the silent-failure class `background` exists to remove.
115
+ def reject_interrupt_handoff_point!(point)
116
+ config = steps[point[:step]]
117
+ return unless config.respond_to?(:interrupt?) && config.interrupt?
118
+
119
+ raise RubyReactor::Error::ValidationError,
120
+ "`background #{point[:mode]}: :#{point[:step]}` names an interrupt step, which cannot be a " \
121
+ "hand-off point. To resume :#{point[:step]} in a worker, declare " \
122
+ "`interrupt :#{point[:step]}, resume: :background` instead; to hand off around it, name an " \
123
+ "ordinary step on the side you need."
124
+ end
125
+ private :reject_interrupt_handoff_point!
126
+ # A step whose work is dispatched to its own independent worker
127
+ # job while this reactor keeps executing every other ready step. Same
128
+ # call shape and same block DSL as `step` — `argument`, `run`,
129
+ # `compensate`, `undo`, `retries`, validators all behave identically;
130
+ # only WHERE the body runs changes.
131
+ #
132
+ # Any step reading `result(:name)` blocks (bounded) until the unit
133
+ # finishes. A failure with no reader does NOT compensate this reactor —
134
+ # compensation is opt-in, via a reader that inspects the result and
135
+ # returns `Failure` itself.
136
+ def async_step(name, impl = nil, &block)
137
+ builder = RubyReactor::Dsl::StepBuilder.new(name, impl, self)
138
+ builder.instance_eval(&block) if block_given?
139
+
140
+ steps[name] = builder.build(async_dispatch: :step)
141
+ end
142
+
143
+ # Dispatch a whole nested reactor to run INDEPENDENTLY — linked
144
+ # to this one by execution id for traceability, but excluded from its
145
+ # compensation graph. Fire-and-forget unless a later step reads
146
+ # `result(:name)`, which blocks until the child is terminal and hands
147
+ # over the child's real Success/Failure to inspect.
148
+ #
149
+ # Contrast with `compose`, which runs the child inline, synchronously,
150
+ # and fully wired into the parent's rollback path.
151
+ def async_reactor(name, child_reactor_class, &block)
152
+ builder = RubyReactor::Dsl::AsyncReactorBuilder.new(name, child_reactor_class, self)
153
+ builder.instance_eval(&block) if block_given?
154
+
155
+ steps[name] = builder.build
156
+ end
157
+
158
+ # A reactor's return value must come from a step that ran in the calling
159
+ # process. An `async_step` / `async_reactor` may still be in flight when
160
+ # this reactor finishes — that is the whole point of dispatching it — so
161
+ # returning it would either mean returning nothing or silently turning
162
+ # the fire-and-forget contract into a blocking wait.
163
+ def reject_async_return_step!(step_name)
164
+ config = steps[step_name]
165
+ return unless config.respond_to?(:async_dispatch?) && config.async_dispatch?
166
+
167
+ kind = config.async_dispatch == :reactor ? "async_reactor" : "async_step"
168
+ raise RubyReactor::Error::ValidationError,
169
+ "`returns :#{step_name}` is invalid: :#{step_name} is an `#{kind}`, which may still be " \
170
+ "running when this reactor finishes. Return a same-process step instead — if you need the " \
171
+ "dispatched outcome, add a step that reads `result(:#{step_name})` and return that."
172
+ end
173
+ private :reject_async_return_step!
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Dsl
5
+ # Builds the `async_reactor` dispatch step. Same `argument` mapping shape as
6
+ # `ComposeBuilder`, but deliberately NOT a subclass of it: `compose`'s
7
+ # builder warns that a child's `with_ordered_lock` is ignored (true for an
8
+ # inline child, which bypasses `Reactor#run`) whereas an `async_reactor`
9
+ # child is dispatched through the full pre-enqueue sequence and DOES get its
10
+ # ordering nonce.
11
+ class AsyncReactorBuilder
12
+ include RubyReactor::Dsl::TemplateHelpers
13
+
14
+ attr_accessor :name, :child_reactor_class, :argument_mappings
15
+
16
+ def initialize(name, child_reactor_class, reactor = nil)
17
+ @name = name
18
+ @child_reactor_class = child_reactor_class
19
+ @reactor = reactor
20
+ @argument_mappings = {}
21
+ @retry_config = {}
22
+ end
23
+
24
+ def argument(child_input_name, source)
25
+ @argument_mappings[child_input_name] = source
26
+ end
27
+
28
+ def retries(max_attempts: 3, backoff: :exponential, base_delay: 1)
29
+ @retry_config = { max_attempts: max_attempts, backoff: backoff, base_delay: base_delay }
30
+ end
31
+
32
+ def build
33
+ RubyReactor::Dsl::StepConfig.new(
34
+ async_dispatch: :reactor,
35
+ name: @name,
36
+ impl: RubyReactor::Step::AsyncReactorStep,
37
+ arguments: {
38
+ async_reactor_class: { source: RubyReactor::Template::Value.new(@child_reactor_class) },
39
+ argument_mappings: { source: RubyReactor::Template::Value.new(@argument_mappings) }
40
+ },
41
+ run_block: nil,
42
+ # No compensate/undo: the child is deliberately outside the parent's
43
+ # compensation graph. Compensation is opt-in, via a later step
44
+ # that reads `result(:name)` and decides to fail.
45
+ compensate_block: nil,
46
+ undo_block: nil,
47
+ conditions: [],
48
+ guards: [],
49
+ dependencies: dependencies_from_mappings,
50
+ args_validator: nil,
51
+ output_validator: nil,
52
+ retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
53
+ )
54
+ end
55
+
56
+ private
57
+
58
+ def dependencies_from_mappings
59
+ @argument_mappings.each_value
60
+ .select { |source| source.is_a?(RubyReactor::Template::Result) }
61
+ .map(&:step_name)
62
+ .uniq
63
+ end
64
+ end
65
+ end
66
+ end
@@ -20,7 +20,6 @@ module RubyReactor
20
20
  end
21
21
  @reactor = reactor
22
22
  @argument_mappings = {}
23
- @async = false
24
23
  @retry_config = {}
25
24
  end
26
25
 
@@ -28,8 +27,21 @@ module RubyReactor
28
27
  @argument_mappings[composed_input_name] = source
29
28
  end
30
29
 
31
- def async(async = true)
32
- @async = async
30
+ # `compose`'s `async` set the very same per-step hand-off flag the
31
+ # step DSL's did, so it goes with it. The exact migration is
32
+ # `background before: :<this compose step>` — that reproduces the old
33
+ # semantics precisely (this step and everything after it move to the
34
+ # worker) without the author having to identify a predecessor step.
35
+ def async(*)
36
+ raise RubyReactor::Error::DeprecatedDslError.new(
37
+ "`async` inside a `compose` block has been removed (it set the same ambiguous per-step " \
38
+ "hand-off flag as `step`'s). Use `background before: :#{@name}` on the reactor to move " \
39
+ ":#{@name} and every step after it to a worker — that reproduces the old behavior exactly. " \
40
+ "For a nested reactor that should run INDEPENDENTLY of this one, use " \
41
+ "`async_reactor :#{@name}, #{@composed_reactor_class&.name || "ChildReactor"}` instead; " \
42
+ "for one step's worth of work, `async_step`.",
43
+ step: @name
44
+ )
33
45
  end
34
46
 
35
47
  def retries(max_attempts: 3, backoff: :exponential, base_delay: 1)
@@ -59,7 +71,6 @@ module RubyReactor
59
71
  dependencies: dependencies,
60
72
  args_validator: nil,
61
73
  output_validator: nil,
62
- async: @async,
63
74
  retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
64
75
  }
65
76
 
@@ -77,6 +88,14 @@ module RubyReactor
77
88
  @composed_reactor_class.compose(name, reactor_class, &block)
78
89
  end
79
90
 
91
+ # An inline compose child is a real reactor class, so it can declare its
92
+ # own hand-off point — delegate rather than making inline children the one
93
+ # place `background` is unavailable.
94
+ def background(**kwargs)
95
+ ensure_composed_reactor_class!
96
+ @composed_reactor_class.background(**kwargs)
97
+ end
98
+
80
99
  private
81
100
 
82
101
  # Composed children bypass `Reactor#run`, so `assign_ordered_lock_nonce!`
@@ -3,12 +3,13 @@
3
3
  module RubyReactor
4
4
  module Dsl
5
5
  class InterruptBuilder < StepBuilder
6
- def initialize(name, reactor = nil)
6
+ def initialize(name, reactor = nil, resume: :inline)
7
7
  super(name, nil, reactor)
8
8
  @correlation_id_block = nil
9
9
  @timeout_config = nil
10
10
  @validation_schema = nil
11
11
  @max_attempts = 1
12
+ @resume_mode = resume
12
13
  end
13
14
 
14
15
  def max_attempts(count)
@@ -51,8 +52,8 @@ module RubyReactor
51
52
  timeout_config: @timeout_config,
52
53
  validation_schema: @validation_schema,
53
54
  max_attempts: @max_attempts,
55
+ resume_mode: @resume_mode,
54
56
  dependencies: @dependencies,
55
- async: false, # Interrupts are effectively boundaries, not async jobs themselves (until resumed)
56
57
  conditions: @conditions,
57
58
  guards: @guards
58
59
  }
@@ -3,7 +3,8 @@
3
3
  module RubyReactor
4
4
  module Dsl
5
5
  class InterruptStepConfig < StepConfig
6
- attr_reader :correlation_id_block, :timeout_config, :validation_schema, :strategy, :max_attempts
6
+ attr_reader :correlation_id_block, :timeout_config, :validation_schema, :strategy, :max_attempts,
7
+ :resume_mode
7
8
 
8
9
  def initialize(config)
9
10
  super
@@ -11,11 +12,16 @@ module RubyReactor
11
12
  @timeout_config = config[:timeout_config]
12
13
  @validation_schema = config[:validation_schema]
13
14
  @max_attempts = config[:max_attempts] || 1
15
+ @resume_mode = config[:resume_mode] || :inline
14
16
  end
15
17
 
16
18
  def interrupt?
17
19
  true
18
20
  end
21
+
22
+ def background_resume?
23
+ @resume_mode == :background
24
+ end
19
25
  end
20
26
  end
21
27
  end
@@ -53,7 +53,7 @@ module RubyReactor
53
53
 
54
54
  # Configure a calendar-aligned dedup window for this reactor. The
55
55
  # reactor will run at most once per bucket per key; subsequent calls
56
- # in the same bucket return `RubyReactor::Skipped` without executing
56
+ # in the same bucket return `RubyReactor::Halt` without executing
57
57
  # any steps.
58
58
  #
59
59
  # Note: `with_period` is *dedup*, not *concurrency*. Two concurrent
@@ -93,13 +93,13 @@ module RubyReactor
93
93
  # assign. Only fully-drained sequences GC themselves.
94
94
  # @param strict [Boolean] When true (default), if any nonce in the
95
95
  # sequence terminates with a `Failure`, all subsequent nonces are
96
- # short-circuited with `Skipped(reason: :ordered_lock_chain_failed)`
96
+ # short-circuited with `Halt(reason: :ordered_lock_chain_failed)`
97
97
  # instead of executing. This models "stop the line on the first
98
98
  # problem" pipelines (e.g. ledger transactions). When false, the
99
99
  # sequence keeps executing every nonce in order regardless of prior
100
100
  # failures. The poison state is per-key and clears on full drain. The
101
101
  # check only applies to a fresh `execute`; an already-started run
102
- # that paused (InterruptResult/AsyncResult) completes on resume even
102
+ # that paused (InterruptResult/DispatchResult) completes on resume even
103
103
  # if the chain failed in the meantime.
104
104
  # @yield [inputs] Block that returns the ordered-lock key string.
105
105
  def with_ordered_lock(poison_pill_timeout: OrderedLock::DEFAULT_POISON_PILL_TIMEOUT,
@@ -107,8 +107,7 @@ module RubyReactor
107
107
  guards: [],
108
108
  dependencies: dependencies.uniq,
109
109
  args_validator: nil,
110
- output_validator: nil,
111
- async: false # MapStep handles async internally via run_async
110
+ output_validator: nil
112
111
  }
113
112
  end
114
113