ruby_reactor 0.8.1 → 0.8.3

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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/speckit-review/SKILL.md +324 -0
  3. data/.release-please-manifest.json +1 -1
  4. data/.specify/extensions.yml +10 -0
  5. data/.specify/feature.json +1 -1
  6. data/.specify/workflows/speckit/workflow.yml +13 -1
  7. data/.specify/workflows/workflow-registry.json +2 -2
  8. data/CHANGELOG.md +89 -0
  9. data/CLAUDE.md +2 -2
  10. data/README.md +36 -3
  11. data/lib/ruby_reactor/adapters/active_job/router.rb +19 -0
  12. data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -0
  13. data/lib/ruby_reactor/context.rb +26 -0
  14. data/lib/ruby_reactor/context_serializer.rb +4 -2
  15. data/lib/ruby_reactor/dsl/interrupt_builder.rb +14 -0
  16. data/lib/ruby_reactor/dsl/lockable.rb +76 -21
  17. data/lib/ruby_reactor/dsl/step_builder.rb +112 -1
  18. data/lib/ruby_reactor/error/async_result_pending.rb +1 -1
  19. data/lib/ruby_reactor/error/execution_parked.rb +16 -0
  20. data/lib/ruby_reactor/error/reactor_contention_park.rb +26 -0
  21. data/lib/ruby_reactor/error/step_contention_park.rb +26 -0
  22. data/lib/ruby_reactor/executor/async_step_dispatch.rb +109 -3
  23. data/lib/ruby_reactor/executor/compensation_manager.rb +99 -17
  24. data/lib/ruby_reactor/executor/ordered_lock_support.rb +76 -44
  25. data/lib/ruby_reactor/executor/result_handler.rb +31 -11
  26. data/lib/ruby_reactor/executor/retry_manager.rb +9 -1
  27. data/lib/ruby_reactor/executor/step_coordination.rb +788 -0
  28. data/lib/ruby_reactor/executor/step_executor.rb +115 -11
  29. data/lib/ruby_reactor/executor.rb +90 -20
  30. data/lib/ruby_reactor/map/element_executor.rb +24 -2
  31. data/lib/ruby_reactor/map/helpers.rb +35 -11
  32. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +2 -2
  33. data/lib/ruby_reactor/open_telemetry.rb +61 -24
  34. data/lib/ruby_reactor/retry_context.rb +31 -2
  35. data/lib/ruby_reactor/rspec/helpers.rb +19 -3
  36. data/lib/ruby_reactor/rspec/matchers.rb +92 -0
  37. data/lib/ruby_reactor/rspec/test_subject.rb +209 -46
  38. data/lib/ruby_reactor/rspec.rb +8 -1
  39. data/lib/ruby_reactor/step/async_reactor_step.rb +40 -24
  40. data/lib/ruby_reactor/step/compose_step.rb +14 -3
  41. data/lib/ruby_reactor/step/map_step.rb +4 -4
  42. data/lib/ruby_reactor/step.rb +49 -7
  43. data/lib/ruby_reactor/step_sweeper.rb +29 -1
  44. data/lib/ruby_reactor/step_worker.rb +260 -37
  45. data/lib/ruby_reactor/version.rb +1 -1
  46. data/lib/ruby_reactor/web/api.rb +72 -7
  47. data/lib/ruby_reactor/web/coordination_serializer.rb +120 -2
  48. data/lib/ruby_reactor/web/public/assets/{index-Dw4KV4QY.js → index-CeZU-ESu.js} +9 -9
  49. data/lib/ruby_reactor/web/public/index.html +1 -1
  50. data/lib/ruby_reactor/worker.rb +56 -30
  51. data/lib/ruby_reactor.rb +27 -5
  52. data/specs/future_improvements.md +250 -0
  53. data/specs/scoped_rspec_matchers.md +158 -0
  54. metadata +9 -28
  55. data/specs/002-step-input-contracts/checklists/requirements.md +0 -49
  56. data/specs/002-step-input-contracts/contracts/dsl-surface.md +0 -193
  57. data/specs/002-step-input-contracts/data-model.md +0 -115
  58. data/specs/002-step-input-contracts/plan.md +0 -165
  59. data/specs/002-step-input-contracts/quickstart.md +0 -170
  60. data/specs/002-step-input-contracts/research.md +0 -233
  61. data/specs/002-step-input-contracts/spec.md +0 -359
  62. data/specs/002-step-input-contracts/tasks.md +0 -367
  63. data/specs/004-inheritable-step-class/checklists/requirements.md +0 -40
  64. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +0 -85
  65. data/specs/004-inheritable-step-class/data-model.md +0 -116
  66. data/specs/004-inheritable-step-class/plan.md +0 -174
  67. data/specs/004-inheritable-step-class/quickstart.md +0 -112
  68. data/specs/004-inheritable-step-class/research.md +0 -308
  69. data/specs/004-inheritable-step-class/spec.md +0 -316
  70. data/specs/004-inheritable-step-class/tasks.md +0 -258
  71. data/specs/active_job.md +0 -259
  72. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +0 -51
  73. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +0 -154
  74. data/specs/deferred-003-step-lock-declarations/data-model.md +0 -131
  75. data/specs/deferred-003-step-lock-declarations/plan.md +0 -166
  76. data/specs/deferred-003-step-lock-declarations/quickstart.md +0 -169
  77. data/specs/deferred-003-step-lock-declarations/research.md +0 -196
  78. data/specs/deferred-003-step-lock-declarations/spec.md +0 -447
  79. data/specs/deferred-003-step-lock-declarations/tasks.md +0 -572
  80. data/specs/possible_feature.md +0 -22
