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,22 +3,49 @@
3
3
  module RubyReactor
4
4
  class Executor
5
5
  class CompensationManager
6
+ # Raised ONLY by the step's own coordination, before its body: a
7
+ # coordination error raised from inside a body (a nested direct
8
+ # `Step.run`) arrives as `StepCoordination::NestedCoordinationError`, and
9
+ # a bare `Lock::AcquisitionError` etc. may come from a nested
10
+ # `Reactor.run` — both mean the body ran, so neither is listed here.
11
+ NEVER_STARTED_ERROR_CLASSES = [
12
+ RubyReactor::Executor::StepCoordination::Contended,
13
+ RubyReactor::Executor::StepCoordination::KeyError,
14
+ RubyReactor::Executor::StepCoordination::DispatchRefused
15
+ ].freeze
16
+
6
17
  def initialize(context)
7
18
  @context = context
8
19
  @undo_trace = []
20
+ @rollback_failures = []
9
21
  end
10
22
 
11
23
  def undo_stack
12
24
  @context.undo_stack
13
25
  end
14
26
 
15
- attr_reader :undo_trace
27
+ # Every undo/compensation that did not complete, in rollback order
28
+ # (005 FR-004). `ResultHandler` attaches it to the final Failure.
29
+ attr_reader :undo_trace, :rollback_failures
16
30
 
17
31
  def add_to_undo_stack(step_info)
18
32
  @context.undo_stack << step_info
19
33
  end
20
34
 
21
35
  def handle_step_failure(step_config, error, arguments)
36
+ # A step whose OWN coordination acquisition failed (contention, or a
37
+ # bad key proc) never ran its body — "no step compensates, the
38
+ # contended step's work has not been attempted" (US3-1/T018), which
39
+ # applies here exactly as it does to a worker park: compensating a
40
+ # step that never started is meaningless, and attempting one would
41
+ # try to re-acquire the very key that is (usually) still contended,
42
+ # turning a plain contention failure into a confusing
43
+ # CompensationError. Prior steps still roll back normally.
44
+ if step_never_started?(error)
45
+ rollback_completed_steps
46
+ return RubyReactor.Failure("Step '#{step_config.name}' failed: #{error}")
47
+ end
48
+
22
49
  # Try compensation
23
50
  compensation_result = compensate_step(step_config, error, arguments)
24
51
  case compensation_result
@@ -70,16 +97,38 @@ module RubyReactor
70
97
  result.respond_to?(:skipped?) && result.skipped?
71
98
  end
72
99
 
100
+ def step_never_started?(error)
101
+ NEVER_STARTED_ERROR_CLASSES.any? { |klass| error.is_a?(klass) }
102
+ end
103
+
104
+ # US6/T047: re-take a step's own lock/semaphore around its compensate
105
+ # or undo body, so a concurrent forward execution cannot enter the
106
+ # step's critical section while rollback is undoing what it protected.
107
+ # A no-op when the step declares no coordination.
108
+ def coordinated_rollback(step_config, arguments, &block)
109
+ return block.call if Executor::StepCoordination.none?(step_config)
110
+
111
+ # The undo stack stores the RESOLVED arguments; the forward key was
112
+ # computed from `coordination_arguments` of them, so rollback does the
113
+ # same and re-takes the very key the forward run held.
114
+ Executor::StepCoordination.new(
115
+ step_config: step_config, arguments: step_config.coordination_arguments(arguments, @context.inputs),
116
+ context: @context, reactor_class: @context.reactor_class, middlewares: middlewares
117
+ ).around_rollback(&block)
118
+ end
119
+
73
120
  def compensate_step(step_config, error, arguments)
74
121
  middlewares.on(:start_compensation, step_config.name, error, arguments, @context)
75
122
  begin
