ruby_reactor 0.8.4 → 0.8.6

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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/.specify/memory/constitution.md +17 -14
  5. data/CHANGELOG.md +254 -0
  6. data/CLAUDE.md +1 -1
  7. data/README.md +66 -15
  8. data/lib/ruby_reactor/adapters/active_job/map_element_rollback_worker.rb +20 -0
  9. data/lib/ruby_reactor/adapters/active_job/router.rb +32 -4
  10. data/lib/ruby_reactor/adapters/sidekiq/map_element_rollback_worker.rb +16 -0
  11. data/lib/ruby_reactor/adapters/sidekiq/router.rb +32 -4
  12. data/lib/ruby_reactor/context.rb +14 -1
  13. data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
  14. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
  15. data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
  16. data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
  17. data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
  18. data/lib/ruby_reactor/dsl/map_builder.rb +27 -9
  19. data/lib/ruby_reactor/dsl/step_builder.rb +93 -34
  20. data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
  21. data/lib/ruby_reactor/error/rescuable.rb +33 -0
  22. data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
  23. data/lib/ruby_reactor/executor/compensation_manager.rb +63 -35
  24. data/lib/ruby_reactor/executor/result_handler.rb +85 -63
  25. data/lib/ruby_reactor/executor/retry_manager.rb +4 -3
  26. data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
  27. data/lib/ruby_reactor/executor/step_executor.rb +61 -49
  28. data/lib/ruby_reactor/executor.rb +221 -7
  29. data/lib/ruby_reactor/map/collector.rb +31 -89
  30. data/lib/ruby_reactor/map/dispatcher.rb +65 -8
  31. data/lib/ruby_reactor/map/element_executor.rb +21 -11
  32. data/lib/ruby_reactor/map/element_rollback.rb +158 -0
  33. data/lib/ruby_reactor/map/helpers.rb +11 -125
  34. data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
  35. data/lib/ruby_reactor/map/step_rollback.rb +136 -0
  36. data/lib/ruby_reactor/map/sweeper.rb +66 -8
  37. data/lib/ruby_reactor/map.rb +17 -0
  38. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
  39. data/lib/ruby_reactor/reactor.rb +65 -2
  40. data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
  41. data/lib/ruby_reactor/rspec/matchers.rb +59 -24
  42. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
  43. data/lib/ruby_reactor/rspec/test_subject.rb +13 -3
  44. data/lib/ruby_reactor/rspec.rb +3 -6
  45. data/lib/ruby_reactor/step/compose_step.rb +10 -1
  46. data/lib/ruby_reactor/step/map_step.rb +144 -36
  47. data/lib/ruby_reactor/step.rb +7 -0
  48. data/lib/ruby_reactor/step_worker.rb +46 -22
  49. data/lib/ruby_reactor/storage/adapter.rb +18 -0
  50. data/lib/ruby_reactor/storage/redis_adapter.rb +27 -2
  51. data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
  52. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  53. data/lib/ruby_reactor/sweeper.rb +3 -1
  54. data/lib/ruby_reactor/version.rb +1 -1
  55. data/lib/ruby_reactor/web/api.rb +11 -1
  56. data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-C6ejszCQ.js} +12 -12
  57. data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
  58. data/lib/ruby_reactor/web/public/index.html +2 -2
  59. data/lib/ruby_reactor/worker.rb +7 -3
  60. data/lib/ruby_reactor.rb +17 -6
  61. data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
  62. data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
  63. data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
  64. data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
  65. data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
  66. data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
  67. data/specs/007-execution-flow-analysis/data-model.md +83 -0
  68. data/specs/007-execution-flow-analysis/evidence/harness.rb +231 -0
  69. data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
  70. data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
  71. data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
  72. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +235 -0
  73. data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
  74. data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
  75. data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
  76. data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
  77. data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
  78. data/specs/007-execution-flow-analysis/plan.md +127 -0
  79. data/specs/007-execution-flow-analysis/quickstart.md +51 -0
  80. data/specs/007-execution-flow-analysis/research.md +202 -0
  81. data/specs/007-execution-flow-analysis/spec.md +270 -0
  82. data/specs/007-execution-flow-analysis/tasks.md +257 -0
  83. data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
  84. data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
  85. data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
  86. data/specs/008-rollback-reliability/data-model.md +139 -0
  87. data/specs/008-rollback-reliability/plan.md +233 -0
  88. data/specs/008-rollback-reliability/quickstart.md +105 -0
  89. data/specs/008-rollback-reliability/research.md +653 -0
  90. data/specs/008-rollback-reliability/spec.md +561 -0
  91. data/specs/008-rollback-reliability/tasks.md +1110 -0
  92. data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
  93. data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
  94. data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
  95. data/specs/009-distributed-map-undo/data-model.md +151 -0
  96. data/specs/009-distributed-map-undo/plan.md +323 -0
  97. data/specs/009-distributed-map-undo/pr-description.md +78 -0
  98. data/specs/009-distributed-map-undo/quickstart.md +154 -0
  99. data/specs/009-distributed-map-undo/research.md +585 -0
  100. data/specs/009-distributed-map-undo/spec.md +360 -0
  101. data/specs/009-distributed-map-undo/tasks.md +1060 -0
  102. data/specs/future_improvements.md +64 -28
  103. metadata +54 -3
  104. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +0 -1
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Error
5
+ # A control signal, never a failure (009 R-04): a fan-out map started its
6
+ # distributed rollback and must wait for its element jobs. Raised from
7
+ # `MapStep#compensate` / `#undo`, it unwinds the rollback without popping
8
+ # the map's undo entry (the resume cursor), passes every `rescue
9
+ # Error::Rescuable` (see `Rescuable.===`), and is rescued only by
10
+ # `Executor` and `Reactor#undo`, which record where the rollback stands,
11
+ # save, and hand the run off as `rolling_back`.
12
+ #
13
+ # `failure` carries the Failure the run will end with, minus its rollback
14
+ # failures, set by the `ResultHandler` it passes through and consumed by
15
+ # the executor of that level.
16
+ class RollbackHandedOff < Base
17
+ attr_reader :map_id, :reactor_class_name
18
+ attr_accessor :failure
19
+
20
+ def initialize(map_id:, reactor_class_name: nil, message: "rollback handed off at map #{map_id}")
21
+ super(message)
22
+ @map_id = map_id
23
+ @reactor_class_name = reactor_class_name
24
+ end
25
+ end
26
+ end
27
+ end
@@ -3,15 +3,17 @@
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
6
+ # Raised ONLY before the step's body: by its own coordination, or while
7
+ # resolving its arguments (008 R-06).
8
+ # A coordination error raised from inside a body (a nested direct
8
9
  # `Step.run`) arrives as `StepCoordination::NestedCoordinationError`, and