@@ -3,13 +3,14 @@
3
3
  module RubyReactor
4
4
  # Tracks retry attempts and state for steps in async execution
5
5
  class RetryContext
6
- attr_accessor :step_attempts, :current_step, :failure_reason, :next_retry_at
6
+ attr_accessor :step_attempts, :current_step, :failure_reason, :next_retry_at, :contention_attempts
7
7
 
8
8
  def initialize
9
9
  @step_attempts = {}
10
10
  @current_step = nil
11
11
  @failure_reason = nil
12
12
  @next_retry_at = nil
13
+ @contention_attempts = {}
13
14
  end
14
15
 
15
16
  def increment_attempt_for_step(step_name)
@@ -18,6 +19,16 @@ module RubyReactor
18
19
  @step_attempts[step_name] += 1
19
20
  end
20
21
 
22
+ # Undoes one `increment_attempt_for_step` — called at the start of a
23
+ # contention park (Finding 2) so a busy key never eats the retry budget
24
+ # meant for genuine failures. Floored at 0.
25
+ def decrement_attempt_for_step(step_name)
26
+ step_name = step_name.to_s
27
+ return unless @step_attempts[step_name]
28
+
29
+ @step_attempts[step_name] = [@step_attempts[step_name] - 1, 0].max
30
+ end
31
+
21
32
  def attempts_for_step(step_name)
22
33
  step_name = step_name.to_s
23
34
  @step_attempts[step_name] || 0
@@ -28,11 +39,27 @@ module RubyReactor
28
39
  attempts < max_attempts
29
40
  end
30
41
 
42
+ def increment_contention_for_step(step_name)
43
+ step_name = step_name.to_s
44
+ @contention_attempts[step_name] ||= 0
45
+ @contention_attempts[step_name] += 1
46
+ end
47
+
48
+ def contention_attempts_for_step(step_name)
49
+ step_name = step_name.to_s
50
+ @contention_attempts[step_name] || 0
51
+ end
52
+
53
+ def clear_contention_for_step(step_name)
54
+ @contention_attempts.delete(step_name.to_s)
55
+ end
56
+
31
57
  def reset
32
58
  @step_attempts = {}
33
59
  @current_step = nil
34
60
  @failure_reason = nil
35
61
  @next_retry_at = nil
62
+ @contention_attempts = {}
36
63
  end
37
64
 
38
65
  def serialize_for_retry
@@ -40,7 +67,8 @@ module RubyReactor
40
67
  step_attempts: @step_attempts,
41
68
  current_step: @current_step,
42
69
  failure_reason: serialize_error(@failure_reason),
43
- next_retry_at: @next_retry_at&.iso8601
70
+ next_retry_at: @next_retry_at&.iso8601,
71
+ contention_attempts: @contention_attempts
44
72
  }
45
73
  end
46
74
 
@@ -50,6 +78,7 @@ module RubyReactor
50
78
  context.current_step = data["current_step"]
51
79
  context.failure_reason = deserialize_error(data["failure_reason"])
52
80
  context.next_retry_at = data["next_retry_at"] ? Time.iso8601(data["next_retry_at"]) : nil
81
+ context.contention_attempts = data["contention_attempts"] || {}
53
82
  context
54
83
  end
55
84
 
@@ -2,9 +2,10 @@
2
2
 
3
3
  module RubyReactor
4
4
  module RSpec
