ruby_reactor 0.5.0 → 0.5.2

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +14 -0
  4. data/README.md +199 -30
  5. data/lib/ruby_reactor/configuration.rb +7 -0
  6. data/lib/ruby_reactor/dsl/compose_builder.rb +20 -0
  7. data/lib/ruby_reactor/dsl/interrupt_builder.rb +18 -2
  8. data/lib/ruby_reactor/dsl/lockable.rb +60 -29
  9. data/lib/ruby_reactor/dsl/reactor.rb +38 -7
  10. data/lib/ruby_reactor/dsl/step_builder.rb +25 -39
  11. data/lib/ruby_reactor/dsl/validation_helpers.rb +34 -0
  12. data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
  13. data/lib/ruby_reactor/executor/ordered_lock_support.rb +307 -0
  14. data/lib/ruby_reactor/executor/result_handler.rb +35 -8
  15. data/lib/ruby_reactor/executor/step_executor.rb +10 -5
  16. data/lib/ruby_reactor/executor.rb +145 -50
  17. data/lib/ruby_reactor/ordered_lock.rb +158 -0
  18. data/lib/ruby_reactor/rate_limit.rb +28 -0
  19. data/lib/ruby_reactor/rate_limit_registry.rb +51 -0
  20. data/lib/ruby_reactor/reactor.rb +41 -0
  21. data/lib/ruby_reactor/rspec/helpers.rb +6 -0
  22. data/lib/ruby_reactor/rspec/matchers.rb +66 -0
  23. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +70 -0
  24. data/lib/ruby_reactor/rspec/storage_reset.rb +23 -0
  25. data/lib/ruby_reactor/rspec/test_subject.rb +14 -28
  26. data/lib/ruby_reactor/rspec.rb +37 -0
  27. data/lib/ruby_reactor/sidekiq_workers/worker.rb +50 -8
  28. data/lib/ruby_reactor/storage/redis_adapter.rb +1 -0
  29. data/lib/ruby_reactor/storage/redis_ordered_locking.rb +382 -0
  30. data/lib/ruby_reactor/validation/base.rb +4 -1
  31. data/lib/ruby_reactor/validation/input_validator.rb +4 -2
  32. data/lib/ruby_reactor/validation/schema_builder.rb +82 -0
  33. data/lib/ruby_reactor/version.rb +1 -1
  34. data/lib/ruby_reactor.rb +1 -0
  35. metadata +7 -1
@@ -20,17 +20,23 @@ module RubyReactor
20
20
  @conditions = []
21
21
  @guards = []
22
22
  @dependencies = []
23
+ @arg_validations = []
24
+ @validate_args_input = nil
23
25
  @args_validator = nil
24
26
  @output_validator = nil
25
27
  @async = false
26
28
  @retry_config = {}
27
29
  end
28
30
 
29
- def argument(name, source, transform: nil)
31
+ def argument(name, source, type = nil, transform: nil, **predicates)
30
32
  @arguments[name] = {
31
33
  source: source,
32
34
  transform: transform
33
35
  }
36
+
37
+ return unless type || predicates.any?
38
+
39
+ @arg_validations << [name, type, false, predicates]
34
40
  end
35
41
 
36
42
  def run(&block)
@@ -57,20 +63,26 @@ module RubyReactor
57
63
  @dependencies.concat(step_names)
58
64
  end
59
65
 
66
+ # Cross-field rules over the whole resolved argument hash. Composes with
67
+ # per-argument inline validations declared via `argument`; the block (or
68
+ # pre-built schema) is applied last and wins on conflicts.
60
69
  def validate_args(schema_or_validator = nil, &block)
61
- if block_given?
62
- @args_validator = build_input_validator(block)
63
- elsif schema_or_validator
64
- @args_validator = build_input_validator(schema_or_validator)
65
- end
70
+ @validate_args_input = block || schema_or_validator
66
71
  end
67
72
 