9
10
  # a bare `Lock::AcquisitionError` etc. may come from a nested
10
11
  # `Reactor.run` — both mean the body ran, so neither is listed here.
11
12
  NEVER_STARTED_ERROR_CLASSES = [
12
13
  RubyReactor::Executor::StepCoordination::Contended,
13
14
  RubyReactor::Executor::StepCoordination::KeyError,
14
- RubyReactor::Executor::StepCoordination::DispatchRefused
15
+ RubyReactor::Executor::StepCoordination::DispatchRefused,
16
+ RubyReactor::Error::ArgumentResolutionError
15
17
  ].freeze
16
18
 
17
19
  def initialize(context)
@@ -28,11 +30,23 @@ module RubyReactor
28
30
  # (005 FR-004). `ResultHandler` attaches it to the final Failure.
29
31
  attr_reader :undo_trace, :rollback_failures
30
32
 
33
+ # The step failure being rolled back: `{ step:, error:, compensated:,
34
+ # compensation_error: }`. A rollback that hands off (009 R-04) records it,
35
+ # so the resume knows whether that step's own compensate still runs.
36
+ attr_reader :pending
37
+
38
+ # Rollback failures collected before a hand-off, saved serialized on
39
+ # the context's `rollback` and restored on resume.
40
+ def restore_rollback_failures(list)
41
+ @rollback_failures.replace(Array(ContextSerializer.deserialize_value(list)))
42
+ end
43
+
31
44
  def add_to_undo_stack(step_info)
