ruby_reactor 0.8.2 → 0.8.4

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 (93) 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 +94 -0
  9. data/CLAUDE.md +2 -2
  10. data/README.md +128 -93
  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/async_reactor_builder.rb +3 -6
  16. data/lib/ruby_reactor/dsl/compose_builder.rb +3 -10
  17. data/lib/ruby_reactor/dsl/interrupt_builder.rb +14 -0
  18. data/lib/ruby_reactor/dsl/lockable.rb +76 -21
  19. data/lib/ruby_reactor/dsl/reactor.rb +8 -11
  20. data/lib/ruby_reactor/dsl/retryable.rb +45 -0
  21. data/lib/ruby_reactor/dsl/step_builder.rb +150 -13
  22. data/lib/ruby_reactor/error/async_result_pending.rb +1 -1
  23. data/lib/ruby_reactor/error/execution_parked.rb +16 -0
  24. data/lib/ruby_reactor/error/reactor_contention_park.rb +26 -0
  25. data/lib/ruby_reactor/error/step_contention_park.rb +26 -0
  26. data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
  27. data/lib/ruby_reactor/executor/async_step_dispatch.rb +109 -3
  28. data/lib/ruby_reactor/executor/compensation_manager.rb +99 -17
  29. data/lib/ruby_reactor/executor/ordered_lock_support.rb +76 -44
  30. data/lib/ruby_reactor/executor/result_handler.rb +34 -13
  31. data/lib/ruby_reactor/executor/retry_manager.rb +20 -13
  32. data/lib/ruby_reactor/executor/step_coordination.rb +788 -0
  33. data/lib/ruby_reactor/executor/step_executor.rb +115 -11
  34. data/lib/ruby_reactor/executor.rb +90 -20
  35. data/lib/ruby_reactor/map/element_executor.rb +24 -2
  36. data/lib/ruby_reactor/map/helpers.rb +35 -11
  37. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +2 -2
  38. data/lib/ruby_reactor/open_telemetry.rb +61 -24
  39. data/lib/ruby_reactor/retry_context.rb +31 -2
  40. data/lib/ruby_reactor/rspec/helpers.rb +15 -0
  41. data/lib/ruby_reactor/rspec/matchers.rb +92 -0
  42. data/lib/ruby_reactor/rspec/test_subject.rb +9 -5
  43. data/lib/ruby_reactor/step/async_reactor_step.rb +46 -26
  44. data/lib/ruby_reactor/step/compose_step.rb +22 -7
  45. data/lib/ruby_reactor/step/input_contract.rb +5 -0
  46. data/lib/ruby_reactor/step/inputs.rb +57 -0
  47. data/lib/ruby_reactor/step/map_step.rb +32 -22
  48. data/lib/ruby_reactor/step.rb +59 -11
  49. data/lib/ruby_reactor/step_sweeper.rb +29 -1
  50. data/lib/ruby_reactor/step_worker.rb +260 -37
  51. data/lib/ruby_reactor/version.rb +1 -1
  52. data/lib/ruby_reactor/web/api.rb +72 -7
  53. data/lib/ruby_reactor/web/coordination_serializer.rb +120 -2
  54. data/lib/ruby_reactor/web/public/assets/{index-Dw4KV4QY.js → index-CeZU-ESu.js} +9 -9
  55. data/lib/ruby_reactor/web/public/index.html +1 -1
  56. data/lib/ruby_reactor/worker.rb +56 -30
  57. data/lib/ruby_reactor.rb +30 -6
  58. data/specs/{004-inheritable-step-class → 006-step-retry-declarations}/checklists/requirements.md +10 -9
  59. data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
  60. data/specs/006-step-retry-declarations/data-model.md +58 -0
  61. data/specs/006-step-retry-declarations/plan.md +187 -0
  62. data/specs/006-step-retry-declarations/quickstart.md +80 -0
  63. data/specs/006-step-retry-declarations/research.md +194 -0
  64. data/specs/006-step-retry-declarations/spec.md +453 -0
  65. data/specs/006-step-retry-declarations/tasks.md +382 -0
  66. data/specs/future_improvements.md +250 -0
  67. data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
  68. metadata +20 -28
  69. data/specs/002-step-input-contracts/checklists/requirements.md +0 -49
  70. data/specs/002-step-input-contracts/contracts/dsl-surface.md +0 -193
  71. data/specs/002-step-input-contracts/data-model.md +0 -115
  72. data/specs/002-step-input-contracts/plan.md +0 -165
  73. data/specs/002-step-input-contracts/quickstart.md +0 -170
  74. data/specs/002-step-input-contracts/research.md +0 -233
  75. data/specs/002-step-input-contracts/spec.md +0 -359
  76. data/specs/002-step-input-contracts/tasks.md +0 -367
  77. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +0 -85
  78. data/specs/004-inheritable-step-class/data-model.md +0 -116
  79. data/specs/004-inheritable-step-class/plan.md +0 -174
  80. data/specs/004-inheritable-step-class/quickstart.md +0 -112
  81. data/specs/004-inheritable-step-class/research.md +0 -308
  82. data/specs/004-inheritable-step-class/spec.md +0 -316
  83. data/specs/004-inheritable-step-class/tasks.md +0 -258
  84. data/specs/active_job.md +0 -259
  85. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +0 -51
  86. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +0 -154
  87. data/specs/deferred-003-step-lock-declarations/data-model.md +0 -131
  88. data/specs/deferred-003-step-lock-declarations/plan.md +0 -166
  89. data/specs/deferred-003-step-lock-declarations/quickstart.md +0 -169
  90. data/specs/deferred-003-step-lock-declarations/research.md +0 -196
  91. data/specs/deferred-003-step-lock-declarations/spec.md +0 -447
  92. data/specs/deferred-003-step-lock-declarations/tasks.md +0 -572
  93. 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
 
