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
@@ -10,6 +10,7 @@ module RubyReactor
10
10
  # ordering nonce.
11
11
  class AsyncReactorBuilder
12
12
  include RubyReactor::Dsl::TemplateHelpers
13
+ include RubyReactor::Dsl::Retryable
13
14
 
14
15
  attr_accessor :name, :child_reactor_class, :argument_mappings
15
16
 
@@ -18,17 +19,13 @@ module RubyReactor
18
19
  @child_reactor_class = child_reactor_class
19
20
  @reactor = reactor
20
21
  @argument_mappings = {}
21
- @retry_config = {}
22
+ @retry_config = nil
22
23
  end
23
24
 
24
25
  def argument(child_input_name, source)
25
26
  @argument_mappings[child_input_name] = source
26
27
  end
27
28
 
28
- def retries(max_attempts: 3, backoff: :exponential, base_delay: 1)
29
- @retry_config = { max_attempts: max_attempts, backoff: backoff, base_delay: base_delay }
30
- end
31
-
32
29
  def build
33
30
  RubyReactor::Dsl::StepConfig.new(
34
31
  async_dispatch: :reactor,
@@ -49,7 +46,7 @@ module RubyReactor
49
46
  dependencies: dependencies_from_mappings,
50
47
  args_validator: nil,
51
48
  output_validator: nil,
52
- retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
49
+ retry_config: @retry_config
53
50
  )
54
51
  end
55
52
 
@@ -4,6 +4,7 @@ module RubyReactor
4
4
  module Dsl
5
5
  class ComposeBuilder
6
6
  include RubyReactor::Dsl::TemplateHelpers
7
+ include RubyReactor::Dsl::Retryable
7
8
 
8
9
  attr_accessor :name, :composed_reactor_class, :argument_mappings
9
10
 
@@ -20,7 +21,7 @@ module RubyReactor
20
21
  end
21
22
  @reactor = reactor
22
23
  @argument_mappings = {}
23
- @retry_config = {}
24
+ @retry_config = nil
24
25
  end
25
26
 
26
27
  def argument(composed_input_name, source)
@@ -44,14 +45,6 @@ module RubyReactor
44
45
  )
45
46
  end
46
47
 
47
- def retries(max_attempts: 3, backoff: :exponential, base_delay: 1)
48
- @retry_config = {
49
- max_attempts: max_attempts,
50
- backoff: backoff,
51
- base_delay: base_delay
52
- }
53
- end
54
-
55
48
  def build
56
49
  warn_if_child_has_ordered_lock!
57
50
  dependencies = extract_dependencies_from_mappings
@@ -71,7 +64,7 @@ module RubyReactor
71
64
  dependencies: dependencies,
72
65
  args_validator: nil,
73
66
  output_validator: nil,
74
- retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
67
+ retry_config: @retry_config
75
68
  }
76
69
 
77
70
  RubyReactor::Dsl::StepConfig.new(step_config)
@@ -42,6 +42,20 @@ module RubyReactor
42
42
  "`validate_payload`."
43
43
  end
44
44
 
45
+ # Step-level coordination is refused on an interrupt (research D6): its
46
+ # body is split across a pause, so a hold taken before the pause would
47
+ # span the gap — held by nothing while the workflow waits, potentially
48
+ # forever. `StepBuilder` (the superclass) gains the five macros once
49
+ # `Lockable` is hosted there; these overrides make sure an interrupt
50
+ # never inherits them silently (Finding 3).
51
+ %i[with_lock with_semaphore with_rate_limit with_period with_ordered_lock].each do |macro|
52
+ define_method(macro) do |*, **|
53
+ raise RubyReactor::Error::ValidationError,
54
+ "interrupt :#{@name} cannot declare step-level coordination: its body is split across a pause, " \
55
+ "so a hold would span the gap. Declare it on the reactor (with_lock etc.) instead."
56
+ end
57
+ end
58
+
45
59
  # Deprecated alias for {#validate_payload}.
46
60
  def validate(schema = nil, &block)
47
61
  unless @warned_validate
@@ -10,6 +10,23 @@ module RubyReactor
10
10
  module ClassMethods
11
11
  attr_reader :lock_config, :semaphore_config, :period_config, :rate_limit_config, :ordered_lock_config
12
12
 
