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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +17 -14
- data/CHANGELOG.md +82 -0
- data/CLAUDE.md +1 -1
- data/README.md +27 -8
- data/lib/ruby_reactor/adapters/active_job/map_element_rollback_worker.rb +20 -0
- data/lib/ruby_reactor/adapters/active_job/router.rb +32 -4
- data/lib/ruby_reactor/adapters/sidekiq/map_element_rollback_worker.rb +16 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +32 -4
- data/lib/ruby_reactor/context.rb +14 -1
- data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
- data/lib/ruby_reactor/dsl/map_builder.rb +27 -7
- data/lib/ruby_reactor/dsl/step_builder.rb +8 -21
- data/lib/ruby_reactor/error/rescuable.rb +6 -1
- data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +39 -9
- data/lib/ruby_reactor/executor/result_handler.rb +88 -52
- data/lib/ruby_reactor/executor/retry_manager.rb +6 -5
- data/lib/ruby_reactor/executor/step_executor.rb +23 -1
- data/lib/ruby_reactor/executor.rb +186 -6
- data/lib/ruby_reactor/map/collector.rb +25 -93
- data/lib/ruby_reactor/map/dispatcher.rb +38 -7
- data/lib/ruby_reactor/map/element_executor.rb +14 -10
- data/lib/ruby_reactor/map/element_rollback.rb +158 -0
- data/lib/ruby_reactor/map/helpers.rb +11 -155
- data/lib/ruby_reactor/map/step_rollback.rb +136 -0
- data/lib/ruby_reactor/map/sweeper.rb +66 -8
- data/lib/ruby_reactor/map.rb +17 -0
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
- data/lib/ruby_reactor/reactor.rb +113 -24
- data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
- data/lib/ruby_reactor/rspec/matchers.rb +55 -37
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
- data/lib/ruby_reactor/rspec/test_subject.rb +21 -21
- data/lib/ruby_reactor/rspec.rb +3 -6
- data/lib/ruby_reactor/step/compose_step.rb +13 -0
- data/lib/ruby_reactor/step/map_step.rb +117 -114
- data/lib/ruby_reactor/storage/adapter.rb +14 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +18 -2
- data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
- data/lib/ruby_reactor/sweeper.rb +3 -1
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +11 -1
- data/lib/ruby_reactor/web/public/assets/{index-CQbgHtd0.js → index-C6ejszCQ.js} +12 -12
- data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor/worker.rb +4 -2
- data/specs/007-execution-flow-analysis/evidence/harness.rb +2 -0
- data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +4 -1
- data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
- data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
- data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
- data/specs/009-distributed-map-undo/data-model.md +151 -0
- data/specs/009-distributed-map-undo/plan.md +323 -0
- data/specs/009-distributed-map-undo/pr-description.md +78 -0
- data/specs/009-distributed-map-undo/quickstart.md +154 -0
- data/specs/009-distributed-map-undo/research.md +585 -0
- data/specs/009-distributed-map-undo/spec.md +360 -0
- data/specs/009-distributed-map-undo/tasks.md +1060 -0
- data/specs/010-interrupt-in-composed-child/checklists/requirements.md +43 -0
- data/specs/010-interrupt-in-composed-child/contracts/api-surface.md +75 -0
- data/specs/010-interrupt-in-composed-child/data-model.md +40 -0
- data/specs/010-interrupt-in-composed-child/plan.md +149 -0
- data/specs/010-interrupt-in-composed-child/quickstart.md +64 -0
- data/specs/010-interrupt-in-composed-child/research.md +207 -0
- data/specs/010-interrupt-in-composed-child/spec.md +267 -0
- data/specs/010-interrupt-in-composed-child/tasks.md +315 -0
- data/specs/future_improvements.md +9 -33
- metadata +29 -3
- 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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
74
|
-
# (Constitution II, 008 R-07)
|
|
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,
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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,
|
|
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[:
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|