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
@@ -29,6 +29,19 @@ module RubyReactor
29
29
  :cancelled, :cancellation_reason, :parent_context_id, :retried_from_id, :status, :failure_reason,
30
30
  :middlewares
31
31
 
32
+ # Transient, NOT serialized (absent from `to_h`, `serialize_for_retry`,
33
+ # and `deserialize_from_retry` below — confirmed by
34
+ # reentrancy_spec.rb US4-16). Overrides `StepCoordination#owner` for
35
+ # everything coordinated with this context (research D5): set by the
36
+ # `async_step` worker to a per-job id so re-entrancy never crosses a
37
+ # process hand-off (US4-5).
38
+ attr_accessor :coordination_owner
39
+
40
+ # Transient, NOT serialized: the step whose coordination hook is firing
41
+ # right now (set by `StepCoordination#emit` for the duration of the hook),
42
+ # so a middleware can tell a step-level hold from a reactor-level one.
43
+ attr_accessor :coordinating_step
44
+
32
45
  def initialize(inputs = {}, reactor_class = nil)
33
46
  @context_id = SecureRandom.uuid
34
47
  @inputs = inputs
@@ -95,6 +108,19 @@ module RubyReactor
95
108
  @intermediate_results[step_name.to_sym] = value
96
109
  end
97
110
 
111
+ # Set once this execution's reactor-level gates (rate limit, period,
112
+ # post-lock period re-check) have passed, and never unset — it rides
113
+ # `private_data`, so it survives parks, retries, pauses and redeliveries.
114
+ # "Is this a fresh run?" reads it instead of inferring from
115
+ # `current_step`, which is only the resume cursor (005 R-03).
116
+ def admitted?
117
+ !!(private_data[:admitted] || private_data["admitted"])
118
+ end
119
+
120
+ def admit!
121
+ private_data[:admitted] = true
122
+ end
123
+
98
124
  def with_step(step_name)
99
125
  old_step = @current_step
100
126
  @current_step = step_name
@@ -57,7 +57,8 @@ module RubyReactor
57
57
  "file_path" => value.file_path,
58
58
  "line_number" => value.line_number,
59
59
  "code_snippet" => serialize_value(value.code_snippet),
60
- "validation_errors" => serialize_value(value.validation_errors)
60
+ "validation_errors" => serialize_value(value.validation_errors),
61
+ "rollback_failures" => serialize_value(value.rollback_failures)
61
62
  }
62
63
  when RubyReactor::Context
63
64
  { "_type" => "Context", "value" => value.serialize_for_retry }
@@ -140,7 +141,8 @@ module RubyReactor
140
141
  file_path: value["file_path"],
141
142
  line_number: value["line_number"],
142
143
  code_snippet: deserialize_value(value["code_snippet"]),
143
- validation_errors: deserialize_value(value["validation_errors"])
144
+ validation_errors: deserialize_value(value["validation_errors"]),
145
+ rollback_failures: deserialize_value(value["rollback_failures"])
144
146
  )
145
147
  when "Context"
146
148
  Context.deserialize_from_retry(value["value"])
@@ -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
@@ -5,6 +5,12 @@ 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
+
10
+ COORDINATION_MACROS = {
11
+ lock_config: "with_lock", semaphore_config: "with_semaphore", rate_limit_config: "with_rate_limit",
12
+ period_config: "with_period", ordered_lock_config: "with_ordered_lock"
13
+ }.freeze
8
14
 
9
15
  attr_accessor :name, :impl, :arguments, :run_block, :compensate_block, :undo_block, :conditions, :guards,
10
16
  :dependencies, :args_validator, :output_validator, :retry_config
@@ -137,6 +143,7 @@ module RubyReactor
137
143
  # `async_reactor`. Nil for an ordinary step.
138
144
  def build(async_dispatch: nil)
139
145
  check_contract_conflicts!
146
+ check_coordination_conflicts!
140
147
  warn_deprecated_rules
141
148
 
142
149
  step_config = {
@@ -153,7 +160,12 @@ module RubyReactor
153
160
  args_validator: @args_validator || build_args_validator(@arg_validations, @validate_args_input),
154
161
  output_validator: @output_validator,
155
162
  inline_contract: @inline_contract,
156
- retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
163
+ retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config,
164
+ lock_config: @lock_config,
165
+ semaphore_config: @semaphore_config,
166
+ rate_limit_config: @rate_limit_config,
167
+ period_config: @period_config,
168
+ ordered_lock_config: @ordered_lock_config
157
169
  }