13
+ # The five configs, nils compacted. Reactors get this too (additive);
14
+ # steps use it to decide whether `StepCoordination` needs building at
15
+ # all (`StepCoordination.none?`).
16
+ def coordination_declarations
17
+ {
18
+ lock: lock_config,
19
+ semaphore: semaphore_config,
20
+ rate_limit: rate_limit_config,
21
+ period: period_config,
22
+ ordered_lock: ordered_lock_config
23
+ }.compact
24
+ end
25
+
26
+ def declares_coordination?
27
+ !coordination_declarations.empty?
28
+ end
29
+
13
30
  # Propagate lock/semaphore/period/rate-limit config to subclasses;
14
31
  # without this a subclass of a configured reactor would silently lose
15
32
  # those settings.
@@ -22,39 +39,65 @@ module RubyReactor
22
39
  subclass.instance_variable_set(:@ordered_lock_config, @ordered_lock_config) if @ordered_lock_config
23
40
  end
24
41
 
25
- # Configure locking for this reactor
42
+ # Configure locking for this reactor or step
26
43
  # @param ttl [Integer] Time to live in seconds (default: 60)
27
44
  # @param wait [Integer] Time to wait for lock in seconds (default: 0)
28
45
  # @param auto_extend [Boolean] When true (default), a background thread
29
46
  # refreshes the lock TTL every ttl/3 seconds while the reactor runs,
30
47
  # protecting steps that may legitimately outlast `ttl`. Pass `false`
31
48
  # to disable and rely solely on `ttl` for expiry.
32
- # @yield [inputs] Block that returns the lock key string
33
- def with_lock(ttl: 60, wait: 0, auto_extend: true, &block)
49
+ # @param rollback_wait [Numeric, nil] STEP only (accepted and ignored on
50
+ # a reactor, whose holds are not re-taken for rollback): how long the
51
+ # step's `undo`/`compensate` waits to re-take this lock. Defaults to
52
+ # `ttl` — a forward holder either finishes or expires within it.
53
+ # Rollback never parks: in a worker the wait blocks the thread. An
54
+ # undo that cannot re-take the key in time is reported on
55
+ # `Failure#rollback_failures`.
56
+ # @yield [inputs] Block that returns the lock key string. On a reactor,
57
+ # `inputs` is the reactor's inputs; on a step, it is the step's own
58
+ # resolved arguments (contract defaults applied).
59
+ def with_lock(ttl: 60, wait: 0, auto_extend: true, rollback_wait: nil, &block)
60
+ validate_rollback_wait!(rollback_wait)
34
61
  @lock_config = {
35
62
  ttl: ttl,
36
63
  wait: wait,
37
64
  auto_extend: auto_extend,
65
+ rollback_wait: rollback_wait,
38
66
  key_proc: block
39
67
  }
40
68
  end
41
69
 
42
- # Configure semaphore for this reactor
70
+ # Configure semaphore for this reactor or step
43
71
  # @param limit [Integer] Maximum concurrent executions
44
72
  # @param wait [Integer] Time to wait for a token in seconds (default: 0)
45
- # @yield [inputs] Block that returns the semaphore key string
46
- def with_semaphore(limit:, wait: 0, &block)
73
+ # @param rollback_wait [Numeric, nil] STEP only, as for `with_lock`.
74
+ # Defaults to 60 seconds: a semaphore slot has no hold expiry.
75
+ # @yield [inputs] Block that returns the semaphore key string. On a
76
+ # reactor, `inputs` is the reactor's inputs; on a step, it is the
77
+ # step's own resolved arguments.
78
+ def with_semaphore(limit:, wait: 0, rollback_wait: nil, &block)
79
+ validate_rollback_wait!(rollback_wait)
47
80
  @semaphore_config = {
48
81
  limit: limit,
49
82
  wait: wait,
83
+ rollback_wait: rollback_wait,
50
84
  key_proc: block
51
85
  }
52
86
  end
53
87
 
54
- # Configure a calendar-aligned dedup window for this reactor. The
55
- # reactor will run at most once per bucket per key; subsequent calls
56
- # in the same bucket return `RubyReactor::Halt` without executing
57
- # any steps.
88
+ def validate_rollback_wait!(value)
89
+ return if value.nil? || (value.is_a?(Numeric) && value >= 0)
90
+
91
+ raise ArgumentError, "rollback_wait must be a number of seconds >= 0 (got #{value.inspect})"
92
+ end
93
+ private :validate_rollback_wait!
94
+
95
+ # Configure a calendar-aligned dedup window for this reactor or step.
96
+ # On a reactor, a hit returns `RubyReactor::Halt` without executing
97
+ # any steps. On a STEP, a hit instead skips just that step
98
+ # (`RubyReactor.Skipped(reason: :period)`) — halting the whole
99
+ # workflow over one deduplicated step would defeat the point of
100
+ # declaring it at step level; the rest of the workflow runs normally.
58
101
  #