32
45
  @context.undo_stack << step_info
33
46
  end
34
47
 
35
48
  def handle_step_failure(step_config, error, arguments)
49
+ @pending = { step: step_config.name, error: error, compensated: false }
36
50
  # A step whose OWN coordination acquisition failed (contention, or a
37
51
  # bad key proc) never ran its body — "no step compensates, the
38
52
  # contended step's work has not been attempted" (US3-1/T018), which
@@ -42,38 +56,61 @@ module RubyReactor
42
56
  # turning a plain contention failure into a confusing
43
57
  # CompensationError. Prior steps still roll back normally.
44
58
  if step_never_started?(error)
59
+ @pending[:compensated] = true
45
60
  rollback_completed_steps
46
- return RubyReactor.Failure("Step '#{step_config.name}' failed: #{error}")
61
+ return RubyReactor.Failure(step_failure_message(step_config, error))
47
62
  end
48
63
 
49
64
  # Try compensation
50
65
  compensation_result = compensate_step(step_config, error, arguments)
66
+ @pending[:compensated] = true
67
+ @pending[:compensation_error] = compensation_error_message(step_config, compensation_result)
51
68
  case compensation_result
52
69
  when RubyReactor::Success
53
70
  # Compensation succeeded, continue with rollback
54
71
  rollback_completed_steps
55
- RubyReactor.Failure("Step '#{step_config.name}' failed: #{error}")
72
+ RubyReactor.Failure(step_failure_message(step_config, error))
56
73
  when RubyReactor::Failure
57
74
  # Compensation failed, this is more serious
58
75
  rollback_completed_steps
59
- raise Error::CompensationError.new(
60
- "Compensation for step '#{step_config.name}' failed: #{compensation_result.error}",
61
- step: step_config.name,
62
- context: @context,
63
- original_error: error
64
- )
76
+ raise Error::CompensationError.new(@pending[:compensation_error], step: step_config.name, context: @context,
77
+ original_error: error)
65
78
  end
66
79
  end
67
80
 
68
- def rollback_completed_steps
69
- undo_stack.reverse_each do |step_info|
81
+ def step_failure_message(step_config, error)
82
+ "Step '#{step_config.name}' failed: #{error}"
83
+ end
84
+
85
+ def compensation_error_message(step_config, compensation_result)
86
+ return unless compensation_result.is_a?(RubyReactor::Failure)
87
+
88
+ "Compensation for step '#{step_config.name}' failed: #{compensation_result.error}"
89
+ end
90
+
91
+ # A unit that compensates itself outside the executor loop (StepWorker's
92
+ # `async_step`): the same coordination re-take, trace, middleware events
93
+ # and `rollback_failures` as a step compensated here.
94
+ def compensate(step_config, error, arguments)
95
+ compensate_step(step_config, error, arguments)
96
+ end
97
+
98
+ # Newest first. Each entry leaves the stack only once its undo returned,
99
+ # so an interruption mid-rollback leaves exactly the entries still to
100
+ # undo — the interrupted one included — for a manual undo (008 R-16).
101
+ # `after_pop` runs after each pop: an element rollback job saves there,
102
+ # so a redelivery resumes after the last undone entry (009 R-07).
103
+ def rollback_completed_steps(&after_pop)
104
+ until undo_stack.empty?
105
+ step_info = undo_stack.last
70
106
  result = @context.with_step(step_info[:step].name) do