@@ -19,6 +19,21 @@ module RubyReactor
19
19
  process_jobs: process_jobs
20
20
  )
21
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
22
37
  end
23
38
  end
24
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
@@ -556,7 +556,6 @@ module RubyReactor
556
556
  @middlewares = superclass.middlewares.dup
557
557
  @return_step = superclass.return_step
558
558
  @background_handoff = superclass.background_handoff
559
- @retry_defaults = superclass.instance_variable_get(:@retry_defaults)
560
559
 
561
560
  # 2. Add Name Handling with Unique Registry Entry
562
561
  # We must register a unique name so that if this reactor is reloaded (e.g. after async child completion),
@@ -648,7 +647,6 @@ module RubyReactor
648
647
  @middlewares = superclass.middlewares.dup
649
648
  @return_step = superclass.return_step
650
649
  @background_handoff = superclass.background_handoff
651
- @retry_defaults = superclass.instance_variable_get(:@retry_defaults)
652
650
  end
653
651
 
654
652
  strip_background_and_async!(child_class)
@@ -736,7 +734,6 @@ module RubyReactor
736
734
  @middlewares = superclass.middlewares.dup
737
735
  @return_step = superclass.return_step
738
736
  @background_handoff = superclass.background_handoff
739
- @retry_defaults = superclass.instance_variable_get(:@retry_defaults)
740
737
  end
741
738
 
742
739
  # Recursively apply interceptors to the child reactor
@@ -765,9 +762,16 @@ module RubyReactor
765
762
  # callable a mock block can invoke.
766
763
  def original_impl_for(step_config_orig, target_step)
767
764
  if step_config_orig.has_run_block?
768
- step_config_orig.run_block
765
+ # A mock may hand `original` a plain Hash (`args.to_h.merge(...)`).
766
+ ->(args, ctx) { step_config_orig.run_block.call(step_config_orig.wrap_inputs(args), ctx) }
769
767
  elsif step_config_orig.has_impl?
770
- ->(args, ctx) { step_config_orig.impl.run(args, ctx) }
768
+ impl = step_config_orig.impl
769
+
770
+ if impl.respond_to?(:run_without_coordination)
771
+ ->(args, ctx) { impl.run_without_coordination(args, ctx) }
772
+ else
773
+ ->(args, ctx) { impl.run(args.to_h, ctx) }
774
+ end
771
775
  else
772
776
  ->(_, _) { raise "No implementation found for #{target_step}" }
773
777
  end
