ruby_reactor 0.6.0 → 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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/CHANGELOG.md +114 -1
  5. data/CLAUDE.md +5 -0
  6. data/README.md +131 -16
  7. data/lib/ruby_reactor/adapters/active_job/router.rb +21 -6
  8. data/lib/ruby_reactor/adapters/active_job/step_worker.rb +20 -0
  9. data/lib/ruby_reactor/adapters/active_job/worker.rb +6 -1
  10. data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -6
  11. data/lib/ruby_reactor/adapters/sidekiq/step_worker.rb +17 -0
  12. data/lib/ruby_reactor/adapters/sidekiq/worker.rb +5 -2
  13. data/lib/ruby_reactor/async_waiter.rb +113 -0
  14. data/lib/ruby_reactor/configuration.rb +30 -1
  15. data/lib/ruby_reactor/context.rb +21 -0
  16. data/lib/ruby_reactor/context_serializer.rb +2 -0
  17. data/lib/ruby_reactor/dsl/async_macros.rb +176 -0
  18. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +66 -0
  19. data/lib/ruby_reactor/dsl/compose_builder.rb +23 -4
  20. data/lib/ruby_reactor/dsl/interrupt_builder.rb +3 -2
  21. data/lib/ruby_reactor/dsl/interrupt_step_config.rb +7 -1
  22. data/lib/ruby_reactor/dsl/lockable.rb +1 -1
  23. data/lib/ruby_reactor/dsl/map_builder.rb +1 -2
  24. data/lib/ruby_reactor/dsl/reactor.rb +29 -10
  25. data/lib/ruby_reactor/dsl/step_builder.rb +30 -11
  26. data/lib/ruby_reactor/error/async_result_pending.rb +21 -0
  27. data/lib/ruby_reactor/error/async_wait_timeout_error.rb +10 -0
  28. data/lib/ruby_reactor/error/deprecated_dsl_error.rb +11 -0
  29. data/lib/ruby_reactor/executor/async_step_dispatch.rb +110 -0
  30. data/lib/ruby_reactor/executor/ordered_lock_support.rb +2 -2
  31. data/lib/ruby_reactor/executor/result_handler.rb +12 -1
  32. data/lib/ruby_reactor/executor/retry_manager.rb +9 -5
  33. data/lib/ruby_reactor/executor/step_executor.rb +58 -11
  34. data/lib/ruby_reactor/executor.rb +162 -8
  35. data/lib/ruby_reactor/lock.rb +19 -0
  36. data/lib/ruby_reactor/map/element_executor.rb +6 -1
  37. data/lib/ruby_reactor/map/helpers.rb +1 -1
  38. data/lib/ruby_reactor/map/result_enumerator.rb +5 -1
  39. data/lib/ruby_reactor/map/result_summary.rb +63 -0
  40. data/lib/ruby_reactor/open_telemetry.rb +1 -1
  41. data/lib/ruby_reactor/reactor.rb +25 -2
  42. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +2 -1
  43. data/lib/ruby_reactor/rspec/step_executor_patch.rb +2 -2
  44. data/lib/ruby_reactor/rspec/test_subject.rb +66 -11
  45. data/lib/ruby_reactor/semaphore.rb +10 -0
  46. data/lib/ruby_reactor/step/async_reactor_step.rb +207 -0
  47. data/lib/ruby_reactor/step/compose_step.rb +1 -1
  48. data/lib/ruby_reactor/step/map_step.rb +1 -1
  49. data/lib/ruby_reactor/step_sweeper.rb +72 -0
  50. data/lib/ruby_reactor/step_worker.rb +260 -0
  51. data/lib/ruby_reactor/storage/adapter.rb +22 -1
  52. data/lib/ruby_reactor/storage/redis_adapter.rb +17 -13
  53. data/lib/ruby_reactor/storage/redis_locking.rb +7 -0
  54. data/lib/ruby_reactor/storage/redis_pub_sub.rb +31 -0
  55. data/lib/ruby_reactor/storage/redis_step_results.rb +49 -0
  56. data/lib/ruby_reactor/sweeper.rb +7 -1
  57. data/lib/ruby_reactor/sweeper_job.rb +1 -0
  58. data/lib/ruby_reactor/template/result.rb +151 -5
  59. data/lib/ruby_reactor/version.rb +1 -1
  60. data/lib/ruby_reactor/web/api.rb +94 -16
  61. data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +1 -0
  62. data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +22 -0
  63. data/lib/ruby_reactor/web/public/index.html +2 -2
  64. data/lib/ruby_reactor/worker.rb +53 -3
  65. data/lib/ruby_reactor.rb +24 -3
  66. data/specs/001-background-async-steps/checklists/requirements.md +39 -0
  67. data/specs/001-background-async-steps/contracts/public-dsl.md +154 -0
  68. data/specs/001-background-async-steps/data-model.md +117 -0
  69. data/specs/001-background-async-steps/plan.md +168 -0
  70. data/specs/001-background-async-steps/quickstart.md +102 -0
  71. data/specs/001-background-async-steps/research.md +150 -0
  72. data/specs/001-background-async-steps/spec.md +146 -0
  73. data/specs/001-background-async-steps/tasks.md +271 -0
  74. data/specs/active_job.md +1 -1
  75. metadata +27 -3
  76. data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +0 -1
  77. data/lib/ruby_reactor/web/public/assets/index-D7IBZvos.js +0 -21