68
- def validate_output(schema_or_validator = nil, &block)
69
- if block_given?
70
- @output_validator = build_input_validator(block)
71
- elsif schema_or_validator
72
- @output_validator = build_input_validator(schema_or_validator)
73
- end
73
+ # Scalar-aware output validation.
74
+ # validate_output :integer, gteq?: 0 # single value
75
+ # validate_output do ... end # hash output
76
+ # validate_output SomeSchema # pre-built schema
77
+ def validate_output(type = nil, **predicates, &block)
78
+ @output_validator =
79
+ if block
80
+ create_input_validator(block)
81
+ elsif type.is_a?(Symbol) || type.is_a?(Module) || predicates.any?
82
+ build_scalar_validator(type, predicates)
83
+ elsif type
84
+ create_input_validator(type)
85
+ end
74
86
  end
75
87
 
76
88
  def async(async = true)
@@ -96,7 +108,7 @@ module RubyReactor
96
108
  conditions: @conditions,
97
109
  guards: @guards,
98
110
  dependencies: @dependencies,
99
- args_validator: @args_validator,
111
+ args_validator: @args_validator || build_args_validator(@arg_validations, @validate_args_input),
100
112
  output_validator: @output_validator,
101
113
  async: @async,
102
114
  retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
@@ -104,32 +116,6 @@ module RubyReactor
104
116
 
105
117
  RubyReactor::Dsl::StepConfig.new(step_config)
106
118
  end
107
-
108
- private
109
-
110
- def build_input_validator(schema_or_block)
111
- check_dry_validation_available!
112
-
113
- schema = case schema_or_block
114
- when Proc
115
- build_validation_schema(&schema_or_block)
116
- else
117
- schema_or_block
118
- end
119
-
120
- RubyReactor::Validation::InputValidator.new(schema)
121
- end
122
-
123
- def build_validation_schema(&block)
124
- RubyReactor::Validation::SchemaBuilder.build_from_block(&block)
125
- end
126
-
127
- def check_dry_validation_available!
128
- return if defined?(Dry::Schema)
129
-
130
- raise LoadError,
131
- "dry-validation gem is required for validation features. Add 'gem \"dry-validation\"' to your Gemfile."
132
- end
133
119
  end
134
120
 
135
121
  class StepConfig
@@ -22,6 +22,40 @@ module RubyReactor
22
22
  RubyReactor::Validation::InputValidator.new(schema)
23
23
  end
24
24
 
25
+ # Form 1 / 1b — inline scalar or class type for a single named value.
26
+ def build_inline_validator(name, type, optional, predicates)
27
+ check_dry_validation_available!
28
+ schema = RubyReactor::Validation::SchemaBuilder.build_inline(name, type, optional, predicates)
29
+ RubyReactor::Validation::InputValidator.new(schema)
30
+ end
31
+
32
+ # Form 2 — block bound to the value's macro (`required`/`optional`).
33
+ def build_macro_validator(name, optional, &block)
34
+ check_dry_validation_available!
35
+ schema = RubyReactor::Validation::SchemaBuilder.build_macro(name, optional, &block)
36
+ RubyReactor::Validation::InputValidator.new(schema)
37
+ end
38
+
39
+ # Compose per-argument inline rules with an optional `validate_args`
40
+ # block / pre-built schema. Returns nil when there is nothing to validate.
41
+ def build_args_validator(inline_rules, validate_input)
42
+ return nil if inline_rules.empty? && validate_input.nil?
43
+
44
+ check_dry_validation_available!
45
+ schema = RubyReactor::Validation::SchemaBuilder.build_args(inline_rules, validate_input)
46
+ return nil unless schema
47
+
48
+ RubyReactor::Validation::InputValidator.new(schema)
49
+ end
50
+
51
+ # Scalar-aware single-value validator (used by `validate_output`). The
52
+ # value is wrapped under `:value` before validation.
53
+ def build_scalar_validator(type, predicates)
54
+ check_dry_validation_available!
55
+ schema = RubyReactor::Validation::SchemaBuilder.build_inline(:value, type, false, predicates)
56
+ RubyReactor::Validation::InputValidator.new(schema, wrap_key: :value)
57
+ end
58
+
25
59
  private
