ruby_reactor 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/demo-app-e2e-verify/SKILL.md +226 -0
- data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +79 -12
- data/.specify/templates/tasks-template.md +7 -0
- data/CHANGELOG.md +125 -1
- data/CLAUDE.md +5 -0
- data/README.md +155 -25
- data/lib/ruby_reactor/adapters/active_job/router.rb +21 -6
- data/lib/ruby_reactor/adapters/active_job/step_worker.rb +20 -0
- data/lib/ruby_reactor/adapters/active_job/worker.rb +6 -1
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -6
- data/lib/ruby_reactor/adapters/sidekiq/step_worker.rb +17 -0
- data/lib/ruby_reactor/adapters/sidekiq/worker.rb +5 -2
- data/lib/ruby_reactor/async_waiter.rb +113 -0
- data/lib/ruby_reactor/configuration.rb +30 -1
- data/lib/ruby_reactor/context.rb +28 -0
- data/lib/ruby_reactor/context_serializer.rb +15 -0
- data/lib/ruby_reactor/dsl/async_macros.rb +176 -0
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +66 -0
- data/lib/ruby_reactor/dsl/compose_builder.rb +23 -4
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +3 -2
- data/lib/ruby_reactor/dsl/interrupt_step_config.rb +7 -1
- data/lib/ruby_reactor/dsl/lockable.rb +3 -3
- data/lib/ruby_reactor/dsl/map_builder.rb +1 -2
- data/lib/ruby_reactor/dsl/reactor.rb +29 -10
- data/lib/ruby_reactor/dsl/step_builder.rb +30 -11
- data/lib/ruby_reactor/dsl/template_helpers.rb +11 -3
- data/lib/ruby_reactor/error/async_result_pending.rb +21 -0
- data/lib/ruby_reactor/error/async_wait_timeout_error.rb +10 -0
- data/lib/ruby_reactor/error/deprecated_dsl_error.rb +11 -0
- data/lib/ruby_reactor/executor/async_step_dispatch.rb +110 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +11 -11
- data/lib/ruby_reactor/executor/result_handler.rb +47 -11
- data/lib/ruby_reactor/executor/retry_manager.rb +13 -6
- data/lib/ruby_reactor/executor/step_executor.rb +77 -24
- data/lib/ruby_reactor/executor.rb +179 -23
- data/lib/ruby_reactor/lock.rb +19 -0
- data/lib/ruby_reactor/map/element_executor.rb +13 -2
- data/lib/ruby_reactor/map/helpers.rb +10 -8
- data/lib/ruby_reactor/map/result_enumerator.rb +7 -1
- data/lib/ruby_reactor/map/result_summary.rb +63 -0
- data/lib/ruby_reactor/map/sweeper.rb +1 -1
- data/lib/ruby_reactor/open_telemetry.rb +8 -5
- data/lib/ruby_reactor/ordered_lock.rb +3 -3
- data/lib/ruby_reactor/reactor.rb +25 -2
- data/lib/ruby_reactor/rspec/matchers.rb +61 -11
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +2 -1
- data/lib/ruby_reactor/rspec/step_executor_patch.rb +2 -2
- data/lib/ruby_reactor/rspec/test_subject.rb +74 -19
- data/lib/ruby_reactor/semaphore.rb +10 -0
- data/lib/ruby_reactor/step/async_reactor_step.rb +207 -0
- data/lib/ruby_reactor/step/compose_step.rb +1 -1
- data/lib/ruby_reactor/step/map_step.rb +6 -2
- data/lib/ruby_reactor/step.rb +10 -4
- data/lib/ruby_reactor/step_signals.rb +33 -0
- data/lib/ruby_reactor/step_sweeper.rb +72 -0
- data/lib/ruby_reactor/step_worker.rb +260 -0
- data/lib/ruby_reactor/storage/adapter.rb +26 -1
- data/lib/ruby_reactor/storage/redis_adapter.rb +4 -71
- data/lib/ruby_reactor/storage/redis_locking.rb +7 -0
- data/lib/ruby_reactor/storage/redis_pub_sub.rb +31 -0
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
- data/lib/ruby_reactor/storage/redis_step_results.rb +49 -0
- data/lib/ruby_reactor/sweeper.rb +7 -1
- data/lib/ruby_reactor/sweeper_job.rb +1 -0
- data/lib/ruby_reactor/template/result.rb +151 -5
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +117 -22
- data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
- data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor/worker.rb +53 -3
- data/lib/ruby_reactor.rb +80 -10
- data/specs/active_job.md +1 -1
- metadata +23 -3
- data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +0 -1
- data/lib/ruby_reactor/web/public/assets/index-D7IBZvos.js +0 -21
|
@@ -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
|
data/lib/ruby_reactor/context.rb
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
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::
|
|
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 `
|
|
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/
|
|
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,
|