@@ -6,6 +6,7 @@ require_relative "executor/graph_manager"
6
6
  require_relative "executor/retry_manager"
7
7
  require_relative "executor/compensation_manager"
8
8
  require_relative "executor/result_handler"
9
+ require_relative "executor/async_step_dispatch"
9
10
  require_relative "executor/step_executor"
10
11
  require_relative "executor/ordered_lock_support"
11
12
 
@@ -53,6 +54,7 @@ module RubyReactor
53
54
  @acquired_semaphore = nil
54
55
  @acquired_context_lock = nil
55
56
  @context_lock_owner = nil
57
+ @parked = false
56
58
  @contention_snooze = false
57
59
  @skip_context_persist = false
58
60
  @last_checkpoint_at = nil
@@ -101,6 +103,7 @@ module RubyReactor
101
103
  input_validator = InputValidator.new(@reactor_class, @context)
102
104
  input_validator.validate!
103
105
 
106
+ reset_held_lock_keys!
104
107
  acquire_locks_with_telemetry
105
108
 
106
109
  # Re-check the period gate now that we hold the lock. The pre-lock check
@@ -132,6 +135,13 @@ module RubyReactor
132
135
  RubyReactor::OrderedLock::WaitError => e
133
136
  @contention_snooze = true
134
137
  raise e
138
+ rescue Error::AsyncResultPending
139
+ # Only reachable when this executor runs nested inside a worker (a
140
+ # composed child; sync callers never park). Propagate to the ROOT
141
+ # resume, which owns the park. This child's own lock/semaphore (if any)
142
+ # ARE released below and re-competed for on redelivery.
143
+ @contention_snooze = true
144
+ raise
135
145
  rescue StandardError => e
136
146
  @result = @result_handler.handle_execution_error(e)
137
147
  update_context_status(@result)
@@ -192,11 +202,19 @@ module RubyReactor
192
202
  # and must not contend on the root's own key.
193
203
  acquire_context_lock
194
204
 
205
+ reset_held_lock_keys!
206
+
195
207
  # Resumes intentionally skip check_rate_limit (a paused run must not
196
208
  # block itself on resume), so acquire lock/semaphore directly rather
197
- # than via acquire_locks.
198
- acquire_exclusive_lock if @reactor_class.respond_to?(:lock_config) && @reactor_class.lock_config
199
- acquire_semaphore if @reactor_class.respond_to?(:semaphore_config) && @reactor_class.semaphore_config
209
+ # than via acquire_locks. A context parked on an async result kept its
210
+ # primitives held across the gap — re-adopt them instead of re-competing.
211
+ parked = consume_parked_primitives!
212
+ if @reactor_class.respond_to?(:lock_config) && @reactor_class.lock_config
213
+ acquire_exclusive_lock(reattach: parked[:lock])
214
+ end
215
+ if @reactor_class.respond_to?(:semaphore_config) && @reactor_class.semaphore_config
216
+ acquire_semaphore(reattach_token: parked[:semaphore_token])
217
+ end
200
218
 