76
- compensate_result = catch(StepSignals::TAG) do
77
- if step_config.compensate_block
78
- step_config.compensate_block.call(error, arguments, @context)
79
- elsif step_config.has_impl?
80
- step_config.impl.compensate(error, arguments, @context)
81
- else
82
- RubyReactor.Skipped() # Default: nothing defined, rollback continues
123
+ compensate_result = coordinated_rollback(step_config, arguments) do
124
+ catch(StepSignals::TAG) do
125
+ if step_config.compensate_block
126
+ step_config.compensate_block.call(error, arguments, @context)
127
+ elsif step_config.has_impl?
128
+ step_config.impl.compensate(error, arguments, @context)
129
+ else
130
+ RubyReactor.Skipped() # Default: nothing defined, rollback continues
131
+ end
83
132
  end
84
133
  end
85
134
 
@@ -96,6 +145,7 @@ module RubyReactor
96
145
  @undo_trace << { type: :compensation, step: step_config.name, error: error, arguments: arguments }
97
146
 
98
147
  if compensate_result.is_a?(RubyReactor::Failure)
148
+ record_rollback_failure(step_config.name, :compensate, compensate_result)
99
149
  middlewares.on(:failed_compensation, step_config.name, compensate_result, @context)
100
150
  else
101
151
  middlewares.on(:complete_compensation, step_config.name, compensate_result, @context)
@@ -103,21 +153,27 @@ module RubyReactor
103
153
 
104
154
  compensate_result
105
155
  rescue StandardError => e
156
+ record_rollback_failure(step_config.name, :compensate, e)
106
157
  middlewares.on(:failed_compensation, step_config.name, e, @context)
107
- raise e
158
+ # A raise is a compensation failure like a returned Failure: the
159
+ # caller still rolls back the completed steps, then raises
160
+ # `CompensationError`. Re-raising here skipped both.
161
+ RubyReactor.Failure(e)
108
162
  end
109
163
  end
110
164
 
111
- def undo_step(step_config, result, arguments)
165
+ def undo_step(step_config, result, arguments) # rubocop:disable Metrics/MethodLength
112
166
  middlewares.on(:start_undo, step_config.name, result, arguments, @context)
113
167
  begin
114
- undo_result = catch(StepSignals::TAG) do
115
- if step_config.undo_block
116
- step_config.undo_block.call(result.value, arguments, @context)
117
- elsif step_config.has_impl?
118
- step_config.impl.undo(result.value, arguments, @context)
119
- else
120
- RubyReactor.Skipped() # Default: nothing defined, rollback continues
168
+ undo_result = coordinated_rollback(step_config, arguments) do
169
+ catch(StepSignals::TAG) do
170
+ if step_config.undo_block
171
+ step_config.undo_block.call(result.value, arguments, @context)
172
+ elsif step_config.has_impl?
173
+ step_config.impl.undo(result.value, arguments, @context)
174
+ else
175
+ RubyReactor.Skipped() # Default: nothing defined, rollback continues
176
+ end
121
177
  end
122
178
  end
123
179
 
@@ -133,6 +189,7 @@ module RubyReactor
133
189
  )
134
190
 
135
191
  if undo_result.is_a?(RubyReactor::Failure)
192
+ record_rollback_failure(step_config.name, :undo, undo_result)
136
193
  middlewares.on(:failed_undo, step_config.name, undo_result, @context)
137
194
  else
138
195
  middlewares.on(:complete_undo, step_config.name, undo_result, @context)
@@ -140,6 +197,7 @@ module RubyReactor
140
197
 
141
198
  undo_result
142
199
  rescue StandardError => e
200
+ record_rollback_failure(step_config.name, :undo, e)
143
201
  middlewares.on(:failed_undo, step_config.name, e, @context)
144
202
  # Log undo failure but don't halt the rollback process
