ruby_reactor 0.5.4 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/speckit-agent-context-update/SKILL.md +32 -0
- data/.claude/skills/speckit-analyze/SKILL.md +262 -0
- data/.claude/skills/speckit-checklist/SKILL.md +374 -0
- data/.claude/skills/speckit-clarify/SKILL.md +286 -0
- data/.claude/skills/speckit-constitution/SKILL.md +157 -0
- data/.claude/skills/speckit-converge/SKILL.md +277 -0
- data/.claude/skills/speckit-implement/SKILL.md +224 -0
- data/.claude/skills/speckit-plan/SKILL.md +171 -0
- data/.claude/skills/speckit-specify/SKILL.md +346 -0
- data/.claude/skills/speckit-tasks/SKILL.md +215 -0
- data/.claude/skills/speckit-taskstoissues/SKILL.md +110 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/extensions/.registry +19 -0
- data/.specify/extensions/agent-context/README.md +66 -0
- data/.specify/extensions/agent-context/agent-context-config.yml +5 -0
- data/.specify/extensions/agent-context/commands/speckit.agent-context.update.md +27 -0
- data/.specify/extensions/agent-context/extension.yml +34 -0
- data/.specify/extensions/agent-context/scripts/bash/update-agent-context.sh +282 -0
- data/.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +353 -0
- data/.specify/extensions.yml +23 -0
- data/.specify/feature.json +3 -0
- data/.specify/init-options.json +9 -0
- data/.specify/integration.json +15 -0
- data/.specify/integrations/claude.manifest.json +17 -0
- data/.specify/integrations/speckit.manifest.json +17 -0
- data/.specify/memory/constitution.md +134 -0
- data/.specify/scripts/bash/check-prerequisites.sh +189 -0
- data/.specify/scripts/bash/common.sh +619 -0
- data/.specify/scripts/bash/create-new-feature.sh +299 -0
- data/.specify/scripts/bash/setup-plan.sh +84 -0
- data/.specify/scripts/bash/setup-tasks.sh +91 -0
- data/.specify/templates/checklist-template.md +40 -0
- data/.specify/templates/constitution-template.md +50 -0
- data/.specify/templates/plan-template.md +113 -0
- data/.specify/templates/spec-template.md +131 -0
- data/.specify/templates/tasks-template.md +252 -0
- data/.specify/workflows/speckit/workflow.yml +77 -0
- data/.specify/workflows/workflow-registry.json +13 -0
- data/CHANGELOG.md +121 -1
- data/CLAUDE.md +5 -0
- data/README.md +181 -39
- data/lib/ruby_reactor/adapters/active_job/compat.rb +24 -0
- data/lib/ruby_reactor/adapters/active_job/map_collector_worker.rb +19 -0
- data/lib/ruby_reactor/adapters/active_job/map_element_worker.rb +19 -0
- data/lib/ruby_reactor/adapters/active_job/router.rb +106 -0
- data/lib/ruby_reactor/adapters/active_job/step_worker.rb +20 -0
- data/lib/ruby_reactor/adapters/active_job/sweeper_worker.rb +16 -0
- data/lib/ruby_reactor/adapters/active_job/worker.rb +29 -0
- data/lib/ruby_reactor/adapters/sidekiq/map_collector_worker.rb +15 -0
- data/lib/ruby_reactor/adapters/sidekiq/map_element_worker.rb +15 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +106 -0
- data/lib/ruby_reactor/adapters/sidekiq/step_worker.rb +17 -0
- data/lib/ruby_reactor/adapters/sidekiq/sweeper_worker.rb +19 -0
- data/lib/ruby_reactor/adapters/sidekiq/worker.rb +28 -0
- data/lib/ruby_reactor/async_waiter.rb +113 -0
- data/lib/ruby_reactor/configuration.rb +54 -5
- data/lib/ruby_reactor/context.rb +21 -0
- data/lib/ruby_reactor/context_serializer.rb +2 -0
- data/lib/ruby_reactor/dsl/async_macros.rb +176 -0
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +66 -0
- data/lib/ruby_reactor/dsl/compose_builder.rb +23 -4
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +3 -2
- data/lib/ruby_reactor/dsl/interrupt_step_config.rb +7 -1
- data/lib/ruby_reactor/dsl/lockable.rb +1 -1
- data/lib/ruby_reactor/dsl/map_builder.rb +1 -2
- data/lib/ruby_reactor/dsl/reactor.rb +29 -10
- data/lib/ruby_reactor/dsl/step_builder.rb +30 -11
- data/lib/ruby_reactor/error/async_result_pending.rb +21 -0
- data/lib/ruby_reactor/error/async_wait_timeout_error.rb +10 -0
- data/lib/ruby_reactor/error/deprecated_dsl_error.rb +11 -0
- data/lib/ruby_reactor/executor/async_step_dispatch.rb +110 -0
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +2 -2
- data/lib/ruby_reactor/executor/result_handler.rb +12 -1
- data/lib/ruby_reactor/executor/retry_manager.rb +9 -5
- data/lib/ruby_reactor/executor/step_executor.rb +58 -11
- data/lib/ruby_reactor/executor.rb +162 -8
- data/lib/ruby_reactor/lock.rb +19 -0
- data/lib/ruby_reactor/map/element_executor.rb +7 -2
- data/lib/ruby_reactor/map/helpers.rb +1 -1
- data/lib/ruby_reactor/map/result_enumerator.rb +5 -1
- data/lib/ruby_reactor/map/result_summary.rb +63 -0
- data/lib/ruby_reactor/open_telemetry.rb +1 -1
- data/lib/ruby_reactor/reactor.rb +25 -2
- data/lib/ruby_reactor/rspec/active_job_helpers.rb +52 -0
- data/lib/ruby_reactor/rspec/async_test_helpers.rb +41 -0
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +4 -3
- data/lib/ruby_reactor/rspec/step_executor_patch.rb +2 -2
- data/lib/ruby_reactor/rspec/test_subject.rb +77 -18
- data/lib/ruby_reactor/rspec.rb +4 -0
- data/lib/ruby_reactor/semaphore.rb +10 -0
- data/lib/ruby_reactor/step/async_reactor_step.rb +207 -0
- data/lib/ruby_reactor/step/compose_step.rb +1 -1
- data/lib/ruby_reactor/step/map_step.rb +1 -1
- data/lib/ruby_reactor/step_sweeper.rb +72 -0
- data/lib/ruby_reactor/step_worker.rb +260 -0
- data/lib/ruby_reactor/storage/adapter.rb +22 -1
- data/lib/ruby_reactor/storage/redis_adapter.rb +17 -13
- data/lib/ruby_reactor/storage/redis_locking.rb +7 -0
- data/lib/ruby_reactor/storage/redis_pub_sub.rb +31 -0
- data/lib/ruby_reactor/storage/redis_step_results.rb +49 -0
- data/lib/ruby_reactor/sweeper.rb +7 -1
- data/lib/ruby_reactor/sweeper_job.rb +71 -0
- data/lib/ruby_reactor/template/result.rb +151 -5
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +94 -16
- data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +1 -0
- data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +22 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor/worker.rb +276 -0
- data/lib/ruby_reactor.rb +64 -4
- data/specs/001-background-async-steps/checklists/requirements.md +39 -0
- data/specs/001-background-async-steps/contracts/public-dsl.md +154 -0
- data/specs/001-background-async-steps/data-model.md +117 -0
- data/specs/001-background-async-steps/plan.md +168 -0
- data/specs/001-background-async-steps/quickstart.md +102 -0
- data/specs/001-background-async-steps/research.md +150 -0
- data/specs/001-background-async-steps/spec.md +146 -0
- data/specs/001-background-async-steps/tasks.md +271 -0
- data/specs/active_job.md +259 -0
- metadata +80 -8
- data/lib/ruby_reactor/sidekiq_adapter.rb +0 -87
- data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +0 -13
- data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +0 -13
- data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +0 -73
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +0 -222
- data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +0 -1
- data/lib/ruby_reactor/web/public/assets/index-D7IBZvos.js +0 -21
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<title>ui</title>
|
|
8
|
-
<script type="module" crossorigin src="./assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
8
|
+
<script type="module" crossorigin src="./assets/index-DPmP4yXT.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="./assets/index-B46p-M6K.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
# Framework-agnostic resume/snooze/escalate logic shared by every queueing
|
|
5
|
+
# backend's worker class. Each backend (`Adapters::Sidekiq::Worker`,
|
|
6
|
+
# `Adapters::ActiveJob::Worker`, ...) includes this and supplies its own
|
|
7
|
+
# `self.class.perform_in` (native on Sidekiq::Worker, via
|
|
8
|
+
# `Adapters::ActiveJob::Compat` on ActiveJob::Base) — nothing here references
|
|
9
|
+
# a specific backend.
|
|
10
|
+
module Worker
|
|
11
|
+
TERMINAL_STATUSES = %w[completed failed cancelled skipped].freeze
|
|
12
|
+
|
|
13
|
+
# Last line of observability when a job burns its whole retry budget on an
|
|
14
|
+
# infrastructure failure and the backend then discards it (Sidekiq runs
|
|
15
|
+
# with `dead: false`): without this, the context would stay "running"
|
|
16
|
+
# forever with zero surface anywhere, and every reader would wait out its
|
|
17
|
+
# full timeout. Called from the backends' retries-exhausted hooks with the
|
|
18
|
+
# job's own args. Best-effort — never raises back into the backend.
|
|
19
|
+
def self.record_retries_exhausted(args, exception)
|
|
20
|
+
context_id, reactor_class_name = args
|
|
21
|
+
return unless context_id
|
|
22
|
+
|
|
23
|
+
reactor_class_name ||= RubyReactor.reactor_storage_name(nil)
|
|
24
|
+
storage = RubyReactor.configuration.storage_adapter
|
|
25
|
+
data = storage.retrieve_context(context_id, reactor_class_name)
|
|
26
|
+
return if data.nil? || TERMINAL_STATUSES.include?((data["status"] || data[:status]).to_s)
|
|
27
|
+
|
|
28
|
+
data["status"] = "failed"
|
|
29
|
+
data["failure_reason"] = {
|
|
30
|
+
"message" => "job retries exhausted: #{exception.class.name}: #{exception.message}",
|
|
31
|
+
"exception_class" => exception.class.name
|
|
32
|
+
}
|
|
33
|
+
storage.store_context(context_id, JSON.generate(data), reactor_class_name)
|
|
34
|
+
storage.publish(RubyReactor.async_reactor_channel(context_id), "failed")
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
RubyReactor.configuration.logger.error(
|
|
37
|
+
"RubyReactor: could not record retries-exhausted failure for #{context_id}: #{e.class.name}: #{e.message}"
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Identity-only payload: storage is the source of truth. Rehydrate the live
|
|
42
|
+
# context from storage by id, then resume. A nil read means the context was
|
|
43
|
+
# swept, expired, or already terminal-and-collected — nothing to resume.
|
|
44
|
+
def perform(context_id, reactor_class_name = nil, snooze_count = 0)
|
|
45
|
+
# Normalize so a nil/omitted name resolves to the same storage key the
|
|
46
|
+
# enqueue path wrote (always via reactor_storage_name). Without this a
|
|
47
|
+
# nil here builds "reactor::context:<id>" and misses the stored
|
|
48
|
+
# "reactor:AnonymousReactor:context:<id>", silently no-op'ing.
|
|
49
|
+
reactor_class_name ||= RubyReactor.reactor_storage_name(nil)
|
|
50
|
+
data = RubyReactor.configuration.storage_adapter.retrieve_context(context_id, reactor_class_name)
|
|
51
|
+
return if data.nil?
|
|
52
|
+
|
|
53
|
+
begin
|
|
54
|
+
context = ContextSerializer.deserialize_hash(data)
|
|
55
|
+
rescue RubyReactor::Error::DeserializationError,
|
|
56
|
+
RubyReactor::Error::SchemaVersionError => e
|
|
57
|
+
# Permanent failures — re-reading the same stored blob will keep
|
|
58
|
+
# failing. Mark the context as failed (best-effort) and return so
|
|
59
|
+
# the job does not burn its retry budget.
|
|
60
|
+
handle_deserialization_failure(context_id, reactor_class_name, e)
|
|
61
|
+
return
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
resolve_reactor_class!(context, reactor_class_name)
|
|
65
|
+
unless context.reactor_class
|
|
66
|
+
# Still unresolved (class not loaded, or an anonymous reactor with no
|
|
67
|
+
# storage name to look up) — Executor.new below would blow up on a nil
|
|
68
|
+
# class and burn the job's retry budget forever. Fail the context now.
|
|
69
|
+
error = RubyReactor::Error::DeserializationError.new(
|
|
70
|
+
"reactor class '#{reactor_class_name}' could not be resolved"
|
|
71
|
+
)
|
|
72
|
+
handle_deserialization_failure(context_id, reactor_class_name, error)
|
|
73
|
+
return
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Mark that we're executing inline to prevent nested async calls
|
|
77
|
+
context.inline_async_execution = true
|
|
78
|
+
|
|
79
|
+
begin
|
|
80
|
+
# Resume execution from the failed step
|
|
81
|
+
executor = Executor.new(context.reactor_class, {}, context)
|
|
82
|
+
executor.resume_execution
|
|
83
|
+
# No explicit save here: resume_execution's ensure block already persists
|
|
84
|
+
# the final root state (`save_context unless skip_context_persist?`), and
|
|
85
|
+
# in the worker the executor's context IS the root, so an extra checkpoint!
|
|
86
|
+
# would just re-write the identical blob to the identical key. The
|
|
87
|
+
# skip_context_persist? guard (stale-batch redelivery of an already-terminal
|
|
88
|
+
# context) is likewise honored there.
|
|
89
|
+
|
|
90
|
+
# Return the executor (which now has the result stored in it)
|
|
91
|
+
executor
|
|
92
|
+
rescue RubyReactor::Lock::AcquisitionError,
|
|
93
|
+
RubyReactor::Semaphore::AcquisitionError,
|
|
94
|
+
RubyReactor::RateLimit::ExceededError,
|
|
95
|
+
RubyReactor::OrderedLock::WaitError,
|
|
96
|
+
RubyReactor::Error::AsyncResultPending => e
|
|
97
|
+
# Snooze on expected concurrency, rate, or ordering contention.
|
|
98
|
+
# OrderedLock::WaitError carries a poison-pill-derived retry hint,
|
|
99
|
+
# consumed by compute_snooze_delay below. We avoid the framework's native
|
|
100
|
+
# retry path so this doesn't burn the job's retry budget or appear
|
|
101
|
+
# as an error in dashboards. After the configured cap is reached we
|
|
102
|
+
# escalate by marking the reactor as failed.
|
|
103
|
+
handle_snooze(context_id, reactor_class_name, context, snooze_count, e)
|
|
104
|
+
rescue RubyReactor::RateLimitRegistry::UnknownLimitError => e
|
|
105
|
+
# Permanent configuration error — snoozing or retrying the same job
|
|
106
|
+
# will keep failing. Mark the context failed immediately.
|
|
107
|
+
escalate_snooze(context, snooze_count, e)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# If reactor_class_name is provided, use it to get the reactor class.
|
|
114
|
+
# This handles cases where the class can't be found via const_get.
|
|
115
|
+
def resolve_reactor_class!(context, reactor_class_name)
|
|
116
|
+
return unless reactor_class_name && context.reactor_class.nil?
|
|
117
|
+
|
|
118
|
+
begin
|
|
119
|
+
context.reactor_class = Object.const_get(reactor_class_name)
|
|
120
|
+
rescue NameError
|
|
121
|
+
# If not found, try to find it in the current namespace
|
|
122
|
+
# This is a fallback for test environments
|
|
123
|
+
begin
|
|
124
|
+
context.reactor_class = reactor_class_name.constantize if reactor_class_name.respond_to?(:constantize)
|
|
125
|
+
rescue NameError
|
|
126
|
+
# Leave reactor_class nil: the caller's guard fails the context with
|
|
127
|
+
# a durable record. Letting this second NameError escape would burn
|
|
128
|
+
# the job's whole retry budget on an error retries can never fix,
|
|
129
|
+
# then vanish — leaving the context "running" forever and any reader
|
|
130
|
+
# waiting out its full timeout.
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def handle_snooze(context_id, reactor_class_name, context, snooze_count, error)
|
|
137
|
+
config = RubyReactor.configuration
|
|
138
|
+
max = config.lock_snooze_max_attempts
|
|
139
|
+
|
|
140
|
+
# OrderedLock::WaitError bypasses the snooze cap. The gate's
|
|
141
|
+
# poison_pill_timeout is the only meaningful upper bound on how long a
|
|
142
|
+
# nonce can legitimately wait; capping snoozes would either fail jobs
|
|
143
|
+
# prematurely or strand the nonce in `assigned_at` until poison_pill
|
|
144
|
+
# eventually advances past it. Snooze until the gate passes (or poison
|
|
145
|
+
# auto-advance moves the cursor past us).
|
|
146
|
+
# The per-context liveness lock (`async:<id>`) is also uncapped: a
|
|
147
|
+
# duplicate of the *same* execution may wait arbitrarily long for the
|
|
148
|
+
# live original to finish (e.g. a sweeper re-enqueue racing a slow but
|
|
149
|
+
# alive worker). Capping it would fail a legitimately-waiting duplicate.
|
|
150
|
+
# A parked async wait is likewise uncapped HERE: its bound is
|
|
151
|
+
# `async_park_timeout`, enforced against `dispatched_at` at the wait
|
|
152
|
+
# site — counting snoozes would double-bound it with the wrong unit.
|
|
153
|
+
capped = !(error.is_a?(RubyReactor::OrderedLock::WaitError) ||
|
|
154
|
+
error.is_a?(RubyReactor::Lock::ContextLockContention) ||
|
|
155
|
+
error.is_a?(RubyReactor::Error::AsyncResultPending))
|
|
156
|
+
|
|
157
|
+
if capped && max != :infinity && snooze_count >= max
|
|
158
|
+
escalate_snooze(context, snooze_count, error)
|
|
159
|
+
return
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
delay = compute_snooze_delay(config, error)
|
|
163
|
+
# Re-enqueue by id: the context is already persisted in storage, so the
|
|
164
|
+
# rescheduled job rehydrates fresh state (no stale blob).
|
|
165
|
+
self.class.perform_in(delay, context_id, reactor_class_name, snooze_count + 1)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Use the error's `retry_after_seconds` hint when available
|
|
169
|
+
# (RateLimit::ExceededError carries the time until the bucket rolls);
|
|
170
|
+
# otherwise fall back to the configured base + jitter for lock/semaphore
|
|
171
|
+
# contention which has no precise hint.
|
|
172
|
+
#
|
|
173
|
+
# OrderedLock::WaitError is deliberately excluded from the hint path: its
|
|
174
|
+
# `retry_after_seconds` is the poison-pill window (the upper bound before
|
|
175
|
+
# a *dead* blocker is force-advanced), NOT how long the *live* blocker
|
|
176
|
+
# will take — which is usually milliseconds. Snoozing for the full window
|
|
177
|
+
# would make every out-of-order nonce sleep up to poison_pill_timeout even
|
|
178
|
+
# though its blocker finishes immediately, collapsing throughput. Re-poll
|
|
179
|
+
# at the base delay instead; poison auto-advance still clears a genuinely
|
|
180
|
+
# dead blocker on a later gate.
|
|
181
|
+
def compute_snooze_delay(config, error)
|
|
182
|
+
jitter = config.lock_snooze_jitter.to_f
|
|
183
|
+
jitter_amount = jitter.positive? ? rand(0.0..jitter) : 0.0
|
|
184
|
+
|
|
185
|
+
if hinted_retry?(error)
|
|
186
|
+
[error.retry_after_seconds.to_f, 0.1].max + jitter_amount
|
|
187
|
+
else
|
|
188
|
+
config.lock_snooze_base_delay.to_f + jitter_amount
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def hinted_retry?(error)
|
|
193
|
+
return false if error.is_a?(RubyReactor::OrderedLock::WaitError)
|
|
194
|
+
|
|
195
|
+
error.respond_to?(:retry_after_seconds) && error.retry_after_seconds
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def escalate_snooze(context, snooze_count, error)
|
|
199
|
+
RubyReactor.configuration.logger.warn(
|
|
200
|
+
"RubyReactor snooze limit reached after #{snooze_count} attempts " \
|
|
201
|
+
"for context #{context.context_id}: #{error.message}"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
context.status = :failed
|
|
205
|
+
context.failure_reason = {
|
|
206
|
+
message: error.message,
|
|
207
|
+
exception_class: error.class.name,
|
|
208
|
+
snooze_attempts: snooze_count
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
serialized = ContextSerializer.serialize(context)
|
|
212
|
+
reactor_class_name = RubyReactor.reactor_storage_name(context.reactor_class)
|
|
213
|
+
RubyReactor.configuration.storage_adapter.store_context(
|
|
214
|
+
context.context_id,
|
|
215
|
+
serialized,
|
|
216
|
+
reactor_class_name
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Escalation is a terminal Failure that never reaches the Executor's
|
|
220
|
+
# ensure path, so advance the ordered-lock cursor here. Without this
|
|
221
|
+
# the nonce stays stranded in assigned_at (successors stall for the
|
|
222
|
+
# full poison_pill_timeout) and, worse, the strict-mode chain marker
|
|
223
|
+
# is never recorded — successors would RUN instead of being skipped.
|
|
224
|
+
info = Executor::OrderedLockSupport.info_from(context)
|
|
225
|
+
Executor::OrderedLockSupport.advance_with_retry(info, failed: true) if info
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def log_infrastructure_failure(msg, exception)
|
|
229
|
+
RubyReactor.configuration.logger.error("RubyReactor infrastructure failure: #{exception.message}")
|
|
230
|
+
RubyReactor.configuration.logger.error("Job details: #{msg.inspect}")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# The id-only payload already carries context_id and reactor_class_name, so
|
|
234
|
+
# there is no blob to parse for metadata — just mark the stored context
|
|
235
|
+
# failed (best-effort) so the job stops retrying a permanently-broken blob.
|
|
236
|
+
def handle_deserialization_failure(context_id, reactor_class_name, error)
|
|
237
|
+
RubyReactor.configuration.logger.error(
|
|
238
|
+
"RubyReactor deserialization failure for context " \
|
|
239
|
+
"#{context_id || "unknown"}: #{error.class.name}: #{error.message}"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
return unless context_id && reactor_class_name
|
|
243
|
+
|
|
244
|
+
payload = build_failed_context_payload(context_id, reactor_class_name, error)
|
|
245
|
+
RubyReactor.configuration.storage_adapter.store_context(
|
|
246
|
+
context_id,
|
|
247
|
+
payload,
|
|
248
|
+
reactor_class_name
|
|
249
|
+
)
|
|
250
|
+
# Written first, signalled second — wake any reader blocked (or parked)
|
|
251
|
+
# on this execution so it fails fast with the real cause instead of
|
|
252
|
+
# waiting out its timeout.
|
|
253
|
+
RubyReactor.configuration.storage_adapter.publish(
|
|
254
|
+
RubyReactor.async_reactor_channel(context_id), "failed"
|
|
255
|
+
)
|
|
256
|
+
rescue StandardError => e
|
|
257
|
+
# Don't let a persistence failure mask the original deserialization error.
|
|
258
|
+
RubyReactor.configuration.logger.error(
|
|
259
|
+
"RubyReactor failed to persist deserialization failure: #{e.class.name}: #{e.message}"
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def build_failed_context_payload(context_id, reactor_class_name, error)
|
|
264
|
+
JSON.generate(
|
|
265
|
+
"schema_version" => ContextSerializer::SCHEMA_VERSION,
|
|
266
|
+
"context_id" => context_id,
|
|
267
|
+
"reactor_class" => reactor_class_name,
|
|
268
|
+
"status" => "failed",
|
|
269
|
+
"failure_reason" => {
|
|
270
|
+
"message" => error.message,
|
|
271
|
+
"exception_class" => error.class.name
|
|
272
|
+
}
|
|
273
|
+
)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
data/lib/ruby_reactor.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "zeitwerk"
|
|
4
4
|
require "pathname"
|
|
5
5
|
require "securerandom"
|
|
6
|
+
require "time"
|
|
6
7
|
require_relative "ruby_reactor/registry"
|
|
7
8
|
require_relative "ruby_reactor/utils/code_extractor"
|
|
8
9
|
require_relative "ruby_reactor/dsl/lockable" # Add this
|
|
@@ -26,6 +27,13 @@ rescue LoadError
|
|
|
26
27
|
# sidekiq is optional, async features won't be available
|
|
27
28
|
end
|
|
28
29
|
|
|
30
|
+
# Load active_job if available (for the ActiveJob async adapter)
|
|
31
|
+
begin
|
|
32
|
+
require "active_job"
|
|
33
|
+
rescue LoadError
|
|
34
|
+
# active_job is optional, only needed when using the ActiveJob adapter
|
|
35
|
+
end
|
|
36
|
+
|
|
29
37
|
loader = Zeitwerk::Loader.for_gem
|
|
30
38
|
loader.inflector.inflect("api" => "API", "rspec" => "RSpec")
|
|
31
39
|
loader.setup
|
|
@@ -285,8 +293,10 @@ module RubyReactor
|
|
|
285
293
|
end
|
|
286
294
|
end
|
|
287
295
|
|
|
288
|
-
#
|
|
289
|
-
|
|
296
|
+
# Sentinel returned when a step's work is handed off to a worker job and is
|
|
297
|
+
# not yet resolved — produced by `background`, `async_step`, `async_reactor`,
|
|
298
|
+
# and map's async element dispatch alike.
|
|
299
|
+
class DispatchResult
|
|
290
300
|
attr_reader :job_id, :intermediate_results, :execution_id
|
|
291
301
|
|
|
292
302
|
def initialize(job_id:, intermediate_results: {}, execution_id: nil)
|
|
@@ -341,6 +351,23 @@ module RubyReactor
|
|
|
341
351
|
# (A truly anonymous class can't be reconstituted by name in another process,
|
|
342
352
|
# so cross-process resume of one is inherently unsupported; this only keeps
|
|
343
353
|
# the keys self-consistent within a process — e.g. inline tests.)
|
|
354
|
+
# Completion-signal channels for the notified wait. One place, because
|
|
355
|
+
# the publishing side and the waiting side must agree exactly and they live in
|
|
356
|
+
# different files (StepWorker / Executor vs Template::Result).
|
|
357
|
+
def self.async_step_channel(context_id, step_name)
|
|
358
|
+
"rr:done:#{context_id}:#{step_name}"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def self.async_reactor_channel(execution_id)
|
|
362
|
+
"rr:done:#{execution_id}"
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Liveness lock for one dispatched `async_step`. Held by StepWorker for the
|
|
366
|
+
# life of the unit, so StepSweeper can tell a slow unit from a lost job.
|
|
367
|
+
def self.async_step_lock_key(context_id, step_name)
|
|
368
|
+
"async_step:#{context_id}:#{step_name}"
|
|
369
|
+
end
|
|
370
|
+
|
|
344
371
|
def self.reactor_storage_name(reactor_class)
|
|
345
372
|
return "AnonymousReactor" if reactor_class.nil?
|
|
346
373
|
|
|
@@ -356,7 +383,39 @@ module RubyReactor
|
|
|
356
383
|
def self.start_sweeper!
|
|
357
384
|
return unless configuration.sweeper_enabled
|
|
358
385
|
|
|
359
|
-
|
|
386
|
+
sweeper_job_class.schedule_next
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# The sweeper job class living alongside the configured `async_router`
|
|
390
|
+
# (e.g. `Adapters::Sidekiq::Router` -> `Adapters::Sidekiq::SweeperWorker`),
|
|
391
|
+
# so the chain is kicked through whichever backend is configured instead of
|
|
392
|
+
# a hardcoded Sidekiq class. Known built-in routers are mapped explicitly;
|
|
393
|
+
# a custom `configuration.async_router` falls back to a sibling-namespace
|
|
394
|
+
# lookup, with a clear error (instead of a bare NameError) when that
|
|
395
|
+
# convention doesn't hold.
|
|
396
|
+
def self.sweeper_job_class
|
|
397
|
+
router = configuration.async_router
|
|
398
|
+
case router.name
|
|
399
|
+
when "RubyReactor::Adapters::Sidekiq::Router" then Adapters::Sidekiq::SweeperWorker
|
|
400
|
+
when "RubyReactor::Adapters::ActiveJob::Router" then Adapters::ActiveJob::SweeperWorker
|
|
401
|
+
else
|
|
402
|
+
inferred_sweeper_job_class(router)
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def self.inferred_sweeper_job_class(router)
|
|
407
|
+
namespace_name = router.name.to_s.rpartition("::").first
|
|
408
|
+
raise sweeper_job_class_error(router) if namespace_name.empty?
|
|
409
|
+
|
|
410
|
+
Object.const_get(namespace_name).const_get(:SweeperWorker)
|
|
411
|
+
rescue NameError
|
|
412
|
+
raise sweeper_job_class_error(router)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def self.sweeper_job_class_error(router)
|
|
416
|
+
"RubyReactor: cannot infer a sweeper job class for custom async_router " \
|
|
417
|
+
"#{router.inspect}. Define a `SweeperWorker` class alongside it " \
|
|
418
|
+
"(same namespace), or override `RubyReactor.sweeper_job_class`."
|
|
360
419
|
end
|
|
361
420
|
|
|
362
421
|
# Run both recovery sweepers exactly once and return their counts. The
|
|
@@ -368,7 +427,8 @@ module RubyReactor
|
|
|
368
427
|
limit ||= configuration.sweeper_limit
|
|
369
428
|
{
|
|
370
429
|
reactors: Sweeper.run_once(limit: limit),
|
|
371
|
-
maps: Map::Sweeper.run_once(limit: limit)
|
|
430
|
+
maps: Map::Sweeper.run_once(limit: limit),
|
|
431
|
+
async_steps: StepSweeper.run_once(limit: limit)
|
|
372
432
|
}
|
|
373
433
|
end
|
|
374
434
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Specification Quality Checklist: Background Execution & Real Async Steps
|
|
2
|
+
|
|
3
|
+
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
|
4
|
+
**Created**: 2026-08-16
|
|
5
|
+
**Feature**: [spec.md](../spec.md)
|
|
6
|
+
|
|
7
|
+
## Content Quality
|
|
8
|
+
|
|
9
|
+
- [x] No implementation details (languages, frameworks, APIs)
|
|
10
|
+
- [x] Focused on user value and business needs
|
|
11
|
+
- [x] Written for non-technical stakeholders
|
|
12
|
+
- [x] All mandatory sections completed
|
|
13
|
+
|
|
14
|
+
## Requirement Completeness
|
|
15
|
+
|
|
16
|
+
- [x] No [NEEDS CLARIFICATION] markers remain
|
|
17
|
+
- [x] Requirements are testable and unambiguous
|
|
18
|
+
- [x] Success criteria are measurable
|
|
19
|
+
- [x] Success criteria are technology-agnostic (no implementation details)
|
|
20
|
+
- [x] All acceptance scenarios are defined
|
|
21
|
+
- [x] Edge cases are identified
|
|
22
|
+
- [x] Scope is clearly bounded
|
|
23
|
+
- [x] Dependencies and assumptions identified
|
|
24
|
+
|
|
25
|
+
## Feature Readiness
|
|
26
|
+
|
|
27
|
+
- [x] All functional requirements have clear acceptance criteria
|
|
28
|
+
- [x] User scenarios cover primary flows
|
|
29
|
+
- [x] Feature meets measurable outcomes defined in Success Criteria
|
|
30
|
+
- [x] No implementation details leak into specification
|
|
31
|
+
|
|
32
|
+
## Notes
|
|
33
|
+
|
|
34
|
+
- Items marked incomplete require spec updates before `/speckit-clarify` or `/speckit-plan`.
|
|
35
|
+
- Note: the spec names concrete terms (`Sidekiq`, `ActiveJob`, `Redis`) only inside the **Assumptions** section, where they describe existing constraints already codified in the project constitution, not new implementation choices being introduced by this feature. All requirement- and success-criteria-level language remains technology-agnostic.
|
|
36
|
+
- 2026-08-16 (post-plan review): added FR-014/SC-006 covering dashboard visibility for `async_step`/`async_reactor`, a gap found while reviewing plan.md against the constitution's Observability principle. Re-validated against this checklist — all items still pass.
|
|
37
|
+
- 2026-08-21 (final consistency pass): fixed stale "blocking-poll" wording left from the superseded wait design (plan Summary/tree, contract, data-model); fixed the async_step dispatch ordering in data-model (record + ref written BEFORE enqueue, matching the F2 rule its own Step Result Record row states); fixed "record absent = pending" to "record still `dispatched` = pending"; reconciled plan Scale/Scope with the tree (dependency_graph.rb unchanged; executor.rb added for the completion-signal publish); aligned FR-015 with contract/research on single-slot semaphores. All items re-verified passing.
|
|
38
|
+
- 2026-08-20 (wait-mechanism & locks review): FR-005 revised from tight-poll to notified wait (record-first publish, subscribe-first check, fallback re-check — durable record stays the source of truth, timeout bound unchanged); added FR-015 (no lock-owner sharing across the async boundary + dispatch-time same-key deadlock guard; reentrancy stays compose-only) and FR-016 (async_reactor dispatch reuses the full pre-enqueue sequence: child input validation + ordered-lock nonce, a gap found during the locks review). Clarifications Session 2026-08-20 records both decisions. All checklist items re-verified passing.
|
|
39
|
+
- 2026-08-16 (full-artifact review): fixed FR-006 (claimed a nonexistent "existing step-result storage"; now: new per-step record for `async_step`, own execution record for `async_reactor`); clarified FR-003 scope (compose's `async` flag is removed too — it sets the same flag; map's `async` is a different mechanism and stays); added edge cases for `returns` × async unit, `background` × whole-reactor `async true`, and a paused awaited `async_reactor` child. Contract additionally pins failure-read semantics for `result()` on async units and lock-window behavior. All checklist items re-verified passing.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Contract: Public DSL Surface
|
|
2
|
+
|
|
3
|
+
This gem's "interface" is its Ruby DSL, consumed by host applications that subclass `RubyReactor::Reactor`. This contract documents the exact public surface this feature adds, changes, or removes, so implementation and tests can be checked against it directly.
|
|
4
|
+
|
|
5
|
+
## Removed
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class MyReactor < RubyReactor::Reactor
|
|
9
|
+
step :do_thing do
|
|
10
|
+
async true # REMOVED — raises a definition-time error (FR-003)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
class MyReactor < RubyReactor::Reactor
|
|
17
|
+
compose :sub_flow, SubReactor do
|
|
18
|
+
async true # ALSO REMOVED — same StepConfig hand-off flag, same definition-time error
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Error contract**: attempting to call `async` inside a `step` **or `compose`** block MUST raise a `RubyReactor::Error::ValidationError` (or a new dedicated error subclass — implementation's choice, but it MUST be raised at reactor **class-definition** time, not at `run` time) whose message names both the removed syntax and its replacement(s): `background after:`/`background before:`, `async_step`, `async_reactor`. (`ComposeBuilder#async` sets the very `StepConfig` flag this feature removes — `dsl/compose_builder.rb:62` — so it cannot survive; the exact migration for a compose that handed off is `background before: :<that compose step>`, which reproduces the old semantics precisely — the flagged step and everything after it moved to the worker — without the author having to identify a predecessor.)
|
|
24
|
+
|
|
25
|
+
**Not removed**: the `async` option inside a `map` block (`MapBuilder#async`, `dsl/map_builder.rb:43`) is a map-internal element-dispatch mode passed as a step *argument* — the map's own `StepConfig` is hardcoded `async: false` (`map_builder.rb:111`), so it does not touch the removed flag and keeps working unchanged.
|
|
26
|
+
|
|
27
|
+
## Added: `background after:` / `background before:`
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
class MyReactor < RubyReactor::Reactor
|
|
31
|
+
step :first
|
|
32
|
+
step :second
|
|
33
|
+
background after: :second # :second is the LAST step in the calling process
|
|
34
|
+
step :third
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class EquivalentInLinearFlow < RubyReactor::Reactor
|
|
38
|
+
step :first
|
|
39
|
+
step :second
|
|
40
|
+
background before: :third # :third is the FIRST step in the worker
|
|
41
|
+
step :third
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- **Signature**: `self.background(after: nil, before: nil)` — reactor class macro, exactly one keyword supplied.
|
|
46
|
+
- **The two forms** name one cut point from opposite sides, and each carries a *guarantee about the step it names*:
|
|
47
|
+
- `after: :x` — `:x` runs in the calling process, and is the last step to do so.
|
|
48
|
+
- `before: :x` — `:x` runs in the worker, and is the first step to do so; it never executes in the calling process.
|
|
49
|
+
|
|
50
|
+
In a linear reactor where `:third` immediately follows `:second`, `after: :second` and `before: :third` are equivalent. In a branching workflow they are not, and the author picks whichever step they actually need pinned.
|
|
51
|
+
- **Constraints** (all raise at class-definition time, FR-002):
|
|
52
|
+
- at most one `background` declaration per reactor class;
|
|
53
|
+
- the named step must be defined in the class (either keyword);
|
|
54
|
+
- the named step must not be an `interrupt` (either keyword) — an interrupt re-enters the reactor from a foreground process, so `after:` would silently never fire on resume and `before:` would enqueue a worker that instantly pauses and swallows the `InterruptResult`; the error points to `interrupt :name, resume: :background` as the correct spelling;
|
|
55
|
+
- exactly one of `after:`/`before:` — supplying both, or neither, raises;
|
|
56
|
+
- combining `background` with whole-reactor `async true` raises (the hand-off point would be silently meaningless inside a reactor that already runs entirely in a worker — see spec Edge Cases).
|
|
57
|
+
- **Runtime contract**: hand-off is triggered by *reaching the named step*, not by the declaration's lexical position — the declaration may sit anywhere in the class body. For `after: :x`, the trigger fires when `:x` completes; for `before: :x`, it fires when `:x` is selected to run, and `:x` is left unexecuted for the worker to run. Either way: checkpoint, enqueue the remainder via `configuration.async_router`, return an `DispatchResult` to the caller. In a DAG with parallel branches, any independent step that became ready and executed before the trigger fired has already run in the calling process; everything not yet executed at the trigger moment runs in the worker — this caveat is identical for both forms. Compensation for worker-side step failures works exactly as it does for any same-process step failure — `background` only changes *where* code runs, not the saga/compensation contract (US1 acceptance scenarios 1-2). Inside the worker the hand-off never re-triggers (the existing `inline_async_execution` guard).
|
|
58
|
+
- **Never-reached trigger**: if the named step is skipped by a `where`/guard condition, or the reactor fails before reaching it, the hand-off simply never fires and the run completes in the calling process. No step is stranded — the hand-off only ever relocates *remaining* work.
|
|
59
|
+
- **`before:` naming the first step** is legal, and is not the same as whole-reactor `async true`: every step body runs in the worker, but input validation still happens in the calling process, so invalid inputs fail the caller synchronously instead of failing inside a worker.
|
|
60
|
+
|
|
61
|
+
## Added: `async_step`
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
class MyReactor < RubyReactor::Reactor
|
|
65
|
+
async_step :send_email do
|
|
66
|
+
argument :to, input(:email)
|
|
67
|
+
run { |args| Mailer.send(args[:to]) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
step :do_something_same_thread do
|
|
71
|
+
run { do_work }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
step :check_email do
|
|
75
|
+
argument :email, result(:send_email) # blocks (notified wait, bounded by timeout) until :send_email is done
|
|
76
|
+
run { |args| ... }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- **Signature**: `self.async_step(name, impl = nil, &block)` — same call shape as `step`, builds the same `StepConfig` fields (`argument`, `run`, `compensate`, `undo`, `validate_args`, `validate_output`, `retries`, etc. all still work identically inside the block).
|
|
82
|
+
- **Runtime contract**:
|
|
83
|
+
- Dispatches the step's work to an independent worker job; does **not** halt the calling reactor's execution of other ready steps that don't depend on it (US2 acceptance scenario 1).
|
|
84
|
+
- Any step that references `result(:async_step_name)` blocks — notified wait: woken by the completion signal the finishing worker publishes after its durable write, with a coarse fallback re-check of the durable record, bounded overall by `Configuration#async_wait_timeout` (spec Clarifications, Session 2026-08-20) — until the async step's terminal result is available. **Read semantics**: on `Success`, the reader receives the same deserialized raw value a same-process step's result would produce (US2 acceptance scenario 2, FR-006); on `Failure`, the reader receives the `Failure` object itself as the argument value — a same-process step's failure would have halted the reactor before any reader ran, so there is no sync-behavior to mirror here, and injecting the `Failure` is what lets the reader "see the failure and decide" per the spec's clarified compensation model (US2 acceptance scenario 3). This matches `async_reactor`'s wrapped-result-on-inspection pattern.
|
|
85
|
+
- `returns :async_step_name` raises at class-definition time — the reactor's return value must come from a same-process step (spec Edge Cases).
|
|
86
|
+
- Dispatch is **not** suppressed inside a worker: an `async_step` declared after a `background` hand-off point (or reached during a worker resume) still dispatches to its own independent job — the existing `inline_async_execution` guard suppresses only the *hand-off* re-trigger, never `async_step`/`async_reactor` dispatch (spec Edge Cases).
|
|
87
|
+
- Reactor-level `lock`/`semaphore`/`rate_limit` windows are held by the process executing the reactor's own steps — the async step's work runs *outside* those windows (in its own job, which acquires nothing). A step body that needs mutual exclusion must arrange it itself.
|
|
88
|
+
- If the async step fails and no later step reads its result, the parent reactor's compensation is **not** automatically triggered (US2 acceptance scenario 3, FR-011). A later step that does read the result and observes failure may itself return `Failure` to trigger compensation.
|
|
89
|
+
- `compensate`/`undo` blocks declared on an `async_step` still register normally — they only run if the step's own failure is surfaced into the parent's compensation path via the opt-in mechanism above, never automatically.
|
|
90
|
+
- A reference to the dispatched unit is recorded on the parent's own context (`composed_contexts[:send_email] = { type: :async_step_ref, ... }`) at dispatch time, and the web dashboard renders `send_email` as an `async_step`-typed node (FR-008, FR-014).
|
|
91
|
+
|
|
92
|
+
## Added: `async_reactor`
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
class MyReactor < RubyReactor::Reactor
|
|
96
|
+
async_reactor :create_profile, CreateProfileReactor # fire-and-forget, no downstream reference
|
|
97
|
+
|
|
98
|
+
async_reactor :create_account, CreateAccountReactor do
|
|
99
|
+
argument :user_id, input(:user_id)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
step :verify_all do
|
|
103
|
+
argument :account, result(:create_account) # blocks until create_account finishes
|
|
104
|
+
run do |args, context|
|
|
105
|
+
if args[:account].success?
|
|
106
|
+
Success(args[:account].value)
|
|
107
|
+
else
|
|
108
|
+
Failure(args[:account].error) # opt-in compensation trigger
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
- **Signature**: `self.async_reactor(name, child_reactor_class, &block)` — `argument` inside the block maps parent-visible sources to the child reactor's inputs, same shape as `compose`.
|
|
116
|
+
- **Dispatch contract** (the part that runs in the parent's process, FR-015/FR-016):
|
|
117
|
+
- Dispatch applies the same pre-enqueue safeguards as a top-level async run: the child's inputs are validated and, if the child declares `with_ordered_lock`, its ordering nonce is assigned at enqueue. A child-input validation failure fails **the dispatching step** (normal saga handling in the parent) — this is a dispatch failure, not a child-execution failure, and is deliberately outside FR-009's no-auto-compensation rule.
|
|
118
|
+
- Deadlock guard: if the child declares an exclusive `lock` (or a `semaphore` with `limit: 1`) whose resolved key equals one the dispatching execution currently holds, the dispatch step fails immediately with an error naming the lock key and both reactor classes. The error message MUST enumerate the three remediations, ranked: (1) use `compose` if the child belongs in the parent's critical section and its result is needed — the wait means the work is sequential anyway; (2) narrow the lock keys if parent and child actually protect different resources; (3) restructure so the locked reactor never reads the child's result — fire-and-forget with verification in the child itself or in a successor reactor outside the lock window. Lock ownership is never shared across the async boundary (parent and child run concurrently — shared ownership would break mutual exclusion); owner-based reentrancy remains for `compose` only. Transitive cross-execution cycles are out of the guard's reach (undetectable at dispatch) — documentation advises consistent key-acquisition order, with the FR-005 timeout as backstop.
|
|
119
|
+
- **Runtime contract**:
|
|
120
|
+
- Dispatches `child_reactor_class` to run independently via `configuration.async_router`, linked to the parent by the child's `execution_id` for traceability/logging (FR-008, US3 acceptance scenario 4) — never added to the parent's compensation graph (FR-009).
|
|
121
|
+
- If nothing in the parent reads `result(:name)`, the child's eventual failure never affects the parent (US3 acceptance scenario 1).
|
|
122
|
+
- If a later step reads `result(:name)`, it blocks (same notified-wait policy as `async_step` — the child publishes its completion signal after its terminal save) until the child reactor reaches a terminal state, then receives the child's actual `Success`/`Failure` result object (not the enqueue-time `DispatchResult`), and may inspect `.success?`/`.value`/`.error` to decide whether to itself return `Failure` (US3 acceptance scenarios 2-3, FR-010).
|
|
123
|
+
- A reference is recorded on the parent's own context (`composed_contexts[:create_profile] = { type: :async_reactor_ref, execution_id:, reactor_class_name:, ... }`) at dispatch time — the web dashboard renders `create_profile`/`create_account` as `async_reactor`-typed nodes and lets an operator open the linked child execution, the same drill-down `compose`/`map` already offer (FR-008, FR-014, US3 acceptance scenario 4).
|
|
124
|
+
- `returns :async_reactor_name` raises at class-definition time, same as for `async_step`.
|
|
125
|
+
- A child that *pauses* at an interrupt step is not terminal: a reader keeps polling and hits the FR-005 timeout unless the child is resumed within the bound (spec Edge Cases). The child is an ordinary independently-recoverable execution — the existing sweeper/durability machinery covers its crash recovery with no new mechanism.
|
|
126
|
+
|
|
127
|
+
## Added: `interrupt ..., resume: :background`
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
class MyReactor < RubyReactor::Reactor
|
|
131
|
+
interrupt :webhook, resume: :background do
|
|
132
|
+
validate_payload { required(:status).filled(:string) }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
step :heavy_work do
|
|
136
|
+
argument :status, result(:webhook)
|
|
137
|
+
run { |args| ... } # runs in a worker, never in the process that delivered the payload
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- **Signature**: `self.interrupt(name, resume: :inline, &block)` — `:inline` (today's behavior, the default) or `:background`. Any other value raises `RubyReactor::Error::ValidationError` at class-definition time.
|
|
143
|
+
- **Why it exists**: `background after:/before:` is edge-triggered on reaching one named step, but an interrupt re-enters the reactor from a foreground process (`continue` runs in whatever process delivered the payload). `resume: :background` makes that re-entry itself the hand-off: the delivering process validates and stores the payload, then enqueues the remainder.
|
|
144
|
+
- **Runtime contract**:
|
|
145
|
+
- Payload validation (`validate_payload`), attempt counting, and `max_attempts` exhaustion (undo + failed) all run **synchronously in the calling process**, before anything is enqueued — validate in the caller, execute in the worker, the same split as `background before: <first step>` and FR-016. An invalid payload enqueues nothing and leaves the reactor paused.
|
|
146
|
+
- On a valid payload: result stored, context persisted (job payload is identity-only), `before_async_enqueue` middleware fires, remainder enqueued via `configuration.async_router`, and `continue` returns an `DispatchResult` (with `execution_id`) instead of the final result.
|
|
147
|
+
- The worker resumes through the existing path: rehydrate by id, `resume_execution`, interrupt's stored result found, remaining steps run under `inline_async_execution` (so a `background` hand-off point never re-triggers there).
|
|
148
|
+
|
|
149
|
+
## Unchanged (explicitly out of scope, called out to prevent accidental regression)
|
|
150
|
+
|
|
151
|
+
- Reactor-level `async true` ("Full Reactor Async") — `self.class.async?`, `lib/ruby_reactor/dsl/reactor.rb:44-50`. (Its only new interaction: combining it with `background after:` is a definition-time error, see above.)
|
|
152
|
+
- `compose` itself — synchronous, fully compensation-linked nested execution, untouched. (Its `async` flag is removed — see the Removed section — but everything else about `compose` is unchanged.)
|
|
153
|
+
- `map`'s dispatch/collection machinery and its full DSL including the map-internal `async` element-dispatch option — reused as an architectural pattern (see research.md) but untouched.
|
|
154
|
+
- `result(:name)` for a **synchronous** step's result — resolves exactly as it does today (`Template::Result#resolve`), with zero added latency; the new notified-wait path only activates for `async_step`/`async_reactor` references.
|