201
219
  # Post-lock re-check (see execute) — closes the period race for the
202
220
  # first run of a locked async reactor.
@@ -227,13 +245,21 @@ module RubyReactor
227
245
  RubyReactor::OrderedLock::WaitError => e
228
246
  @contention_snooze = true
229
247
  raise e
248
+ rescue Error::AsyncResultPending => e
249
+ # An awaited async unit is not terminal yet: park. Exclusive lock and
250
+ # semaphore stay HELD (recorded on the context for the resuming job to
251
+ # re-adopt); the worker snoozes the job. The context lock is still
252
+ # released below — the redelivered job must be able to take it.
253
+ park_held_primitives!
254
+ @contention_snooze = true
255
+ raise e
230
256
  rescue StandardError => e
231
257
  handle_resume_error(e)
232
258
  update_context_status(@result)
233
259
  completed = true
234
260
  @result
235
261
  ensure
236
- release_locks
262
+ release_locks unless @parked
237
263
  @acquired_context_lock&.release
238
264
  @acquired_context_lock = nil
239
265
  leave_ordered_lock_scope
@@ -265,6 +291,25 @@ module RubyReactor
265
291
  # Serialize context
266
292
  serialized_context = ContextSerializer.serialize(@context)
267
293
  storage.store_context(@context.context_id, serialized_context, reactor_class_name)
294
+ publish_completion_signal(storage)
295
+ end
296
+
297
+ # Wake any parent blocked in the notified wait on this execution. Published
298
+ # AFTER the durable save, never before: the context row is the answer and
299
+ # the signal only saves the waiter a fallback interval. Unconditional —
300
+ # publishing to a channel with no subscribers is near-free, so there is no
301
+ # need for an "am I awaited?" marker.
302
+ def publish_completion_signal(storage)
303
+ return unless @context.finished?
304
+
305
+ log_completion
306
+ storage.publish(RubyReactor.async_reactor_channel(@context.context_id), @context.status.to_s)
307
+ rescue StandardError => e
308
+ # The signal is an optimisation; losing it costs the waiter one fallback
309
+ # interval and must never fail the run that just completed.
310
+ RubyReactor.configuration.logger.warn(
311
+ "RubyReactor: could not publish completion signal for #{@context.context_id}: #{e.message}"
312
+ )
268
313
  end
269
314
 
270
315
  # Durable per-step checkpoint. Unlike save_context (which serializes THIS
@@ -386,6 +431,33 @@ module RubyReactor
386
431
  RubyReactor::Period.key(base, config[:every])
387
432
  end
388
433
 
434
+ # One machine-parseable line whenever an execution reaches a terminal
435
+ # state, carrying the parent link. A child dispatched fire-and-forget may
436
+ # have no other surface in its parent at all, so a failure entry also names
437
+ # the reason.
438
+ def log_completion
439
+ return unless @context.parent_context_id
440
+
441
+ fields = {
442
+ event: "ruby_reactor.async_reactor.completed",
443
+ reactor: @reactor_class&.name,
444
+ execution_id: @context.context_id,
445
+ parent_execution_id: @context.parent_context_id,
446
+ status: @context.status.to_s
447
+ }
448
+ fields[:failure] = failure_summary if @context.failed?
449
+
450
+ RubyReactor.configuration.logger.public_send(
451
+ @context.failed? ? :warn : :info,
452
+ fields.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")
453
+ )
454
+ end
455
+
456
+ def failure_summary
457
+ reason = @context.failure_reason
458
+ reason.respond_to?(:error) ? reason.error.to_s : reason.to_s
459
+ end
460
+
389
461
  # Per-execution liveness lock on the root context id. Owner is a fresh UUID