59
102
  # Note: `with_period` is *dedup*, not *concurrency*. Two concurrent
60
103
  # racers can both see no marker and both run. Pair with `with_lock`
@@ -64,7 +107,9 @@ module RubyReactor
64
107
  # :month / :year, or an integer number of seconds for a sliding
65
108
  # bucket (index = `time.to_i / every`).
66
109
  # @yield [inputs] Block that returns the period key base. The final
67
- # Redis marker key is `period:<base>:<bucket_id>`.
110
+ # Redis marker key is `period:<base>:<bucket_id>`. On a reactor,
111
+ # `inputs` is the reactor's inputs; on a step, it is the step's own
112
+ # resolved arguments.
68
113
  def with_period(every:, &block)
69
114
  # Validate eagerly so misconfiguration surfaces at class load time.
70
115
  RubyReactor::Period.period_seconds(every)
@@ -75,11 +120,13 @@ module RubyReactor
75
120
  }
76
121
  end
77
122
 
78
- # Configure strict-ordering nonce gating for this reactor. A
79
- # monotonically increasing nonce is assigned at enqueue time; the
80
- # worker can only proceed when its nonce equals `last_completed + 1`.
81
- # Otherwise the worker raises {OrderedLock::WaitError} and the Sidekiq
82
- # worker snoozes via `perform_in`.
123
+ # Configure strict-ordering nonce gating for this reactor or step. On
124
+ # a reactor, a monotonically increasing nonce is assigned at enqueue
125
+ # time; the worker can only proceed when its nonce equals
126
+ # `last_completed + 1`. Otherwise the worker raises
127
+ # {OrderedLock::WaitError} and the Sidekiq worker snoozes via
128
+ # `perform_in`. On a step, the nonce is assigned on first arrival
129
+ # instead — see the `@yield` note below.
83
130
  #
84
131
  # Counters reset to 0 once the sequence fully drains (last_completed
85
132
  # catches up to next). Re-entrancy is NOT supported — a nested reactor
@@ -101,7 +148,13 @@ module RubyReactor
101
148
  # check only applies to a fresh `execute`; an already-started run
102
149
  # that paused (InterruptResult/DispatchResult) completes on resume even
103
150
  # if the chain failed in the meantime.
104
- # @yield [inputs] Block that returns the ordered-lock key string.
151
+ # @yield [inputs] Block that returns the ordered-lock key string. On a
152
+ # STEP, the position is assigned when the execution first REACHES
153
+ # the step (its key reads step arguments, which do not exist until
154
+ # then), so executions are ordered by ARRIVAL at that step, not by
155
+ # enqueue — identical to the reactor form only when the step is
156
+ # first in its reactor. The deeper the step, the weaker the
157
+ # promise (research D8, contract §1).
105
158
  def with_ordered_lock(poison_pill_timeout: OrderedLock::DEFAULT_POISON_PILL_TIMEOUT,
106
159
  ttl: OrderedLock::DEFAULT_TTL,
107
160
  strict: true,
@@ -114,9 +167,9 @@ module RubyReactor
114
167
  }
115
168
  end
116
169
 
117
- # Configure rate limiting for this reactor (fixed-window counter).
118
- # Pass either a single window via `limit:` + `period:`, or a hash of
119
- # windows via `limits:` for layered API quotas.
170
+ # Configure rate limiting for this reactor or step (fixed-window
171
+ # counter). Pass either a single window via `limit:` + `period:`, or
172
+ # a hash of windows via `limits:` for layered API quotas.
120
173
  #
121
174
  # @example Single window
122
175
  # with_rate_limit(limit: 3, period: :second) { |i| "stripe:#{i[:account_id]}" }
@@ -138,7 +191,9 @@ module RubyReactor
138
191
  # :week / :month / :year, or integer seconds (single-window form)
139
192
  # @param limits [Hash{Symbol,Integer => Integer}] mapping of period
140
193
  # unit to limit (multi-window form)