5
- # Globally-included helpers. Only methods whose names clearly belong to
6
- # RubyReactor's test surface live here (`test_reactor`). Sidekiq-coupled
7
- # helpers live in `SidekiqHelpers` and are scoped to `type: :reactor`.
5
+ # Entry-point helpers (`test_reactor`), auto-included into examples tagged
6
+ # `type: :reactor`. Specs that want them outside that tag should
7
+ # `include RubyReactor::RSpec::Helpers` explicitly. Sidekiq-coupled helpers
8
+ # live in `SidekiqHelpers`, scoped the same way.
8
9
  module Helpers
9
10
  # Build a `TestSubject` around a reactor invocation. Captures the run for
10
11
  # later introspection via matchers; runs the reactor lazily on first
@@ -18,6 +19,21 @@ module RubyReactor
18
19
  process_jobs: process_jobs
19
20
  )
20
21
  end
22
+
23
+ # Holds `key` as an EXTERNAL owner for the duration of the block, so a
24
+ # spec can assert against genuine lock contention (a step or reactor
25
+ # failing/parking because something else already holds its key)
26
+ # without reaching into `RubyReactor::Lock` or raw Redis calls itself.
27
+ # Released even if the block raises.
28
+ #
29
+ # hold_lock("acct:1") { expect(ChargeStep.run({ account_id: 1 }, nil)).to be_a(RubyReactor::Failure) }
30
+ def hold_lock(key, owner: "spec", ttl: 30)
31
+ lock = RubyReactor::Lock.new(key, owner: owner, ttl: ttl, wait: 0, auto_extend: true)
32
+ lock.acquire
33
+ yield
34
+ ensure
35
+ lock&.release
36
+ end
21
37
  end
22
38
  end
23
39
  end
@@ -528,6 +528,98 @@ module RubyReactor
528
528
  end
529
529
  end
530
530
 
531
+ # Asserts a `:contention_park` trace entry exists for a step (US7-4,
532
+ # T054). Subject can be a `test_reactor` wrapper, a dispatch/find result
533
+ # (`.context`), or a raw `Context` (`.execution_trace`) — matching
534
+ # `have_run_step`'s subject flexibility. Use `.on(key)` to pin the key.
535
+ #
536
+ # expect(result).to have_contended_at(:charge)
537
+ # expect(result).to have_contended_at(:charge).on("acct:1")
538
+ ::RSpec::Matchers.define :have_contended_at do |step_name|
539
+ match do |subject|
540
+ trace = execution_trace_for(subject)
541
+ @entry = trace.find do |t|
542
+ t[:type].to_s == "contention_park" && t[:step].to_s == step_name.to_s &&
543
+ (@expected_key.nil? || t[:key].to_s == @expected_key.to_s)
544
+ end
545
+ !@entry.nil?
546
+ end
547
+
548
+ chain :on do |key|
549
+ @expected_key = key
550
+ end
551
+
552
+ def execution_trace_for(subject)
553
+ subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
554
+ if subject.respond_to?(:reactor_instance)
555
+ subject.reactor_instance.context.execution_trace
556
+ elsif subject.respond_to?(:context)
557
+ subject.context.execution_trace
558
+ else
559
+ subject.execution_trace
560
+ end
561
+ end
562
+
563
+ failure_message do |_subject|
564
+ msg = "expected execution trace to contain a contention_park entry for step :#{step_name}"
565
+ msg += " on key #{@expected_key.inspect}" if @expected_key
566
+ msg
567
+ end
568
+
569
+ failure_message_when_negated do |_subject|
570
+ msg = "expected execution trace not to contain a contention_park entry for step :#{step_name}"
571
+ msg += " on key #{@expected_key.inspect}" if @expected_key
572
+ msg
573
+ end
574
+ end
575
+
576
+ # Asserts that a failed run reports a rollback (undo or compensation)
577
+ # of `step_name` that did not complete, on `Failure#rollback_failures`.
578
+ # Subject is a `Failure` or a `test_reactor` wrapper (its `result`).
579
+ #
580
+ # expect(result).to have_rollback_failure(:charge)
581
+ # expect(subject).to have_rollback_failure(:charge).for_key("acct:1").because(:coordination_unavailable)
582
+ # expect(subject).not_to have_rollback_failure(:charge)
583
+ ::RSpec::Matchers.define :have_rollback_failure do |step_name|
584
+ match do |subject|
585
+ @entries = rollback_failures_for(subject)
586
+ @entries.any? do |e|
587
+ e[:step].to_s == step_name.to_s &&
588
+ (@expected_key.nil? || e[:key].to_s == @expected_key.to_s) &&
589
+ (@expected_reason.nil? || e[:reason].to_s == @expected_reason.to_s)
590
+ end
591
+ end
592
+
593
+ chain :for_key do |key|
594
+ @expected_key = key
595
+ end
596
+
597
+ chain :because do |reason|
598
+ @expected_reason = reason
599
+ end
600
+
601
+ def rollback_failures_for(subject)
602
+ subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
603
+ actual = subject.respond_to?(:result) ? subject.result : subject
604
+ actual.respond_to?(:rollback_failures) ? actual.rollback_failures : []
605
+ end
606
+
607
+ def expectation_text(step_name)
608
+ text = "a rollback failure for :#{step_name}"
609
+ text += " on key #{@expected_key.inspect}" if @expected_key
610
+ text += " because #{@expected_reason.inspect}" if @expected_reason
611
+ text
612
+ end
613
+
614
+ failure_message do |_subject|
615
+ "expected #{expectation_text(step_name)}, got rollback_failures #{@entries.inspect}"
616
+ end
617
+
618
+ failure_message_when_negated do |_subject|
619
+ "expected no #{expectation_text(step_name)}, got rollback_failures #{@entries.inspect}"
620
+ end
621
+ end
622
+
531
623
  # Add more matchers as per plan