390
462
  # per execution (NOT the context_id): a duplicate delivery of the *same*
391
463
  # context from a different worker must be blocked, so reentrancy by id would
@@ -422,7 +494,7 @@ module RubyReactor
422
494
  defined?(Sidekiq::Testing) && Sidekiq::Testing.respond_to?(:inline?) && Sidekiq::Testing.inline?
423
495
  end
424
496
 
425
- def acquire_exclusive_lock
497
+ def acquire_exclusive_lock(reattach: false)
426
498
  config = @reactor_class.lock_config
427
499
  key = config[:key_proc].call(@context.inputs)
428
500
 
@@ -436,9 +508,21 @@ module RubyReactor
436
508
  wait: contention_wait(config[:wait]),
437
509
  auto_extend: config.fetch(:auto_extend, true)
438
510
  )
511
+
512
+ # Re-adopting a lock held across a parked gap: no :lock_acquired event —
513
+ # the original acquisition already emitted it, and the eventual release
514
+ # emits exactly one :lock_released. A lapsed TTL falls through to a
515
+ # fresh acquire.
516
+ if reattach && lock.reattach
517
+ @acquired_lock = lock
518
+ held_lock_keys << key
519
+ return
520
+ end
521
+
439
522
  begin
440
523
  lock.acquire
441
524
  @acquired_lock = lock
525
+ held_lock_keys << key
442
526
  middlewares.on(:lock_acquired, key, @context)
443
527
  rescue RubyReactor::Lock::AcquisitionError => e
444
528
  middlewares.on(:lock_failed, key, e, @context)
@@ -446,15 +530,29 @@ module RubyReactor
446
530
  end
447
531
  end
448
532
 
449
- def acquire_semaphore
533
+ def acquire_semaphore(reattach_token: nil)
450
534
  config = @reactor_class.semaphore_config
451
535
  key = config[:key_proc].call(@context.inputs)
452
536
  limit = config[:limit]
453
537
 
454
538
  semaphore = RubyReactor::Semaphore.new(key, limit: limit, wait: contention_wait(config[:wait]))
539
+
540
+ # Same shape as the lock reattach above: keep the slot held across the
541
+ # parked gap, no duplicate :semaphore_acquired event, fall through to a
542
+ # fresh acquire when the token was lost in between.
543
+ if reattach_token && semaphore.reattach(reattach_token)
544
+ @acquired_semaphore = semaphore
545
+ held_lock_keys << key if limit == 1
546
+ return
547
+ end
548
+
455
549
  begin
456
550
  semaphore.acquire
457
551
  @acquired_semaphore = semaphore
552
+ # Only a single-slot semaphore has the circular-wait shape the
553
+ # async_reactor deadlock guard can act on; higher limits are ordinary
554
+ # contention and must keep snoozing.
555
+ held_lock_keys << key if limit == 1
458
556
  middlewares.on(:semaphore_acquired, key, limit, @context)
459
557
  rescue RubyReactor::Semaphore::AcquisitionError => e
460
558
  middlewares.on(:semaphore_failed, key, limit, e, @context)
@@ -471,10 +569,47 @@ module RubyReactor
471
569
  configured_wait
472
570
  end
473
571
 
572
+ # Park on a pending async result: keep exclusive lock / semaphore checked
573
+ # out through the gap, recording just enough on the (about-to-be-saved)
574
+ # context for the resuming job to re-adopt them. The lock's auto-extender
575
+ # dies with this process, so the parked gap is bounded by the lock TTL —
576
+ # the snooze redelivery (seconds) sits comfortably inside the default 60s.
577
+ def park_held_primitives!
578
+ @parked = true
579
+ parked = {}
580
+
581
+ if @acquired_lock
582
+ @acquired_lock.detach
583
+ parked[:lock] = true
584
+ @acquired_lock = nil
585
+ end
586
+
587
+ if @acquired_semaphore
588
+ parked[:semaphore_token] = @acquired_semaphore.token
589
+ @acquired_semaphore = nil
590
+ end
591
+
592
+ @context.private_data[:parked_primitives] = parked if parked.any?
593
+ end
594
+
595
+ # One-shot: the marker is deleted on read so a crash after this point
596
+ # degrades to a fresh acquire (reentrant by owner for the lock) rather
597
+ # than a stale reattach on some later, unrelated resume.
598
+ def consume_parked_primitives!
599
+ raw = @context.private_data.delete(:parked_primitives) ||
600
+ @context.private_data.delete("parked_primitives") || {}
601
+
602
+ {
603
+ lock: raw[:lock] || raw["lock"],
604
+ semaphore_token: raw[:semaphore_token] || raw["semaphore_token"]
605
+ }
606
+ end
607
+
474
608
  def release_locks