145
203
  @context.append_execution_trace(
@@ -148,6 +206,30 @@ module RubyReactor
148
206
  RubyReactor.Failure(e)
149
207
  end
150
208
  end
209
+
210
+ # `outcome` is the Failure an undo/compensate returned, or the exception
211
+ # it raised. A composed child's Failure already carries its own list —
212
+ # flatten it instead of adding one opaque entry for the compose step.
213
+ def record_rollback_failure(step_name, kind, outcome)
214
+ if outcome.is_a?(RubyReactor::Failure) && outcome.rollback_failures.any?
215
+ @rollback_failures.concat(outcome.rollback_failures)
216
+ return
217
+ end
218
+
219
+ error = outcome.is_a?(RubyReactor::Failure) ? outcome.error : outcome
220
+ contended = error.is_a?(StepCoordination::Contended)
221
+ reason = if contended
222
+ :coordination_unavailable
223
+ elsif outcome.is_a?(Exception)
224
+ :raised
225
+ else
226
+ :returned_failure
227
+ end
228
+ @rollback_failures << {
229
+ step: step_name.to_sym, kind: kind, key: (error.key if contended), reason: reason,
230
+ message: error.respond_to?(:message) ? error.message : error.to_s
231
+ }
232
+ end
151
233
  end
152
234
  end
153
235
  end
@@ -45,27 +45,39 @@ module RubyReactor
45
45
  }
46
46
  end
47
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)
48
+ # THE strict-ordering gate classifier, shared by the reactor level
49
+ # (`enter_ordered_lock_scope`) and the step level
50
+ # (`StepCoordination#ordered_lock_gate`), so a gate state one level
51
+ # handles cannot fall through to "run" at the other (005 R-06, F7).
52
+ # Exhaustive: a state nobody mapped raises instead of running.
53
+ #
54
+ # :go — proceed (includes a poison advance past a dead blocker)
55
+ # :skip_chain — strict chain already failed, and this run is `fresh`
56
+ # (an in-flight run that paused completes regardless)
57
+ # :stale — the position belongs to a drained, reused generation
58
+ # :drained — the batch drained and GC'd while this caller slept
59
+ #
60
+ # Raises {OrderedLock::WaitError} when it is not this nonce's turn.
61
+ def self.gate(info, fresh:)
62
+ state = OrderedLock.new(
63
+ info.fetch(:key), nonce: info.fetch(:nonce), epoch: info.fetch(:epoch),
64
+ poison_pill_timeout: info.fetch(:poison_pill_timeout), strict: info.fetch(:strict)
63
65
  ).check!
66
+
67
+ case state
68
+ when :go then :go
69
+ when :skip_chain_failed then fresh ? :skip_chain : :go
70
+ when :stale_batch then :stale
71
+ when :drained_go then :drained
72
+ else raise ArgumentError, "unhandled ordered-lock gate state #{state.inspect}"
73
+ end
64
74
  end
65
75
 
66
76
  # Combined gate-check + thread-local push. Call at the top of
67
77
  # `execute` / `resume_execution`. Pair with `leave_ordered_lock_scope`
68
- # in `ensure`.
78
+ # in `ensure`. The gate runs BEFORE rate-limit / lock / semaphore so a
79
+ # waiting nonce never holds any other primitive; a `WaitError` goes to
80
+ # the worker, which snoozes.
69
81
  #
70
82
  # The strict-mode chain-skip only fires on a *fresh* start (no step
71
83
  # has run yet on this context). This lets an in-flight run that paused
@@ -74,20 +86,19 @@ module RubyReactor
74
86
  # strict to a fresh Sidekiq job (which enters via `resume_execution`
75
87
  # but has no prior step state).
76
88
  def enter_ordered_lock_scope
77
- gate = check_ordered_lock_gate
89
+ info = ordered_lock_info
90
+ outcome = info ? OrderedLockSupport.gate(info, fresh: fresh_ordered_lock_start?) : :go
78
91
  # 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
92
+ # its numbering belongs to a drained generation.
93
+ @ordered_lock_stale_batch = outcome == :stale
94
+ @ordered_lock_chain_skip = outcome == :skip_chain
83
95
 
84
96
  # Drained-batch gate: the batch GC'd while this caller slept. A genuine
85
97
  # late straggler runs (poison semantics); a Sidekiq redelivery of an
86
98
  # ALREADY-terminal context must not re-execute its steps. Only the
