ruby_reactor 0.8.2 → 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 (77) 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 +82 -0
  9. data/CLAUDE.md +2 -2
  10. data/README.md +35 -2
  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 +15 -0
  36. data/lib/ruby_reactor/rspec/matchers.rb +92 -0
  37. data/lib/ruby_reactor/rspec/test_subject.rb +7 -1
  38. data/lib/ruby_reactor/step/async_reactor_step.rb +40 -24
  39. data/lib/ruby_reactor/step/compose_step.rb +14 -3
  40. data/lib/ruby_reactor/step.rb +49 -7
  41. data/lib/ruby_reactor/step_sweeper.rb +29 -1
  42. data/lib/ruby_reactor/step_worker.rb +260 -37
  43. data/lib/ruby_reactor/version.rb +1 -1
  44. data/lib/ruby_reactor/web/api.rb +72 -7
  45. data/lib/ruby_reactor/web/coordination_serializer.rb +120 -2
  46. data/lib/ruby_reactor/web/public/assets/{index-Dw4KV4QY.js → index-CeZU-ESu.js} +9 -9
  47. data/lib/ruby_reactor/web/public/index.html +1 -1
  48. data/lib/ruby_reactor/worker.rb +56 -30
  49. data/lib/ruby_reactor.rb +27 -5
  50. data/specs/future_improvements.md +250 -0
  51. metadata +8 -28
  52. data/specs/002-step-input-contracts/checklists/requirements.md +0 -49
  53. data/specs/002-step-input-contracts/contracts/dsl-surface.md +0 -193
  54. data/specs/002-step-input-contracts/data-model.md +0 -115
  55. data/specs/002-step-input-contracts/plan.md +0 -165
  56. data/specs/002-step-input-contracts/quickstart.md +0 -170
  57. data/specs/002-step-input-contracts/research.md +0 -233
  58. data/specs/002-step-input-contracts/spec.md +0 -359
  59. data/specs/002-step-input-contracts/tasks.md +0 -367
  60. data/specs/004-inheritable-step-class/checklists/requirements.md +0 -40
  61. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +0 -85
  62. data/specs/004-inheritable-step-class/data-model.md +0 -116
  63. data/specs/004-inheritable-step-class/plan.md +0 -174
  64. data/specs/004-inheritable-step-class/quickstart.md +0 -112
  65. data/specs/004-inheritable-step-class/research.md +0 -308
  66. data/specs/004-inheritable-step-class/spec.md +0 -316
  67. data/specs/004-inheritable-step-class/tasks.md +0 -258
  68. data/specs/active_job.md +0 -259
  69. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +0 -51
  70. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +0 -154
  71. data/specs/deferred-003-step-lock-declarations/data-model.md +0 -131
  72. data/specs/deferred-003-step-lock-declarations/plan.md +0 -166
  73. data/specs/deferred-003-step-lock-declarations/quickstart.md +0 -169
  74. data/specs/deferred-003-step-lock-declarations/research.md +0 -196
  75. data/specs/deferred-003-step-lock-declarations/spec.md +0 -447
  76. data/specs/deferred-003-step-lock-declarations/tasks.md +0 -572
  77. 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
@@ -767,7 +767,13 @@ module RubyReactor
767
767
  if step_config_orig.has_run_block?
768
768
  step_config_orig.run_block
769
769
  elsif step_config_orig.has_impl?
770
- ->(args, ctx) { step_config_orig.impl.run(args, ctx) }
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
771
777
  else
772
778
  ->(_, _) { raise "No implementation found for #{target_step}" }
773
779
  end
@@ -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?
@@ -21,7 +21,9 @@ module RubyReactor
21
21
  end
22
22
 
23
23
  # Compensating a failed compose and undoing a completed one are the same
24
- # work: roll back whatever the child reactor completed.
24
+ # work: roll back whatever the child reactor completed. Any child undo
25
+ # that did not complete comes back on the Failure, which the parent's
26
+ # `CompensationManager` flattens into its own `rollback_failures`.
25
27
  def compensate
26
28
  step_name = context.current_step
27
29
  composed_data = context.composed_contexts[step_name]
@@ -32,7 +34,10 @@ module RubyReactor
32
34
  executor.undo_all
33
35
  executor.save_context
34
36
 
