ruby_reactor 0.8.5 → 0.8.7

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 (73) 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 +82 -0
  6. data/CLAUDE.md +1 -1
  7. data/README.md +27 -8
  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/definition_warnings.rb +30 -0
  14. data/lib/ruby_reactor/dsl/map_builder.rb +27 -7
  15. data/lib/ruby_reactor/dsl/step_builder.rb +8 -21
  16. data/lib/ruby_reactor/error/rescuable.rb +6 -1
  17. data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
  18. data/lib/ruby_reactor/executor/compensation_manager.rb +39 -9
  19. data/lib/ruby_reactor/executor/result_handler.rb +88 -52
  20. data/lib/ruby_reactor/executor/retry_manager.rb +6 -5
  21. data/lib/ruby_reactor/executor/step_executor.rb +23 -1
  22. data/lib/ruby_reactor/executor.rb +186 -6
  23. data/lib/ruby_reactor/map/collector.rb +25 -93
  24. data/lib/ruby_reactor/map/dispatcher.rb +38 -7
  25. data/lib/ruby_reactor/map/element_executor.rb +14 -10
  26. data/lib/ruby_reactor/map/element_rollback.rb +158 -0
  27. data/lib/ruby_reactor/map/helpers.rb +11 -155
  28. data/lib/ruby_reactor/map/step_rollback.rb +136 -0
  29. data/lib/ruby_reactor/map/sweeper.rb +66 -8
  30. data/lib/ruby_reactor/map.rb +17 -0
  31. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
  32. data/lib/ruby_reactor/reactor.rb +113 -24
  33. data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
  34. data/lib/ruby_reactor/rspec/matchers.rb +55 -37
  35. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
  36. data/lib/ruby_reactor/rspec/test_subject.rb +21 -21
  37. data/lib/ruby_reactor/rspec.rb +3 -6
  38. data/lib/ruby_reactor/step/compose_step.rb +13 -0
  39. data/lib/ruby_reactor/step/map_step.rb +117 -114
  40. data/lib/ruby_reactor/storage/adapter.rb +14 -0
  41. data/lib/ruby_reactor/storage/redis_adapter.rb +18 -2
  42. data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
  43. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  44. data/lib/ruby_reactor/sweeper.rb +3 -1
  45. data/lib/ruby_reactor/version.rb +1 -1
  46. data/lib/ruby_reactor/web/api.rb +11 -1
  47. data/lib/ruby_reactor/web/public/assets/{index-CQbgHtd0.js → index-C6ejszCQ.js} +12 -12
  48. data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
  49. data/lib/ruby_reactor/web/public/index.html +2 -2
  50. data/lib/ruby_reactor/worker.rb +4 -2
  51. data/specs/007-execution-flow-analysis/evidence/harness.rb +2 -0
  52. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +4 -1
  53. data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
  54. data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
  55. data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
  56. data/specs/009-distributed-map-undo/data-model.md +151 -0
  57. data/specs/009-distributed-map-undo/plan.md +323 -0
  58. data/specs/009-distributed-map-undo/pr-description.md +78 -0
  59. data/specs/009-distributed-map-undo/quickstart.md +154 -0
  60. data/specs/009-distributed-map-undo/research.md +585 -0
  61. data/specs/009-distributed-map-undo/spec.md +360 -0
  62. data/specs/009-distributed-map-undo/tasks.md +1060 -0
  63. data/specs/010-interrupt-in-composed-child/checklists/requirements.md +43 -0
  64. data/specs/010-interrupt-in-composed-child/contracts/api-surface.md +75 -0
  65. data/specs/010-interrupt-in-composed-child/data-model.md +40 -0
  66. data/specs/010-interrupt-in-composed-child/plan.md +149 -0
  67. data/specs/010-interrupt-in-composed-child/quickstart.md +64 -0
  68. data/specs/010-interrupt-in-composed-child/research.md +207 -0
  69. data/specs/010-interrupt-in-composed-child/spec.md +267 -0
  70. data/specs/010-interrupt-in-composed-child/tasks.md +315 -0
  71. data/specs/future_improvements.md +9 -33
  72. metadata +29 -3
  73. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +0 -1