87
99
  # latter — confirmed by a terminal stored status — is short-circuited.
88
- @ordered_lock_drained_replay = gate == :drained_go && stored_status_terminal?
100
+ @ordered_lock_drained_replay = outcome == :drained && stored_status_terminal?
89
101
 
90
- info = ordered_lock_info
91
102
  return unless info
92
103
 
93
104
  OrderedLockSupport.active_keys << info[:key]
@@ -101,8 +112,9 @@ module RubyReactor
101
112
  start_ordered_lock_heartbeat(info)
102
113
  end
103
114
 
115
+ # Same predicate as `Executor#first_execution?` (005 R-03).
104
116
  def fresh_ordered_lock_start?
105
- @context.intermediate_results.empty? && @context.current_step.nil?
117
+ first_execution?
106
118
  end
107
119
 
108
120
  def ordered_lock_chain_skip?
@@ -215,17 +227,49 @@ module RubyReactor
215
227
  def start_ordered_lock_heartbeat(info)
216
228
  return if @ordered_lock_heartbeat_running
217
229
 
230
+ @ordered_lock_heartbeat_running = true
231
+ @ordered_lock_heartbeat = OrderedLockSupport.start_heartbeat(info)
232
+ end
233
+
234
+ def stop_ordered_lock_heartbeat
235
+ return unless @ordered_lock_heartbeat_running
236
+
237
+ @ordered_lock_heartbeat_running = false
238
+ @ordered_lock_heartbeat&.stop
239
+ @ordered_lock_heartbeat = nil
240
+ end
241
+
242
+ # Value object so a caller can `heartbeat.stop` without reaching into
243
+ # the thread/flag it wraps. Returned by `.start_heartbeat`.
244
+ class Heartbeat
245
+ def initialize(thread, running)
246
+ @thread = thread
247
+ @running = running
248
+ end
249
+
250
+ def stop
251
+ @running[0] = false
252
+ @thread.wakeup if @thread.alive?
253
+ @thread.join(0.1)
254
+ rescue StandardError
255
+ # Best-effort shutdown; never let heartbeat teardown break the caller's ensure chain.
256
+ end
257
+ end
258
+
259
+ # Shared by the reactor-level heartbeat above (`#start_ordered_lock_heartbeat`)
260
+ # and the step-level gate (`StepCoordination#ordered_lock_gate`, T061) — the
261
+ # restamp logic and interval math are identical, only the caller's own
262
+ # on/off bookkeeping differs.
263
+ def self.start_heartbeat(info)
218
264
  pp = info[:poison_pill_timeout].to_f
219
265
  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
- )
266
+ running = [true]
267
+ lock = OrderedLock.new(info.fetch(:key), nonce: info.fetch(:nonce), epoch: info.fetch(:epoch))
224
268
 
225
- @ordered_lock_heartbeat = Thread.new do
226
- while @ordered_lock_heartbeat_running
269
+ thread = Thread.new do
270
+ while running[0]
227
271
  sleep interval
228
- break unless @ordered_lock_heartbeat_running
272
+ break unless running[0]
229
273
 
230
274
  begin
231
275
  lock.heartbeat!
@@ -238,20 +282,8 @@ module RubyReactor
238
282
  end
239
283
  end
240
284
  end
241
- end
242
285
 
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.
286
+ Heartbeat.new(thread, running)
255
287
  end
256
288
 
257
289
  # Advance the cursor when this run reached a *terminal* status.
@@ -32,7 +32,26 @@ module RubyReactor
32
32
  end
33
33
  end
34
34
 
35
+ # Every reactor-level Failure that follows a rollback is built here, so
36
+ # this is the one place `rollback_failures` is attached (005 R-08).
35
37
  def handle_execution_error(error)
38
+ failure = build_execution_failure(error)
39
+ failure.rollback_failures.concat(@compensation_manager.rollback_failures) if failure.is_a?(RubyReactor::Failure)
40
+ failure
41
+ end
42
+
43
+ def final_result(reactor_class)
44
+ if reactor_class.return_step
45
+ result_value = @context.get_result(reactor_class.return_step)
46
+ RubyReactor.Success(result_value)
47
+ else
48
+ RubyReactor.Success(@context.intermediate_results)
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def build_execution_failure(error)
36
55
  case error