532
624
  # rubocop:enable Metrics/BlockLength
533
625
  end
@@ -48,44 +48,93 @@ module RubyReactor
48
48
  self
49
49
  end
50
50
 
51
- # Fluent API for mocking nested map steps
52
- # @example
53
- # reactor.map(:my_map).mock_step(:inner_step) { ... }
51
+ # Fluent/scoped API for mocking nested map steps.
52
+ #
53
+ # Without a block, returns a proxy scoped to this map step — chain
54
+ # `mock_step`/`failing_at`/`map`/`composed` on it to configure several
55
+ # things inside the same nested reactor:
56
+ # reactor.map(:my_map).mock_step(:a) { ... }.mock_step(:b) { ... }
57
+ #
58
+ # With a block, the proxy is yielded and the *outer* subject is
59
+ # returned, so sibling nested reactors can be configured in the same
60
+ # chain without leaking scope between them:
61
+ # test_reactor(Parent, params)
62
+ # .map(:my_map) { |m| m.mock_step(:a) { ... } }
63
+ # .composed(:other_child) { |c| c.mock_step(:b) { ... } }
54
64
  def map(step_name)
55
- StepProxy.new(self, step_name)
65
+ proxy = StepProxy.new(self, [step_name])
66
+ return proxy unless block_given?
67
+
68
+ yield proxy
69
+ self
56
70
  end
57
71
 
58
- # Fluent API for mocking nested compose steps
72
+ # Fluent/scoped API for mocking nested compose steps. See `#map` for
73
+ # the block vs. no-block behavior.
59
74
  # @example
60
- # reactor.compose(:my_sub_reactor).mock_step(:inner_step) { ... }
75
+ # reactor.composed(:my_sub_reactor).mock_step(:inner_step) { ... }
61
76
  def composed(step_name)
62
77
  # If already executed, return the traversed subject
63
78
  return traverse_composed(step_name) if @executed
64
79
 
65
- # Otherwise return a configuration proxy
66
- StepProxy.new(self, step_name)
80
+ proxy = StepProxy.new(self, [step_name])
81
+ return proxy unless block_given?
82
+
83
+ yield proxy
84
+ self
67
85
  end
68
86
  alias compose composed
69
87
 
70
- # Proxy class for fluent mocking configuration
88
+ # Proxy scoped to a nested step path (e.g. [:parent_compose, :child_map]).
89
+ # All mutating calls stay scoped to this path: `mock_step`/`failing_at`
90
+ # return the proxy itself (not the outer subject) so several inner
91
+ # steps of the *same* nested reactor can be chained without resetting
92
+ # scope back to the parent.
71
93
  class StepProxy
72
- def initialize(subject, step_name)
94
+ def initialize(subject, step_path)
73
95
  @subject = subject
74
- @step_name = step_name
96
+ @step_path = step_path
97
+ end
98
+
99
+ def mock_step(inner_step_name, *nested_steps, element_index: nil, &block)
100
+ @subject.mock_step(*@step_path, inner_step_name, *nested_steps, element_index: element_index, &block)
101
+ self
75
102
  end
76
103
 
77
- def mock_step(inner_step_name, *nested_steps, &block)
78
- @subject.mock_step(@step_name, inner_step_name, *nested_steps, &block)
79
- @subject # Return subject to allow chaining or calling run
104
+ def failing_at(inner_step_name, *nested_steps, element_index: nil, &block)
105
+ @subject.failing_at(*@step_path, inner_step_name, *nested_steps, element_index: element_index, &block)
106
+ self
80
107
  end