141
- # @yield [inputs] Block returning the rate-limit key base (inline forms).
194
+ # @yield [inputs] Block returning the rate-limit key base (inline
195
+ # forms). On a reactor, `inputs` is the reactor's inputs; on a
196
+ # step, it is the step's own resolved arguments.
142
197
  def with_rate_limit(name = nil, limit: nil, period: nil, limits: nil, &block)
143
198
  if name
144
199
  if limit || period || limits || block
@@ -11,7 +11,6 @@ module RubyReactor
11
11
  base.instance_variable_set(:@middlewares, [])
12
12
  base.instance_variable_set(:@input_validations, {})
13
13
  base.instance_variable_set(:@background_handoff, nil)
14
- base.instance_variable_set(:@retry_defaults, { max_attempts: 3, backoff: :exponential, base_delay: 1 })
15
14
  end
16
15
 
17
16
  module ClassMethods
@@ -55,16 +54,14 @@ module RubyReactor
55
54
  "inputs inside the worker."
56
55
  end
57
56
 
58
- def retry_defaults(**kwargs)
59
- if kwargs.empty?
60
- @retry_defaults ||= { max_attempts: 1, backoff: :exponential, base_delay: 1 }
61
- else
62
- @retry_defaults = {
63
- max_attempts: kwargs[:max_attempts] || 1,
64
- backoff: kwargs[:backoff] || :exponential,
65
- base_delay: kwargs[:base_delay] || 1
66
- }
67
- end
57
+ # Reactor-wide retry defaults are gone: the builders snapshotted them at
58
+ # `step` time, so they silently applied only to steps declared AFTER
59
+ # the `retry_defaults` line. A step's retry policy now lives on the step.
60
+ def retry_defaults(*, **)
61
+ raise RubyReactor::Error::DeprecatedDslError,
62
+ "`retry_defaults` has been removed from #{name || "this reactor"}: reactor-wide defaults " \
63
+ "silently applied only to steps declared after them. Declare `retries` on each step class " \
64
+ "(or step block) that should retry; a step with no `retries` runs once."
68
65
  end
69
66
 
70
67
  # rubocop:disable Metrics/ParameterLists
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Dsl
5
+ # The one `retries` vocabulary. A step class EXTENDS it (class-level DSL);
6
+ # the step, compose and async_reactor builders INCLUDE it, so every form
7
+ # shares the same defaults and validation. Modeled on
8
+ # `Lockable::ClassMethods`: `inherited` copies the declaration down, so a
9
+ # subclass redeclaring leaves its parent and siblings untouched.
10
+ module Retryable
11
+ BACKOFF_STRATEGIES = %i[exponential linear fixed].freeze
12
+
13
+ # The declared policy, or nil when nothing was declared.
14
+ attr_reader :retry_config
15
+
16
+ def retries(max_attempts: 3, backoff: :exponential, base_delay: 1)
17
+ unless max_attempts.is_a?(Integer) && max_attempts >= 1
18
+ invalid_retries!(:max_attempts, "an Integer >= 1", max_attempts)
19
+ end
20
+ invalid_retries!(:backoff, "one of #{BACKOFF_STRATEGIES.inspect}", backoff) unless
21
+ BACKOFF_STRATEGIES.include?(backoff)
22
+ invalid_retries!(:base_delay, "a Numeric >= 0", base_delay) unless
23
+ base_delay.is_a?(Numeric) && base_delay >= 0
24
+
25
+ @retry_config = { max_attempts: max_attempts, backoff: backoff, base_delay: base_delay }
26
+ end
27
+
28
+ def inherited(subclass)
29
+ super
30
+ subclass.instance_variable_set(:@retry_config, @retry_config) if @retry_config
31
+ end
32
+
33
+ private
34
+
35
+ def invalid_retries!(option, expected, value)
36
+ raise ArgumentError, "#{retry_owner_label}: retries #{option} must be #{expected} (got #{value.inspect})"
37
+ end
38
+
39
+ # A step class's name, or a builder's step name.
40
+ def retry_owner_label
41
+ name&.to_s || inspect
42
+ end
43
+ end
44
+ end
45
+ end
@@ -5,9 +5,16 @@ module RubyReactor
5
5
  class StepBuilder
6
6
  include RubyReactor::Dsl::TemplateHelpers
7
7
  include RubyReactor::Dsl::ValidationHelpers