26
60
 
27
61
  def check_dry_validation_available!
@@ -4,6 +4,10 @@ module RubyReactor
4
4
  module Error
5
5
  class InputValidationError < Base
6
6
  attr_reader :field_errors
7
+ # Step attribution, set at the raise site when the failure happened at a
8
+ # step boundary (argument or output validation) rather than at reactor
9
+ # input validation. Nil for reactor-level input failures.
10
+ attr_accessor :step_name, :step_arguments
7
11
 
8
12
  def initialize(field_errors)
9
13
  @field_errors = field_errors
@@ -0,0 +1,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ class Executor
5
+ # Gate check and terminal-advance logic for `with_ordered_lock`. Mixed
6
+ # into Executor to keep that class under the length limit. All methods
7
+ # read from `@context.private_data[:ordered_lock]`, which Reactor#run
8
+ # populates at enqueue time.
9
+ module OrderedLockSupport
10
+ # Thread-local stack of ordered-lock keys whose steps are currently
11
+ # running in this thread. Used to detect a synchronous `Reactor.run`
12
+ # nested under another ordered-lock reactor on the same key — which
13
+ # would deadlock since the outer holds the slot and the inner can never
14
+ # advance.
15
+ THREAD_LOCAL_ACTIVE_KEYS = :ruby_reactor_active_ordered_locks
16
+
17
+ # Minimum interval between liveness heartbeats; protects very small
18
+ # poison_pill_timeouts (mirrors Lock::MIN_EXTEND_INTERVAL).
19
+ HEARTBEAT_MIN_INTERVAL = 1.0
20
+
21
+ def self.active_keys
22
+ Thread.current[THREAD_LOCAL_ACTIVE_KEYS] ||= []
23
+ end
24
+
25
+ # Parse the ordered-lock stash from a context's private_data, surviving
26
+ # the JSON round-trip (symbol or string keys). Module-level so the
27
+ # Sidekiq worker can advance the nonce on escalation paths that never
28
+ # construct an Executor.
29
+ def self.info_from(context)
30
+ data = context.private_data[:ordered_lock] || context.private_data["ordered_lock"]
31
+ return nil unless data
32
+
33
+ strict_raw = data[:strict]
34
+ strict_raw = data["strict"] if strict_raw.nil?
35
+ strict_raw = true if strict_raw.nil?
36
+
37
+ {
38
+ key: data[:key] || data["key"],
39
+ nonce: (data[:nonce] || data["nonce"]).to_i,
40
+ epoch: (data[:epoch] || data["epoch"]).to_i,
41
+ poison_pill_timeout: (data[:poison_pill_timeout] || data["poison_pill_timeout"] ||
42
+ OrderedLock::DEFAULT_POISON_PILL_TIMEOUT).to_i,
43
+ ttl: (data[:ttl] || data["ttl"] || OrderedLock::DEFAULT_TTL).to_i,
44
+ strict: [true, "true"].include?(strict_raw)
45
+ }
46
+ end
47
+
48
+ # Strict-ordering gate. Runs BEFORE rate-limit / lock / semaphore so a
49
+ # waiting nonce never holds any other primitive — preventing
50
+ # hold-and-wait deadlocks when `with_lock` and `with_ordered_lock`
51
+ # share inputs. Raises {OrderedLock::WaitError}; the Sidekiq worker
52
+ # rescues and snoozes.
53
+ def check_ordered_lock_gate
54
+ info = ordered_lock_info
55
+ return :go unless info
56
+
57
+ OrderedLock.new(
58
+ info.fetch(:key),
59
+ nonce: info.fetch(:nonce),
60
+ epoch: info.fetch(:epoch),
61
+ poison_pill_timeout: info.fetch(:poison_pill_timeout),
62
+ strict: info.fetch(:strict)
63
+ ).check!
64
+ end
65
+
66
+ # Combined gate-check + thread-local push. Call at the top of
67
+ # `execute` / `resume_execution`. Pair with `leave_ordered_lock_scope`
68
+ # in `ensure`.
69
+ #
70
+ # The strict-mode chain-skip only fires on a *fresh* start (no step
71
+ # has run yet on this context). This lets an in-flight run that paused
72
+ # (Interrupt / AsyncResult) complete on resume regardless of chain
73
+ # failures that landed while it was parked, while still applying
74
+ # strict to a fresh Sidekiq job (which enters via `resume_execution`
75
+ # but has no prior step state).
76
+ def enter_ordered_lock_scope
77
+ gate = check_ordered_lock_gate
78
+ # A stale-batch run never participates regardless of fresh/resume state —
79
+ # its numbering belongs to a drained generation. Chain-skip stays gated
80
+ # on a fresh start so an in-flight paused run still completes on resume.
81
+ @ordered_lock_stale_batch = gate == :stale_batch
82
+ @ordered_lock_chain_skip = fresh_ordered_lock_start? && gate == :skip_chain_failed
83
+
84
+ # Drained-batch gate: the batch GC'd while this caller slept. A genuine
85
+ # late straggler runs (poison semantics); a Sidekiq redelivery of an
86
+ # ALREADY-terminal context must not re-execute its steps. Only the
87
+ # latter — confirmed by a terminal stored status — is short-circuited.
88
+ @ordered_lock_drained_replay = gate == :drained_go && stored_status_terminal?
89
+
90
+ info = ordered_lock_info
91
+ return unless info
92
+
93
+ OrderedLockSupport.active_keys << info[:key]
94
+
95
+ # Only a run that will actually execute steps needs a heartbeat. A
96
+ # short-circuiting run (stale batch / strict chain skip / drained
97
+ # redelivery) does no work and terminally advances immediately, so
98
+ # starting a thread for it is pointless churn.
99
+ return if @ordered_lock_stale_batch || @ordered_lock_chain_skip || @ordered_lock_drained_replay
100
+
101
+ start_ordered_lock_heartbeat(info)
102
+ end
103
+
104
+ def fresh_ordered_lock_start?
105
+ @context.intermediate_results.empty? && @context.current_step.nil?
106
+ end
107
+
108
+ def ordered_lock_chain_skip?
109
+ @ordered_lock_chain_skip == true
110
+ end
111
+
112
+ def ordered_lock_stale_batch?
113
+ @ordered_lock_stale_batch == true
114
+ end
115
+
116
+ def ordered_lock_drained_replay?
117
+ @ordered_lock_drained_replay == true
118
+ end
119
+
120
+ # Terminal Skipped result when the ordered-lock gate short-circuits this
121
+ # run (stale batch, strict chain failure, or a drained-batch redelivery of
122
+ # an already-terminal context), or nil to continue. Shared by `execute`
123
+ # and `resume_execution`.
124
+ def ordered_lock_short_circuit
125
+ return RubyReactor::Skipped.new(reason: :ordered_lock_stale_batch) if ordered_lock_stale_batch?
126
+ return RubyReactor::Skipped.new(reason: :ordered_lock_drained_replay) if ordered_lock_drained_replay?
127
+ return RubyReactor::Skipped.new(reason: :ordered_lock_chain_failed) if ordered_lock_chain_skip?
128
+
129
+ nil
130
+ end
131
+
132
+ # Pre-step short-circuit: ordered-lock gate skip or already-marked
133
+ # period bucket. Returns a terminal result or nil.
134
+ def short_circuit_result
135
+ ordered_lock_short_circuit || check_period_gate
136
+ end
137
+
138
+ def short_circuit!(result)
139
+ @result = result
140
+
141
+ # A stale-batch or drained-batch-redelivery skip means this run's epoch
142
+ # belongs to a drained generation — typically a slow straggler or a
143
+ # Sidekiq at-least-once redelivery. If the redelivery is of a job that
144
+ # ALREADY reached a terminal status, its stored context is the source of
145
+ # truth; writing :skipped over a :completed/:failed record would silently
146
+ # corrupt the outcome. Return the skip to the worker (so it stops)
147
+ # without saving. The `@skip_context_persist` flag also suppresses the
148
+ # ensure-block save in execute / resume_execution, which would otherwise
149
+ # clobber the stored terminal record with this run's stale in-memory
150
+ # status.
151
+ if redelivery_of_terminal?(result)
152
+ @skip_context_persist = true
153
+ return @result
154
+ end
155
+
156
+ update_context_status(@result)
157
+ save_context
158
+ @result
159
+ end
160
+
161
+ def skip_context_persist?
162
+ @skip_context_persist == true
163
+ end
164
+
165
+ # True when the skip is one of the drained-generation reasons (stale batch
166
+ # or drained-batch replay) AND the stored context already reached a
167
+ # terminal status — i.e. this is a redelivery of an already-finished run
168
+ # whose record must not be overwritten. The drained-replay flag is only
169
+ # set when the status was terminal, but re-checking keeps both paths
170
+ # uniform and self-guarding.
171
+ def redelivery_of_terminal?(result)
172
+ return false unless result.is_a?(RubyReactor::Skipped)
173
+ return false unless %i[ordered_lock_stale_batch ordered_lock_drained_replay].include?(result.reason)
174
+
175
+ stored_status_terminal?
176
+ end
177
+
178
+ def stored_status_terminal?
179
+ %w[completed failed skipped].include?(stored_context_status)
180
+ end
181
+
182
+ def stored_context_status
183
+ reactor_class_name = @reactor_class.name || "AnonymousReactor-#{@reactor_class.object_id}"
184
+ data = RubyReactor.configuration.storage_adapter.retrieve_context(@context.context_id, reactor_class_name)
185
+ return nil unless data
186
+
187
+ (data["status"] || data[:status]).to_s
188
+ rescue StandardError
189
+ nil
190
+ end
191
+
192
+ # Combined terminal-advance + thread-local pop. Idempotent: safe to call
193
+ # in `ensure` even if `enter_ordered_lock_scope` never pushed (gate
194
+ # raised, or no ordered_lock configured).
195
+ def leave_ordered_lock_scope
196
+ # Stop (and join) the heartbeat BEFORE advancing: the advance HDELs this
197
+ # nonce's assigned_at, and a heartbeat racing that HDEL could restamp it.
198
+ # The HEARTBEAT_SCRIPT's hexists guard makes a late restamp a harmless
199
+ # no-op, but joining first keeps the ordering deterministic.
200
+ stop_ordered_lock_heartbeat
201
+ advance_ordered_lock_if_terminal
202
+ info = ordered_lock_info
203
+ return unless info
204
+
205
+ stack = OrderedLockSupport.active_keys
206
+ idx = stack.rindex(info[:key])
207
+ stack.delete_at(idx) if idx
208
+ end
209
+
210
+ # Background thread that restamps this nonce's assigned_at every pp/3
211
+ # seconds (floored at HEARTBEAT_MIN_INTERVAL) while its steps run, so a
212
+ # legitimately slow blocker is not poison-passed by a successor. Mirrors
213
+ # the Lock auto-extend thread. The thread sleeps FIRST, so a job that
214
+ # finishes faster than one interval never touches Redis.
215
+ def start_ordered_lock_heartbeat(info)
216
+ return if @ordered_lock_heartbeat_running
217
+
218
+ pp = info[:poison_pill_timeout].to_f
219
+ interval = [pp / 3.0, HEARTBEAT_MIN_INTERVAL].max
220
+ @ordered_lock_heartbeat_running = true
221
+ lock = OrderedLock.new(
222
+ info.fetch(:key), nonce: info.fetch(:nonce), epoch: info.fetch(:epoch)
223
+ )
224
+
225
+ @ordered_lock_heartbeat = Thread.new do
226
+ while @ordered_lock_heartbeat_running
227
+ sleep interval
228
+ break unless @ordered_lock_heartbeat_running
229
+
230
+ begin
231
+ lock.heartbeat!
232
+ rescue StandardError => e
233
+ RubyReactor.configuration.logger.warn(
234
+ "RubyReactor ordered_lock heartbeat failed for '#{info[:key]}' " \
235
+ "nonce #{info[:nonce]}: #{e.message}"
236
+ )
237
+ break
238
+ end
239
+ end
240
+ end
241
+ end
242
+
243
+ def stop_ordered_lock_heartbeat
244
+ return unless @ordered_lock_heartbeat_running
245
+
246
+ @ordered_lock_heartbeat_running = false
247
+ thread = @ordered_lock_heartbeat
248
+ @ordered_lock_heartbeat = nil
249
+ return unless thread
250
+
251
+ thread.wakeup if thread.alive?
252
+ thread.join(0.1)
253
+ rescue StandardError
254
+ # Best-effort shutdown; never let heartbeat teardown break the ensure chain.
255
+ end
256
+
257
+ # Advance the cursor when this run reached a *terminal* status.
258
+ # Retry-queued, interrupted, or async-handed-off results keep the same
259
+ # nonce owning the slot — a Sidekiq retry must not double-advance. A
260
+ # terminal `Failure` is also recorded as the chain's poison marker
261
+ # (only the FIRST such failure sticks).
262
+ def advance_ordered_lock_if_terminal
263
+ info = ordered_lock_info
264
+ return unless info
265
+ return unless terminal_for_ordered_lock?(@result)
266
+
267
+ OrderedLockSupport.advance_with_retry(info, failed: @result.is_a?(RubyReactor::Failure))
268
+ end
269
+
270
+ # A missed advance on a terminal result stalls every successor for up to
271
+ # poison_pill_timeout with only a warn line as evidence, so one transient
272
+ # Redis blip is worth absorbing here before giving up.
273
+ def self.advance_with_retry(info, failed:)
274
+ attempts = 0
275
+ begin
276
+ attempts += 1
277
+ OrderedLock.new(
278
+ info.fetch(:key), nonce: info.fetch(:nonce), epoch: info.fetch(:epoch), ttl: info.fetch(:ttl)
279
+ ).advance!(failed: failed)
280
+ rescue StandardError => e
281
+ retry if attempts < 2
282
+
283
+ RubyReactor.configuration.logger.warn(
284
+ "RubyReactor failed to advance ordered_lock '#{info[:key]}' nonce #{info[:nonce]} " \
285
+ "after #{attempts} attempts: #{e.message} — successors will stall until " \
286
+ "poison_pill_timeout (#{info[:poison_pill_timeout]}s) expires"
287
+ )
288
+ end
289
+ end
290
+
291
+ private
292
+
293
+ def ordered_lock_info
294
+ OrderedLockSupport.info_from(@context)
295
+ end
296
+
297
+ def terminal_for_ordered_lock?(result)
298
+ case result
299
+ when RubyReactor::AsyncResult, RubyReactor::InterruptResult, RetryQueuedResult
300
+ false
301
+ when RubyReactor::Success, RubyReactor::Failure
302
+ true
303
+ end
304
+ end
305
+ end
306
+ end
307
+ end
@@ -33,8 +33,11 @@ module RubyReactor
33
33
  when Error::StepFailureError