37
56
  when Error::StepFailureError
38
57
  handle_step_failure_error(error)
@@ -52,17 +71,6 @@ module RubyReactor
52
71
  end
53
72
  end
54
73
 
55
- def final_result(reactor_class)
56
- if reactor_class.return_step
57
- result_value = @context.get_result(reactor_class.return_step)
58
- RubyReactor.Success(result_value)
59
- else
60
- RubyReactor.Success(@context.intermediate_results)
61
- end
62
- end
63
-
64
- private
65
-
66
74
  # Failure for a validation error (reactor inputs, step arguments, or
67
75
  # step output), carrying both the structured field errors and the step/
68
76
  # reactor attribution stamped at the raise site (nil step_name for
@@ -138,7 +146,16 @@ module RubyReactor
138
146
  step_config.respond_to?(:async_dispatch?) && step_config.async_dispatch?
139
147
  end
140
148
 
149
+ # A composed child's Failure carries the child's own rollback failures;
150
+ # fold them in BEFORE this level rolls back, so they come first.
151
+ def adopt_rollback_failures(result)
152
+ return unless result.respond_to?(:rollback_failures)
153
+
154
+ @compensation_manager.rollback_failures.concat(result.rollback_failures)
155
+ end
156
+
141
157
  def handle_retries_exhausted(step_config, result, resolved_arguments)
158
+ adopt_rollback_failures(result)
142
159
  @compensation_manager.handle_step_failure(step_config, result.original_error, resolved_arguments)
143
160
  orig_err = result.original_error.is_a?(Exception) ? result.original_error : nil
144
161
  error = Error::StepFailureError.new(result.error, step: step_config.name, context: @context,
@@ -154,6 +171,7 @@ module RubyReactor
154
171
  end
155
172
 
156
173
  def handle_failure(step_config, result, resolved_arguments)
174
+ adopt_rollback_failures(result)
157
175
  failure_result = @compensation_manager.handle_step_failure(step_config, result.error, resolved_arguments)
158
176
  orig_err = result.error.is_a?(Exception) ? result.error : nil
159
177
  # A step that propagates another unit's validation failure (an
@@ -233,6 +251,8 @@ module RubyReactor
233
251
  end
234
252
 
235
253
  def resolve_exception_class(original_error, error)
254
+ # A step's own contention is reported by its cause (Lock::AcquisitionError, ...).
255
+ original_error = original_error.original if original_error.is_a?(StepCoordination::Contended)
236
256
  return original_error.class.name if original_error
237
257
 
238
258
  error.respond_to?(:exception_class) ? error.exception_class : nil
@@ -38,6 +38,13 @@ module RubyReactor
38
38
  @context.current_step = step_config.name
39
39
  delay = calculate_backoff_delay(step_config, error, reactor_class)
40
40
 
41
+ requeue_job(step_config, delay)
42
+ end
43
+
44
+ # The requeue for a failure retry, given an already-decided backoff
45
+ # delay. (A contention park never comes through here: it raises
46
+ # `Error::StepContentionPark` and is requeued by the worker, 005 R-01.)
47
+ def requeue_job(_step_config, delay)
41
48
  # Serialize context and requeue the job
42
49
  # Use root context if available to ensure we serialize the full tree
43
50
  # BUT for map elements (which have map_metadata), we must serialize the element context itself
@@ -186,7 +193,8 @@ module RubyReactor
186
193
  end,
187
194
  reactor_name: reactor_class.name,
188
195
  step_arguments: result.respond_to?(:step_arguments) ? result.step_arguments : {},
189
- validation_errors: result.validation_errors
196
+ validation_errors: result.validation_errors,
197
+ rollback_failures: result.rollback_failures
190
198
  )
191
199
  end
192
200