81
108
 
82
- # Support deep nesting?
83
109
  def map(inner_step_name)
84
- StepProxy.new(@subject, [@step_name, inner_step_name].flatten)
110
+ proxy = self.class.new(@subject, @step_path + [inner_step_name])
111
+ return proxy unless block_given?
112
+
113
+ yield proxy
114
+ self
85
115
  end
86
116
 
87
117
  def composed(inner_step_name)
88
- StepProxy.new(@subject, [@step_name, inner_step_name].flatten)
118
+ proxy = self.class.new(@subject, @step_path + [inner_step_name])
119
+ return proxy unless block_given?
120
+
121
+ yield proxy
122
+ self
123
+ end
124
+ alias compose composed
125
+
126
+ # Delegate everything else (run, success?, result, step_result, ...)
127
+ # to the outer subject, so a proxy can stand in for it wherever a
128
+ # `TestSubject` is expected (e.g. `expect(subject).to be_success`
129
+ # right after a non-block `.composed(:x).mock_step(:y)` chain).
130
+ def method_missing(name, ...)
131
+ return @subject.public_send(name, ...) if @subject.respond_to?(name)
132
+
133
+ super
134
+ end
135
+
136
+ def respond_to_missing?(name, include_private = false)
137
+ @subject.respond_to?(name, include_private) || super
89
138
  end
90
139
  end
91
140
 
@@ -491,12 +540,13 @@ module RubyReactor
491
540
  end
492
541
 
493
542
  def prepare_execution_class
543
+ @force_sync = @async == false
544
+
494
545
  # Even if no interceptors, we might need to subclass to force the whole
495
546
  # reactor to run in-process.
496
- return @reactor_class if @interceptors.empty? && @async != false
547
+ return @reactor_class if @interceptors.empty? && !@force_sync
497
548
 
498
549
  interceptors = @interceptors
499
- force_sync = @async == false
500
550
 
501
551
  execution_class = Class.new(@reactor_class) do
502
552
  # 1. Copy configuration from parent
@@ -514,19 +564,17 @@ module RubyReactor
514
564
  unique_name = "#{superclass.name}Mock#{object_id}"
515
565
  define_singleton_method(:name) { unique_name }
516
566
  RubyReactor::Registry.register(unique_name, self)
567
+ end
517
568
 
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.
522
- if force_sync
523
- @background_handoff = nil
524
- @steps.each do |name, config|
525
- next unless config.respond_to?(:async_dispatch?) && config.async_dispatch?
526
-
527
- @steps[name] = config.clone.tap { |c| c.instance_variable_set(:@async_dispatch, nil) }
528
- end
529
- end
569
+ # 3. `async: false` / `run_async(false)` means "run this reactor's full
570
+ # logic here, in one process" — all the way down. Suppress this
571
+ # class's own `background` hand-off and `async_step`/`async_reactor`
572
+ # dispatch, then recursively do the same for every `compose`d/`map`ped
573
+ # child reactor (`apply_nested_interceptors` below clones from
574
+ # whatever class ends up wired in here, so it inherits this).
575
+ if @force_sync
576
+ strip_background_and_async!(execution_class)
577
+ force_sync_nested_reactors!(execution_class)
530
578
  end
531
579
 
532
580
  # 4. Apply Interceptors
@@ -535,6 +583,79 @@ module RubyReactor
535
583
  execution_class
536
584
  end
537
585
 