34
34
  handle_step_failure_error(error)
35
35
  when Error::InputValidationError
36
- # Preserve validation errors as-is for proper error handling
37
- RubyReactor.Failure(error, validation_errors: error.field_errors)
36
+ # Unified validation failure shape (inputs, step args, step output).
37
+ # Roll back any completed steps so saga semantics hold for mid-reactor
38
+ # validation failures (a no-op for input validation at reactor start).
39
+ @compensation_manager.rollback_completed_steps
40
+ build_validation_failure(error)
38
41
  when Error::Base
39
42
  # Other errors need rollback
40
43
  @compensation_manager.rollback_completed_steps
@@ -56,6 +59,25 @@ module RubyReactor
56
59
 
57
60
  private
58
61
 
62
+ # Failure for a validation error (reactor inputs, step arguments, or
63
+ # step output), carrying both the structured field errors and the step/
64
+ # reactor attribution stamped at the raise site (nil step_name for
65
+ # reactor-level input failures).
66
+ def build_validation_failure(error)
67
+ redact_inputs = []
68
+ redact_inputs = @context.reactor_class.inputs.select { |_, c| c[:redact] }.keys if @context.reactor_class
69
+
70
+ RubyReactor.Failure(
71
+ error,
72
+ validation_errors: error.field_errors,
73
+ step_name: error.step_name,
74
+ step_arguments: error.step_arguments || {},
75
+ inputs: @context.inputs,
76
+ redact_inputs: redact_inputs,
77
+ reactor_name: @context.reactor_class&.name
78
+ )
79
+ end
80
+
59
81
  # A step returned `RubyReactor.Skipped(...)`. Halt cleanly: record the
