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.
- 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 +254 -0
- data/CLAUDE.md +1 -1
- data/README.md +66 -15
- 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/async_macros.rb +30 -1
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
- data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
- data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
- data/lib/ruby_reactor/dsl/map_builder.rb +27 -9
- data/lib/ruby_reactor/dsl/step_builder.rb +93 -34
- data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
- data/lib/ruby_reactor/error/rescuable.rb +33 -0
- data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +63 -35
- data/lib/ruby_reactor/executor/result_handler.rb +85 -63
- data/lib/ruby_reactor/executor/retry_manager.rb +4 -3
- data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
- data/lib/ruby_reactor/executor/step_executor.rb +61 -49
- data/lib/ruby_reactor/executor.rb +221 -7
- data/lib/ruby_reactor/map/collector.rb +31 -89
- data/lib/ruby_reactor/map/dispatcher.rb +65 -8
- data/lib/ruby_reactor/map/element_executor.rb +21 -11
- data/lib/ruby_reactor/map/element_rollback.rb +158 -0
- data/lib/ruby_reactor/map/helpers.rb +11 -125
- data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
- 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 +65 -2
- data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
- data/lib/ruby_reactor/rspec/matchers.rb +59 -24
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
- data/lib/ruby_reactor/rspec/test_subject.rb +13 -3
- data/lib/ruby_reactor/rspec.rb +3 -6
- data/lib/ruby_reactor/step/compose_step.rb +10 -1
- data/lib/ruby_reactor/step/map_step.rb +144 -36
- data/lib/ruby_reactor/step.rb +7 -0
- data/lib/ruby_reactor/step_worker.rb +46 -22
- data/lib/ruby_reactor/storage/adapter.rb +18 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +27 -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-CeZU-ESu.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 +7 -3
- data/lib/ruby_reactor.rb +17 -6
- data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
- data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
- data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
- data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
- data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
- data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
- data/specs/007-execution-flow-analysis/data-model.md +83 -0
- data/specs/007-execution-flow-analysis/evidence/harness.rb +231 -0
- data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
- data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
- data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
- data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +235 -0
- data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
- data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
- data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
- data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
- data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
- data/specs/007-execution-flow-analysis/plan.md +127 -0
- data/specs/007-execution-flow-analysis/quickstart.md +51 -0
- data/specs/007-execution-flow-analysis/research.md +202 -0
- data/specs/007-execution-flow-analysis/spec.md +270 -0
- data/specs/007-execution-flow-analysis/tasks.md +257 -0
- data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
- data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
- data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
- data/specs/008-rollback-reliability/data-model.md +139 -0
- data/specs/008-rollback-reliability/plan.md +233 -0
- data/specs/008-rollback-reliability/quickstart.md +105 -0
- data/specs/008-rollback-reliability/research.md +653 -0
- data/specs/008-rollback-reliability/spec.md +561 -0
- data/specs/008-rollback-reliability/tasks.md +1110 -0
- 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/future_improvements.md +64 -28
- metadata +54 -3
- 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
|
|
7
|
-
#
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
#
|
|
70
|
-
|
|
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(...)
|
|
112
|
-
#
|
|
113
|
-
#
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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,
|
|
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[:
|
|
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
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
517
|
-
# `
|
|
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
|
|
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
|
-
|
|
658
|
-
|
|
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)
|