586
+ # Clears the background hand-off and any `async_dispatch` flags on a
587
+ # class's OWN steps. Nested composed/map children are separate classes,
588
+ # handled by `force_sync_nested_reactors!`.
589
+ def strip_background_and_async!(klass)
590
+ klass.instance_variable_set(:@background_handoff, nil)
591
+ klass.steps.each do |name, config|
592
+ next unless config.respond_to?(:async_dispatch?) && config.async_dispatch?
593
+
594
+ klass.steps[name] = config.clone.tap { |c| c.instance_variable_set(:@async_dispatch, nil) }
595
+ end
596
+ end
597
+
598
+ # Rewrites every `compose`/`map` step to target a subclass that has
599
+ # itself been force-synced (background/async stripped, and recursively,
600
+ # its own nested composed/map children), so `run_async(false)` reaches
601
+ # every level of a reactor tree instead of only the top one.
602
+ def force_sync_nested_reactors!(klass)
603
+ klass.steps.each do |step_name, step_config|
604
+ arg_key, source = nested_reactor_source(step_config)
605
+ next unless source
606
+
607
+ forced_child = build_forced_sync_class(source.value)
608
+
609
+ new_args = step_config.arguments.dup
610
+ new_args[arg_key] = new_args[arg_key].merge(source: RubyReactor::Template::Value.new(forced_child))
611
+ # A fan-out map is NOT an `async_dispatch` step — its hand-off lives in
612
+ # the `fan_out` argument, so `strip_background_and_async!` never sees
613
+ # it. Turn it off here (on the clone, leaving the original config
614
+ # untouched for normal runs) or `run_async(false)` would still
615
+ # dispatch element jobs and leave the reactor parked at the map.
616
+ if arg_key == :mapped_reactor_class && new_args[:fan_out]
617
+ new_args[:fan_out] = new_args[:fan_out].merge(source: RubyReactor::Template::Value.new(false))
618
+ end
619
+
620
+ new_step_config = step_config.clone
621
+ new_step_config.instance_variable_set(:@arguments, new_args)
622
+ klass.steps[step_name] = new_step_config
623
+ end
624
+ end
625
+
626
+ def nested_reactor_source(step_config)
627
+ return [nil, nil] unless step_config.respond_to?(:arguments)
628
+
629
+ args = step_config.arguments
630
+ # `async_reactor` is included: clearing its dispatch marker makes the
631
+ # child run inline, but inline means `child_class.run(...)` on the
632
+ # ORIGINAL class — whose own background/async steps would dispatch
633
+ # again. The child has to be force-synced too.
634
+ %i[mapped_reactor_class composed_reactor_class async_reactor_class].each do |arg_key|
635
+ source = args[arg_key]&.[](:source)
636
+ return [arg_key, source] if source.is_a?(RubyReactor::Template::Value)
637
+ end
638
+
639
+ [nil, nil]
640
+ end
641
+
642
+ def build_forced_sync_class(original_child_reactor)
643
+ child_class = Class.new(original_child_reactor) do
644
+ define_singleton_method(:name) { original_child_reactor.name }
645
+ @steps = superclass.steps.dup
646
+ @inputs = superclass.inputs.dup
647
+ @input_validations = superclass.input_validations.dup
648
+ @middlewares = superclass.middlewares.dup
649
+ @return_step = superclass.return_step
650
+ @background_handoff = superclass.background_handoff
651
+ @retry_defaults = superclass.instance_variable_get(:@retry_defaults)
652
+ end
653
+
654
+ strip_background_and_async!(child_class)
655
+ force_sync_nested_reactors!(child_class)
656
+ child_class
657
+ end
658
+
538
659
  def apply_interceptors(klass, interceptors)
539
660
  # Group interceptors by the current level step
540
661
  grouped = interceptors.group_by { |i| i[:step_path].first }
@@ -564,7 +685,7 @@ module RubyReactor
564
685
  direct_interceptors.each do |interceptor|
565
686
  case interceptor[:type]
566
687
  when :failure
567
- apply_failure_interceptor(step_config, target_step)
688
+ apply_failure_interceptor(step_config, target_step, step_config_orig, interceptor)
568
689
  when :mock
569
690
  apply_mock_interceptor(step_config, target_step, step_config_orig, interceptor)
570
691
  end
@@ -595,9 +716,19 @@ module RubyReactor
595
716
 
596
717
  original_child_reactor = target_reactor_class_source.value
597
718
 
598
- # Dynamically subclass the child reactor
719
+ # Dynamically subclass the child reactor.
720
+ #
721
+ # It needs a resolvable identity of its OWN: a fan-out map serializes
722
+ # only `mapped_reactor_class.name` into each element job, and the worker
723
+ # resolves that name back through `const_get` first. Keeping the
724
+ # original name would therefore resolve to the original, unmocked class
725
+ # in every element. Registering a unique name makes the fan-out payload
726
+ # (and the `element_reactor_class` that `#map_elements` traverses) point
727
+ # at THIS class.
599
728
  mocked_child_reactor = Class.new(original_child_reactor) do
600
- define_singleton_method(:name) { original_child_reactor.name }
729
+ unique_name = "#{original_child_reactor.name || "AnonymousReactor"}Mock#{object_id}"
730
+ define_singleton_method(:name) { unique_name }
731
+ RubyReactor::Registry.register(unique_name, self)
601
732
  # Copy configuration
602
733
  @steps = superclass.steps.dup
603
734
  @inputs = superclass.inputs.dup