60
82
  # event in the trace, do NOT push to the undo stack (so existing
61
83
  # completed steps stay as-is — no compensation), and stamp the step
@@ -180,12 +202,17 @@ module RubyReactor
180
202
  output_validation_result = step_config.output_validator.call(value)
181
203
  return if output_validation_result.success?
182
204
 
183
- raise Error::StepFailureError.new(
184
- "Step '#{step_config.name}' output validation failed: #{output_validation_result.error.message}",
185
- step: step_config.name,
186
- context: @context,
187
- step_arguments: resolved_arguments
188
- )
205
+ error = output_validation_result.error
206
+ error.step_name = step_config.name
207
+ error.step_arguments = resolved_arguments
208
+
209
+ # The step DID run — its side effect exists even though its output is
210
+ # invalid. Treat it like a step failure: run the step's own
211
+ # compensation and roll back prior steps, so the side effect is not
212
+ # orphaned. Then surface the structured validation error (the later
213
+ # rollback in handle_execution_error is a no-op — stack already clear).
214
+ @compensation_manager.handle_step_failure(step_config, error, resolved_arguments)
215
+ raise error
189
216
  end
190
217
 
191
218
  def extract_location(backtrace)
@@ -128,6 +128,10 @@ module RubyReactor
128
128
  def safe_execute_step_sync(step_config, resolved_arguments = nil)
129
129
  resolved_arguments ||= resolve_arguments(step_config)
130
130
  execute_step_sync_without_result_handling(step_config, resolved_arguments)
131
+ rescue Error::InputValidationError
132
+ # Validation failures are not retryable and must surface as a structured
133
+ # InputValidationError (with field_errors), so let them propagate.
134
+ raise
131
135
  rescue StandardError => e
132
136
  # Identify redacted inputs
133
137
  redact_inputs = @reactor_class.inputs.select { |_, config| config[:redact] }.keys
@@ -240,11 +244,12 @@ module RubyReactor
240
244
  validation_result = step_config.args_validator.call(resolved_arguments)
241
245
  return if validation_result.success?
242
246
 
243
- raise Error::StepFailureError.new(
244
- "Step '#{step_config.name}' argument validation failed: #{validation_result.error.message}",
245
- step: step_config.name,
246
- context: @context
247
- )
247
+ # Stamp step attribution so the resulting Failure can say WHERE the
248
+ # validation failed, not just what was invalid.
249
+ error = validation_result.error
250
+ error.step_name = step_config.name
251
+ error.step_arguments = resolved_arguments
252
+ raise error
248
253
  end
249
254
 
250
255
  def resolve_arguments(step_config)