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.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/speckit-agent-context-update/SKILL.md +32 -0
  3. data/.claude/skills/speckit-analyze/SKILL.md +262 -0
  4. data/.claude/skills/speckit-checklist/SKILL.md +374 -0
  5. data/.claude/skills/speckit-clarify/SKILL.md +286 -0
  6. data/.claude/skills/speckit-constitution/SKILL.md +157 -0
  7. data/.claude/skills/speckit-converge/SKILL.md +277 -0
  8. data/.claude/skills/speckit-implement/SKILL.md +224 -0
  9. data/.claude/skills/speckit-plan/SKILL.md +171 -0
  10. data/.claude/skills/speckit-specify/SKILL.md +346 -0
  11. data/.claude/skills/speckit-tasks/SKILL.md +215 -0
  12. data/.claude/skills/speckit-taskstoissues/SKILL.md +110 -0
  13. data/.release-please-manifest.json +1 -1
  14. data/.specify/extensions/.registry +19 -0
  15. data/.specify/extensions/agent-context/README.md +66 -0
  16. data/.specify/extensions/agent-context/agent-context-config.yml +5 -0
  17. data/.specify/extensions/agent-context/commands/speckit.agent-context.update.md +27 -0
  18. data/.specify/extensions/agent-context/extension.yml +34 -0
  19. data/.specify/extensions/agent-context/scripts/bash/update-agent-context.sh +282 -0
  20. data/.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +353 -0
  21. data/.specify/extensions.yml +23 -0
  22. data/.specify/feature.json +3 -0
  23. data/.specify/init-options.json +9 -0
  24. data/.specify/integration.json +15 -0
  25. data/.specify/integrations/claude.manifest.json +17 -0
  26. data/.specify/integrations/speckit.manifest.json +17 -0
  27. data/.specify/memory/constitution.md +134 -0
  28. data/.specify/scripts/bash/check-prerequisites.sh +189 -0
  29. data/.specify/scripts/bash/common.sh +619 -0
  30. data/.specify/scripts/bash/create-new-feature.sh +299 -0
  31. data/.specify/scripts/bash/setup-plan.sh +84 -0
  32. data/.specify/scripts/bash/setup-tasks.sh +91 -0
  33. data/.specify/templates/checklist-template.md +40 -0
  34. data/.specify/templates/constitution-template.md +50 -0
  35. data/.specify/templates/plan-template.md +113 -0
  36. data/.specify/templates/spec-template.md +131 -0
  37. data/.specify/templates/tasks-template.md +252 -0
  38. data/.specify/workflows/speckit/workflow.yml +77 -0
  39. data/.specify/workflows/workflow-registry.json +13 -0
  40. data/CHANGELOG.md +121 -1
  41. data/CLAUDE.md +5 -0
  42. data/README.md +181 -39
  43. data/lib/ruby_reactor/adapters/active_job/compat.rb +24 -0
  44. data/lib/ruby_reactor/adapters/active_job/map_collector_worker.rb +19 -0
  45. data/lib/ruby_reactor/adapters/active_job/map_element_worker.rb +19 -0
  46. data/lib/ruby_reactor/adapters/active_job/router.rb +106 -0
  47. data/lib/ruby_reactor/adapters/active_job/step_worker.rb +20 -0
  48. data/lib/ruby_reactor/adapters/active_job/sweeper_worker.rb +16 -0
  49. data/lib/ruby_reactor/adapters/active_job/worker.rb +29 -0
  50. data/lib/ruby_reactor/adapters/sidekiq/map_collector_worker.rb +15 -0
  51. data/lib/ruby_reactor/adapters/sidekiq/map_element_worker.rb +15 -0
  52. data/lib/ruby_reactor/adapters/sidekiq/router.rb +106 -0
  53. data/lib/ruby_reactor/adapters/sidekiq/step_worker.rb +17 -0
  54. data/lib/ruby_reactor/adapters/sidekiq/sweeper_worker.rb +19 -0
  55. data/lib/ruby_reactor/adapters/sidekiq/worker.rb +28 -0
  56. data/lib/ruby_reactor/async_waiter.rb +113 -0
  57. data/lib/ruby_reactor/configuration.rb +54 -5
  58. data/lib/ruby_reactor/context.rb +21 -0
  59. data/lib/ruby_reactor/context_serializer.rb +2 -0
  60. data/lib/ruby_reactor/dsl/async_macros.rb +176 -0
  61. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +66 -0
  62. data/lib/ruby_reactor/dsl/compose_builder.rb +23 -4
  63. data/lib/ruby_reactor/dsl/interrupt_builder.rb +3 -2
  64. data/lib/ruby_reactor/dsl/interrupt_step_config.rb +7 -1
  65. data/lib/ruby_reactor/dsl/lockable.rb +1 -1
  66. data/lib/ruby_reactor/dsl/map_builder.rb +1 -2
  67. data/lib/ruby_reactor/dsl/reactor.rb +29 -10
  68. data/lib/ruby_reactor/dsl/step_builder.rb +30 -11
  69. data/lib/ruby_reactor/error/async_result_pending.rb +21 -0
  70. data/lib/ruby_reactor/error/async_wait_timeout_error.rb +10 -0
  71. data/lib/ruby_reactor/error/deprecated_dsl_error.rb +11 -0
  72. data/lib/ruby_reactor/executor/async_step_dispatch.rb +110 -0
  73. data/lib/ruby_reactor/executor/ordered_lock_support.rb +2 -2
  74. data/lib/ruby_reactor/executor/result_handler.rb +12 -1
  75. data/lib/ruby_reactor/executor/retry_manager.rb +9 -5
  76. data/lib/ruby_reactor/executor/step_executor.rb +58 -11
  77. data/lib/ruby_reactor/executor.rb +162 -8
  78. data/lib/ruby_reactor/lock.rb +19 -0
  79. data/lib/ruby_reactor/map/element_executor.rb +7 -2
  80. data/lib/ruby_reactor/map/helpers.rb +1 -1
  81. data/lib/ruby_reactor/map/result_enumerator.rb +5 -1
  82. data/lib/ruby_reactor/map/result_summary.rb +63 -0
  83. data/lib/ruby_reactor/open_telemetry.rb +1 -1
  84. data/lib/ruby_reactor/reactor.rb +25 -2
  85. data/lib/ruby_reactor/rspec/active_job_helpers.rb +52 -0
  86. data/lib/ruby_reactor/rspec/async_test_helpers.rb +41 -0
  87. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +4 -3
  88. data/lib/ruby_reactor/rspec/step_executor_patch.rb +2 -2
  89. data/lib/ruby_reactor/rspec/test_subject.rb +77 -18
  90. data/lib/ruby_reactor/rspec.rb +4 -0
  91. data/lib/ruby_reactor/semaphore.rb +10 -0
  92. data/lib/ruby_reactor/step/async_reactor_step.rb +207 -0
  93. data/lib/ruby_reactor/step/compose_step.rb +1 -1
  94. data/lib/ruby_reactor/step/map_step.rb +1 -1
  95. data/lib/ruby_reactor/step_sweeper.rb +72 -0
  96. data/lib/ruby_reactor/step_worker.rb +260 -0
  97. data/lib/ruby_reactor/storage/adapter.rb +22 -1
  98. data/lib/ruby_reactor/storage/redis_adapter.rb +17 -13
  99. data/lib/ruby_reactor/storage/redis_locking.rb +7 -0
  100. data/lib/ruby_reactor/storage/redis_pub_sub.rb +31 -0
  101. data/lib/ruby_reactor/storage/redis_step_results.rb +49 -0
  102. data/lib/ruby_reactor/sweeper.rb +7 -1
  103. data/lib/ruby_reactor/sweeper_job.rb +71 -0
  104. data/lib/ruby_reactor/template/result.rb +151 -5
  105. data/lib/ruby_reactor/version.rb +1 -1
  106. data/lib/ruby_reactor/web/api.rb +94 -16
  107. data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +1 -0
  108. data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +22 -0
  109. data/lib/ruby_reactor/web/public/index.html +2 -2
  110. data/lib/ruby_reactor/worker.rb +276 -0
  111. data/lib/ruby_reactor.rb +64 -4
  112. data/specs/001-background-async-steps/checklists/requirements.md +39 -0
  113. data/specs/001-background-async-steps/contracts/public-dsl.md +154 -0
  114. data/specs/001-background-async-steps/data-model.md +117 -0
  115. data/specs/001-background-async-steps/plan.md +168 -0
  116. data/specs/001-background-async-steps/quickstart.md +102 -0
  117. data/specs/001-background-async-steps/research.md +150 -0
  118. data/specs/001-background-async-steps/spec.md +146 -0
  119. data/specs/001-background-async-steps/tasks.md +271 -0
  120. data/specs/active_job.md +259 -0
  121. metadata +80 -8
  122. data/lib/ruby_reactor/sidekiq_adapter.rb +0 -87
  123. data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +0 -13
  124. data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +0 -13
  125. data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +0 -73
  126. data/lib/ruby_reactor/sidekiq_workers/worker.rb +0 -222
  127. data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +0 -1
  128. 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 && defined?(Sidekiq::Testing)
195
- # Ensure SidekiqAdapter is used to capture jobs in fake mode
196
- allow(RubyReactor.configuration).to receive(:async_router).and_return(RubyReactor::SidekiqAdapter)
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 && defined?(Sidekiq::Testing)
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 && defined?(Sidekiq::Testing)
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 defined?(Sidekiq::Testing)
487
+ return unless AsyncTestHelpers.active?
434
488
 
435
- SidekiqHelpers.drain_async_jobs
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 override async steps
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
- @async = superclass.async?
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. Apply Force Sync (Disable async on all steps)
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.async?
525
+ next unless config.respond_to?(:async_dispatch?) && config.async_dispatch?
467
526
 
468
- # Clone and modify
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
- step_config.instance_variable_set(:@async, false)
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
- @async = superclass.async?
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
- step_config.instance_variable_set(:@async, false)
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
@@ -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::AsyncResult) || result.is_a?(RubyReactor::RetryQueuedResult)
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::AsyncResult.new(
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