158
170
 
159
171
  RubyReactor::Dsl::StepConfig.new(step_config)
@@ -161,6 +173,23 @@ module RubyReactor
161
173
 
162
174
  private
163
175
 
176
+ # The same primitive declared BOTH inline and on the step class is
177
+ # ambiguous — two keys for one slot of the fixed acquisition order, and
178
+ # `StepConfig`'s readers would silently let the inline one win. Refuse it
179
+ # at class-definition time rather than pick a winner silently.
180
+ def check_coordination_conflicts!
181
+ return unless @impl
182
+
183
+ COORDINATION_MACROS.each do |reader, macro|
184
+ next unless instance_variable_get(:"@#{reader}")
185
+ next unless @impl.respond_to?(reader) && @impl.public_send(reader)
186
+
187
+ raise Error::ValidationError,
188
+ "#{reactor_label} step :#{@name} declares `#{macro}` inline, but #{@impl} declares it too. " \
189
+ "Keep ONE: drop the inline declaration to use #{@impl}'s, or remove it from #{@impl}."
190
+ end
191
+ end
192
+
164
193
  # A step that owns its input contract takes wiring only from the
165
194
  # reactor: rules here would be a second, overlapping rule set.
166
195
  def check_contract_conflicts!
@@ -240,6 +269,88 @@ module RubyReactor
240
269
  @output_validator = config[:output_validator]
241
270
  @inline_contract = config[:inline_contract]
242
271
  @retry_config = { max_attempts: 1 }.merge(config[:retry_config] || {})
272
+ @lock_config = config[:lock_config]
273
+ @semaphore_config = config[:semaphore_config]
274
+ @rate_limit_config = config[:rate_limit_config]
275
+ @period_config = config[:period_config]
276
+ @ordered_lock_config = config[:ordered_lock_config]
277
+ end
278
+
279
+ # A step's EFFECTIVE coordination: its own (inline) declaration if it has
280
+ # one, else the class step's (`impl`). Every consumer — forward
281
+ # execution, rollback, the dispatch guard, the dashboard — reads only
282
+ # these five readers, so a step mixing inline and class declarations is
283
+ # acquired by ONE `StepCoordination` in one global order. The two sources
284
+ # can never both carry the SAME primitive (`check_coordination_conflicts!`
285
+ # rejects that at class-definition time), so this is a union.
286
+ def lock_config
287
+ @lock_config || (impl.lock_config if impl.respond_to?(:lock_config))
288
+ end
289
+
290
+ def semaphore_config
291
+ @semaphore_config || (impl.semaphore_config if impl.respond_to?(:semaphore_config))
292
+ end
293
+
294
+ def rate_limit_config
295
+ @rate_limit_config || (impl.rate_limit_config if impl.respond_to?(:rate_limit_config))
296
+ end
297
+
298
+ def period_config
299
+ @period_config || (impl.period_config if impl.respond_to?(:period_config))
300
+ end
301
+
302
+ def ordered_lock_config
303
+ @ordered_lock_config || (impl.ordered_lock_config if impl.respond_to?(:ordered_lock_config))
304
+ end
305
+
306
+ def coordination_declarations
307
+ {
308
+ lock: lock_config,
309
+ semaphore: semaphore_config,
310
+ rate_limit: rate_limit_config,
311
+ period: period_config,
312
+ ordered_lock: ordered_lock_config
313
+ }.compact
314
+ end
315
+
316
+ def declares_coordination?
317
+ !coordination_declarations.empty?
318
+ end
319
+
320
+ # The ONE derivation of what a step's body — and therefore every
321
+ # coordination key — receives: an inline step with no `argument` wiring
322
+ # gets the reactor's inputs, and the step's contract applies its
323
+ # defaults. Forward execution (`body_arguments`), rollback, the async
324
+ # dispatch guard and the dashboard all go through here, so a key can
325
+ # never be computed from different values in different places.
326
+ # Never raises — `enforce!` returns exactly `apply_defaults(args)` when
327
+ # the arguments are valid, and rollback must not fail on invalid ones.
328
+ def coordination_arguments(resolved, inputs)
329
+ args = has_run_block? && resolved.empty? ? inputs : resolved
330
+ input_contract && args.is_a?(Hash) ? input_contract.apply_defaults(args) : args
331
+ end
332
+
333
+ # `coordination_arguments`, validated: raises InputValidationError
334
+ # BEFORE any coordination is taken (Finding 8).
335
+ def body_arguments(resolved, inputs)
336
+ args = has_run_block? && resolved.empty? ? inputs : resolved
337
+ input_contract ? input_contract.enforce!(args) : args
338
+ end
339
+
340
+ # The step's work as a reactor runs it. Coordination is NOT taken here:
341
+ # the caller (StepExecutor / StepWorker) takes this config's effective
342
+ # declarations — inline and class alike — in one fixed order around it.
343
+ def call_body(arguments, context)
344
+ catch(StepSignals::TAG) do
345
+ if has_run_block?
346
+ run_block.call(arguments, context)
347
+ elsif impl.respond_to?(:run_without_coordination)
348
+ impl.run_without_coordination(arguments, context)
349
+ else
350
+ # A duck-typed impl (any `.run(args, ctx)`) never coordinates itself.
351
+ impl.run(arguments, context)
352
+ end
353
+ end
243
354
  end