475
609
  if @acquired_semaphore
476
610
  key = @acquired_semaphore.key
477
611
  release_one("semaphore", @acquired_semaphore)
612
+ held_lock_keys.delete(key)
478
613
  middlewares.on(:semaphore_released, key, @context)
479
614
  end
480
615
  @acquired_semaphore = nil
@@ -483,10 +618,29 @@ module RubyReactor
483
618
 
484
619
  key = @acquired_lock.key
485
620
  release_one("lock", @acquired_lock)
621
+ held_lock_keys.delete(key)
486
622
  @acquired_lock = nil
487
623
  middlewares.on(:lock_released, key, @context)
488
624
  end
489
625
 
626
+ # Exclusive keys this EXECUTION currently holds, recorded on the root
627
+ # context so a dispatching step anywhere in the tree can see the whole
628
+ # chain. Read by the async_reactor deadlock guard; nothing else
629
+ # depends on it, so a stale entry can only cost a false positive — hence
630
+ # the reset on the way in.
631
+ def held_lock_keys
632
+ root = @context.root_context || @context
633
+ root.private_data[:held_lock_keys] ||= []
634
+ end
635
+
636
+ # A rehydrated context can carry keys from the process that died holding
637
+ # them. Only the root executor resets, and only on the way in.
638
+ def reset_held_lock_keys!
639
+ return unless (@context.root_context || @context).equal?(@context)
640
+
641
+ (@context.root_context || @context).private_data[:held_lock_keys] = []
642
+ end
643
+
490
644
  def release_one(kind, primitive)
491
645
  released = primitive.release
492
646
  return if released
@@ -506,7 +660,7 @@ module RubyReactor
506
660
  return unless result
507
661
 
508
662
  case result
509
- when RubyReactor::AsyncResult
663
+ when RubyReactor::DispatchResult
510
664
  @context.status = :running
511
665
  when RubyReactor::Skipped
512
666
  @context.status = :skipped
@@ -547,7 +701,7 @@ module RubyReactor
547
701
  when RubyReactor::Skipped,
548
702
  RetryQueuedResult,
549
703
  RubyReactor::Failure,
550
- RubyReactor::AsyncResult,
704
+ RubyReactor::DispatchResult,
551
705
  RubyReactor::InterruptResult
552
706
  # Terminal: step was skipped, requeued, failed, paused, or handed
553
707
  # off to async. Return the result as-is.
@@ -54,6 +54,25 @@ module RubyReactor
54
54
  adapter.lock_release(@key, @owner)
55
55
  end
56
56
 
57
+ # Park support: stop refreshing the TTL but LEAVE the key held — the
58
+ # parked execution keeps ownership through the gap, bounded by the TTL.
59
+ def detach
60
+ stop_extender
61
+ end
62
+
63
+ # Resume ownership after a parked gap: verify we are still the owner and
64
+ # refresh the TTL — WITHOUT incrementing the reentrancy count. The count
65
+ # was never decremented at park, so a plain `acquire` here would bump it
66
+ # to 2 and the final release would leave the key behind until TTL.
67
+ # Returns false when ownership lapsed (TTL expired mid-park); the caller
68
+ # falls back to a fresh acquire.
69
+ def reattach # rubocop:disable Naming/PredicateMethod
70
+ return false unless adapter.lock_extend(@key, @owner, @ttl)
71
+
72
+ start_extender if @auto_extend
73
+ true
74
+ end
75
+
57
76
  def synchronize