35
- RubyReactor.Success()
37
+ failures = executor.compensation_manager.rollback_failures
38
+ return RubyReactor.Success() if failures.empty?
39
+
40
+ RubyReactor.Failure("composed :#{step_name} rollback incomplete", rollback_failures: failures)
36
41
  end
37
42
 
38
43
  alias undo compensate
@@ -79,7 +84,13 @@ module RubyReactor
79
84
  def execute_child_reactor(composed_reactor, child_context, composed_data)
80
85
  executor = RubyReactor::Executor.new(composed_reactor, {}, child_context)
81
86
 
82
- if composed_data && child_context.current_step
87
+ # Resume once the child has been admitted, not by its `current_step`:
88
+ # a park two levels down unwinds the child's `with_step` and clears
89
+ # it, and `execute` would then re-charge the child's rate limit and
90
+ # fresh-acquire its lock instead of re-adopting the parked hold
91
+ # (005 R-02). `current_step` stays as the fallback for a child saved
92
+ # before `admitted` existed.
93
+ if composed_data && (child_context.admitted? || child_context.current_step)
83
94
  executor.resume_execution
84
95
  else
85
96
  executor.execute
@@ -13,9 +13,15 @@ module RubyReactor
13
13
  # 1. Resolve `inputs`: the given arguments with the contract's defaults
14
14
  # applied. `run`, `undo`, and `compensate` all see the same values.
15
15
  # 2. `.run` ONLY: enforce the declared input contract, raising
16
- # `Error::InputValidationError` before any instance exists. `.undo` and
17
- # `.compensate` NEVER enforce it: rollback must not fail on the very
18
- # inputs that may have caused the failure.
16
+ # `Error::InputValidationError` before any instance exists.
17
+ # 2b. `.run` ONLY: step-scoped coordination (`with_lock` etc, if declared)
18
+ # is acquired around the rest of `.run`, keyed on the just-validated
19
+ # inputs — after contract enforcement, so a step that will fail
20
+ # validation never takes a hold (research Finding 7 / Finding 8). A
21
+ # reactor never goes through here: it calls `.run_without_coordination`
22
+ # and coordinates the step itself (research D2).
23
+ # `.undo` and `.compensate` NEVER enforce the input contract: rollback
24
+ # must not fail on the very inputs that may have caused the failure.
19
25
  # 3. Build a FRESH instance, never reused across actions. An ivar set in
20
26
  # `run` is gone by the time `undo` runs on its own instance, so async
21
27
  # execution running `run` and `undo` in different processes behaves
@@ -23,10 +29,13 @@ module RubyReactor
23
29
  # 4. Invoke the matching instance method, translating any `StepSignals`
24
30
  # throw (`success!`/`skip!`/`fail!`/`halt!`) into its result wrapper.
25
31
  #
26
- # No `prepend`/`extend`/`define_method`/`method_missing` — every step in the
27
- # class reads top to bottom as ordinary method calls.
32
+ # No `prepend`/`define_method`/`method_missing` — every step in the class
33
+ # reads top to bottom as ordinary method calls. The one `extend` is
34
+ # `Dsl::Lockable::ClassMethods` (the five coordination macros), a
35
+ # self-contained module with no hooks of its own (Finding 9).
28
36
  class Step
29
37
  include RubyReactor::StepSignals
38
+ extend RubyReactor::Dsl::Lockable::ClassMethods
30
39
 
31
40
  attr_reader :inputs, :context, :result, :reason
32
41
 
@@ -68,12 +77,28 @@ module RubyReactor
68
77
  # rubocop:enable Naming/MethodName
69
78
 
70
79
  class << self
71
- def run(arguments, context)
80
+ # A DIRECT invocation — application code, or another step's body. It is
81
+ # its own unit of work: it takes this class's coordination itself and,
82
+ # having no queue to park into, waits then fails (FR-016/FR-023).
83
+ # `context` is optional: a stand-alone `ChargeStep.run(args)` is its own
84
+ # execution, with no reactor context to inherit ownership from.
85
+ def run(arguments, context = nil)
72
86
  validated = enforce_contract!(arguments)
73
- catch(StepSignals::TAG) { new(validated, context).run }
87
+ coordinate(validated, context) { run_without_coordination(validated, context) }
74
88
  end
75
89
  alias call run
76
90
 