244
355
 
245
356
  # True for `async_step` / `async_reactor` — the step's work leaves this
@@ -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
@@ -28,6 +28,14 @@ module RubyReactor
28
28
  return RubyReactor.Success(nil)
29
29
  end
30
30
 
31
+ deadlock = check_async_step_deadlock(step_config)
32
+ # Through the normal step-failure handler, never returned bare: a bare
33
+ # Failure goes straight back to `Executor#execute`, which marks the
34
+ # context failed WITHOUT rolling back — leaving an earlier step's side
35
+ # effect uncompensated. `handle_step_result` compensates and raises the
36
+ # `StepFailureError` the executor's own rescue chain expects.
37
+ return @result_handler.handle_step_result(step_config, deadlock, {}) if deadlock
38
+
31
39
  record_async_step_dispatch(step_config)
32
40
  enqueue_async_step(step_config)
33
41
 
@@ -43,6 +51,101 @@ module RubyReactor
43
51
  !storage.retrieve_step_result(@context.context_id, step_config.name, async_step_class_name).nil?
44
52
  end
45
53
 
54
+ # US4/T034: an `async_step` whose step class declares a key this
55
+ # execution currently holds would deadlock exactly like `async_reactor`
56
+ # dispatching into one — refuse before the durable record is written or
57
+ # the job is enqueued, naming the key (Step::AsyncReactorStep's guard
58
+ # already covers `async_reactor`; this reuses its message and registry).
59
+ def check_async_step_deadlock(step_config)
60
+ held = Step::AsyncReactorStep.held_lock_keys(@context)
61
+ return nil if held.empty?
62
+
63
+ # Only lock and a limit-1 semaphore have the circular-wait shape this
64
+ # guard defends against (same registry `with_lock`/limit-1
65
+ # `with_semaphore` push to, T032). Rate limit, period, and the
66
+ # ordered lock never enter `held_lock_keys`, so they cannot deadlock
67
+ # a hand-off and are deliberately not checked here.
68
+ lock_config = step_config.lock_config
69
+ semaphore_config = step_config.semaphore_config
70
+ return nil unless lock_config || (semaphore_config && semaphore_config[:limit] == 1)
71
+
72
+ args = resolve_args_for_deadlock_check(step_config)
73
+ return args if args.is_a?(RubyReactor::Failure) # KeyError or "skip, can't resolve without blocking"
74
+ return nil if args.nil? # skipped — non-blocking resolution was not possible
75
+
76
+ collision = async_step_lock_keys(lock_config, semaphore_config, args, step_config)
77
+ .find { |key| held.include?(key) }
78
+ return nil unless collision
79
+
80
+ # A never-started error, not a bare message: the step was neither
81
+ # dispatched nor run, so rollback must not compensate it.
82
+ message = Step::AsyncReactorStep.deadlock_message(collision, "#{@reactor_class&.name}##{step_config.name}",
83
+ @context, kind: "async_step")
84
+ never_started_failure(Executor::StepCoordination::DispatchRefused.new(message, step: step_config.name),
85
+ step_config)
86
+ rescue Executor::StepCoordination::KeyError => e
87
+ # A guard key that cannot be computed is the same non-retryable step
88
+ # failure the real acquisition would raise (FR-007) — never a generic
89
+ # execution error, which would skip rollback of the earlier steps.
90
+ never_started_failure(e, step_config)
91
+ end
92
+
93
+ # Through `StepCoordination.resolve_key`, so a nil/empty or raising key
94
+ # proc fails here exactly as it would at acquisition time.
95
+ def async_step_lock_keys(lock_config, semaphore_config, args, step_config)
96
+ keys = []
97
+ keys << Executor::StepCoordination.resolve_key(lock_config, args, step_config.name) if lock_config
98
+ if semaphore_config && semaphore_config[:limit] == 1
99
+ keys << Executor::StepCoordination.resolve_key(semaphore_config, args, step_config.name)
100
+ end
101
+ keys.compact
102
+ end
103
+
104
+ def never_started_failure(error, step_config)
105
+ RubyReactor::Failure(error, step_name: step_config.name, reactor_name: @reactor_class&.name,
106
+ retryable: false)
107
+ end
108
+
109
+ # Finding 5: `async_step` defers argument resolution to the worker, so
110
+ # computing the key here means resolving early — safe UNLESS an
111
+ # argument reads a still-pending async result, which would BLOCK (or
112
+ # park) this dispatching step just to run a guard check. Detect that
113
+ # case without calling `.resolve` at all, skip the guard, and log it.
114
+ def resolve_args_for_deadlock_check(step_config)
115
+ if step_config.arguments.values.any? { |cfg| pending_async_source?(cfg[:source]) }
116
+ log_guard_skipped(step_config)
117
+ return nil
118
+ end
119
+
120
+ resolved = {}
121
+ step_config.arguments.each do |name, cfg|
122
+ value = cfg[:source].resolve(@context)
123
+ value = cfg[:transform].call(value) if cfg[:transform]
124
+ resolved[name] = value
125
+ end
126
+ # The worker keys off `coordination_arguments` of these same values;
127
+ # anything else here would let a self-deadlocking job through.
128
+ step_config.coordination_arguments(resolved, @context.inputs)
129
+ rescue Executor::StepCoordination::KeyError => e
130
+ never_started_failure(e, step_config)
131
+ end
132
+
133
+ def pending_async_source?(source)
134
+ return false unless source.is_a?(RubyReactor::Template::Result)
135
+ return false if @context.intermediate_results.key?(source.step_name.to_sym) ||
136
+ @context.intermediate_results.key?(source.step_name.to_s)
137
+
138
+ ref = @context.composed_contexts[source.step_name] || @context.composed_contexts[source.step_name.to_s]
139
+ ref.is_a?(Hash) && %i[async_step_ref async_reactor_ref].include?(ref[:type]&.to_sym)
140
+ end
141
+
142
+ def log_guard_skipped(step_config)
143
+ configuration.logger.info(
144
+ "event=\"ruby_reactor.step_coordination.guard_skipped\" reactor=#{@reactor_class&.name.inspect} " \
145
+ "step=#{step_config.name.inspect} execution_id=#{@context.context_id.inspect}"
146
+ )
147
+ end
148
+
46
149
  def record_async_step_dispatch(step_config)
47
150
  root = @context.root_context || @context
48
151
  @context.composed_contexts[step_config.name] = {
@@ -98,11 +201,14 @@ module RubyReactor
98
201
  end
99
202
 
100
203
  # One machine-parseable line per hand-off / dispatch, carrying the
101
- # three identifiers needed to correlate it with everything else.
102
- def log_async_event(event, step_name)
204
+ # three identifiers needed to correlate it with everything else, plus
205
+ # any extra key=value fields (e.g. a park's key/primitive/attempt),
206
+ # inserted before execution_id so it always trails the line.
207
+ def log_async_event(event, step_name, **fields)
208
+ extra = fields.map { |k, v| " #{k}=#{v.inspect}" }.join
103
209
  configuration.logger.info(
104
210
  "event=\"ruby_reactor.#{event}\" reactor=#{@reactor_class&.name.inspect} " \
105
- "step=#{step_name.inspect} execution_id=#{@context.context_id.inspect}"
211
+ "step=#{step_name.inspect}#{extra} execution_id=#{@context.context_id.inspect}"
106
212
  )
107
213
  end
108
214
  end