58
77
  acquire
59
78
  yield
@@ -146,7 +146,12 @@ module RubyReactor
146
146
  parent_class, strict_ordering: arguments[:strict_ordering])
147
147
  else
148
148
  executor.undo_all
149
- storage.store_map_result(map_id, index, { _error: result.error }, parent_class,
149
+ # Store the whole serialized Failure, not just its message: step_name,
150
+ # backtrace, file_path and code_snippet are the only record of why this
151
+ # element failed once its context row expires, and the dashboard has
152
+ # nothing else to show for a non-fail_fast map.
153
+ storage.store_map_result(map_id, index,
154
+ { _error: ContextSerializer.serialize_value(result) }, parent_class,
150
155
  strict_ordering: arguments[:strict_ordering])
151
156
 
152
157
  if arguments[:fail_fast]
@@ -95,7 +95,7 @@ module RubyReactor
95
95
 
96
96
  # Manually update execution trace to reflect completion
97
97
  # This is necessary because resume_execution continues from the NEXT step
98
- # and the async step (which returned AsyncResult) needs to be marked as done with actual value
98
+ # and the async step (which returned DispatchResult) needs to be marked as done with actual value
99
99
  parent_context.execution_trace << {
100
100
  type: :result,
101
101
  step: step_name_sym,
@@ -96,7 +96,11 @@ module RubyReactor
96
96
 
97
97
  def wrap_result(result)
98
98
  if result.is_a?(Hash) && result.key?("_error")
99
- RubyReactor::Failure.new(result["_error"])
99
+ # `backtrace: []` is load-bearing: without it Failure falls back to
100
+ # `caller`, so a bare-message element failure would carry the stack of
101
+ # whoever happened to materialize the enumerator (the JSON encoder, in
102
+ # the dashboard's case) instead of the stack of the step that failed.
103
+ RubyReactor::Failure.new(result["_error"], backtrace: [])
100
104
  else
101
105
  RubyReactor::Success.new(ContextSerializer.deserialize_value(result))
102
106
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Map
5
+ # A map's result set is unbounded, so the API describes it instead of
6
+ # inlining it. Inlining meant the JSON encoder materialized the lazy
7
+ # ResultEnumerator itself — one storage read per element — and rendered every
8
+ # element failure as an opaque blob with no count of how many there were.
9
+ class ResultSummary
10
+ # How many failed elements are described in full. The rest are only
11
+ # counted — a map failing 50k elements must not produce a 50k-entry
12
+ # response.
13
+ SAMPLE_LIMIT = 25
14
+
15
+ # Sampled failures are a list, not a detail view, and the same summary is
16
+ # echoed in intermediate_results, the execution trace and the undo stack —
17
+ # full traces on each would triple a large payload.
18
+ BACKTRACE_FRAMES = 10
19
+
20
+ # ponytail: one windowed read of the whole map; paginate if maps grow past
21
+ # what fits in a single response.
22
+ def self.build(enumerator)
23
+ total = enumerator.count
24
+ results = RubyReactor.configuration.storage_adapter.retrieve_map_results_batch(
25
+ enumerator.map_id, enumerator.reactor_class_name, offset: 0, limit: total
26
+ )
27
+ # Slots are index-keyed and the window starts at 0, so a full read means
28
+ # position == element index. A short read means the map is still filling
29
+ # in and gaps would shift positions — then indices are omitted.
30
+ failures = collect_failures(results, indexed: results.size == total)
31
+
32
+ {
33
+ "_type" => "map_results",
34
+ "total" => total,
35
+ "succeeded" => total - failures[:count],
36
+ "failed" => failures[:count],
37
+ "failures" => failures[:sample],
38
+ "failures_truncated" => failures[:count] > failures[:sample].size
39
+ }
40
+ end
41
+
42
+ def self.collect_failures(results, indexed:)
43
+ count = 0
44
+ sample = []
45
+
46
+ results.each_with_index do |raw, position|
47
+ next unless raw.is_a?(Hash) && raw.key?("_error")
48
+
49
+ count += 1
50
+ next if sample.size >= SAMPLE_LIMIT
51
+
52
+ entry = ContextSerializer.simplify_for_api(Failure.new(raw["_error"], backtrace: []))
53
+ entry["backtrace"] = Array(entry["backtrace"]).first(BACKTRACE_FRAMES)
54
+ entry["index"] = position if indexed
55
+ sample << entry
56
+ end
57
+
58
+ { count: count, sample: sample }
59
+ end
60
+ private_class_method :collect_failures
61
+ end
62
+ end
63
+ end
@@ -147,7 +147,7 @@ module RubyReactor
147
147
  span = @step_spans.delete(step_name)
148
148
  return unless span
149
149
 
150
- if result.is_a?(RubyReactor::AsyncResult)
150
+ if result.is_a?(RubyReactor::DispatchResult)
151
151
  # The step was handed off to a background worker; the run block did not
152
152
  # execute here. Rename the span so it is not confused with the real
153
153
  # execution span emitted later under the resumed reactor span.
@@ -118,7 +118,7 @@ module RubyReactor
118
118
  RubyReactor.reactor_storage_name(self.class),
119
119
  intermediate_results: @context.intermediate_results)
120
120
 
121
- # Even if it's an AsyncResult, it might have finished inline (e.g. Sidekiq::Testing.inline!)
121
+ # Even if it's an DispatchResult, it might have finished inline (e.g. Sidekiq::Testing.inline!)
122
122
  # Check storage to see if it's already finished or paused (interrupted).
123
123
  begin
124
124
  reloaded = self.class.find(@context.context_id)
@@ -167,6 +167,14 @@ module RubyReactor
167
167
  target_step = step_name
168
168
  @context.set_result(target_step, payload)
169
169
 
170
+ # `interrupt :x, resume: :background` — payload is validated and stored
171
+ # (above, in this process); the remaining work goes to a worker instead
172
+ # of running inline in the delivering process.
173
+ step_config = self.class.steps[step_name.to_sym]
174
+ if step_config.respond_to?(:background_resume?) && step_config.background_resume?
175
+ return @result = enqueue_background_resume
176
+ end
177
+
170
178
  # Resume execution
171
179
  executor = Executor.new(self.class, {}, @context)
172
180
  @result = executor.resume_execution
@@ -311,6 +319,21 @@ module RubyReactor
311
319
  save_context
312
320
  end
313
321
 
322
+ # Mirror of perform_async_run for the interrupt-resume path: persist the
323
+ # context (now carrying the validated payload) BEFORE enqueue — the job
324
+ # payload is identity-only (F2) — then hand the remainder to a worker.
325
+ def enqueue_background_resume
326
+ @context.status = :running
327
+ Executor.middlewares_for(self.class).on(:before_async_enqueue, @context)
328
+ save_context
329
+
330
+ @result = configuration.async_router.perform_async(@context.context_id,
331
+ RubyReactor.reactor_storage_name(self.class),
332
+ intermediate_results: @context.intermediate_results)
333
+
334
+ check_for_inline_completion || @result
335
+ end
336
+
314
337
  def perform_async_run
315
338
  @context.status = :running
316
339
  # Persist BEFORE enqueue — the job payload is identity-only (F2).
@@ -324,7 +347,7 @@ module RubyReactor
324
347
  end
325
348
 
326
349
  def check_for_inline_completion
327
- # Even if it's an AsyncResult, it might have finished inline (e.g. Sidekiq::Testing.inline!)
350
+ # Even if it's an DispatchResult, it might have finished inline (e.g. Sidekiq::Testing.inline!)
328
351
  # Check storage to see if it's already finished or paused (interrupted).
329
352
  reloaded = self.class.find(@context.context_id)
330
353
  if reloaded.finished? || reloaded.context.status.to_s == "paused"
@@ -38,7 +38,8 @@ module RubyReactor
38
38
  @worker_classes ||= [
39
39
  RubyReactor::Adapters::Sidekiq::Worker,
40
40
  RubyReactor::Adapters::Sidekiq::MapElementWorker,
41
- RubyReactor::Adapters::Sidekiq::MapCollectorWorker
41
+ RubyReactor::Adapters::Sidekiq::MapCollectorWorker,
42
+ RubyReactor::Adapters::Sidekiq::StepWorker
42
43
  ]
43
44
  end
44
45
 
@@ -14,7 +14,7 @@ module RubyReactor
14
14
  result = super
15
15
 
16
16
  # 2. Add test-specific logic for inline async execution
17
- # Only interfere if we got an AsyncResult and we are in a testing environment that supports inline execution
17
+ # Only interfere if we got an DispatchResult and we are in a testing environment that supports inline execution
18
18
  if should_check_inline_completion?(result)
19
19
  # Check if it finished or paused inline (e.g. Sidekiq::Testing.inline!)
20
20
  refresh_context_from_storage
@@ -75,7 +75,7 @@ module RubyReactor
75
75
  end
76
76
 
77
77
  def should_check_inline_completion?(result)
78
- return false unless result.is_a?(RubyReactor::AsyncResult) || result.is_a?(RubyReactor::RetryQueuedResult)
78
+ return false unless result.is_a?(RubyReactor::DispatchResult) || result.is_a?(RubyReactor::RetryQueuedResult)
79
79
  return true if defined?(Sidekiq::Testing) && Sidekiq::Testing.inline?
80
80
 
81
81
  false
@@ -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)
@@ -441,7 +491,8 @@ module RubyReactor
441
491
  end