@@ -15,9 +15,51 @@ 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
+ # Untyped, so no validation: declared only so `inputs.x` can read them.
19
+ input :async_reactor_class
20
+ input :argument_mappings, optional: true
21
+
22
+ class << self
23
+ # Exclusive keys this EXECUTION currently holds, read from the root
24
+ # context's registry. Shared by the `async_reactor` dispatch check
25
+ # below (via `detect_lock_deadlock`) and the `async_step` dispatch-time
26
+ # guard (`Executor::AsyncStepDispatch`, T034) — both hand off work to
27
+ # a process that will never share this execution's owner, so a key
28
+ # the execution currently holds would deadlock the dispatched unit.
29
+ def held_lock_keys(context)
30
+ root = context.root_context || context
31
+ Array(root.private_data[:held_lock_keys] || root.private_data["held_lock_keys"])
32
+ end
33
+
34
+ # `kind:` names what is being dispatched ("async_reactor" / "async_step")
35
+ # so the two guards share one message shape instead of drifting apart.
36
+ def deadlock_message(key, dispatched_name, context, kind: "async_reactor")
37
+ parent = context.reactor_class&.name || "the dispatching reactor"
38
+ <<~MSG.strip
39
+ #{kind} dispatch of #{dispatched_name || "<anonymous>"} would deadlock: it declares the lock key \
40
+ '#{key}', which #{parent} currently holds and will not release until it finishes.
41
+ The child would snooze forever, and if #{parent} later reads this child's result it would wait \
42
+ for work that can never start. Lock ownership is never shared across the async boundary — the \
43
+ two run concurrently, so sharing it would break mutual exclusion outright.
44
+ Fix, in order of preference:
45
+ 1. Use `compose` instead of `async_reactor` if the child belongs inside #{parent}'s critical \
46
+ section and its result is needed — waiting for it means the work is sequential anyway.
47
+ 2. Narrow the lock keys, if parent and child actually protect different resources.
48
+ 3. Restructure so the locked reactor never reads the child's result — fire-and-forget, and \
49
+ verify in the child itself or in a successor reactor outside the lock window.#{async_step_remedy(kind)}
50
+ MSG
51
+ end
52
+
53
+ def async_step_remedy(kind)
54
+ return "" unless kind == "async_step"
55
+
56
+ "\n 4. Run the step inline (drop `async_step`) if it belongs inside the critical section."
57
+ end
58
+ end
59
+
18
60
  def run
19
- child_class = inputs[:async_reactor_class]
20
- child_inputs = build_child_inputs(inputs[:argument_mappings] || {})
61
+ child_class = inputs.async_reactor_class
62
+ child_inputs = build_child_inputs(inputs.argument_mappings || {})
21
63
 
22
64
  # A dispatch-time failure fails the DISPATCHING step, i.e. normal saga
23
65
  # handling in the parent. That is deliberately outside the
@@ -60,13 +102,13 @@ module RubyReactor
60
102
  # loudly, at dispatch. Ordinary cross-execution contention on the same
61
103
  # key is unaffected and still snoozes normally.
62
104
  def detect_lock_deadlock(child_class, child_inputs)
63
- held = held_lock_keys
105
+ held = self.class.held_lock_keys(context)
64
106
  return nil if held.empty?
65
107
 
66
108
  collision = child_lock_keys(child_class, child_inputs).find { |key| held.include?(key) }
67
109
  return nil unless collision
68
110
 
69
- RubyReactor.Failure(deadlock_message(collision, child_class))
111
+ RubyReactor.Failure(self.class.deadlock_message(collision, child_class.name, context))
70
112
  end
71
113
 
72
114
  def child_lock_keys(child_class, child_inputs)
@@ -83,28 +125,6 @@ module RubyReactor
83
125
  keys.compact
84
126
  end
85
127
 
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
128
  # `RSpec::TestSubject`'s `async: false` clears the dispatch marker to run
109
129
  # the whole reactor in one process.
110
130
  def run_inline?
@@ -3,6 +3,10 @@
3
3
  module RubyReactor
4
4
  class Step
5
5
  class ComposeStep < RubyReactor::Step
6
+ # Untyped, so no validation: declared only so `inputs.x` can read them.
7
+ input :composed_reactor_class
8
+ input :argument_mappings, optional: true
9
+
6
10
  def run
7
11
  step_name = context.current_step
8
12
  composed_data = context.composed_contexts[step_name]
@@ -12,7 +16,7 @@ module RubyReactor
12
16
  store_child_context(step_name, child_context)