8
+ include RubyReactor::Dsl::Lockable::ClassMethods
9
+ include RubyReactor::Dsl::Retryable
10
+
11
+ COORDINATION_MACROS = {
12
+ lock_config: "with_lock", semaphore_config: "with_semaphore", rate_limit_config: "with_rate_limit",
13
+ period_config: "with_period", ordered_lock_config: "with_ordered_lock"
14
+ }.freeze
8
15
 
9
16
  attr_accessor :name, :impl, :arguments, :run_block, :compensate_block, :undo_block, :conditions, :guards,
10
- :dependencies, :args_validator, :output_validator, :retry_config
17
+ :dependencies, :args_validator, :output_validator
11
18
 
12
19
  def initialize(name, impl = nil, reactor = nil)
13
20
  @name = name
@@ -24,7 +31,7 @@ module RubyReactor
24
31
  @validate_args_input = nil
25
32
  @args_validator = nil
26
33
  @output_validator = nil
27
- @retry_config = {}
34
+ @retry_config = nil
28
35
  @inline_contract = nil
29
36
  @rule_sites = []
30
37
  end
@@ -124,19 +131,13 @@ module RubyReactor
124
131
  )
125
132
  end
126
133
 
127
- def retries(max_attempts: 3, backoff: :exponential, base_delay: 1)
128
- @retry_config = {
129
- max_attempts: max_attempts,
130
- backoff: backoff,
131
- base_delay: base_delay
132
- }
133
- end
134
-
135
134
  # `async_dispatch` marks a step whose work is dispatched as an independent
136
135
  # unit rather than run inline — `:step` for `async_step`, `:reactor` for
137
136
  # `async_reactor`. Nil for an ordinary step.
138
137
  def build(async_dispatch: nil)
139
138
  check_contract_conflicts!
139
+ check_coordination_conflicts!
140
+ check_retry_conflict!
140
141
  warn_deprecated_rules
141
142
 
142
143
  step_config = {
@@ -153,7 +154,12 @@ module RubyReactor
153
154
  args_validator: @args_validator || build_args_validator(@arg_validations, @validate_args_input),
154
155
  output_validator: @output_validator,
155
156
  inline_contract: @inline_contract,
156
- retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
157
+ retry_config: @retry_config,
158
+ lock_config: @lock_config,
159
+ semaphore_config: @semaphore_config,
160
+ rate_limit_config: @rate_limit_config,
161
+ period_config: @period_config,
162
+ ordered_lock_config: @ordered_lock_config
157
163
  }
158
164
 
159
165
  RubyReactor::Dsl::StepConfig.new(step_config)
@@ -161,6 +167,33 @@ module RubyReactor
161
167
 
162
168
  private
163
169
 
170
+ # The same primitive declared BOTH inline and on the step class is
171
+ # ambiguous — two keys for one slot of the fixed acquisition order, and
172
+ # `StepConfig`'s readers would silently let the inline one win. Refuse it
173
+ # at class-definition time rather than pick a winner silently.
174
+ def check_coordination_conflicts!
175
+ return unless @impl
176
+
177
+ COORDINATION_MACROS.each do |reader, macro|
178
+ next unless instance_variable_get(:"@#{reader}")
179
+ next unless @impl.respond_to?(reader) && @impl.public_send(reader)
180
+
181
+ raise Error::ValidationError,
182
+ "#{reactor_label} step :#{@name} declares `#{macro}` inline, but #{@impl} declares it too. " \
183
+ "Keep ONE: drop the inline declaration to use #{@impl}'s, or remove it from #{@impl}."
184
+ end
185
+ end
186
+
187
+ # Same rule as the coordination macros: one retry policy per step.
188
+ def check_retry_conflict!
189
+ return unless @retry_config && @impl.respond_to?(:retry_config) && @impl.retry_config
190
+
191
+ raise Error::ValidationError,
192
+ "#{reactor_label} step :#{@name} declares `retries` inline, but #{@impl} declares it too. " \
193
+ "Keep ONE: drop the inline declaration to use #{@impl}'s, or remove it from #{@impl}. " \
194
+ "To vary the policy per workflow, subclass #{@impl} and declare `retries` there."
195
+ end
196
+
164
197
  # A step that owns its input contract takes wiring only from the
165
198
  # reactor: rules here would be a second, overlapping rule set.
166
199
  def check_contract_conflicts!
@@ -221,8 +254,10 @@ module RubyReactor
221
254
  end
222
255
 
223
256
  class StepConfig