91
+ # The reactor's entry (`StepConfig#call_body`): the executor or
92
+ # `StepWorker` has already taken this step's EFFECTIVE coordination —
93
+ # the class's declarations and any inline ones, in one fixed order — so
94
+ # taking the class's here again would split acquisition across two
95
+ # layers. Still enforces the contract (idempotent: it only applies
96
+ # defaults to already-valid arguments).
97
+ def run_without_coordination(arguments, context)
98
+ validated = enforce_contract!(arguments)
99
+ catch(StepSignals::TAG) { new(validated, context).run }
100
+ end
101
+
77
102
  # Same `inputs` as `.run` (defaults applied), but NEVER enforces the contract.
78
103
  def undo(result, arguments, context)
79
104
  catch(StepSignals::TAG) { new(with_defaults(arguments), context, result: result).undo }
@@ -117,6 +142,23 @@ module RubyReactor
117
142
 
118
143
  private
119
144
 
145
+ # Step-scoped coordination for a DIRECT call (`direct: true`): never
146
+ # parks, keeps no state on `context`, and so can never be mistaken for
147
+ # the coordination of the reactor step whose body made the call.
148
+ # After `enforce_contract!`, so a step that will fail validation never
149
+ # takes a hold (Finding 8). A bare step pays one check. `context` may be
150
+ # nil (a stand-alone `MyStep.run(args)`) — `StepCoordination#owner`
151
+ # handles that case.
152
+ def coordinate(validated, context, &block)
153
+ return block.call if Executor::StepCoordination.none?(self)
154
+
155
+ ctx = context if context.is_a?(RubyReactor::Context)
156
+ Executor::StepCoordination.new(
157
+ step_config: self, arguments: validated, context: ctx, reactor_class: ctx&.reactor_class,
158
+ middlewares: ctx&.middlewares || Executor.middlewares_for(ctx&.reactor_class), direct: true
159
+ ).around_run(&block)
160
+ end
161
+
120
162
  def own_input_contract
121
163
  @own_input_contract ||= Step::InputContract.new(owner: self)
122
164
  end
@@ -42,9 +42,10 @@ module RubyReactor
42
42
 
43
43
  arguments = dispatch_arguments(record)
44
44
  next unless arguments
45
+ next if parked?(record)
45
46
  next if live?(arguments)
46
47
 
47
- @async_router.perform_step_async(**arguments)
48
+ redispatch(record, arguments)
48
49
  redispatched += 1
49
50
  rescue StandardError => e
50
51
  # One bad record must not abort the whole sweep.
@@ -56,6 +57,17 @@ module RubyReactor
56
57
 
57
58
  private
58
59
 
60
+ # A swept park must not restart its contention counter: rebuilt from the
61
+ # record alone, a zeroed payload would let `lock_snooze_max_attempts`
62
+ # never bite. `perform_step_in(0, ...)` is the only re-dispatch that
63
+ # carries the count.
64
+ def redispatch(record, arguments)
65
+ attempts = record["contention_attempts"].to_i
66
+ return @async_router.perform_step_async(**arguments) unless attempts.positive?
67
+
68
+ @async_router.perform_step_in(0, **arguments, contention_attempts: attempts)
69
+ end
70
+
59
71
  def dispatch_arguments(record)
60
72
  values = record.values_at(*DISPATCH_KEYS)
61
73
  return nil if values.any?(&:nil?)
@@ -63,6 +75,22 @@ module RubyReactor
63
75
  DISPATCH_KEYS.map(&:to_sym).zip(values).to_h
64
76
  end
65
77
 
78
+ # A unit parked on step-level contention (StepWorker#handle_contention)
79
+ # released its liveness lock on purpose and has a redelivery already
80
+ # scheduled. It looks exactly like a lost unit, so without this the sweep
81
+ # would dispatch a duplicate that runs the body a second time once the
82
+ # redelivery fires. Past the stamped window it is fair game again — a
83
+ # redelivery that eventually lands on a finished unit is dropped by
84
+ # `StepWorker#already_completed?`.
85
+ def parked?(record)
86
+ parked_until = record["parked_until"]
87
+ return false unless parked_until
88
+
89
+ Time.iso8601(parked_until) > Time.now
90
+ rescue ArgumentError, TypeError
91
+ false
92
+ end
93
+
66
94
  def live?(arguments)
67
95
  @storage.lock_held?(
68
96
  RubyReactor.async_step_lock_key(arguments[:step_context_id], arguments[:step_name])