13
17
 
14
18
  # Execute the composed reactor
15
- result = execute_child_reactor(inputs[:composed_reactor_class], child_context, composed_data)
19
+ result = execute_child_reactor(inputs.composed_reactor_class, child_context, composed_data)
16
20
 
17
21
  # Update the stored context
18
22
  store_child_context(step_name, child_context)
@@ -21,18 +25,23 @@ module RubyReactor
21
25
  end
22
26
 
23
27
  # Compensating a failed compose and undoing a completed one are the same
24
- # work: roll back whatever the child reactor completed.
28
+ # work: roll back whatever the child reactor completed. Any child undo
29
+ # that did not complete comes back on the Failure, which the parent's
30
+ # `CompensationManager` flattens into its own `rollback_failures`.
25
31
  def compensate
26
32
  step_name = context.current_step
27
33
  composed_data = context.composed_contexts[step_name]
28
34
  return RubyReactor.Success() unless composed_data && composed_data[:context]
29
35
 
30
36
  child_context = composed_data[:context]
31
- executor = RubyReactor::Executor.new(inputs[:composed_reactor_class], {}, child_context)
37
+ executor = RubyReactor::Executor.new(inputs.composed_reactor_class, {}, child_context)
32
38
  executor.undo_all
33
39
  executor.save_context
34
40
 
35
- RubyReactor.Success()
41
+ failures = executor.compensation_manager.rollback_failures
42
+ return RubyReactor.Success() if failures.empty?
43
+
44
+ RubyReactor.Failure("composed :#{step_name} rollback incomplete", rollback_failures: failures)
36
45
  end
37
46
 
38
47
  alias undo compensate
@@ -54,8 +63,8 @@ module RubyReactor
54
63
  child_context = composed_data ? composed_data[:context] : nil
55
64
 
56
65
  unless child_context
57
- composed_inputs = build_composed_inputs(inputs[:argument_mappings] || {})
58
- child_context = RubyReactor::Context.new(composed_inputs, inputs[:composed_reactor_class])
66
+ composed_inputs = build_composed_inputs(inputs.argument_mappings || {})
67
+ child_context = RubyReactor::Context.new(composed_inputs, inputs.composed_reactor_class)
59
68
  end
60
69
 
61
70
  link_contexts(child_context, context)
@@ -79,7 +88,13 @@ module RubyReactor
79
88
  def execute_child_reactor(composed_reactor, child_context, composed_data)
80
89
  executor = RubyReactor::Executor.new(composed_reactor, {}, child_context)
81
90
 
82
- if composed_data && child_context.current_step
91
+ # Resume once the child has been admitted, not by its `current_step`:
92
+ # a park two levels down unwinds the child's `with_step` and clears
93
+ # it, and `execute` would then re-charge the child's rate limit and
94
+ # fresh-acquire its lock instead of re-adopting the parked hold
95
+ # (005 R-02). `current_step` stays as the fallback for a child saved
96
+ # before `admitted` existed.
97
+ if composed_data && (child_context.admitted? || child_context.current_step)
83
98
  executor.resume_execution
84
99
  else
85
100
  executor.execute
@@ -25,6 +25,11 @@ module RubyReactor
25
25
  def input(name, type = nil, optional: false, default: nil, redact: false, validate: nil, **predicates, &block)
26
26
  # rubocop:enable Metrics/ParameterLists
27
27
  check_dry_validation_available!
28
+ if Step::Inputs.public_method_defined?(name)
29
+ raise Error::ValidationError,
30
+ "input :#{name} is reserved: step code reads inputs as `inputs.#{name}`, and `#{name}` is already " \
31
+ "a method there. Rename the input."
32
+ end
28
33
  unless default.nil? || optional
29
34
  raise Error::ValidationError,