257
+ NO_RETRIES = { max_attempts: 1, backoff: :exponential, base_delay: 1 }.freeze
258
+
224
259
  attr_reader :name, :impl, :arguments, :run_block, :compensate_block, :undo_block, :conditions, :guards,
225
- :dependencies, :args_validator, :output_validator, :retry_config, :async_dispatch,
260
+ :dependencies, :args_validator, :output_validator, :async_dispatch,
226
261
  :inline_contract
227
262
 
228
263
  def initialize(config)
@@ -239,7 +274,104 @@ module RubyReactor
239
274
  @args_validator = config[:args_validator]
240
275
  @output_validator = config[:output_validator]
241
276
  @inline_contract = config[:inline_contract]
242
- @retry_config = { max_attempts: 1 }.merge(config[:retry_config] || {})
277
+ @retry_config = config[:retry_config]
278
+ @lock_config = config[:lock_config]
279
+ @semaphore_config = config[:semaphore_config]
280
+ @rate_limit_config = config[:rate_limit_config]
281
+ @period_config = config[:period_config]
282
+ @ordered_lock_config = config[:ordered_lock_config]
283
+ end
284
+
285
+ # A step's EFFECTIVE coordination: its own (inline) declaration if it has
286
+ # one, else the class step's (`impl`). Every consumer — forward
287
+ # execution, rollback, the dispatch guard, the dashboard — reads only
288
+ # these five readers, so a step mixing inline and class declarations is
289
+ # acquired by ONE `StepCoordination` in one global order. The two sources
290
+ # can never both carry the SAME primitive (`check_coordination_conflicts!`
291
+ # rejects that at class-definition time), so this is a union.
292
+ def lock_config
293
+ @lock_config || (impl.lock_config if impl.respond_to?(:lock_config))
294
+ end
295
+
296
+ # A step's EFFECTIVE retry policy, resolved like the coordination
297
+ # readers: its own (step block) declaration, else the class step's,
298
+ # else a single attempt. Never nil. The reactor is never consulted.
299
+ def retry_config
300
+ @retry_config || (impl.retry_config if impl.respond_to?(:retry_config)) || NO_RETRIES
301
+ end
302
+
303
+ # Where `retry_config` came from: :step_block, :step_class or :none.
304
+ def retry_source
305
+ return :step_block if @retry_config
306
+ return :step_class if impl.respond_to?(:retry_config) && impl.retry_config
307
+
308
+ :none
309
+ end
310
+
311
+ def semaphore_config
312
+ @semaphore_config || (impl.semaphore_config if impl.respond_to?(:semaphore_config))
313
+ end
314
+
315
+ def rate_limit_config
316
+ @rate_limit_config || (impl.rate_limit_config if impl.respond_to?(:rate_limit_config))
317
+ end
318
+
319
+ def period_config
320
+ @period_config || (impl.period_config if impl.respond_to?(:period_config))
321
+ end
322
+
323
+ def ordered_lock_config
324
+ @ordered_lock_config || (impl.ordered_lock_config if impl.respond_to?(:ordered_lock_config))
325
+ end
326
+
327
+ def coordination_declarations
328
+ {
329
+ lock: lock_config,
330
+ semaphore: semaphore_config,
331
+ rate_limit: rate_limit_config,
332
+ period: period_config,
333
+ ordered_lock: ordered_lock_config
334
+ }.compact
335
+ end
336
+
337
+ def declares_coordination?
338
+ !coordination_declarations.empty?
339
+ end
340
+
341
+ # The ONE derivation of what a step's body — and therefore every
342
+ # coordination key — receives: an inline step with no `argument` wiring
343
+ # gets the reactor's inputs, and the step's contract applies its
344
+ # defaults. Forward execution (`body_arguments`), rollback, the async
345
+ # dispatch guard and the dashboard all go through here, so a key can
346
+ # never be computed from different values in different places.
347
+ # Never raises — `enforce!` returns exactly `apply_defaults(args)` when
348
+ # the arguments are valid, and rollback must not fail on invalid ones.
349
+ def coordination_arguments(resolved, inputs)
350
+ args = has_run_block? && resolved.empty? ? inputs : resolved
351
+ input_contract && args.is_a?(Hash) ? input_contract.apply_defaults(args) : args
352
+ end
353
+
354
+ # `coordination_arguments`, validated: raises InputValidationError
355
+ # BEFORE any coordination is taken (Finding 8).
356
+ def body_arguments(resolved, inputs)
357
+ args = has_run_block? && resolved.empty? ? inputs : resolved
358
+ input_contract ? input_contract.enforce!(args) : args
359
+ end
360
+
361
+ # The step's work as a reactor runs it. Coordination is NOT taken here:
362
+ # the caller (StepExecutor / StepWorker) takes this config's effective
363
+ # declarations — inline and class alike — in one fixed order around it.
364
+ def call_body(arguments, context)
365
+ catch(StepSignals::TAG) do
366
+ if has_run_block?
367
+ run_block.call(wrap_inputs(arguments), context)
368
+ elsif impl.respond_to?(:run_without_coordination)
369
+ impl.run_without_coordination(arguments, context)
370
+ else
371
+ # A duck-typed impl (any `.run(args, ctx)`) never coordinates itself.
372
+ impl.run(arguments, context)
373
+ end
374
+ end
243
375
  end