71
107
  undo_step(step_info[:step], step_info[:result], step_info[:arguments])
72
108
  end
109
+ undo_stack.pop
73
110
  @undo_trace << { type: :undo, step: step_info[:step].name, result: result,
74
111
  arguments: step_info[:arguments] }
112
+ after_pop&.call
75
113
  end
76
- undo_stack.clear
77
114
  end
78
115
 
79
116
  private
@@ -117,19 +154,18 @@ module RubyReactor
117
154
  ).around_rollback(&block)
118
155
  end
119
156
 
157
+ # Under `with_step`, as `rollback_completed_steps` runs each undo: a
158
+ # construct (compose, map) reads `context.current_step` to find its own
159
+ # state during either rollback moment.
120
160
  def compensate_step(step_config, error, arguments)
161
+ @context.with_step(step_config.name) { compensate_step_body(step_config, error, arguments) }
162
+ end
163
+
164
+ def compensate_step_body(step_config, error, arguments)
121
165
  middlewares.on(:start_compensation, step_config.name, error, arguments, @context)
122
166
  begin
123
167
  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, step_config.wrap_inputs(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
132
- end
168
+ step_config.call_compensate(error, arguments, @context)
133
169
  end
134
170
 
135
171
  @context.append_execution_trace(
@@ -152,7 +188,7 @@ module RubyReactor
152
188
  end
153
189
 
154
190
  compensate_result
155
- rescue StandardError => e
191
+ rescue Error::Rescuable => e
156
192
  record_rollback_failure(step_config.name, :compensate, e)
157
193
  middlewares.on(:failed_compensation, step_config.name, e, @context)
158
194
  # A raise is a compensation failure like a returned Failure: the
@@ -162,19 +198,11 @@ module RubyReactor
162
198
  end
163
199
  end
164
200
 
165
- def undo_step(step_config, result, arguments) # rubocop:disable Metrics/MethodLength
201
+ def undo_step(step_config, result, arguments)
166
202
  middlewares.on(:start_undo, step_config.name, result, arguments, @context)
167
203
  begin
168
204
  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, step_config.wrap_inputs(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
177
- end
205
+ step_config.call_undo(result.value, arguments, @context)
178
206
  end
179
207
 
180
208
  @context.append_execution_trace(
@@ -196,7 +224,7 @@ module RubyReactor
196
224
  end
197
225
 
198
226
  undo_result
199
- rescue StandardError => e
227
+ rescue Error::Rescuable => e
200
228
  record_rollback_failure(step_config.name, :undo, e)
201
229
  middlewares.on(:failed_undo, step_config.name, e, @context)
202
230
  # Log undo failure but don't halt the rollback process
@@ -35,9 +35,13 @@ module RubyReactor
35
35
  # Every reactor-level Failure that follows a rollback is built here, so
36
36
  # this is the one place `rollback_failures` is attached (005 R-08).
37
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
38
+ with_rollback_failures(build_execution_failure(error))
39
+ end
40
+
41
+ # The end of a rollback that handed off (009 R-04): the Failure recorded
42
+ # at the hand-off, with every rollback failure collected since.
43
+ def handed_off_failure(failure)
44
+ with_rollback_failures(failure)
41
45
  end
42
46
 
43
47
  def final_result(reactor_class)
@@ -51,26 +55,62 @@ module RubyReactor
51
55
 
52
56
  private
53
57
 
58
+ def with_rollback_failures(failure)
59
+ failure.rollback_failures.concat(@compensation_manager.rollback_failures) if failure.is_a?(RubyReactor::Failure)
60
+ failure
61
+ end
62
+
63
+ # Every error rolls back the completed steps first (Constitution II), then
64
+ # becomes the run's Failure.
54
65
  def build_execution_failure(error)
66
+ if error.is_a?(Error::StepFailureError)
67
+ current_context = error.context || @context
68
+ current_context.current_step = error.step
69
+ store_failed_map_context(current_context) if current_context.map_metadata
70
+ end
71
+ handing_off_as(error) { @compensation_manager.rollback_completed_steps }
72
+ failure_for(error)
73
+ end
74
+
75
+ # A rollback that hands off mid-way (009 R-04) carries the Failure this
76
+ # run will end with, minus its rollback failures, to the executor that
77
+ # records the hand-off. `error` is what the run fails with once the
78
+ # rollback is done (a lambda when it is costly or not built yet).
79
+ def handing_off_as(error)
80
+ yield
81
+ rescue Error::RollbackHandedOff => e
82
+ e.failure ||= failure_for(error.respond_to?(:call) ? error.call : error)
83
+ raise
84
+ end
85
+
86
+ def failure_for(error)
55
87
  case error
56
88
  when Error::StepFailureError
57
- handle_step_failure_error(error)
89
+ create_failure_from_error(error, redacted_input_names(error.context))
58
90
  when Error::InputValidationError
59
91
  # Unified validation failure shape (inputs, step args, step output).
60
- # Roll back any completed steps so saga semantics hold for mid-reactor
61
- # validation failures (a no-op for input validation at reactor start).
62
- @compensation_manager.rollback_completed_steps
63
92
  build_validation_failure(error)
64
93
  when Error::Base
65
- # Other errors need rollback
66
- @compensation_manager.rollback_completed_steps
67
- RubyReactor.Failure("Execution error: #{error.message}", exception_class: error.class.name)
94
+ # A `CompensationError` names the step whose compensation failed.
95
+ RubyReactor.Failure("Execution error: #{error.message}", exception_class: error.class.name,
96
+ step_name: error.step || @context.current_step,
97
+ reactor_name: @context.reactor_class&.name)
68
98
  else
69
- # Unknown errors - don't rollback as they may not be reactor-related
70
- RubyReactor.Failure("Execution failed: #{error.message}", exception_class: error.class.name)
99
+ # Any other StandardError after completed work (a checkpoint write,
100
+ # a hook) still leaves a partial saga, rolled back like any failure
101
+ # (Constitution II, 008 R-07); name the step that was executing.
102
+ RubyReactor.Failure("Execution failed: #{error.message}", exception_class: error.class.name,
103
+ step_name: @context.current_step,
104
+ reactor_name: @context.reactor_class&.name)
71
105
  end
72
106
  end
73
107
 
108
+ def redacted_input_names(context)
109
+ return [] unless context&.reactor_class
110
+
111
+ context.reactor_class.inputs.select { |_, config| config[:redact] }.keys
112
+ end
113
+
74
114
  # Failure for a validation error (reactor inputs, step arguments, or
75
115
  # step output), carrying both the structured field errors and the step/
76
116
  # reactor attribution stamped at the raise site (nil step_name for
@@ -108,9 +148,9 @@ module RubyReactor
108
148
  result
109
149
  end
110
150
 
111
- # A step returned `RubyReactor.Skipped(...)`. The reactor continues:
112
- # validate and record the value exactly like a Success, but do NOT push
113
- # to the undo stack (nothing happened, so there is nothing to undo).
151
+ # A step returned `RubyReactor.Skipped(...)`: the run continues exactly as
152
+ # for a Success (value, hand-off, period mark), but the step is NOT pushed
153
+ # for undo — it had nothing to do, so there is nothing to revert (008 R-19).
114
154
  def handle_skipped(step_config, result, resolved_arguments)
115
155
  validate_step_output(step_config, result.value, resolved_arguments)
116
156
  @step_results[step_config.name] = result
@@ -130,22 +170,15 @@ module RubyReactor
130
170
  def handle_success(step_config, result, resolved_arguments)
131
171
  validate_step_output(step_config, result.value, resolved_arguments)
132
172
  @step_results[step_config.name] = result
133
- # `async_step` / `async_reactor` dispatches are independent units of
134
- # work with their own compensation flows — the parent rolling back must
135
- # not "undo" a dispatch whose unit runs (and may still succeed)
136
- # elsewhere. They never enter the parent's undo stack.
137
- unless async_unit?(step_config)
138
- @compensation_manager.add_to_undo_stack({ step: step_config, arguments: resolved_arguments,
173
+ if step_config.rollback_tracked?
174
+ @compensation_manager.add_to_undo_stack({ step: step_config,
175
+ arguments: step_config.rollback_arguments(resolved_arguments),
139
176
  result: result })
140
177
  end
141
178
  @context.set_result(step_config.name, result.value)
142
179
  @dependency_graph.complete_step(step_config.name)
143
180
  end
144
181
 
145
- def async_unit?(step_config)
146
- step_config.respond_to?(:async_dispatch?) && step_config.async_dispatch?
147
- end
148
-
149
182
  # A composed child's Failure carries the child's own rollback failures;
150
183
  # fold them in BEFORE this level rolls back, so they come first.
151
184
  def adopt_rollback_failures(result)
@@ -156,36 +189,38 @@ module RubyReactor
156
189
 
157
190
  def handle_retries_exhausted(step_config, result, resolved_arguments)
158
191
  adopt_rollback_failures(result)
159
- @compensation_manager.handle_step_failure(step_config, result.original_error, resolved_arguments)
160
- orig_err = result.original_error.is_a?(Exception) ? result.original_error : nil
161
- error = Error::StepFailureError.new(result.error, step: step_config.name, context: @context,
162
- original_error: orig_err,
163
- step_arguments: resolved_arguments,
164
- validation_errors: result.validation_errors)
165
- if result.respond_to?(:backtrace) && result.backtrace
166
- error.set_backtrace(result.backtrace)
167
- elsif orig_err
168
- error.set_backtrace(orig_err.backtrace)
192
+ error = step_failure_error(step_config, result.error, result, resolved_arguments, cause: result.original_error)
193
+ handing_off_as(error) do
194
+ @compensation_manager.handle_step_failure(step_config, result.original_error, resolved_arguments)
169
195
  end
170
196
  raise error
171
197
  end
172
198
 
173
199
  def handle_failure(step_config, result, resolved_arguments)
174
200
  adopt_rollback_failures(result)
175
- failure_result = @compensation_manager.handle_step_failure(step_config, result.error, resolved_arguments)
176
- orig_err = result.error.is_a?(Exception) ? result.error : nil
177
- # A step that propagates another unit's validation failure (an
178
- # async_step reader) keeps its field errors on the reactor's failure.
179
- error = Error::StepFailureError.new(failure_result.error, step: step_config.name, context: @context,
180
- original_error: orig_err,
181
- step_arguments: resolved_arguments,
182
- validation_errors: result.validation_errors)
201
+ message = @compensation_manager.step_failure_message(step_config, result.error)
202
+ failure_result = handing_off_as(-> { step_failure_error(step_config, message, result, resolved_arguments) }) do
203
+ @compensation_manager.handle_step_failure(step_config, result.error, resolved_arguments)
204
+ end
205
+ raise step_failure_error(step_config, failure_result.error, result, resolved_arguments)
206
+ end
207
+
208
+ # A step that propagates another unit's failure (an async_step reader, a
209
+ # map adopting a fan-out element's Failure) keeps its field errors and
210
+ # exception class on the reactor's failure.
211
+ def step_failure_error(step_config, message, result, resolved_arguments, cause: result.error)
212
+ orig_err = cause.is_a?(Exception) ? cause : nil
213
+ error = Error::StepFailureError.new(message, step: step_config.name, context: @context,
214
+ original_error: orig_err,
215
+ step_arguments: resolved_arguments,
216
+ exception_class: (result.exception_class unless orig_err),
217
+ validation_errors: result.validation_errors)
183
218
  if result.respond_to?(:backtrace) && result.backtrace
184
219
  error.set_backtrace(result.backtrace)
185
220
  elsif orig_err
186
221
  error.set_backtrace(orig_err.backtrace)
187
222
  end
188
- raise error
223
+ error
189
224
  end
190
225
 
191
226
  # Wrapped first, so a returned `Step::Inputs` is validated and stored as its Hash.
@@ -193,31 +228,16 @@ module RubyReactor
193
228
  success_result = RubyReactor.Success(result)
194
229
  validate_step_output(step_config, success_result.value, resolved_arguments)
195
230
  @step_results[step_config.name] = success_result
196
- @compensation_manager.add_to_undo_stack({ step: step_config, arguments: resolved_arguments,
231
+ @compensation_manager.add_to_undo_stack({ step: step_config,
232
+ arguments: step_config.rollback_arguments(resolved_arguments),
197
233
  result: success_result })
198
234
  @context.set_result(step_config.name, success_result.value)
199
235
  @dependency_graph.complete_step(step_config.name)
200
236
  end
201
237
 
202
- def handle_step_failure_error(error)
203
- current_context = error.context || @context
204
- current_context.current_step = error.step
205
-
206
- store_failed_map_context(current_context) if current_context.map_metadata
207
-
208
- @compensation_manager.rollback_completed_steps
209
-
210
- redact_inputs = []
211
- if error.context&.reactor_class
212
- redact_inputs = error.context.reactor_class.inputs.select { |_, config| config[:redact] }.keys
213
- end
214
-
215
- create_failure_from_error(error, redact_inputs)
216
- end
217
-
218
238
  def store_failed_map_context(context)
219
239
  return unless context.map_metadata && context.map_metadata[:map_id]
220
- return unless context.map_metadata[:fail_fast]
240
+ return unless Map::Helpers.normalize_arguments(context.map_metadata)[:atomic]
221
241
 
222
242
  storage = RubyReactor.configuration.storage_adapter
223
243
  storage.store_map_failed_context_id(
@@ -254,6 +274,8 @@ module RubyReactor
254
274
  def resolve_exception_class(original_error, error)
255
275
  # A step's own contention is reported by its cause (Lock::AcquisitionError, ...).
256
276
  original_error = original_error.original if original_error.is_a?(StepCoordination::Contended)
277
+ # Argument failures report their cause's class (008 R-06).
278
+ return original_error.exception_class if original_error.is_a?(Error::ArgumentResolutionError)
257
279
  return original_error.class.name if original_error
258
280
 
259
281
  error.respond_to?(:exception_class) ? error.exception_class : nil
@@ -274,7 +296,7 @@ module RubyReactor
274
296
  # compensation and roll back prior steps, so the side effect is not
275
297
  # orphaned. Then surface the structured validation error (the later
276
298
  # rollback in handle_execution_error is a no-op — stack already clear).
277
- @compensation_manager.handle_step_failure(step_config, error, resolved_arguments)
299
+ handing_off_as(error) { @compensation_manager.handle_step_failure(step_config, error, resolved_arguments) }
278
300
  raise error
279
301
  end
280
302
 
@@ -61,7 +61,7 @@ module RubyReactor
61
61
  serialized_context = ContextSerializer.serialize(context_to_serialize)
62
62
 
63
63
  if @context.map_metadata
64
- map_args = @context.map_metadata.transform_keys(&:to_sym)
64
+ map_args = Map::Helpers.normalize_arguments(@context.map_metadata)
65
65
  configuration.async_router.perform_map_element_in(
66
66
  delay,
67
67
  map_id: map_args[:map_id],
@@ -75,7 +75,7 @@ module RubyReactor
75
75
  step_name: map_args[:step_name],
76
76
  batch_size: map_args[:batch_size],
77
77
  serialized_context: serialized_context,
78
- fail_fast: map_args[:fail_fast]
78
+ atomic: map_args[:atomic]
79
79
  )
80
80
  else
81
81
  # Persist BEFORE enqueue — the job payload is identity-only (F2). The
@@ -193,7 +193,8 @@ module RubyReactor
193
193
  reactor_name: reactor_class.name,
194
194
  step_arguments: result.respond_to?(:step_arguments) ? result.step_arguments : {},
195
195
  validation_errors: result.validation_errors,
196
- rollback_failures: result.rollback_failures
196
+ rollback_failures: result.rollback_failures,
197
+ exception_class: result.exception_class
197
198
  )
198
199
  end
199
200
 
@@ -99,7 +99,7 @@ module RubyReactor
99
99
  key
100
100
  rescue KeyError
101
101
  raise
102
- rescue StandardError => e
102
+ rescue RubyReactor::Error::Rescuable => e
103
103
  raise KeyError.new(
104
104
  "#{step_name}: coordination key proc raised #{e.class}: #{e.message}",
105
105
  step: step_name, original_error: e
@@ -188,7 +188,7 @@ module RubyReactor
188
188
  # counter and marker must survive the park.
189
189
  rescue Contended, Error::ExecutionParked
190
190
  raise
191
- rescue StandardError
191
+ rescue Error::Rescuable
192
192
  clear_contention_state
193
193
  raise
194
194
  end
@@ -498,7 +498,7 @@ module RubyReactor
498
498
  rescue Error::ExecutionParked
499
499
  outcome = :parked
500
500
  raise
501
- rescue StandardError
501
+ rescue Error::Rescuable
502
502
  outcome = :failed
503
503
  raise
504
504
  ensure
@@ -513,8 +513,8 @@ module RubyReactor
513
513
  # step again and must keep its place: the stash survives, so the next
514
514
  # attempt re-reads the SAME nonce. The heartbeat is stopped across the
515
515
  # gap; `poison_pill_timeout` bounds it.
516
- # - :abandoned — an exit that is not a `StandardError` (`Sidekiq::Shutdown`,
517
- # `NoMemoryError`, ...). Not advanced: `Sidekiq::Shutdown` pushes the
516
+ # - :abandoned — an interruption (`Sidekiq::Shutdown`, `NoMemoryError`,
517
+ # ...; `Error::Rescuable`, 008 R-16). Not advanced: `Sidekiq::Shutdown` pushes the
518
518
  # job back to run again, which must keep this place, and `failed: true`
519
519
  # would poison successors for work that may yet complete. With the
520
520
  # heartbeat stopped, the poison pill releases the position within
@@ -644,7 +644,7 @@ module RubyReactor
644
644
  return skipped_for_period if storage_adapter.period_seen?(key)
645
645
 
646
646
  result = yield
647
- if plain_success?(result) && !chain_failed?(result)
647
+ if continuing_success?(result) && !chain_failed?(result)
648
648
  storage_adapter.period_mark(key, RubyReactor::Period.ttl_seconds(config[:every]))
649
649
  end
650
650
  result
@@ -654,8 +654,11 @@ module RubyReactor
654
654
  RubyReactor.Skipped(nil, reason: :period, step_name: step_name)
655
655
  end
656
656
 
657
- def plain_success?(result)
658
- result.is_a?(RubyReactor::Success) && !result.is_a?(RubyReactor::Halt) && !result.is_a?(RubyReactor::Skipped)
657
+ # A body's `Skipped` marks the bucket like a Success: it is only an
658
+ # instrumentation mark. The library's own skips (period, ordered lock)
659
+ # are returned by the gates around this one, never through it.
660
+ def continuing_success?(result)
661
+ result.is_a?(RubyReactor::Success) && !result.is_a?(RubyReactor::Halt)
659
662
  end
660
663
 
661
664
  def period_key(config)