ruby_reactor 0.7.0 → 0.8.0
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/.claude/skills/demo-app-e2e-verify/SKILL.md +226 -0
- data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +92 -15
- data/.specify/templates/plan-template.md +4 -0
- data/.specify/templates/tasks-template.md +8 -1
- data/CHANGELOG.md +151 -0
- data/CLAUDE.md +2 -2
- data/README.md +149 -40
- data/lib/ruby_reactor/context.rb +9 -2
- data/lib/ruby_reactor/context_serializer.rb +13 -0
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
- data/lib/ruby_reactor/dsl/lockable.rb +2 -2
- data/lib/ruby_reactor/dsl/reactor.rb +36 -18
- data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
- data/lib/ruby_reactor/dsl/template_helpers.rb +13 -5
- data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
- data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
- data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +9 -9
- data/lib/ruby_reactor/executor/result_handler.rb +44 -13
- data/lib/ruby_reactor/executor/retry_manager.rb +6 -2
- data/lib/ruby_reactor/executor/step_executor.rb +27 -14
- data/lib/ruby_reactor/executor.rb +20 -15
- data/lib/ruby_reactor/map/element_executor.rb +7 -1
- data/lib/ruby_reactor/map/helpers.rb +9 -7
- data/lib/ruby_reactor/map/result_enumerator.rb +2 -0
- data/lib/ruby_reactor/map/sweeper.rb +1 -1
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
- data/lib/ruby_reactor/open_telemetry.rb +7 -4
- data/lib/ruby_reactor/ordered_lock.rb +3 -3
- data/lib/ruby_reactor/reactor.rb +9 -12
- data/lib/ruby_reactor/rspec/matchers.rb +64 -17
- data/lib/ruby_reactor/rspec/test_subject.rb +8 -8
- data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
- data/lib/ruby_reactor/step/compose_step.rb +56 -75
- data/lib/ruby_reactor/step/input_contract.rb +128 -0
- data/lib/ruby_reactor/step/map_step.rb +178 -215
- data/lib/ruby_reactor/step.rb +119 -18
- data/lib/ruby_reactor/step_signals.rb +37 -0
- data/lib/ruby_reactor/step_worker.rb +25 -10
- data/lib/ruby_reactor/storage/adapter.rb +4 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +1 -72
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
- data/lib/ruby_reactor/template/result.rb +9 -2
- data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +23 -6
- data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
- data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor.rb +61 -9
- data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
- data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
- data/specs/002-step-input-contracts/data-model.md +115 -0
- data/specs/002-step-input-contracts/plan.md +165 -0
- data/specs/002-step-input-contracts/quickstart.md +170 -0
- data/specs/002-step-input-contracts/research.md +233 -0
- data/specs/002-step-input-contracts/spec.md +359 -0
- data/specs/002-step-input-contracts/tasks.md +367 -0
- data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
- data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
- data/specs/004-inheritable-step-class/data-model.md +116 -0
- data/specs/004-inheritable-step-class/plan.md +174 -0
- data/specs/004-inheritable-step-class/quickstart.md +112 -0
- data/specs/004-inheritable-step-class/research.md +308 -0
- data/specs/004-inheritable-step-class/spec.md +316 -0
- data/specs/004-inheritable-step-class/tasks.md +258 -0
- data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
- data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
- data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
- data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
- data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
- data/specs/deferred-003-step-lock-declarations/research.md +196 -0
- data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
- data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
- data/specs/possible_feature.md +22 -0
- metadata +34 -11
- data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +0 -1
- data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +0 -22
- data/specs/001-background-async-steps/checklists/requirements.md +0 -39
- data/specs/001-background-async-steps/contracts/public-dsl.md +0 -154
- data/specs/001-background-async-steps/data-model.md +0 -117
- data/specs/001-background-async-steps/plan.md +0 -168
- data/specs/001-background-async-steps/quickstart.md +0 -102
- data/specs/001-background-async-steps/research.md +0 -150
- data/specs/001-background-async-steps/spec.md +0 -146
- data/specs/001-background-async-steps/tasks.md +0 -271
|
@@ -25,17 +25,38 @@ module RubyReactor
|
|
|
25
25
|
@args_validator = nil
|
|
26
26
|
@output_validator = nil
|
|
27
27
|
@retry_config = {}
|
|
28
|
+
@inline_contract = nil
|
|
29
|
+
@rule_sites = []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Deprecation notices print once per declaration site for the process.
|
|
33
|
+
def self.deprecation_sites
|
|
34
|
+
@deprecation_sites ||= Set.new
|
|
28
35
|
end
|
|
29
36
|
|
|
30
37
|
def argument(name, source, type = nil, transform: nil, **predicates)
|
|
31
38
|
@arguments[name] = {
|
|
32
39
|
source: source,
|
|
33
|
-
transform: transform
|
|
40
|
+
transform: transform,
|
|
41
|
+
origin: :explicit
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
return unless type || predicates.any?
|
|
37
45
|
|
|
38
46
|
@arg_validations << [name, type, false, predicates]
|
|
47
|
+
@rule_sites << [name, caller_locations(1, 1).first]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# An inline step's input contract. Inside the block `input` declares, as
|
|
51
|
+
# in a step class; outside it `input(:x)` stays the template reference.
|
|
52
|
+
def inputs(&block)
|
|
53
|
+
if @impl
|
|
54
|
+
raise Error::ValidationError,
|
|
55
|
+
"step :#{@name}: `inputs` is for inline steps; declare `input` inside #{@impl} instead."
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
@inline_contract ||= RubyReactor::Step::InputContract.new(owner: @name)
|
|
59
|
+
@inline_contract.instance_eval(&block)
|
|
39
60
|
end
|
|
40
61
|
|
|
41
62
|
def run(&block)
|
|
@@ -67,6 +88,7 @@ module RubyReactor
|
|
|
67
88
|
# pre-built schema) is applied last and wins on conflicts.
|
|
68
89
|
def validate_args(schema_or_validator = nil, &block)
|
|
69
90
|
@validate_args_input = block || schema_or_validator
|
|
91
|
+
@validate_args_site = caller_locations(1, 1).first
|
|
70
92
|
end
|
|
71
93
|
|
|
72
94
|
# Scalar-aware output validation.
|
|
@@ -114,6 +136,9 @@ module RubyReactor
|
|
|
114
136
|
# unit rather than run inline — `:step` for `async_step`, `:reactor` for
|
|
115
137
|
# `async_reactor`. Nil for an ordinary step.
|
|
116
138
|
def build(async_dispatch: nil)
|
|
139
|
+
check_contract_conflicts!
|
|
140
|
+
warn_deprecated_rules
|
|
141
|
+
|
|
117
142
|
step_config = {
|
|
118
143
|
async_dispatch: async_dispatch,
|
|
119
144
|
name: @name,
|
|
@@ -127,16 +152,78 @@ module RubyReactor
|
|
|
127
152
|
dependencies: @dependencies,
|
|
128
153
|
args_validator: @args_validator || build_args_validator(@arg_validations, @validate_args_input),
|
|
129
154
|
output_validator: @output_validator,
|
|
155
|
+
inline_contract: @inline_contract,
|
|
130
156
|
retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
|
|
131
157
|
}
|
|
132
158
|
|
|
133
159
|
RubyReactor::Dsl::StepConfig.new(step_config)
|
|
134
160
|
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
# A step that owns its input contract takes wiring only from the
|
|
165
|
+
# reactor: rules here would be a second, overlapping rule set.
|
|
166
|
+
def check_contract_conflicts!
|
|
167
|
+
contract = owned_contract
|
|
168
|
+
return unless contract
|
|
169
|
+
|
|
170
|
+
owner = @impl || "its `inputs do ... end` block"
|
|
171
|
+
if (arg = @arg_validations.first&.first)
|
|
172
|
+
raise Error::ValidationError,
|
|
173
|
+
"#{reactor_label} step :#{@name} declares rules on argument :#{arg}, but #{owner} owns its input " \
|
|
174
|
+
"contract. Move the rule into #{owner} (`input :#{arg}, ...`) and keep only the wiring here: " \
|
|
175
|
+
"`argument :#{arg}, <source>`."
|
|
176
|
+
end
|
|
177
|
+
if @validate_args_input
|
|
178
|
+
raise Error::ValidationError,
|
|
179
|
+
"#{reactor_label} step :#{@name} declares `validate_args`, but #{owner} owns its input contract. " \
|
|
180
|
+
"Move the rule into #{owner} (`validate_inputs ...`) and keep only the wiring here."
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
unknown = @arguments.keys.find { |name| !contract.declares?(name) }
|
|
184
|
+
return unless unknown
|
|
185
|
+
|
|
186
|
+
raise Error::ValidationError,
|
|
187
|
+
"#{reactor_label} step :#{@name} wires argument :#{unknown}, which #{owner} does not declare. " \
|
|
188
|
+
"Declared inputs: #{contract.declarations.keys.join(", ")}."
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Rules on `argument` / `validate_args` still work for a step without a
|
|
192
|
+
# contract; they now belong on the step itself.
|
|
193
|
+
def warn_deprecated_rules
|
|
194
|
+
target = @impl || "an `inputs do ... end` block"
|
|
195
|
+
@rule_sites.each do |arg, site|
|
|
196
|
+
warn_deprecation(site, "step :#{@name} declares rules on `argument :#{arg}`. Declare them on the step " \
|
|
197
|
+
"instead (`input :#{arg}, ...` in #{target}) and keep `argument :#{arg}, <source>` " \
|
|
198
|
+
"for wiring.")
|
|
199
|
+
end
|
|
200
|
+
return unless @validate_args_site
|
|
201
|
+
|
|
202
|
+
warn_deprecation(@validate_args_site, "step :#{@name} declares rules with `validate_args`. Declare them on " \
|
|
203
|
+
"the step instead (`validate_inputs` in #{target}).")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def warn_deprecation(site, message)
|
|
207
|
+
location = "#{site.path}:#{site.lineno}"
|
|
208
|
+
return unless StepBuilder.deprecation_sites.add?(location)
|
|
209
|
+
|
|
210
|
+
warn "[RubyReactor] DEPRECATION: #{location} #{reactor_label} #{message} " \
|
|
211
|
+
"Removal no earlier than the next MAJOR."
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def owned_contract
|
|
215
|
+
@inline_contract || (@impl.input_contract if @impl.respond_to?(:declares_inputs?) && @impl.declares_inputs?)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def reactor_label
|
|
219
|
+
@reactor&.name || @reactor.inspect
|
|
220
|
+
end
|
|
135
221
|
end
|
|
136
222
|
|
|
137
223
|
class StepConfig
|
|
138
224
|
attr_reader :name, :impl, :arguments, :run_block, :compensate_block, :undo_block, :conditions, :guards,
|
|
139
|
-
:dependencies, :args_validator, :output_validator, :retry_config, :async_dispatch
|
|
225
|
+
:dependencies, :args_validator, :output_validator, :retry_config, :async_dispatch,
|
|
226
|
+
:inline_contract
|
|
140
227
|
|
|
141
228
|
def initialize(config)
|
|
142
229
|
@async_dispatch = config[:async_dispatch]
|
|
@@ -151,6 +238,7 @@ module RubyReactor
|
|
|
151
238
|
@dependencies = config[:dependencies] || []
|
|
152
239
|
@args_validator = config[:args_validator]
|
|
153
240
|
@output_validator = config[:output_validator]
|
|
241
|
+
@inline_contract = config[:inline_contract]
|
|
154
242
|
@retry_config = { max_attempts: 1 }.merge(config[:retry_config] || {})
|
|
155
243
|
end
|
|
156
244
|
|
|
@@ -165,6 +253,11 @@ module RubyReactor
|
|
|
165
253
|
!@impl.nil?
|
|
166
254
|
end
|
|
167
255
|
|
|
256
|
+
# The contract that governs this step's inputs, or nil when it has none.
|
|
257
|
+
def input_contract
|
|
258
|
+
@inline_contract || (@impl.input_contract if @impl.respond_to?(:declares_inputs?) && @impl.declares_inputs?)
|
|
259
|
+
end
|
|
260
|
+
|
|
168
261
|
def has_run_block?
|
|
169
262
|
!@run_block.nil?
|
|
170
263
|
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module RubyReactor
|
|
4
4
|
module Dsl
|
|
5
5
|
module TemplateHelpers
|
|
6
|
+
include RubyReactor::StepSignals
|
|
7
|
+
|
|
6
8
|
def input(name, path = nil)
|
|
7
9
|
RubyReactor::Template::Input.new(name, path)
|
|
8
10
|
end
|
|
@@ -19,7 +21,7 @@ module RubyReactor
|
|
|
19
21
|
RubyReactor::Template::Element.new(map_name, path)
|
|
20
22
|
end
|
|
21
23
|
|
|
22
|
-
# Make Success, Failure, and Skipped available in DSL contexts
|
|
24
|
+
# Make Success, Failure, Halt, and Skipped available in DSL contexts
|
|
23
25
|
# rubocop:disable Naming/MethodName
|
|
24
26
|
def Success(value = nil)
|
|
25
27
|
# rubocop:enable Naming/MethodName
|
|
@@ -27,15 +29,21 @@ module RubyReactor
|
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
# rubocop:disable Naming/MethodName
|
|
30
|
-
def Failure(
|
|
32
|
+
def Failure(...)
|
|
33
|
+
# rubocop:enable Naming/MethodName
|
|
34
|
+
RubyReactor.Failure(...)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# rubocop:disable Naming/MethodName
|
|
38
|
+
def Halt(reason: nil, **kwargs)
|
|
31
39
|
# rubocop:enable Naming/MethodName
|
|
32
|
-
RubyReactor.
|
|
40
|
+
RubyReactor.Halt(reason: reason, **kwargs)
|
|
33
41
|
end
|
|
34
42
|
|
|
35
43
|
# rubocop:disable Naming/MethodName
|
|
36
|
-
def Skipped(
|
|
44
|
+
def Skipped(...)
|
|
37
45
|
# rubocop:enable Naming/MethodName
|
|
38
|
-
RubyReactor.Skipped(
|
|
46
|
+
RubyReactor.Skipped(...)
|
|
39
47
|
end
|
|
40
48
|
end
|
|
41
49
|
end
|
|
@@ -36,6 +36,23 @@ module RubyReactor
|
|
|
36
36
|
RubyReactor::Validation::InputValidator.new(schema)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
# Dispatch across the layered `input` forms, shared by reactor inputs and
|
|
40
|
+
# step input contracts:
|
|
41
|
+
# Form 3 — pre-built schema / contract (`validate:`)
|
|
42
|
+
# Form 2 — block bound to the value macro (`do |i| ... end`)
|
|
43
|
+
# legacy — single-key schema block (`do required(:name)... end`)
|
|
44
|
+
# Form 1 / 1b — inline scalar or class type
|
|
45
|
+
# Form 0 — declaration only (no validator)
|
|
46
|
+
def build_declaration_validator(name, type, optional, validate, predicates, &block)
|
|
47
|
+
if validate
|
|
48
|
+
create_input_validator(validate)
|
|
49
|
+
elsif block
|
|
50
|
+
block.arity.nonzero? ? build_macro_validator(name, optional, &block) : create_input_validator(block)
|
|
51
|
+
elsif type || predicates.any?
|
|
52
|
+
build_inline_validator(name, type, optional, predicates)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
39
56
|
# Compose per-argument inline rules with an optional `validate_args`
|
|
40
57
|
# block / pre-built schema. Returns nil when there is nothing to validate.
|
|
41
58
|
def build_args_validator(inline_rules, validate_input)
|
|
@@ -3,18 +3,25 @@
|
|
|
3
3
|
module RubyReactor
|
|
4
4
|
module Error
|
|
5
5
|
class StepFailureError < Base
|
|
6
|
-
attr_reader :step_arguments, :exception_class
|
|
6
|
+
attr_reader :step_arguments, :exception_class, :validation_errors
|
|
7
7
|
|
|
8
8
|
# rubocop:disable Metrics/ParameterLists
|
|
9
|
-
def initialize(message, step: nil, context: nil, original_error: nil, step_arguments: {}, exception_class: nil
|
|
9
|
+
def initialize(message, step: nil, context: nil, original_error: nil, step_arguments: {}, exception_class: nil,
|
|
10
|
+
validation_errors: nil)
|
|
10
11
|
# rubocop:enable Metrics/ParameterLists
|
|
11
12
|
super(message, step: step, context: context, original_error: original_error)
|
|
12
13
|
@step_arguments = step_arguments
|
|
13
14
|
@exception_class = exception_class
|
|
15
|
+
@validation_errors = validation_errors
|
|
14
16
|
end
|
|
15
17
|
|
|
18
|
+
# Defers to the original error when it has an opinion (e.g. a step's own
|
|
19
|
+
# `Error::InputValidationError`, always non-retryable) so that opinion
|
|
20
|
+
# survives being wrapped into a StepFailureError — the same
|
|
21
|
+
# `respond_to?(:retryable?)` protocol `RubyReactor::Failure` itself uses.
|
|
22
|
+
# Defaults to `true` for an ordinary step failure, unchanged from before.
|
|
16
23
|
def retryable?
|
|
17
|
-
true
|
|
24
|
+
original_error.respond_to?(:retryable?) ? original_error.retryable? : true
|
|
18
25
|
end
|
|
19
26
|
end
|
|
20
27
|
end
|
|
@@ -55,33 +55,44 @@ module RubyReactor
|
|
|
55
55
|
@context.middlewares || RubyReactor::MiddlewareRunner.new([])
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
# Ensure we have a value to log (if it's a Success/Failure object, get the value or error)
|
|
59
|
+
def loggable_value(result)
|
|
60
|
+
if result.respond_to?(:value)
|
|
61
|
+
result.value
|
|
62
|
+
elsif result.respond_to?(:error)
|
|
63
|
+
result.error
|
|
64
|
+
else
|
|
65
|
+
result
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def skipped_result?(result)
|
|
70
|
+
result.respond_to?(:skipped?) && result.skipped?
|
|
71
|
+
end
|
|
72
|
+
|
|
58
73
|
def compensate_step(step_config, error, arguments)
|
|
59
74
|
middlewares.on(:start_compensation, step_config.name, error, arguments, @context)
|
|
60
75
|
begin
|
|
61
|
-
compensate_result =
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
timestamp: Time.now,
|
|
82
|
-
result: logged_result,
|
|
83
|
-
arguments: arguments
|
|
84
|
-
}
|
|
76
|
+
compensate_result = catch(StepSignals::TAG) do
|
|
77
|
+
if step_config.compensate_block
|
|
78
|
+
step_config.compensate_block.call(error, arguments, @context)
|
|
79
|
+
elsif step_config.has_impl?
|
|
80
|
+
step_config.impl.compensate(error, arguments, @context)
|
|
81
|
+
else
|
|
82
|
+
RubyReactor.Skipped() # Default: nothing defined, rollback continues
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
@context.append_execution_trace(
|
|
87
|
+
{
|
|
88
|
+
type: :compensate,
|
|
89
|
+
step: step_config.name,
|
|
90
|
+
timestamp: Time.now,
|
|
91
|
+
result: loggable_value(compensate_result),
|
|
92
|
+
arguments: arguments,
|
|
93
|
+
skipped: skipped_result?(compensate_result)
|
|
94
|
+
}
|
|
95
|
+
)
|
|
85
96
|
@undo_trace << { type: :compensation, step: step_config.name, error: error, arguments: arguments }
|
|
86
97
|
|
|
87
98
|
if compensate_result.is_a?(RubyReactor::Failure)
|
|
@@ -100,25 +111,26 @@ module RubyReactor
|
|
|
100
111
|
def undo_step(step_config, result, arguments)
|
|
101
112
|
middlewares.on(:start_undo, step_config.name, result, arguments, @context)
|
|
102
113
|
begin
|
|
103
|
-
undo_result =
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
114
|
+
undo_result = catch(StepSignals::TAG) do
|
|
115
|
+
if step_config.undo_block
|
|
116
|
+
step_config.undo_block.call(result.value, arguments, @context)
|
|
117
|
+
elsif step_config.has_impl?
|
|
118
|
+
step_config.impl.undo(result.value, arguments, @context)
|
|
119
|
+
else
|
|
120
|
+
RubyReactor.Skipped() # Default: nothing defined, rollback continues
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
@context.append_execution_trace(
|
|
125
|
+
{
|
|
126
|
+
type: :undo,
|
|
127
|
+
step: step_config.name,
|
|
128
|
+
timestamp: Time.now,
|
|
129
|
+
result: loggable_value(undo_result),
|
|
130
|
+
arguments: arguments,
|
|
131
|
+
skipped: skipped_result?(undo_result)
|
|
132
|
+
}
|
|
133
|
+
)
|
|
122
134
|
|
|
123
135
|
if undo_result.is_a?(RubyReactor::Failure)
|
|
124
136
|
middlewares.on(:failed_undo, step_config.name, undo_result, @context)
|
|
@@ -130,8 +142,9 @@ module RubyReactor
|
|
|
130
142
|
rescue StandardError => e
|
|
131
143
|
middlewares.on(:failed_undo, step_config.name, e, @context)
|
|
132
144
|
# Log undo failure but don't halt the rollback process
|
|
133
|
-
@context.
|
|
134
|
-
|
|
145
|
+
@context.append_execution_trace(
|
|
146
|
+
{ type: :undo_failure, step: step_config.name, timestamp: Time.now, error: e.message }
|
|
147
|
+
)
|
|
135
148
|
RubyReactor.Failure(e)
|
|
136
149
|
end
|
|
137
150
|
end
|
|
@@ -117,14 +117,14 @@ module RubyReactor
|
|
|
117
117
|
@ordered_lock_drained_replay == true
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
-
# Terminal
|
|
120
|
+
# Terminal Halt result when the ordered-lock gate short-circuits this
|
|
121
121
|
# run (stale batch, strict chain failure, or a drained-batch redelivery of
|
|
122
122
|
# an already-terminal context), or nil to continue. Shared by `execute`
|
|
123
123
|
# and `resume_execution`.
|
|
124
124
|
def ordered_lock_short_circuit
|
|
125
|
-
return RubyReactor::
|
|
126
|
-
return RubyReactor::
|
|
127
|
-
return RubyReactor::
|
|
125
|
+
return RubyReactor::Halt.new(reason: :ordered_lock_stale_batch) if ordered_lock_stale_batch?
|
|
126
|
+
return RubyReactor::Halt.new(reason: :ordered_lock_drained_replay) if ordered_lock_drained_replay?
|
|
127
|
+
return RubyReactor::Halt.new(reason: :ordered_lock_chain_failed) if ordered_lock_chain_skip?
|
|
128
128
|
|
|
129
129
|
nil
|
|
130
130
|
end
|
|
@@ -138,12 +138,12 @@ module RubyReactor
|
|
|
138
138
|
def short_circuit!(result)
|
|
139
139
|
@result = result
|
|
140
140
|
|
|
141
|
-
# A stale-batch or drained-batch-redelivery
|
|
141
|
+
# A stale-batch or drained-batch-redelivery halt means this run's epoch
|
|
142
142
|
# belongs to a drained generation — typically a slow straggler or a
|
|
143
143
|
# Sidekiq at-least-once redelivery. If the redelivery is of a job that
|
|
144
144
|
# ALREADY reached a terminal status, its stored context is the source of
|
|
145
|
-
# truth; writing :
|
|
146
|
-
# corrupt the outcome. Return the
|
|
145
|
+
# truth; writing :halted over a :completed/:failed record would silently
|
|
146
|
+
# corrupt the outcome. Return the halt to the worker (so it stops)
|
|
147
147
|
# without saving. The `@skip_context_persist` flag also suppresses the
|
|
148
148
|
# ensure-block save in execute / resume_execution, which would otherwise
|
|
149
149
|
# clobber the stored terminal record with this run's stale in-memory
|
|
@@ -169,14 +169,14 @@ module RubyReactor
|
|
|
169
169
|
# set when the status was terminal, but re-checking keeps both paths
|
|
170
170
|
# uniform and self-guarding.
|
|
171
171
|
def redelivery_of_terminal?(result)
|
|
172
|
-
return false unless result.is_a?(RubyReactor::
|
|
172
|
+
return false unless result.is_a?(RubyReactor::Halt)
|
|
173
173
|
return false unless %i[ordered_lock_stale_batch ordered_lock_drained_replay].include?(result.reason)
|
|
174
174
|
|
|
175
175
|
stored_status_terminal?
|
|
176
176
|
end
|
|
177
177
|
|
|
178
178
|
def stored_status_terminal?
|
|
179
|
-
%w[completed failed skipped].include?(stored_context_status)
|
|
179
|
+
%w[completed failed halted skipped].include?(stored_context_status)
|
|
180
180
|
end
|
|
181
181
|
|
|
182
182
|
def stored_context_status
|
|
@@ -14,9 +14,13 @@ module RubyReactor
|
|
|
14
14
|
|
|
15
15
|
def handle_step_result(step_config, result, resolved_arguments)
|
|
16
16
|
case result
|
|
17
|
+
when RubyReactor::Halt
|
|
18
|
+
# Important: must come before Skipped and Success — both are Halt's
|
|
19
|
+
# siblings under Success, and Halt takes precedence over either.
|
|
20
|
+
handle_halt(step_config, result)
|
|
17
21
|
when RubyReactor::Skipped
|
|
18
|
-
#
|
|
19
|
-
handle_skipped(step_config, result)
|
|
22
|
+
# Must come before the Success branch — Skipped < Success.
|
|
23
|
+
handle_skipped(step_config, result, resolved_arguments)
|
|
20
24
|
when RubyReactor::Success
|
|
21
25
|
handle_success(step_config, result, resolved_arguments)
|
|
22
26
|
when RubyReactor::MaxRetriesExhaustedFailure
|
|
@@ -78,19 +82,40 @@ module RubyReactor
|
|
|
78
82
|
)
|
|
79
83
|
end
|
|
80
84
|
|
|
81
|
-
# A step returned `RubyReactor.
|
|
85
|
+
# A step returned `RubyReactor.Halt(...)`. Halt cleanly: record the
|
|
82
86
|
# event in the trace, do NOT push to the undo stack (so existing
|
|
83
87
|
# completed steps stay as-is — no compensation), and stamp the step
|
|
84
88
|
# name on the result so the caller can see who halted.
|
|
85
|
-
def
|
|
89
|
+
def handle_halt(step_config, result)
|
|
86
90
|
@step_results[step_config.name] = result
|
|
87
91
|
result.instance_variable_set(:@step_name, step_config.name) if result.step_name.nil?
|
|
88
|
-
@context.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
@context.append_execution_trace(
|
|
93
|
+
{
|
|
94
|
+
type: :halt,
|
|
95
|
+
step: step_config.name,
|
|
96
|
+
timestamp: Time.now,
|
|
97
|
+
reason: result.reason
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
result
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A step returned `RubyReactor.Skipped(...)`. The reactor continues:
|
|
104
|
+
# validate and record the value exactly like a Success, but do NOT push
|
|
105
|
+
# to the undo stack (nothing happened, so there is nothing to undo).
|
|
106
|
+
def handle_skipped(step_config, result, resolved_arguments)
|
|
107
|
+
validate_step_output(step_config, result.value, resolved_arguments)
|
|
108
|
+
@step_results[step_config.name] = result
|
|
109
|
+
@context.set_result(step_config.name, result.value)
|
|
110
|
+
@dependency_graph.complete_step(step_config.name)
|
|
111
|
+
@context.append_execution_trace(
|
|
112
|
+
{
|
|
113
|
+
type: :skipped,
|
|
114
|
+
step: step_config.name,
|
|
115
|
+
timestamp: Time.now,
|
|
116
|
+
reason: result.reason
|
|
117
|
+
}
|
|
118
|
+
)
|
|
94
119
|
result
|
|
95
120
|
end
|
|
96
121
|
|
|
@@ -118,7 +143,8 @@ module RubyReactor
|
|
|
118
143
|
orig_err = result.original_error.is_a?(Exception) ? result.original_error : nil
|
|
119
144
|
error = Error::StepFailureError.new(result.error, step: step_config.name, context: @context,
|
|
120
145
|
original_error: orig_err,
|
|
121
|
-
step_arguments: resolved_arguments
|
|
146
|
+
step_arguments: resolved_arguments,
|
|
147
|
+
validation_errors: result.validation_errors)
|
|
122
148
|
if result.respond_to?(:backtrace) && result.backtrace
|
|
123
149
|
error.set_backtrace(result.backtrace)
|
|
124
150
|
elsif orig_err
|
|
@@ -130,9 +156,12 @@ module RubyReactor
|
|
|
130
156
|
def handle_failure(step_config, result, resolved_arguments)
|
|
131
157
|
failure_result = @compensation_manager.handle_step_failure(step_config, result.error, resolved_arguments)
|
|
132
158
|
orig_err = result.error.is_a?(Exception) ? result.error : nil
|
|
159
|
+
# A step that propagates another unit's validation failure (an
|
|
160
|
+
# async_step reader) keeps its field errors on the reactor's failure.
|
|
133
161
|
error = Error::StepFailureError.new(failure_result.error, step: step_config.name, context: @context,
|
|
134
162
|
original_error: orig_err,
|
|
135
|
-
step_arguments: resolved_arguments
|
|
163
|
+
step_arguments: resolved_arguments,
|
|
164
|
+
validation_errors: result.validation_errors)
|
|
136
165
|
if result.respond_to?(:backtrace) && result.backtrace
|
|
137
166
|
error.set_backtrace(result.backtrace)
|
|
138
167
|
elsif orig_err
|
|
@@ -197,7 +226,9 @@ module RubyReactor
|
|
|
197
226
|
exception_class: exception_class,
|
|
198
227
|
file_path: file_path,
|
|
199
228
|
line_number: line_number,
|
|
200
|
-
code_snippet: code_snippet
|
|
229
|
+
code_snippet: code_snippet,
|
|
230
|
+
validation_errors: error.validation_errors,
|
|
231
|
+
retryable: error.retryable?
|
|
201
232
|
)
|
|
202
233
|
end
|
|
203
234
|
|
|
@@ -94,7 +94,10 @@ module RubyReactor
|
|
|
94
94
|
|
|
95
95
|
def handle_retry_result(step_config, reactor_class, result)
|
|
96
96
|
case result
|
|
97
|
-
when RubyReactor::Success
|
|
97
|
+
when RubyReactor::Halt, RubyReactor::Skipped, RubyReactor::Success
|
|
98
|
+
# Halt and Skipped are Success subclasses, so they already take this
|
|
99
|
+
# path via inheritance; the explicit arms are readability plus a
|
|
100
|
+
# guard against a future hierarchy change (R5).
|
|
98
101
|
clear_retry_state
|
|
99
102
|
result
|
|
100
103
|
when RubyReactor::Failure
|
|
@@ -182,7 +185,8 @@ module RubyReactor
|
|
|
182
185
|
[]
|
|
183
186
|
end,
|
|
184
187
|
reactor_name: reactor_class.name,
|
|
185
|
-
step_arguments: result.respond_to?(:step_arguments) ? result.step_arguments : {}
|
|
188
|
+
step_arguments: result.respond_to?(:step_arguments) ? result.step_arguments : {},
|
|
189
|
+
validation_errors: result.validation_errors
|
|
186
190
|
)
|
|
187
191
|
end
|
|
188
192
|
|
|
@@ -37,10 +37,10 @@ module RubyReactor
|
|
|
37
37
|
# If a step returns RetryQueuedResult, we need to stop and return it
|
|
38
38
|
return result if result.is_a?(RetryQueuedResult)
|
|
39
39
|
|
|
40
|
-
# If a step returns
|
|
40
|
+
# If a step returns Halt, stop the reactor cleanly (no
|
|
41
41
|
# compensation). Must be checked BEFORE Failure / Success because
|
|
42
|
-
#
|
|
43
|
-
return result if result.is_a?(RubyReactor::
|
|
42
|
+
# Halt is a Success subclass.
|
|
43
|
+
return result if result.is_a?(RubyReactor::Halt)
|
|
44
44
|
|
|
45
45
|
# If a step returns Failure, we need to stop execution and return it
|
|
46
46
|
return result if result.is_a?(RubyReactor::Failure)
|
|
@@ -48,13 +48,17 @@ module RubyReactor
|
|
|
48
48
|
# If a step returns InterruptResult, we need to stop execution and return it
|
|
49
49
|
return result if result.is_a?(RubyReactor::InterruptResult)
|
|
50
50
|
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
51
|
+
# A Skipped step (or a plain Success) continues the loop — Skipped
|
|
52
|
+
# is a Success subclass, so this also fires the durable checkpoint
|
|
53
|
+
# for it, same as a plain success.
|
|
54
|
+
#
|
|
55
|
+
# Only a continue-Success/Skipped reaches here (Async/Retry/Halt/
|
|
56
|
+
# Failure/Interrupt all returned above; nil is inline-async test
|
|
57
|
+
# mode). It is the one outcome where the loop proceeds to more
|
|
58
|
+
# steps with no other save in between — every terminal/handoff
|
|
59
|
+
# result persists via its own path. Write a durable checkpoint so
|
|
60
|
+
# a crash re-runs at most this one step. Ordering: side-effect ->
|
|
61
|
+
# record result (inside execute_step) -> checkpoint here.
|
|
58
62
|
@on_step_complete&.call if result.is_a?(RubyReactor::Success)
|
|
59
63
|
end
|
|
60
64
|
end
|
|
@@ -179,9 +183,13 @@ module RubyReactor
|
|
|
179
183
|
def safe_execute_step_sync(step_config, resolved_arguments = nil)
|
|
180
184
|
resolved_arguments ||= resolve_arguments(step_config)
|
|
181
185
|
execute_step_sync_without_result_handling(step_config, resolved_arguments)
|
|
182
|
-
rescue Error::InputValidationError
|
|
186
|
+
rescue Error::InputValidationError => e
|
|
183
187
|
# Validation failures are not retryable and must surface as a structured
|
|
184
188
|
# InputValidationError (with field_errors), so let them propagate.
|
|
189
|
+
# A step class stamps its own class name; inside a reactor the step's
|
|
190
|
+
# name there is the useful attribution, so it overwrites.
|
|
191
|
+
e.step_name = step_config.name
|
|
192
|
+
e.step_arguments ||= resolved_arguments
|
|
185
193
|
raise
|
|
186
194
|
rescue StandardError => e
|
|
187
195
|
# Identify redacted inputs
|
|
@@ -336,15 +344,20 @@ module RubyReactor
|
|
|
336
344
|
end
|
|
337
345
|
|
|
338
346
|
def run_step_implementation(step_config, arguments)
|
|
339
|
-
|
|
347
|
+
contract = step_config.input_contract
|
|
348
|
+
@context.append_execution_trace(
|
|
349
|
+
{ type: :run, step: step_config.name, timestamp: Time.now,
|
|
350
|
+
arguments: contract ? contract.redact(arguments) : arguments }
|
|
351
|
+
)
|
|
340
352
|
if step_config.has_run_block?
|
|
341
353
|
# Execute inline block
|
|
342
354
|
# If no arguments are defined for the step, pass the reactor inputs as arguments
|
|
343
355
|
args_to_pass = arguments.empty? ? @context.inputs : arguments
|
|
344
|
-
step_config.
|
|
356
|
+
args_to_pass = step_config.inline_contract.enforce!(args_to_pass) if step_config.inline_contract
|
|
357
|
+
catch(StepSignals::TAG) { step_config.run_block.call(args_to_pass, @context) }
|
|
345
358
|
elsif step_config.has_impl?
|
|
346
359
|
# Execute step class
|
|
347
|
-
step_config.impl.run(arguments, @context)
|
|
360
|
+
catch(StepSignals::TAG) { step_config.impl.run(arguments, @context) }
|
|
348
361
|
else
|
|
349
362
|
raise Error::ValidationError.new(
|
|
350
363
|
"Step '#{step_config.name}' has no implementation",
|