244
376
 
245
377
  # True for `async_step` / `async_reactor` — the step's work leaves this
@@ -262,6 +394,11 @@ module RubyReactor
262
394
  !@run_block.nil?
263
395
  end
264
396
 
397
+ # What an inline `run`/`undo`/`compensate` block receives as its inputs.
398
+ def wrap_inputs(arguments)
399
+ RubyReactor::Step::Inputs.new(arguments, contract: input_contract, owner: "step :#{name}")
400
+ end
401
+
265
402
  def retryable?
266
403
  (retry_config[:max_attempts] || 0) > 1
267
404
  end
@@ -9,7 +9,7 @@ module RubyReactor
9
9
  # re-enqueues itself via the snooze path, and the wait resumes on
10
10
  # redelivery. Bounded by `Configuration#async_park_timeout`, enforced at
11
11
  # the wait site before this is raised.
12
- class AsyncResultPending < Base
12
+ class AsyncResultPending < ExecutionParked
13
13
  attr_reader :channel
14
14
 
15
15
  def initialize(message, channel: nil)
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Error
5
+ # Base for the internal "this execution parks" signals raised inside a
6
+ # worker (`inline_async_execution`). Never persisted, never reaches a
7
+ # synchronous caller.
8
+ #
9
+ # Contract: every rescue between the raise site and the final handler
10
+ # (`Worker#perform`, or `Map::ElementExecutor` for a map element) either
11
+ # re-raises it untouched, or parks its own holds and then re-raises. The
12
+ # requeue happens once, at the top, after every executor on the stack has
13
+ # parked and saved.
14
+ class ExecutionParked < Base; end
15
+ end
16
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Error
5
+ # A composed child's OWN reactor-level lock, semaphore or rate limit was
6
+ # contended inside a worker, before the child was admitted. Nothing between
7
+ # the child and the worker snoozes a nested executor's contention error, so
8
+ # the child raises this park signal instead: every executor above it keeps
9
+ # its holds, and the worker requeues the job. Carries the contention error,
10
+ # and delegates `original`/`retry_after_seconds` to it so `Worker.snooze_delay`
11
+ # and `Worker.hinted_retry?` treat it exactly like the error it wraps (as
12
+ # `StepContentionPark` does).
13
+ class ReactorContentionPark < ExecutionParked
14
+ attr_reader :original
15
+
16
+ def initialize(original)
17
+ super(original.message)
18
+ @original = original
19
+ end
20
+
21
+ def retry_after_seconds
22
+ original.retry_after_seconds if original.respond_to?(:retry_after_seconds)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Error
5
+ # A step's own coordination was contended inside a worker: park the
6
+ # execution at that step. Carries the `StepCoordination::Contended`, and
7
+ # delegates `original`/`retry_after_seconds` to it so `Worker.snooze_delay`
8
+ # and `Worker.hinted_retry?` treat it exactly like the contention it wraps.
9
+ class StepContentionPark < ExecutionParked
10
+ attr_reader :contended
11
+
12
+ def initialize(contended)
13
+ super(contended.message)
14
+ @contended = contended
15
+ end
16
+
17
+ def original
18
+ contended.original
19
+ end
20
+
21
+ def retry_after_seconds
22
+ contended.retry_after_seconds
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Error
5
+ # A step read an input it can't read (a typo, or a name it never
6
+ # declared). It fails the same way on every attempt, so it never retries.
7
+ class UndeclaredInputError < NoMethodError
8
+ def retryable?
9
+ false
10
+ end
11
+ end
12
+ end
13
+ end