@@ -629,9 +760,46 @@ module RubyReactor
629
760
  step_config.arguments[arg_key][:source] = RubyReactor::Template::Value.new(mocked_child_reactor)
630
761
  end
631
762
 
632
- def apply_failure_interceptor(step_config, target_step)
633
- failure_impl = lambda do |_input, _context|
634
- RubyReactor::Failure("Simulated failure at #{target_step}")
763
+ # The original run behavior for a step, used both as the fallback when
764
+ # `element_index` excludes the current map element and as the `original`
765
+ # callable a mock block can invoke.
766
+ def original_impl_for(step_config_orig, target_step)
767
+ if step_config_orig.has_run_block?
768
+ step_config_orig.run_block
769
+ elsif step_config_orig.has_impl?
770
+ impl = step_config_orig.impl
771
+
772
+ if impl.respond_to?(:run_without_coordination)
773
+ ->(args, ctx) { impl.run_without_coordination(args, ctx) }
774
+ else
775
+ ->(args, ctx) { impl.run(args, ctx) }
776
+ end
777
+ else
778
+ ->(_, _) { raise "No implementation found for #{target_step}" }
779
+ end
780
+ end
781
+
782
+ # A map element's own context carries `map_metadata[:index]`. When an
783
+ # interceptor is scoped to one element via `element_index:`, only that
784
+ # element's context matches — everything else falls through to the
785
+ # original implementation.
786
+ def matches_element_index?(context, element_index)
787
+ return true if element_index.nil?
788
+
789
+ meta = context.map_metadata
790
+ meta && (meta[:index] == element_index || meta["index"] == element_index)
791
+ end
792
+
793
+ def apply_failure_interceptor(step_config, target_step, step_config_orig, interceptor)
794
+ element_index = interceptor[:conditions][:element_index]
795
+ original_impl = original_impl_for(step_config_orig, target_step)
796
+
797
+ failure_impl = lambda do |input, context|
798
+ if matches_element_index?(context, element_index)
799
+ RubyReactor::Failure("Simulated failure at #{target_step}")
800
+ else
801
+ original_impl.call(input, context)
802
+ end
635
803
  end
636
804
 
637
805
  step_config.instance_variable_set(:@run_block, failure_impl)
@@ -639,18 +807,13 @@ module RubyReactor
639
807
 
640
808
  def apply_mock_interceptor(step_config, target_step, step_config_orig, interceptor)
641
809
  mock_block = interceptor[:conditions][:block]
642
-
643
- # Prepare original implementation call
644
- original_impl = if step_config_orig.has_run_block?
645
- step_config_orig.run_block
646
- elsif step_config_orig.has_impl?
647
- ->(args, ctx) { step_config_orig.impl.run(args, ctx) }
648
- else
649
- ->(_, _) { raise "No implementation found for #{target_step}" }
650
- end
810
+ element_index = interceptor[:conditions][:element_index]
811
+ original_impl = original_impl_for(step_config_orig, target_step)
651
812
 
652
813
  # Create the new implementation that wraps the user block
653
814
  wrapper_impl = lambda do |args, context|
815
+ next original_impl.call(args, context) unless matches_element_index?(context, element_index)
816
+
654
817
  if mock_block.arity == 3
655
818
  mock_block.call(args, context, original_impl)
656
819
  else
@@ -21,7 +21,14 @@ module RubyReactor
21
21
  def self.configure(config)
22
22
  require_relative "rspec/step_executor_patch"
23
23
 
24
- config.include RubyReactor::RSpec::Helpers
24
+ # `test_reactor` and the async-job helpers are only auto-included into
25
+ # examples tagged `type: :reactor`, so a host app's other specs keep a
26
+ # clean namespace. Specs that want them without the tag can
27
+ # `include RubyReactor::RSpec::Helpers` explicitly.
28
+ #
29
+ # Matchers stay unconditional: `::RSpec::Matchers.define` registers them
30
+ # globally at load time, so gating this include would not scope anything.
31
+ config.include RubyReactor::RSpec::Helpers, REACTOR_METADATA
25
32
  config.include RubyReactor::RSpec::Matchers
26
33
  config.include RubyReactor::RSpec::SidekiqHelpers, REACTOR_METADATA
27
34
 
@@ -15,6 +15,44 @@ module RubyReactor
15
15
  # ordered-lock nonce at ENQUEUE time (so ordering matches caller order), and
16
16
  # persist before enqueueing (F2).
17
17
  class AsyncReactorStep < RubyReactor::Step