@@ -7,6 +7,7 @@ module RubyReactor
7
7
  include RubyReactor::Dsl::ValidationHelpers
8
8
  include RubyReactor::Dsl::Lockable::ClassMethods
9
9
  include RubyReactor::Dsl::Retryable
10
+ include RubyReactor::Dsl::DefinitionWarnings
10
11
 
11
12
  COORDINATION_MACROS = {
12
13
  lock_config: "with_lock", semaphore_config: "with_semaphore", rate_limit_config: "with_rate_limit",
@@ -34,11 +35,6 @@ module RubyReactor
34
35
  @rule_sites = []
35
36
  end
36
37
 
37
- # Deprecation notices print once per declaration site for the process.
38
- def self.deprecation_sites
39
- @deprecation_sites ||= Set.new
40
- end
41
-
42
38
  def argument(name, source, type = nil, transform: nil, **predicates)
43
39
  @arguments[name] = {
44
40
  source: source,
@@ -244,25 +240,9 @@ module RubyReactor
244
240
  "the step instead (`validate_inputs` in #{target}).")
245
241
  end
246
242
 
247
- def warn_deprecation(site, message)
248
- warn_definition(site, "DEPRECATION:", "#{message} Removal no earlier than the next MAJOR.")
249
- end
250
-
251
- # A definition-time warning, printed once per declaration site.
252
- def warn_definition(site, prefix, message)
253
- location = "#{site.path}:#{site.lineno}"
254
- return unless StepBuilder.deprecation_sites.add?(location)
255
-
256
- warn ["[RubyReactor]", prefix, location, reactor_label, message].compact.join(" ")
257
- end
258
-
259
243
  def owned_contract
260
244
  @inline_contract || (@impl.input_contract if @impl.respond_to?(:declares_inputs?) && @impl.declares_inputs?)
261
245
  end
262
-
263
- def reactor_label
264
- @reactor&.name || @reactor.inspect
265
- end
266
246
  end
267
247
 
268
248
  class StepConfig
@@ -450,6 +430,13 @@ module RubyReactor
450
430
  rollback_tracked? && has_impl? && impl.respond_to?(:undoes_partial_run?) && impl.undoes_partial_run?
451
431
  end
452
432
 
433
+ # What the undo record keeps of the step's resolved arguments: all of
434
+ # them, unless the step reads its rollback state elsewhere (a map reads
435
+ # its declaration and the map records, 009 R-14).
436
+ def rollback_arguments(resolved)
437
+ has_impl? && impl.respond_to?(:rollback_arguments) ? impl.rollback_arguments(resolved) : resolved
438
+ end
439
+
453
440
  def has_impl?
454
441
  !@impl.nil?
455
442
  end
@@ -12,11 +12,16 @@ module RubyReactor
12
12
  # raises — rescuing that would stop the caller's timeout from ever firing.
13
13
  # Running rollback code during any of them is unsafe; the executor marks
14
14
  # the run `aborted` for a manual undo instead.
15
+ #
16
+ # Nor does it match `RollbackHandedOff`, a control signal (009 R-04): it
17
+ # must pass the `rescue Error::Rescuable` sites between `MapStep#undo` and
18
+ # the executor (`CompensationManager#undo_step`, `StepExecutor`), which
19
+ # would otherwise record it as an undo failure.
15
20
  module Rescuable
16
21
  INTERRUPTIONS = [SignalException, SystemExit, NoMemoryError].freeze
17
22
 
18
23
  def self.===(exception)
19
- exception.is_a?(Exception) && !interruption?(exception)
24
+ exception.is_a?(Exception) && !interruption?(exception) && !exception.is_a?(RollbackHandedOff)
20
25
  end
21
26
 
22
27
  def self.interruption?(exception)
@@ -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
@@ -30,11 +30,29 @@ module RubyReactor
30
30
  # (005 FR-004). `ResultHandler` attaches it to the final Failure.
31
31
  attr_reader :undo_trace, :rollback_failures
32
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
+
44
+ # A step that paused (a compose whose child hit an interrupt) is already
45
+ # on top as a partial run; its re-run replaces that entry, so the step is
46
+ # undone once (010 R-04).
47
+ # ponytail: assumes the paused step's entry is on top when it re-runs,
48
+ # which holds because a run stops at a pause and resumes at that step.
33
49
  def add_to_undo_stack(step_info)
50
+ undo_stack.pop if undo_stack.last && undo_stack.last[:step].name == step_info[:step].name
34
51
  @context.undo_stack << step_info
35
52
  end
36
53
 
37
54
  def handle_step_failure(step_config, error, arguments)
55
+ @pending = { step: step_config.name, error: error, compensated: false }
38
56
  # A step whose OWN coordination acquisition failed (contention, or a
39
57
  # bad key proc) never ran its body — "no step compensates, the
40
58
  # contended step's work has not been attempted" (US3-1/T018), which
@@ -44,29 +62,38 @@ module RubyReactor
44
62
  # turning a plain contention failure into a confusing
45
63
  # CompensationError. Prior steps still roll back normally.
46
64
  if step_never_started?(error)
65
+ @pending[:compensated] = true
47
66
  rollback_completed_steps
48
- return RubyReactor.Failure("Step '#{step_config.name}' failed: #{error}")
67
+ return RubyReactor.Failure(step_failure_message(step_config, error))
49
68
  end
50
69
 
51
70
  # Try compensation
52
71
  compensation_result = compensate_step(step_config, error, arguments)
72
+ @pending[:compensated] = true
73
+ @pending[:compensation_error] = compensation_error_message(step_config, compensation_result)
53
74
  case compensation_result
54
75
  when RubyReactor::Success
55
76
  # Compensation succeeded, continue with rollback
56
77
  rollback_completed_steps
57
- RubyReactor.Failure("Step '#{step_config.name}' failed: #{error}")
78
+ RubyReactor.Failure(step_failure_message(step_config, error))
58
79
  when RubyReactor::Failure
59
80
  # Compensation failed, this is more serious
60
81
  rollback_completed_steps
61
- raise Error::CompensationError.new(
62
- "Compensation for step '#{step_config.name}' failed: #{compensation_result.error}",
63
- step: step_config.name,
64
- context: @context,
65
- original_error: error
66
- )
82
+ raise Error::CompensationError.new(@pending[:compensation_error], step: step_config.name, context: @context,
83
+ original_error: error)
67
84
  end
68
85
  end
69
86
 
87
+ def step_failure_message(step_config, error)
88
+ "Step '#{step_config.name}' failed: #{error}"
89
+ end
90
+
91
+ def compensation_error_message(step_config, compensation_result)
92
+ return unless compensation_result.is_a?(RubyReactor::Failure)
93
+
94
+ "Compensation for step '#{step_config.name}' failed: #{compensation_result.error}"
95
+ end
96
+
70
97
  # A unit that compensates itself outside the executor loop (StepWorker's
71
98
  # `async_step`): the same coordination re-take, trace, middleware events
72
99
  # and `rollback_failures` as a step compensated here.
@@ -77,7 +104,9 @@ module RubyReactor
77
104
  # Newest first. Each entry leaves the stack only once its undo returned,
78
105
  # so an interruption mid-rollback leaves exactly the entries still to
79
106
  # undo — the interrupted one included — for a manual undo (008 R-16).
80
- def rollback_completed_steps
107
+ # `after_pop` runs after each pop: an element rollback job saves there,
108
+ # so a redelivery resumes after the last undone entry (009 R-07).
109
+ def rollback_completed_steps(&after_pop)
81
110
  until undo_stack.empty?
82
111
  step_info = undo_stack.last
83
112
  result = @context.with_step(step_info[:step].name) do
@@ -86,6 +115,7 @@ module RubyReactor
86
115
  undo_stack.pop
87
116
  @undo_trace << { type: :undo, step: step_info[:step].name, result: result,
88
117
  arguments: step_info[:arguments] }
118
+ after_pop&.call
89
119
  end
90
120
  end
91
121
 
@@ -27,6 +27,8 @@ module RubyReactor
27
27
  handle_retries_exhausted(step_config, result, resolved_arguments)
28
28
  when RubyReactor::Failure
29
29
  handle_failure(step_config, result, resolved_arguments)
30
+ when RubyReactor::InterruptResult
31
+ handle_interrupted(step_config, resolved_arguments)
30
32
  else
31
33
  handle_unknown_result(step_config, result, resolved_arguments)
32
34
  end
@@ -35,9 +37,13 @@ module RubyReactor
35
37
  # Every reactor-level Failure that follows a rollback is built here, so
36
38
  # this is the one place `rollback_failures` is attached (005 R-08).
37
39
  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
40
+ with_rollback_failures(build_execution_failure(error))
41
+ end
42
+
43
+ # The end of a rollback that handed off (009 R-04): the Failure recorded
44
+ # at the hand-off, with every rollback failure collected since.
45
+ def handed_off_failure(failure)
46
+ with_rollback_failures(failure)
41
47
  end
42
48
 
43
49
  def final_result(reactor_class)
@@ -51,34 +57,62 @@ module RubyReactor
51
57
 
52
58
  private
53
59
 
60
+ def with_rollback_failures(failure)
61
+ failure.rollback_failures.concat(@compensation_manager.rollback_failures) if failure.is_a?(RubyReactor::Failure)
62
+ failure
63
+ end
64
+
65
+ # Every error rolls back the completed steps first (Constitution II), then
66
+ # becomes the run's Failure.
54
67
  def build_execution_failure(error)
68
+ if error.is_a?(Error::StepFailureError)
69
+ current_context = error.context || @context
70
+ current_context.current_step = error.step
71
+ store_failed_map_context(current_context) if current_context.map_metadata
72
+ end
73
+ handing_off_as(error) { @compensation_manager.rollback_completed_steps }
74
+ failure_for(error)
75
+ end
76
+
77
+ # A rollback that hands off mid-way (009 R-04) carries the Failure this
78
+ # run will end with, minus its rollback failures, to the executor that
79
+ # records the hand-off. `error` is what the run fails with once the
80
+ # rollback is done (a lambda when it is costly or not built yet).
81
+ def handing_off_as(error)
82
+ yield
83
+ rescue Error::RollbackHandedOff => e
84
+ e.failure ||= failure_for(error.respond_to?(:call) ? error.call : error)
85
+ raise
86
+ end
87
+
88
+ def failure_for(error)
55
89
  case error
56
90
  when Error::StepFailureError
57
- handle_step_failure_error(error)
91
+ create_failure_from_error(error, redacted_input_names(error.context))
58
92
  when Error::InputValidationError
59
93
  # 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
94
  build_validation_failure(error)
64
95
  when Error::Base
65
- # Other errors need rollback. A `CompensationError` names the step
66
- # whose compensation failed.
67
- @compensation_manager.rollback_completed_steps
96
+ # A `CompensationError` names the step whose compensation failed.
68
97
  RubyReactor.Failure("Execution error: #{error.message}", exception_class: error.class.name,
69
98
  step_name: error.step || @context.current_step,
70
99
  reactor_name: @context.reactor_class&.name)
71
100
  else
72
101
  # Any other StandardError after completed work (a checkpoint write,
73
- # a hook) still leaves a partial saga: roll it back like any failure
74
- # (Constitution II, 008 R-07) and name the step that was executing.
75
- @compensation_manager.rollback_completed_steps
102
+ # a hook) still leaves a partial saga, rolled back like any failure
103
+ # (Constitution II, 008 R-07); name the step that was executing.
76
104
  RubyReactor.Failure("Execution failed: #{error.message}", exception_class: error.class.name,
77
105
  step_name: @context.current_step,
78
106
  reactor_name: @context.reactor_class&.name)
79
107
  end
80
108
  end
81
109
 
110
+ def redacted_input_names(context)
111
+ return [] unless context&.reactor_class
112
+
113
+ context.reactor_class.inputs.select { |_, config| config[:redact] }.keys
114
+ end
115
+
82
116
  # Failure for a validation error (reactor inputs, step arguments, or
83
117
  # step output), carrying both the structured field errors and the step/
84
118
  # reactor attribution stamped at the raise site (nil step_name for
@@ -116,6 +150,20 @@ module RubyReactor
116
150
  result
117
151
  end
118
152
 
153
+ # A composed child paused at an interrupt (010): the step stays
154
+ # incomplete, and this run pauses on it. `with_step` cleared
155
+ # `current_step` on the way out; it is the run's resume cursor. The
156
+ # child's completed steps are only undone through this step, so it joins
157
+ # the undo stack as a partial run, as an interrupted compose does (R-04).
158
+ def handle_interrupted(step_config, resolved_arguments)
159
+ @context.current_step = step_config.name
160
+ return unless step_config.undoes_partial_run?
161
+
162
+ @compensation_manager.add_to_undo_stack({ step: step_config,
163
+ arguments: step_config.rollback_arguments(resolved_arguments),
164
+ result: RubyReactor.Success(nil) })
165
+ end
166
+
119
167
  # A step returned `RubyReactor.Skipped(...)`: the run continues exactly as
120
168
  # for a Success (value, hand-off, period mark), but the step is NOT pushed
121
169
  # for undo — it had nothing to do, so there is nothing to revert (008 R-19).
@@ -139,7 +187,8 @@ module RubyReactor
139
187
  validate_step_output(step_config, result.value, resolved_arguments)
140
188
  @step_results[step_config.name] = result
141
189
  if step_config.rollback_tracked?
142
- @compensation_manager.add_to_undo_stack({ step: step_config, arguments: resolved_arguments,
190
+ @compensation_manager.add_to_undo_stack({ step: step_config,
191
+ arguments: step_config.rollback_arguments(resolved_arguments),
143
192
  result: result })
144
193
  end
145
194
  @context.set_result(step_config.name, result.value)
@@ -156,36 +205,38 @@ module RubyReactor
156
205
 
157
206
  def handle_retries_exhausted(step_config, result, resolved_arguments)
158
207
  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)
208
+ error = step_failure_error(step_config, result.error, result, resolved_arguments, cause: result.original_error)
209
+ handing_off_as(error) do
210
+ @compensation_manager.handle_step_failure(step_config, result.original_error, resolved_arguments)
169
211
  end
170
212
  raise error
171
213
  end
172
214
 
173
215
  def handle_failure(step_config, result, resolved_arguments)
174
216
  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)
217
+ message = @compensation_manager.step_failure_message(step_config, result.error)
218
+ failure_result = handing_off_as(-> { step_failure_error(step_config, message, result, resolved_arguments) }) do
219
+ @compensation_manager.handle_step_failure(step_config, result.error, resolved_arguments)
220
+ end
221
+ raise step_failure_error(step_config, failure_result.error, result, resolved_arguments)
222
+ end
223
+
224
+ # A step that propagates another unit's failure (an async_step reader, a
225
+ # map adopting a fan-out element's Failure) keeps its field errors and
226
+ # exception class on the reactor's failure.
227
+ def step_failure_error(step_config, message, result, resolved_arguments, cause: result.error)
228
+ orig_err = cause.is_a?(Exception) ? cause : nil
229
+ error = Error::StepFailureError.new(message, step: step_config.name, context: @context,
230
+ original_error: orig_err,
231
+ step_arguments: resolved_arguments,
232
+ exception_class: (result.exception_class unless orig_err),
233
+ validation_errors: result.validation_errors)
183
234
  if result.respond_to?(:backtrace) && result.backtrace
184
235
  error.set_backtrace(result.backtrace)
185
236
  elsif orig_err
186
237
  error.set_backtrace(orig_err.backtrace)
187
238
  end
188
- raise error
239
+ error
189
240
  end
190
241
 
191
242
  # Wrapped first, so a returned `Step::Inputs` is validated and stored as its Hash.
@@ -193,31 +244,16 @@ module RubyReactor
193
244
  success_result = RubyReactor.Success(result)
194
245
  validate_step_output(step_config, success_result.value, resolved_arguments)
195
246
  @step_results[step_config.name] = success_result
196
- @compensation_manager.add_to_undo_stack({ step: step_config, arguments: resolved_arguments,
247
+ @compensation_manager.add_to_undo_stack({ step: step_config,
248
+ arguments: step_config.rollback_arguments(resolved_arguments),
197
249
  result: success_result })
198
250
  @context.set_result(step_config.name, success_result.value)
199
251
  @dependency_graph.complete_step(step_config.name)
200
252
  end
201
253
 
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
254
  def store_failed_map_context(context)
219
255
  return unless context.map_metadata && context.map_metadata[:map_id]
220
- return unless context.map_metadata[:fail_fast]
256
+ return unless Map::Helpers.normalize_arguments(context.map_metadata)[:atomic]
221
257
 
222
258
  storage = RubyReactor.configuration.storage_adapter
223
259
  storage.store_map_failed_context_id(
@@ -276,7 +312,7 @@ module RubyReactor
276
312
  # compensation and roll back prior steps, so the side effect is not
277
313
  # orphaned. Then surface the structured validation error (the later
278
314
  # rollback in handle_execution_error is a no-op — stack already clear).
279
- @compensation_manager.handle_step_failure(step_config, error, resolved_arguments)
315
+ handing_off_as(error) { @compensation_manager.handle_step_failure(step_config, error, resolved_arguments) }
280
316
  raise error
281
317
  end
282
318
 
@@ -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
@@ -108,8 +108,8 @@ module RubyReactor
108
108
  result
109
109
  when RubyReactor::Failure
110
110
  handle_failure_result(step_config, reactor_class, result)
111
- when RetryQueuedResult, RubyReactor::DispatchResult
112
- # Pass through async results
111
+ when RetryQueuedResult, RubyReactor::DispatchResult, RubyReactor::InterruptResult
112
+ # Pass through async results, and a composed child's pause
113
113
  result
114
114
  else
115
115
  clear_retry_state
@@ -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
 
@@ -133,9 +133,11 @@ module RubyReactor
133
133
  # the stack. A worker run is redelivered instead, and re-runs the step.
134
134
  def track_interrupted_construct(step_config, arguments, error)
135
135
  return if @context.inline_async_execution || Error::Rescuable === error # rubocop:disable Style/CaseEquality
136
+ return if error.is_a?(Error::RollbackHandedOff) # a rollback signal, not an interruption
136
137
  return unless step_config.undoes_partial_run?
137
138
 
138
- @compensation_manager.add_to_undo_stack({ step: step_config, arguments: arguments,
139
+ @compensation_manager.add_to_undo_stack({ step: step_config,
140
+ arguments: step_config.rollback_arguments(arguments),
139
141
  result: RubyReactor.Success(nil) })
140
142
  end
141
143
 
@@ -429,6 +431,15 @@ module RubyReactor
429
431
  return RubyReactor.Success(result)
430
432
  end
431
433
 
434
+ # A map element cannot pause: nothing would resume it. The step fails
435
+ # like any other, rolling back what the element completed (010 R-08).
436
+ if inside_map_element?
437
+ failure = RubyReactor::Failure("interrupt :#{step_config.name} is not supported inside a map element",
438
+ step_name: step_config.name, reactor_name: @reactor_class.name,
439
+ retryable: false)
440
+ return @result_handler.handle_step_result(step_config, failure, {})
441
+ end
442
+
432
443
  # We are pausing
433
444
  correlation_id = nil
434
445
  correlation_id = step_config.correlation_id_block.call(@context) if step_config.correlation_id_block
@@ -443,6 +454,17 @@ module RubyReactor
443
454
  )
444
455
  end
445
456
 
457
+ # This run, or one it is composed into, is a map element.
458
+ def inside_map_element?
459
+ context = @context
460
+ while context
461
+ return true if context.map_metadata
462
+
463
+ context = context.parent_context
464
+ end
465
+ false
466
+ end
467
+
446
468
  def configuration
447
469
  RubyReactor::Configuration.instance
448
470
  end