30
35
  "input :#{name} declares `default:` but is required; a default only applies to an " \
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ class Step
5
+ # What step code (`run`, `undo`, `compensate`) receives as its inputs: a
6
+ # frozen, read-only view of the argument Hash with one reader per name the
7
+ # step may read. Any other name raises `Error::UndeclaredInputError` on the
8
+ # line that reads it, instead of returning nil and failing somewhere else.
9
+ #
10
+ # inputs.order_guid # the value, or nil for an absent optional input
11
+ # inputs.order_id # raises UndeclaredInputError
12
+ # inputs.to_h # supplied values, readable names only
13
+ # Service.call(**inputs) # via `to_hash`
14
+ #
15
+ # Readable names are the contract's declarations, or the keys present when
16
+ # the step has none. Built only where inputs reach step code; everything
17
+ # else in the gem keeps the Hash.
18
+ class Inputs
19
+ def initialize(values, contract: nil, owner: nil)
20
+ @values = values.to_h
21
+ @owner = owner
22
+ declared = contract&.declarations&.keys || []
23
+ @names = declared.empty? ? @values.keys.map(&:to_sym).uniq : declared
24
+ @redacted = contract&.redacted_names || []
25
+ freeze
26
+ end
27
+
28
+ def to_h
29
+ @names.each_with_object({}) do |name, hash|
30
+ hash[name] = Utils::FetchIndifferent.call(@values, name) if supplied?(name)
31
+ end
32
+ end
33
+ alias to_hash to_h
34
+
35
+ def inspect
36
+ to_h.to_h { |name, value| [name, @redacted.include?(name) ? InputContract::REDACTED : value] }.inspect
37
+ end
38
+
39
+ private
40
+
41
+ def method_missing(name, *args)
42
+ return Utils::FetchIndifferent.call(@values, name) if args.empty? && @names.include?(name)
43
+
44
+ declared = @names.empty? ? "none" : @names.map(&:inspect).join(", ")
45
+ raise Error::UndeclaredInputError.new("#{@owner} has no input :#{name}. Declared inputs: #{declared}.", name)
46
+ end
47
+
48
+ def respond_to_missing?(name, include_private = false)
49
+ @names.include?(name) || super
50
+ end
51
+
52
+ def supplied?(name)
53
+ @values.key?(name) || @values.key?(name.to_s)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -3,8 +3,18 @@
3
3
  module RubyReactor
4
4
  class Step
5
5
  class MapStep < RubyReactor::Step
6
+ # Untyped, so no validation: declared only so `inputs.x` can read them.
7
+ input :source
8
+ input :mapped_reactor_class
9
+ input :argument_mappings, optional: true
10
+ input :strict_ordering, optional: true
11
+ input :batch_size, optional: true
12
+ input :collect_block, optional: true
13
+ input :fail_fast, optional: true
14
+ input :fan_out, optional: true
15
+
6
16
  def run
7
- return RubyReactor::Failure("Map source cannot be nil") if inputs[:source].nil?
17
+ return RubyReactor::Failure("Map source cannot be nil") if inputs.source.nil?
8
18
 
9
19
  # Initialize map state in context if not present
10
20
  context.map_operations ||= {}
@@ -77,21 +87,21 @@ module RubyReactor
77
87
  def fan_out?
78
88
  return false if context.map_metadata || context.root_context&.map_metadata
79
89
 
80
- inputs[:fan_out]
90
+ inputs.fan_out
81
91
  end
82
92
 
83
93
  def run_inline
84
94
  results = execute_inline_map
85
95
  return results if results.is_a?(RubyReactor::Failure) || results.is_a?(RubyReactor::Halt)
86
96
 
87
- process_results(results, inputs[:collect_block], inputs[:fail_fast])
97
+ process_results(results, inputs.collect_block, inputs.fail_fast)
88
98
  end
89
99
 
90
100
  def execute_inline_map
91
101
  results = []
92
- fail_fast = inputs[:fail_fast].nil? || inputs[:fail_fast]
102
+ fail_fast = inputs.fail_fast.nil? || inputs.fail_fast
93
103
 
94
- inputs[:source].each_with_index do |element, index|
104
+ inputs.source.each_with_index do |element, index|
95
105
  result = execute_single_element(element, index)
96
106
 
97
107
  # An element-level Halt propagates as a run halt: stop immediately
@@ -110,8 +120,8 @@ module RubyReactor
110
120
  end
111
121
 
112
122
  def execute_single_element(element, index)
113
- mapped_inputs = self.class.build_mapped_inputs(inputs[:argument_mappings] || {}, context, element)
114
- child_context = RubyReactor::Context.new(mapped_inputs, inputs[:mapped_reactor_class])
123
+ mapped_inputs = self.class.build_mapped_inputs(inputs.argument_mappings || {}, context, element)
124
+ child_context = RubyReactor::Context.new(mapped_inputs, inputs.mapped_reactor_class)
115
125
 
