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
|
@@ -130,6 +130,56 @@ module RubyReactor
|
|
|
130
130
|
elements[index]
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
+
# The durable record for a dispatched `async_step`, or nil if the step was
|
|
134
|
+
# never dispatched (e.g. under `async: false`, where it ran inline and its
|
|
135
|
+
# value is an ordinary `step_result`). Mirrors `#composed` / `#map` in
|
|
136
|
+
# reading the reference off `composed_contexts`.
|
|
137
|
+
#
|
|
138
|
+
# subject.async_step(:send_email) # => the raw record hash
|
|
139
|
+
# subject.async_step(:send_email, :status) # => "dispatched" / "completed"
|
|
140
|
+
def async_step(step_name, key = nil)
|
|
141
|
+
ensure_executed!
|
|
142
|
+
|
|
143
|
+
entry = composed_entry(step_name)
|
|
144
|
+
return nil unless entry && entry[:type] == :async_step_ref
|
|
145
|
+
|
|
146
|
+
record = RubyReactor.configuration.storage_adapter.retrieve_step_result(
|
|
147
|
+
entry[:context_id] || @reactor_instance.context.context_id,
|
|
148
|
+
step_name,
|
|
149
|
+
RubyReactor.reactor_storage_name(@reactor_instance.class)
|
|
150
|
+
)
|
|
151
|
+
key && record ? record[key.to_s] : record
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The child execution a dispatched `async_reactor` created, as its own
|
|
155
|
+
# TestSubject — the same drill-down `#composed` gives for an inline child.
|
|
156
|
+
def async_reactor(step_name)
|
|
157
|
+
ensure_executed!
|
|
158
|
+
|
|
159
|
+
entry = composed_entry(step_name)
|
|
160
|
+
return nil unless entry && entry[:type] == :async_reactor_ref
|
|
161
|
+
|
|
162
|
+
child_class = RubyReactor::Context.resolve_reactor_class(entry[:reactor_class_name])
|
|
163
|
+
return nil unless child_class
|
|
164
|
+
|
|
165
|
+
child_instance = child_class.find(entry[:execution_id])
|
|
166
|
+
self.class.new(
|
|
167
|
+
reactor_class: child_instance.class,
|
|
168
|
+
inputs: child_instance.context.inputs,
|
|
169
|
+
context: child_instance.context,
|
|
170
|
+
async: @async,
|
|
171
|
+
process_jobs: @process_jobs
|
|
172
|
+
).tap do |s|
|
|
173
|
+
s.instance_variable_set(:@executed, true)
|
|
174
|
+
s.instance_variable_set(:@reactor_instance, child_instance)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def composed_entry(step_name)
|
|
179
|
+
contexts = @reactor_instance.context.composed_contexts
|
|
180
|
+
contexts[step_name] || contexts[step_name.to_s] || contexts[step_name.to_sym]
|
|
181
|
+
end
|
|
182
|
+
|
|
133
183
|
private
|
|
134
184
|
|
|
135
185
|
def traverse_composed(step_name)
|
|
@@ -191,9 +241,9 @@ module RubyReactor
|
|
|
191
241
|
end
|
|
192
242
|
|
|
193
243
|
@run_result = nil
|
|
194
|
-
if @process_jobs &&
|
|
195
|
-
# Ensure
|
|
196
|
-
allow(RubyReactor.configuration).to receive(:async_router).and_return(RubyReactor::
|
|
244
|
+
if @process_jobs && AsyncTestHelpers.sidekiq_testing?
|
|
245
|
+
# Ensure the Sidekiq router is used to capture jobs in fake mode
|
|
246
|
+
allow(RubyReactor.configuration).to receive(:async_router).and_return(RubyReactor::Adapters::Sidekiq::Router)
|
|
197
247
|
|
|
198
248
|
# Avoid nesting error which happens in Sidekiq 7+ if a mode is already set
|
|
199
249
|
begin
|
|
@@ -203,6 +253,10 @@ module RubyReactor
|
|
|
203
253
|
rescue Sidekiq::Testing::TestModeAlreadySetError
|
|
204
254
|
@run_result = execution_class.run(@inputs)
|
|
205
255
|
end
|
|
256
|
+
elsif @process_jobs && AsyncTestHelpers.active_job_testing?
|
|
257
|
+
# Ensure the ActiveJob router is used to capture jobs in the :test adapter
|
|
258
|
+
allow(RubyReactor.configuration).to receive(:async_router).and_return(RubyReactor::Adapters::ActiveJob::Router)
|
|
259
|
+
@run_result = execution_class.run(@inputs)
|
|
206
260
|
else
|
|
207
261
|
@run_result = execution_class.run(@inputs)
|
|
208
262
|
end
|
|
@@ -250,7 +304,7 @@ module RubyReactor
|
|
|
250
304
|
skipped_result(ctx)
|
|
251
305
|
when "running"
|
|
252
306
|
# Try to determine if it is truly running or if we just missed the completion
|
|
253
|
-
if @process_jobs &&
|
|
307
|
+
if @process_jobs && AsyncTestHelpers.active?
|
|
254
308
|
# Force one more check
|
|
255
309
|
process_pending_jobs
|
|
256
310
|
# Reload status
|
|
@@ -357,7 +411,7 @@ module RubyReactor
|
|
|
357
411
|
@reactor_instance.continue(payload: payload, step_name: step_name)
|
|
358
412
|
|
|
359
413
|
# Process any pending async jobs
|
|
360
|
-
process_pending_jobs if @process_jobs &&
|
|
414
|
+
process_pending_jobs if @process_jobs && AsyncTestHelpers.active?
|
|
361
415
|
|
|
362
416
|
# Reload the reactor instance to get updated state
|
|
363
417
|
@reactor_instance = @reactor_class.find(@reactor_instance.context.context_id)
|
|
@@ -430,14 +484,15 @@ module RubyReactor
|
|
|
430
484
|
private
|
|
431
485
|
|
|
432
486
|
def process_pending_jobs
|
|
433
|
-
return unless
|
|
487
|
+
return unless AsyncTestHelpers.active?
|
|
434
488
|
|
|
435
|
-
|
|
489
|
+
AsyncTestHelpers.drain_async_jobs
|
|
436
490
|
@reactor_instance = @reactor_class.find(@reactor_instance.context.context_id)
|
|
437
491
|
end
|
|
438
492
|
|
|
439
493
|
def prepare_execution_class
|
|
440
|
-
# Even if no interceptors, we might need to subclass to
|
|
494
|
+
# Even if no interceptors, we might need to subclass to force the whole
|
|
495
|
+
# reactor to run in-process.
|
|
441
496
|
return @reactor_class if @interceptors.empty? && @async != false
|
|
442
497
|
|
|
443
498
|
interceptors = @interceptors
|
|
@@ -450,7 +505,7 @@ module RubyReactor
|
|
|
450
505
|
@input_validations = superclass.input_validations.dup
|
|
451
506
|
@middlewares = superclass.middlewares.dup
|
|
452
507
|
@return_step = superclass.return_step
|
|
453
|
-
@
|
|
508
|
+
@background_handoff = superclass.background_handoff
|
|
454
509
|
@retry_defaults = superclass.instance_variable_get(:@retry_defaults)
|
|
455
510
|
|
|
456
511
|
# 2. Add Name Handling with Unique Registry Entry
|
|
@@ -460,15 +515,16 @@ module RubyReactor
|
|
|
460
515
|
define_singleton_method(:name) { unique_name }
|
|
461
516
|
RubyReactor::Registry.register(unique_name, self)
|
|
462
517
|
|
|
463
|
-
# 3.
|
|
518
|
+
# 3. `async: false` / `run_async(false)` means "run this reactor's full
|
|
519
|
+
# logic here, in one process". Under the new DSL that is three things:
|
|
520
|
+
# suppress the `background` hand-off, and run `async_step` /
|
|
521
|
+
# `async_reactor` units inline instead of dispatching them.
|
|
464
522
|
if force_sync
|
|
523
|
+
@background_handoff = nil
|
|
465
524
|
@steps.each do |name, config|
|
|
466
|
-
next unless config.
|
|
525
|
+
next unless config.respond_to?(:async_dispatch?) && config.async_dispatch?
|
|
467
526
|
|
|
468
|
-
|
|
469
|
-
new_config = config.clone
|
|
470
|
-
new_config.instance_variable_set(:@async, false)
|
|
471
|
-
@steps[name] = new_config
|
|
527
|
+
@steps[name] = config.clone.tap { |c| c.instance_variable_set(:@async_dispatch, nil) }
|
|
472
528
|
end
|
|
473
529
|
end
|
|
474
530
|
end
|
|
@@ -499,7 +555,8 @@ module RubyReactor
|
|
|
499
555
|
|
|
500
556
|
if nested_interceptors.any?
|
|
501
557
|
apply_nested_interceptors(step_config, nested_interceptors)
|
|
502
|
-
|
|
558
|
+
# A mocked inner step only takes effect if the child runs here.
|
|
559
|
+
step_config.instance_variable_set(:@async_dispatch, nil)
|
|
503
560
|
end
|
|
504
561
|
|
|
505
562
|
# Apply direct interceptors (mocks/failures on this step)
|
|
@@ -547,7 +604,7 @@ module RubyReactor
|
|
|
547
604
|
@input_validations = superclass.input_validations.dup
|
|
548
605
|
@middlewares = superclass.middlewares.dup
|
|
549
606
|
@return_step = superclass.return_step
|
|
550
|
-
@
|
|
607
|
+
@background_handoff = superclass.background_handoff
|
|
551
608
|
@retry_defaults = superclass.instance_variable_get(:@retry_defaults)
|
|
552
609
|
end
|
|
553
610
|
|
|
@@ -602,7 +659,9 @@ module RubyReactor
|
|
|
602
659
|
end
|
|
603
660
|
|
|
604
661
|
step_config.instance_variable_set(:@run_block, wrapper_impl)
|
|
605
|
-
|
|
662
|
+
# The mock replaces the step's body, so it must run where the spec can
|
|
663
|
+
# observe it rather than being dispatched to a worker.
|
|
664
|
+
step_config.instance_variable_set(:@async_dispatch, nil)
|
|
606
665
|
end
|
|
607
666
|
end
|
|
608
667
|
# rubocop:enable Metrics/ClassLength
|
data/lib/ruby_reactor/rspec.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require_relative "rspec/helpers"
|
|
4
4
|
require_relative "rspec/matchers"
|
|
5
5
|
require_relative "rspec/sidekiq_helpers"
|
|
6
|
+
require_relative "rspec/active_job_helpers"
|
|
7
|
+
require_relative "rspec/async_test_helpers"
|
|
6
8
|
require_relative "rspec/storage_reset"
|
|
7
9
|
require_relative "rspec/test_subject"
|
|
8
10
|
|
|
@@ -44,6 +46,8 @@ module RubyReactor
|
|
|
44
46
|
::Sidekiq::Worker.clear_all
|
|
45
47
|
end
|
|
46
48
|
|
|
49
|
+
::ActiveJob::Base.queue_adapter.enqueued_jobs.clear if AsyncTestHelpers.active_job_testing?
|
|
50
|
+
|
|
47
51
|
adapter = ::RubyReactor.configuration.storage_adapter
|
|
48
52
|
adapter.reset! if adapter.respond_to?(:reset!)
|
|
49
53
|
|
|
@@ -40,6 +40,16 @@ module RubyReactor
|
|
|
40
40
|
release
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
# Park support: re-adopt a token that stayed checked out across a parked
|
|
44
|
+
# gap. Verifies the token is still in the held set — a reset/expiry in
|
|
45
|
+
# between means the slot was lost and the caller must acquire fresh.
|
|
46
|
+
def reattach(token) # rubocop:disable Naming/PredicateMethod
|
|
47
|
+
return false unless token && adapter.semaphore_held?(@key, token)
|
|
48
|
+
|
|
49
|
+
@token = token
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
43
53
|
private
|
|
44
54
|
|
|
45
55
|
def ensure_initialized
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Step
|
|
5
|
+
# The dispatching half of `async_reactor`: everything that happens in the
|
|
6
|
+
# PARENT's process. The child then runs as an ordinary independently
|
|
7
|
+
# dispatched reactor execution — no new enqueue primitive, no new storage
|
|
8
|
+
# primitive, and no entry in the parent's compensation graph.
|
|
9
|
+
#
|
|
10
|
+
# Dispatch reuses the full pre-enqueue sequence of a top-level async run
|
|
11
|
+
# rather than a raw `perform_async`, because `Reactor#run` does
|
|
12
|
+
# three load-bearing things a naive `Context.new` + enqueue would silently
|
|
13
|
+
# skip: validate the child's inputs (the worker's resume path never
|
|
14
|
+
# validates, so skipping here starts a child on garbage), assign the
|
|
15
|
+
# ordered-lock nonce at ENQUEUE time (so ordering matches caller order), and
|
|
16
|
+
# persist before enqueueing (F2).
|
|
17
|
+
class AsyncReactorStep
|
|
18
|
+
include RubyReactor::Step
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def run(arguments, context)
|
|
22
|
+
child_class = arguments[:async_reactor_class]
|
|
23
|
+
child_inputs = build_child_inputs(arguments[:argument_mappings] || {}, context)
|
|
24
|
+
|
|
25
|
+
# A dispatch-time failure fails the DISPATCHING step, i.e. normal saga
|
|
26
|
+
# handling in the parent. That is deliberately outside the
|
|
27
|
+
# no-auto-compensation rule, which governs the child's own execution.
|
|
28
|
+
validation = validate_child_inputs(child_class, child_inputs)
|
|
29
|
+
return validation if validation
|
|
30
|
+
|
|
31
|
+
deadlock = detect_lock_deadlock(child_class, child_inputs, context)
|
|
32
|
+
return deadlock if deadlock
|
|
33
|
+
|
|
34
|
+
return run_inline(child_class, child_inputs, context) if run_inline?(context)
|
|
35
|
+
|
|
36
|
+
dispatch(child_class, child_inputs, context)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def build_child_inputs(mappings, context)
|
|
42
|
+
mappings.transform_values { |source| source.resolve(context) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_child_inputs(child_class, child_inputs)
|
|
46
|
+
return nil unless child_class.respond_to?(:validate_inputs)
|
|
47
|
+
|
|
48
|
+
result = child_class.validate_inputs(child_inputs)
|
|
49
|
+
return nil unless result.failure?
|
|
50
|
+
|
|
51
|
+
RubyReactor.Failure(
|
|
52
|
+
"async_reactor child #{child_class.name} rejected its inputs: #{result.error.message}",
|
|
53
|
+
validation_errors: (result.error.field_errors if result.error.respond_to?(:field_errors))
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Lock ownership is NOT shared across the async boundary: parent
|
|
58
|
+
# and child run concurrently, so giving the child the parent's owner
|
|
59
|
+
# would put both inside the critical section at once — mutual exclusion
|
|
60
|
+
# silently broken, which is worse than a stall. Instead the one
|
|
61
|
+
# GUARANTEED deadlock (the parent holds a key its child will wait on,
|
|
62
|
+
# while the parent may go on to wait for that child) is caught here,
|
|
63
|
+
# loudly, at dispatch. Ordinary cross-execution contention on the same
|
|
64
|
+
# key is unaffected and still snoozes normally.
|
|
65
|
+
def detect_lock_deadlock(child_class, child_inputs, context)
|
|
66
|
+
held = held_lock_keys(context)
|
|
67
|
+
return nil if held.empty?
|
|
68
|
+
|
|
69
|
+
collision = child_lock_keys(child_class, child_inputs).find { |key| held.include?(key) }
|
|
70
|
+
return nil unless collision
|
|
71
|
+
|
|
72
|
+
RubyReactor.Failure(deadlock_message(collision, child_class, context))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def child_lock_keys(child_class, child_inputs)
|
|
76
|
+
keys = []
|
|
77
|
+
if child_class.respond_to?(:lock_config) && child_class.lock_config
|
|
78
|
+
keys << child_class.lock_config[:key_proc].call(child_inputs)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A semaphore with a single slot has the identical circular-wait
|
|
82
|
+
# shape. Higher limits are contention, not guaranteed deadlock.
|
|
83
|
+
semaphore = child_class.respond_to?(:semaphore_config) ? child_class.semaphore_config : nil
|
|
84
|
+
keys << semaphore[:key_proc].call(child_inputs) if semaphore && semaphore[:limit] == 1
|
|
85
|
+
|
|
86
|
+
keys.compact
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def held_lock_keys(context)
|
|
90
|
+
root = context.root_context || context
|
|
91
|
+
Array(root.private_data[:held_lock_keys] || root.private_data["held_lock_keys"])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def deadlock_message(key, child_class, context)
|
|
95
|
+
parent = context.reactor_class&.name || "the dispatching reactor"
|
|
96
|
+
<<~MSG.strip
|
|
97
|
+
async_reactor dispatch of #{child_class.name || "<anonymous>"} would deadlock: it declares the \
|
|
98
|
+
lock key '#{key}', which #{parent} currently holds and will not release until it finishes.
|
|
99
|
+
The child would snooze forever, and if #{parent} later reads this child's result it would wait \
|
|
100
|
+
for work that can never start. Lock ownership is never shared across the async boundary — the \
|
|
101
|
+
two run concurrently, so sharing it would break mutual exclusion outright.
|
|
102
|
+
Fix, in order of preference:
|
|
103
|
+
1. Use `compose` instead of `async_reactor` if the child belongs inside #{parent}'s critical \
|
|
104
|
+
section and its result is needed — waiting for it means the work is sequential anyway.
|
|
105
|
+
2. Narrow the lock keys, if parent and child actually protect different resources.
|
|
106
|
+
3. Restructure so the locked reactor never reads the child's result — fire-and-forget, and \
|
|
107
|
+
verify in the child itself or in a successor reactor outside the lock window.
|
|
108
|
+
MSG
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# `RSpec::TestSubject`'s `async: false` clears the dispatch marker to run
|
|
112
|
+
# the whole reactor in one process.
|
|
113
|
+
def run_inline?(context)
|
|
114
|
+
config = context.reactor_class&.steps&.[](context.current_step)
|
|
115
|
+
config.respond_to?(:async_dispatch?) && !config.async_dispatch?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def run_inline(child_class, child_inputs, context)
|
|
119
|
+
result = child_class.run(child_inputs)
|
|
120
|
+
store_reference(context, execution_id: (result.execution_id if result.respond_to?(:execution_id)),
|
|
121
|
+
child_class: child_class)
|
|
122
|
+
# Success(nil) for the same reason dispatch returns it: `result(:name)`
|
|
123
|
+
# must route through the reference (which now points at an already
|
|
124
|
+
# terminal child) rather than through a recorded value.
|
|
125
|
+
RubyReactor.Success(nil)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def dispatch(child_class, child_inputs, context)
|
|
129
|
+
child_context = build_child_context(child_class, child_inputs, context)
|
|
130
|
+
assign_ordered_lock_nonce!(child_class, child_context)
|
|
131
|
+
|
|
132
|
+
# Persist BEFORE enqueue (F2) — the payload is identity-only.
|
|
133
|
+
child_context.status = :running
|
|
134
|
+
# Marks the child as owned by a job of its own, which is what makes it
|
|
135
|
+
# sweepable: a compose child carries the same parent_context_id but runs
|
|
136
|
+
# inline in the parent, so re-enqueueing one would double-run it.
|
|
137
|
+
child_context.private_data[:async_dispatched] = true
|
|
138
|
+
save(child_context, child_class)
|
|
139
|
+
|
|
140
|
+
# The reference is written synchronously, by the process that is about
|
|
141
|
+
# to keep running other steps, so there is no cross-process race on it
|
|
142
|
+
# (unlike the child's eventual result, which the child itself writes).
|
|
143
|
+
store_reference(context, execution_id: child_context.context_id, child_class: child_class)
|
|
144
|
+
log_dispatch(context, child_class, child_context)
|
|
145
|
+
|
|
146
|
+
RubyReactor.configuration.async_router.perform_async(
|
|
147
|
+
child_context.context_id, RubyReactor.reactor_storage_name(child_class)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
RubyReactor.Success(nil)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def build_child_context(child_class, child_inputs, context)
|
|
154
|
+
child_context = RubyReactor::Context.new(child_inputs, child_class)
|
|
155
|
+
# Linked for traceability only — the child is NOT nested inside the
|
|
156
|
+
# parent's context tree, because it must survive the parent finishing.
|
|
157
|
+
child_context.parent_context_id = context.context_id
|
|
158
|
+
child_context
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def assign_ordered_lock_nonce!(child_class, child_context)
|
|
162
|
+
return unless child_class.respond_to?(:ordered_lock_config) && child_class.ordered_lock_config
|
|
163
|
+
|
|
164
|
+
config = child_class.ordered_lock_config
|
|
165
|
+
key = config[:key_proc].call(child_context.inputs)
|
|
166
|
+
nonce, epoch = RubyReactor::OrderedLock.assign(key, ttl: config[:ttl])
|
|
167
|
+
|
|
168
|
+
child_context.private_data[:ordered_lock] = {
|
|
169
|
+
key: key, nonce: nonce, epoch: epoch,
|
|
170
|
+
poison_pill_timeout: config[:poison_pill_timeout],
|
|
171
|
+
ttl: config[:ttl], strict: config.fetch(:strict, true)
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def store_reference(context, execution_id:, child_class:)
|
|
176
|
+
context.composed_contexts[context.current_step] = {
|
|
177
|
+
name: context.current_step,
|
|
178
|
+
type: :async_reactor_ref,
|
|
179
|
+
execution_id: execution_id,
|
|
180
|
+
reactor_class_name: RubyReactor.reactor_storage_name(child_class),
|
|
181
|
+
dispatched_at: Time.now
|
|
182
|
+
}
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def save(child_context, child_class)
|
|
186
|
+
RubyReactor.configuration.storage_adapter.store_context(
|
|
187
|
+
child_context.context_id,
|
|
188
|
+
RubyReactor::ContextSerializer.serialize(child_context),
|
|
189
|
+
RubyReactor.reactor_storage_name(child_class)
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The link between parent and child, machine-parseable. Matters
|
|
194
|
+
# most here — a fire-and-forget child's failure may have no other surface
|
|
195
|
+
# in the parent at all.
|
|
196
|
+
def log_dispatch(context, child_class, child_context)
|
|
197
|
+
RubyReactor.configuration.logger.info(
|
|
198
|
+
"event=\"ruby_reactor.async_reactor.dispatched\" " \
|
|
199
|
+
"reactor=#{context.reactor_class&.name.inspect} step=#{context.current_step.inspect} " \
|
|
200
|
+
"execution_id=#{context.context_id.inspect} child_reactor=#{child_class.name.inspect} " \
|
|
201
|
+
"child_execution_id=#{child_context.context_id.inspect}"
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -108,7 +108,7 @@ module RubyReactor
|
|
|
108
108
|
end
|
|
109
109
|
|
|
110
110
|
def handle_execution_result(result)
|
|
111
|
-
return result if result.is_a?(RubyReactor::
|
|
111
|
+
return result if result.is_a?(RubyReactor::DispatchResult) || result.is_a?(RubyReactor::RetryQueuedResult)
|
|
112
112
|
|
|
113
113
|
if result.success?
|
|
114
114
|
RubyReactor.Success(result.value)
|
|
@@ -168,7 +168,7 @@ module RubyReactor
|
|
|
168
168
|
element_reactor_class: arguments[:mapped_reactor_class].name
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
RubyReactor::
|
|
171
|
+
RubyReactor::DispatchResult.new(
|
|
172
172
|
job_id: job_id,
|
|
173
173
|
intermediate_results: context.intermediate_results,
|
|
174
174
|
execution_id: context.context_id
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
# Re-dispatches `async_step` units whose job was lost.
|
|
5
|
+
#
|
|
6
|
+
# An `async_step` is the one dispatched unit with no context of its own, so
|
|
7
|
+
# neither Sweeper (top-level contexts) nor Map::Sweeper (map elements) can see
|
|
8
|
+
# it. All it leaves behind is a Step Result Record stuck at `dispatched`, and
|
|
9
|
+
# because that record is also the re-attach marker, `already_dispatched?`
|
|
10
|
+
# actively refuses to dispatch again — so without this sweep a lost unit strands
|
|
11
|
+
# its parent forever, re-parking on every recovery of the parent itself.
|
|
12
|
+
#
|
|
13
|
+
# Liveness is the `async_step:` lock StepWorker holds for the life of the unit,
|
|
14
|
+
# mirroring how Sweeper reads `async:` and Map::Sweeper reads `map_element:`. A
|
|
15
|
+
# duplicate that races a live worker loses that lock and drops itself, so a
|
|
16
|
+
# mis-judged unit re-runs its body at most once.
|
|
17
|
+
class StepSweeper
|
|
18
|
+
DEFAULT_LIMIT = 1000
|
|
19
|
+
|
|
20
|
+
# The re-dispatch arguments, which the record carries verbatim because its
|
|
21
|
+
# key names the reactor that owns the step rather than the root the worker
|
|
22
|
+
# must load. Records written before recovery existed lack them and are skipped.
|
|
23
|
+
DISPATCH_KEYS = %w[root_context_id reactor_class_name step_context_id step_name].freeze
|
|
24
|
+
|
|
25
|
+
def self.run_once(limit: DEFAULT_LIMIT)
|
|
26
|
+
new.run_once(limit: limit)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(storage: nil, async_router: nil, logger: nil)
|
|
30
|
+
@storage = storage || RubyReactor.configuration.storage_adapter
|
|
31
|
+
@async_router = async_router || RubyReactor.configuration.async_router
|
|
32
|
+
@logger = logger || RubyReactor.configuration.logger
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Scans stored Step Result Records and re-dispatches the dispatched-but-dead
|
|
36
|
+
# ones. Returns the number re-dispatched.
|
|
37
|
+
def run_once(limit: DEFAULT_LIMIT)
|
|
38
|
+
redispatched = 0
|
|
39
|
+
|
|
40
|
+
@storage.scan_step_results(count: limit).each do |record|
|
|
41
|
+
next unless record["status"] == "dispatched"
|
|
42
|
+
|
|
43
|
+
arguments = dispatch_arguments(record)
|
|
44
|
+
next unless arguments
|
|
45
|
+
next if live?(arguments)
|
|
46
|
+
|
|
47
|
+
@async_router.perform_step_async(**arguments)
|
|
48
|
+
redispatched += 1
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
# One bad record must not abort the whole sweep.
|
|
51
|
+
@logger.warn("RubyReactor::StepSweeper failed to re-dispatch #{record["step_name"]}: #{e.class}: #{e.message}")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
redispatched
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def dispatch_arguments(record)
|
|
60
|
+
values = record.values_at(*DISPATCH_KEYS)
|
|
61
|
+
return nil if values.any?(&:nil?)
|
|
62
|
+
|
|
63
|
+
DISPATCH_KEYS.map(&:to_sym).zip(values).to_h
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def live?(arguments)
|
|
67
|
+
@storage.lock_held?(
|
|
68
|
+
RubyReactor.async_step_lock_key(arguments[:step_context_id], arguments[:step_name])
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|