18
+ class << self
19
+ # Exclusive keys this EXECUTION currently holds, read from the root
20
+ # context's registry. Shared by the `async_reactor` dispatch check
21
+ # below (via `detect_lock_deadlock`) and the `async_step` dispatch-time
22
+ # guard (`Executor::AsyncStepDispatch`, T034) — both hand off work to
23
+ # a process that will never share this execution's owner, so a key
24
+ # the execution currently holds would deadlock the dispatched unit.
25
+ def held_lock_keys(context)
26
+ root = context.root_context || context
27
+ Array(root.private_data[:held_lock_keys] || root.private_data["held_lock_keys"])
28
+ end
29
+
30
+ # `kind:` names what is being dispatched ("async_reactor" / "async_step")
31
+ # so the two guards share one message shape instead of drifting apart.
32
+ def deadlock_message(key, dispatched_name, context, kind: "async_reactor")
33
+ parent = context.reactor_class&.name || "the dispatching reactor"
34
+ <<~MSG.strip
35
+ #{kind} dispatch of #{dispatched_name || "<anonymous>"} would deadlock: it declares the lock key \
36
+ '#{key}', which #{parent} currently holds and will not release until it finishes.
37
+ The child would snooze forever, and if #{parent} later reads this child's result it would wait \
38
+ for work that can never start. Lock ownership is never shared across the async boundary — the \
39
+ two run concurrently, so sharing it would break mutual exclusion outright.
40
+ Fix, in order of preference:
41
+ 1. Use `compose` instead of `async_reactor` if the child belongs inside #{parent}'s critical \
42
+ section and its result is needed — waiting for it means the work is sequential anyway.
43
+ 2. Narrow the lock keys, if parent and child actually protect different resources.
44
+ 3. Restructure so the locked reactor never reads the child's result — fire-and-forget, and \
45
+ verify in the child itself or in a successor reactor outside the lock window.#{async_step_remedy(kind)}
46
+ MSG
47
+ end
48
+
49
+ def async_step_remedy(kind)
50
+ return "" unless kind == "async_step"
51
+
52
+ "\n 4. Run the step inline (drop `async_step`) if it belongs inside the critical section."
53
+ end
54
+ end
55
+
18
56
  def run
19
57
  child_class = inputs[:async_reactor_class]
20
58
  child_inputs = build_child_inputs(inputs[:argument_mappings] || {})
@@ -60,13 +98,13 @@ module RubyReactor
60
98
  # loudly, at dispatch. Ordinary cross-execution contention on the same
61
99
  # key is unaffected and still snoozes normally.
62
100
  def detect_lock_deadlock(child_class, child_inputs)
63
- held = held_lock_keys
101
+ held = self.class.held_lock_keys(context)
64
102
  return nil if held.empty?
65
103
 
66
104
  collision = child_lock_keys(child_class, child_inputs).find { |key| held.include?(key) }
67
105
  return nil unless collision
68
106
 
69
- RubyReactor.Failure(deadlock_message(collision, child_class))
107
+ RubyReactor.Failure(self.class.deadlock_message(collision, child_class.name, context))
70
108
  end
71
109
 
72
110
  def child_lock_keys(child_class, child_inputs)
@@ -83,28 +121,6 @@ module RubyReactor
83
121
  keys.compact
84
122
  end
85
123
 
86
- def held_lock_keys
87
- root = context.root_context || context
88
- Array(root.private_data[:held_lock_keys] || root.private_data["held_lock_keys"])
89
- end
90
-
91
- def deadlock_message(key, child_class)
92
- parent = context.reactor_class&.name || "the dispatching reactor"
93
- <<~MSG.strip
94
- async_reactor dispatch of #{child_class.name || "<anonymous>"} would deadlock: it declares the \
95
- lock key '#{key}', which #{parent} currently holds and will not release until it finishes.
96
- The child would snooze forever, and if #{parent} later reads this child's result it would wait \
97
- for work that can never start. Lock ownership is never shared across the async boundary — the \
98
- two run concurrently, so sharing it would break mutual exclusion outright.
99
- Fix, in order of preference:
100
- 1. Use `compose` instead of `async_reactor` if the child belongs inside #{parent}'s critical \
101
- section and its result is needed — waiting for it means the work is sequential anyway.
102
- 2. Narrow the lock keys, if parent and child actually protect different resources.
103
- 3. Restructure so the locked reactor never reads the child's result — fire-and-forget, and \
104
- verify in the child itself or in a successor reactor outside the lock window.
105
- MSG
106
- end
107
-
108
124
  # `RSpec::TestSubject`'s `async: false` clears the dispatch marker to run
109
125
  # the whole reactor in one process.
110
126
  def run_inline?