116
126
  link_contexts(child_context, context)
117
127
 
@@ -131,10 +141,10 @@ module RubyReactor
131
141
  name: context.current_step,
132
142
  type: :map_ref,
133
143
  map_id: map_id,
134
- element_reactor_class: inputs[:mapped_reactor_class].name
144
+ element_reactor_class: inputs.mapped_reactor_class.name
135
145
  }
136
146
 
137
- executor = RubyReactor::Executor.new(inputs[:mapped_reactor_class], {}, child_context)
147
+ executor = RubyReactor::Executor.new(inputs.mapped_reactor_class, {}, child_context)
138
148
  executor.execute
139
149
  executor.result
140
150
  end
@@ -162,9 +172,9 @@ module RubyReactor
162
172
  def run_async(step_name)
163
173
  map_id = "#{context.context_id}:#{step_name}"
164
174
  context.map_operations[step_name.to_s] = map_id
165
- prepare_async_execution(map_id, inputs[:source].size)
175
+ prepare_async_execution(map_id, inputs.source.size)
166
176
 
167
- reactor_class_info = build_reactor_class_info(inputs[:mapped_reactor_class], step_name)
177
+ reactor_class_info = build_reactor_class_info(inputs.mapped_reactor_class, step_name)
168
178
 
169
179
  initialize_map_metadata(map_id, reactor_class_info)
170
180
 
@@ -175,7 +185,7 @@ module RubyReactor
175
185
  name: step_name.to_s,
176
186
  type: :map_ref,
177
187
  map_id: map_id,
178
- element_reactor_class: inputs[:mapped_reactor_class].name
188
+ element_reactor_class: inputs.mapped_reactor_class.name
179
189
  }
180
190
 
181
191
  RubyReactor::DispatchResult.new(
@@ -188,9 +198,9 @@ module RubyReactor
188
198
  def initialize_map_metadata(map_id, reactor_class_info)
189
199
  storage = RubyReactor.configuration.storage_adapter
190
200
  storage.initialize_map_operation(
191
- map_id, inputs[:source].size, context.reactor_class.name,
192
- strict_ordering: inputs[:strict_ordering], reactor_class_info: reactor_class_info,
193
- **map_recovery_metadata(inputs[:step_name] || context.current_step)
201
+ map_id, inputs.source.size, context.reactor_class.name,
202
+ strict_ordering: inputs.strict_ordering, reactor_class_info: reactor_class_info,
203
+ **map_recovery_metadata(context.current_step)
194
204
  )
195
205
  end
196
206
 
@@ -215,21 +225,21 @@ module RubyReactor
215
225
  # own worker, with the map counter/collector tracking completion. This
216
226
  # lets elements with async steps or async retries hand off correctly
217
227
  # instead of being forced to run synchronously in a single worker.
218
- batch_size = inputs[:batch_size] || inputs[:source].size
228
+ batch_size = inputs.batch_size || inputs.source.size
219
229
 
220
230
  RubyReactor::Map::Dispatcher.perform(
221
231
  map_id: map_id,
222
232
  parent_context_id: context.context_id,
223
233
  parent_reactor_class_name: context.reactor_class.name,
224
- source: inputs[:source],
234
+ source: inputs.source,
225
235
  batch_size: batch_size,
226
236
  step_name: step_name,
227
- argument_mappings: inputs[:argument_mappings],
228
- strict_ordering: inputs[:strict_ordering],
229
- mapped_reactor_class: inputs[:mapped_reactor_class],
230
- fail_fast: inputs[:fail_fast].nil? || inputs[:fail_fast]
237
+ argument_mappings: inputs.argument_mappings,
238
+ strict_ordering: inputs.strict_ordering,
239
+ mapped_reactor_class: inputs.mapped_reactor_class,
240
+ fail_fast: inputs.fail_fast.nil? || inputs.fail_fast
231
241
  )
232
- queue_collector(map_id, step_name, inputs[:strict_ordering])
242
+ queue_collector(map_id, step_name, inputs.strict_ordering)
233
243
  "map:#{map_id}"
234
244
  end
235
245