442
492
 
443
493
  def prepare_execution_class
444
- # 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.
445
496
  return @reactor_class if @interceptors.empty? && @async != false
446
497
 
447
498
  interceptors = @interceptors
@@ -454,7 +505,7 @@ module RubyReactor
454
505
  @input_validations = superclass.input_validations.dup
455
506
  @middlewares = superclass.middlewares.dup
456
507
  @return_step = superclass.return_step
457
- @async = superclass.async?
508
+ @background_handoff = superclass.background_handoff
458
509
  @retry_defaults = superclass.instance_variable_get(:@retry_defaults)
459
510
 
460
511
  # 2. Add Name Handling with Unique Registry Entry
@@ -464,15 +515,16 @@ module RubyReactor
464
515
  define_singleton_method(:name) { unique_name }
465
516
  RubyReactor::Registry.register(unique_name, self)
466
517
 
467
- # 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.
468
522
  if force_sync
523
+ @background_handoff = nil
469
524
  @steps.each do |name, config|
470
- next unless config.async?
525
+ next unless config.respond_to?(:async_dispatch?) && config.async_dispatch?
471
526
 
472
- # Clone and modify
473
- new_config = config.clone
474
- new_config.instance_variable_set(:@async, false)
475
- @steps[name] = new_config
527
+ @steps[name] = config.clone.tap { |c| c.instance_variable_set(:@async_dispatch, nil) }
476
528
  end
477
529
  end
478
530
  end
@@ -503,7 +555,8 @@ module RubyReactor
503
555
 
504
556
  if nested_interceptors.any?
505
557
  apply_nested_interceptors(step_config, nested_interceptors)
506
- 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)
507
560
  end
508
561
 
509
562
  # Apply direct interceptors (mocks/failures on this step)
@@ -551,7 +604,7 @@ module RubyReactor
551
604
  @input_validations = superclass.input_validations.dup
552
605
  @middlewares = superclass.middlewares.dup
553
606
  @return_step = superclass.return_step
554
- @async = superclass.async?
607
+ @background_handoff = superclass.background_handoff
555
608
  @retry_defaults = superclass.instance_variable_get(:@retry_defaults)
556
609
  end
557
610
 
@@ -606,7 +659,9 @@ module RubyReactor
606
659
  end
607
660
 
608
661
  step_config.instance_variable_set(:@run_block, wrapper_impl)
609
- 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)
610
665
  end
611
666
  end
612
667
  # rubocop:enable Metrics/ClassLength
@@ -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