ruby_reactor 0.8.3 → 0.8.5
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/CHANGELOG.md +208 -0
- data/CLAUDE.md +1 -1
- data/README.md +140 -102
- data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +10 -7
- data/lib/ruby_reactor/dsl/compose_builder.rb +10 -11
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
- data/lib/ruby_reactor/dsl/map_builder.rb +0 -2
- data/lib/ruby_reactor/dsl/reactor.rb +8 -11
- data/lib/ruby_reactor/dsl/retryable.rb +45 -0
- data/lib/ruby_reactor/dsl/step_builder.rb +131 -33
- data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
- data/lib/ruby_reactor/error/rescuable.rb +28 -0
- data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +30 -26
- data/lib/ruby_reactor/executor/result_handler.rb +21 -18
- data/lib/ruby_reactor/executor/retry_manager.rb +11 -12
- data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
- data/lib/ruby_reactor/executor/step_executor.rb +59 -49
- data/lib/ruby_reactor/executor.rb +38 -4
- data/lib/ruby_reactor/map/collector.rb +21 -11
- data/lib/ruby_reactor/map/dispatcher.rb +29 -3
- data/lib/ruby_reactor/map/element_executor.rb +9 -3
- data/lib/ruby_reactor/map/helpers.rb +32 -2
- data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
- data/lib/ruby_reactor/reactor.rb +24 -0
- data/lib/ruby_reactor/rspec/matchers.rb +19 -3
- data/lib/ruby_reactor/rspec/test_subject.rb +3 -5
- data/lib/ruby_reactor/step/async_reactor_step.rb +6 -2
- data/lib/ruby_reactor/step/compose_step.rb +14 -4
- data/lib/ruby_reactor/step/input_contract.rb +5 -0
- data/lib/ruby_reactor/step/inputs.rb +57 -0
- data/lib/ruby_reactor/step/map_step.rb +140 -25
- data/lib/ruby_reactor/step.rb +20 -7
- data/lib/ruby_reactor/step_worker.rb +46 -22
- data/lib/ruby_reactor/storage/adapter.rb +4 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +9 -0
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +1 -1
- data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-CQbgHtd0.js} +10 -10
- data/lib/ruby_reactor/web/public/index.html +1 -1
- data/lib/ruby_reactor/worker.rb +3 -1
- data/lib/ruby_reactor.rb +20 -7
- data/specs/006-step-retry-declarations/checklists/requirements.md +41 -0
- data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
- data/specs/006-step-retry-declarations/data-model.md +58 -0
- data/specs/006-step-retry-declarations/plan.md +187 -0
- data/specs/006-step-retry-declarations/quickstart.md +80 -0
- data/specs/006-step-retry-declarations/research.md +194 -0
- data/specs/006-step-retry-declarations/spec.md +453 -0
- data/specs/006-step-retry-declarations/tasks.md +382 -0
- 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 +229 -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 +232 -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/future_improvements.md +48 -0
- data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
- metadata +47 -2
|
@@ -18,15 +18,21 @@ module RubyReactor
|
|
|
18
18
|
@child_reactor_class = child_reactor_class
|
|
19
19
|
@reactor = reactor
|
|
20
20
|
@argument_mappings = {}
|
|
21
|
-
@retry_config = {}
|
|
22
21
|
end
|
|
23
22
|
|
|
24
23
|
def argument(child_input_name, source)
|
|
25
24
|
@argument_mappings[child_input_name] = source
|
|
26
25
|
end
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
# A parent never retries a nested reactor as a whole (008 R-14): the
|
|
28
|
+
# child owns its steps' retries. Kept as a stub to name the replacement.
|
|
29
|
+
def retries(*)
|
|
30
|
+
raise RubyReactor::Error::DeprecatedDslError.new(
|
|
31
|
+
"`retries` on an `async_reactor` has been removed: a parent never retries a nested " \
|
|
32
|
+
"reactor as a whole. Declare `retries` on :#{@name}'s child reactor's own steps " \
|
|
33
|
+
"(`retries max_attempts: 3` in the step block or the step class); the child retries them itself.",
|
|
34
|
+
step: @name
|
|
35
|
+
)
|
|
30
36
|
end
|
|
31
37
|
|
|
32
38
|
def build
|
|
@@ -44,12 +50,9 @@ module RubyReactor
|
|
|
44
50
|
# that reads `result(:name)` and decides to fail.
|
|
45
51
|
compensate_block: nil,
|
|
46
52
|
undo_block: nil,
|
|
47
|
-
conditions: [],
|
|
48
|
-
guards: [],
|
|
49
53
|
dependencies: dependencies_from_mappings,
|
|
50
54
|
args_validator: nil,
|
|
51
|
-
output_validator: nil
|
|
52
|
-
retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
|
|
55
|
+
output_validator: nil
|
|
53
56
|
)
|
|
54
57
|
end
|
|
55
58
|
|
|
@@ -20,7 +20,6 @@ module RubyReactor
|
|
|
20
20
|
end
|
|
21
21
|
@reactor = reactor
|
|
22
22
|
@argument_mappings = {}
|
|
23
|
-
@retry_config = {}
|
|
24
23
|
end
|
|
25
24
|
|
|
26
25
|
def argument(composed_input_name, source)
|
|
@@ -44,12 +43,15 @@ module RubyReactor
|
|
|
44
43
|
)
|
|
45
44
|
end
|
|
46
45
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
46
|
+
# A parent never retries a nested reactor as a whole (008 R-14): the
|
|
47
|
+
# child owns its steps' retries. Kept as a stub to name the replacement.
|
|
48
|
+
def retries(*)
|
|
49
|
+
raise RubyReactor::Error::DeprecatedDslError.new(
|
|
50
|
+
"`retries` on a `compose` has been removed: a parent never retries a nested " \
|
|
51
|
+
"reactor as a whole. Declare `retries` on :#{@name}'s child reactor's own steps " \
|
|
52
|
+
"(`retries max_attempts: 3` in the step block or the step class); the child retries them itself.",
|
|
53
|
+
step: @name
|
|
54
|
+
)
|
|
53
55
|
end
|
|
54
56
|
|
|
55
57
|
def build
|
|
@@ -66,12 +68,9 @@ module RubyReactor
|
|
|
66
68
|
run_block: nil,
|
|
67
69
|
compensate_block: nil,
|
|
68
70
|
undo_block: nil,
|
|
69
|
-
conditions: [],
|
|
70
|
-
guards: [],
|
|
71
71
|
dependencies: dependencies,
|
|
72
72
|
args_validator: nil,
|
|
73
|
-
output_validator: nil
|
|
74
|
-
retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
|
|
73
|
+
output_validator: nil
|
|
75
74
|
}
|
|
76
75
|
|
|
77
76
|
RubyReactor::Dsl::StepConfig.new(step_config)
|
|
@@ -73,9 +73,7 @@ module RubyReactor
|
|
|
73
73
|
validation_schema: @validation_schema,
|
|
74
74
|
max_attempts: @max_attempts,
|
|
75
75
|
resume_mode: @resume_mode,
|
|
76
|
-
dependencies: @dependencies
|
|
77
|
-
conditions: @conditions,
|
|
78
|
-
guards: @guards
|
|
76
|
+
dependencies: @dependencies
|
|
79
77
|
}
|
|
80
78
|
|
|
81
79
|
RubyReactor::Dsl::InterruptStepConfig.new(step_config)
|
|
@@ -11,7 +11,6 @@ module RubyReactor
|
|
|
11
11
|
base.instance_variable_set(:@middlewares, [])
|
|
12
12
|
base.instance_variable_set(:@input_validations, {})
|
|
13
13
|
base.instance_variable_set(:@background_handoff, nil)
|
|
14
|
-
base.instance_variable_set(:@retry_defaults, { max_attempts: 3, backoff: :exponential, base_delay: 1 })
|
|
15
14
|
end
|
|
16
15
|
|
|
17
16
|
module ClassMethods
|
|
@@ -55,16 +54,14 @@ module RubyReactor
|
|
|
55
54
|
"inputs inside the worker."
|
|
56
55
|
end
|
|
57
56
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
end
|
|
57
|
+
# Reactor-wide retry defaults are gone: the builders snapshotted them at
|
|
58
|
+
# `step` time, so they silently applied only to steps declared AFTER
|
|
59
|
+
# the `retry_defaults` line. A step's retry policy now lives on the step.
|
|
60
|
+
def retry_defaults(*, **)
|
|
61
|
+
raise RubyReactor::Error::DeprecatedDslError,
|
|
62
|
+
"`retry_defaults` has been removed from #{name || "this reactor"}: reactor-wide defaults " \
|
|
63
|
+
"silently applied only to steps declared after them. Declare `retries` on each step class " \
|
|
64
|
+
"(or step block) that should retry; a step with no `retries` runs once."
|
|
68
65
|
end
|
|
69
66
|
|
|
70
67
|
# rubocop:disable Metrics/ParameterLists
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Dsl
|
|
5
|
+
# The one `retries` vocabulary. A step class EXTENDS it (class-level DSL);
|
|
6
|
+
# the step, compose and async_reactor builders INCLUDE it, so every form
|
|
7
|
+
# shares the same defaults and validation. Modeled on
|
|
8
|
+
# `Lockable::ClassMethods`: `inherited` copies the declaration down, so a
|
|
9
|
+
# subclass redeclaring leaves its parent and siblings untouched.
|
|
10
|
+
module Retryable
|
|
11
|
+
BACKOFF_STRATEGIES = %i[exponential linear fixed].freeze
|
|
12
|
+
|
|
13
|
+
# The declared policy, or nil when nothing was declared.
|
|
14
|
+
attr_reader :retry_config
|
|
15
|
+
|
|
16
|
+
def retries(max_attempts: 3, backoff: :exponential, base_delay: 1)
|
|
17
|
+
unless max_attempts.is_a?(Integer) && max_attempts >= 1
|
|
18
|
+
invalid_retries!(:max_attempts, "an Integer >= 1", max_attempts)
|
|
19
|
+
end
|
|
20
|
+
invalid_retries!(:backoff, "one of #{BACKOFF_STRATEGIES.inspect}", backoff) unless
|
|
21
|
+
BACKOFF_STRATEGIES.include?(backoff)
|
|
22
|
+
invalid_retries!(:base_delay, "a Numeric >= 0", base_delay) unless
|
|
23
|
+
base_delay.is_a?(Numeric) && base_delay >= 0
|
|
24
|
+
|
|
25
|
+
@retry_config = { max_attempts: max_attempts, backoff: backoff, base_delay: base_delay }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def inherited(subclass)
|
|
29
|
+
super
|
|
30
|
+
subclass.instance_variable_set(:@retry_config, @retry_config) if @retry_config
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def invalid_retries!(option, expected, value)
|
|
36
|
+
raise ArgumentError, "#{retry_owner_label}: retries #{option} must be #{expected} (got #{value.inspect})"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A step class's name, or a builder's step name.
|
|
40
|
+
def retry_owner_label
|
|
41
|
+
name&.to_s || inspect
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -6,14 +6,15 @@ module RubyReactor
|
|
|
6
6
|
include RubyReactor::Dsl::TemplateHelpers
|
|
7
7
|
include RubyReactor::Dsl::ValidationHelpers
|
|
8
8
|
include RubyReactor::Dsl::Lockable::ClassMethods
|
|
9
|
+
include RubyReactor::Dsl::Retryable
|
|
9
10
|
|
|
10
11
|
COORDINATION_MACROS = {
|
|
11
12
|
lock_config: "with_lock", semaphore_config: "with_semaphore", rate_limit_config: "with_rate_limit",
|
|
12
13
|
period_config: "with_period", ordered_lock_config: "with_ordered_lock"
|
|
13
14
|
}.freeze
|
|
14
15
|
|
|
15
|
-
attr_accessor :name, :impl, :arguments, :run_block, :compensate_block, :undo_block,
|
|
16
|
-
:dependencies, :args_validator, :output_validator
|
|
16
|
+
attr_accessor :name, :impl, :arguments, :run_block, :compensate_block, :undo_block,
|
|
17
|
+
:dependencies, :args_validator, :output_validator
|
|
17
18
|
|
|
18
19
|
def initialize(name, impl = nil, reactor = nil)
|
|
19
20
|
@name = name
|
|
@@ -23,14 +24,12 @@ module RubyReactor
|
|
|
23
24
|
@run_block = nil
|
|
24
25
|
@compensate_block = nil
|
|
25
26
|
@undo_block = nil
|
|
26
|
-
@conditions = []
|
|
27
|
-
@guards = []
|
|
28
27
|
@dependencies = []
|
|
29
28
|
@arg_validations = []
|
|
30
29
|
@validate_args_input = nil
|
|
31
30
|
@args_validator = nil
|
|
32
31
|
@output_validator = nil
|
|
33
|
-
@retry_config =
|
|
32
|
+
@retry_config = nil
|
|
34
33
|
@inline_contract = nil
|
|
35
34
|
@rule_sites = []
|
|
36
35
|
end
|
|
@@ -77,12 +76,15 @@ module RubyReactor
|
|
|
77
76
|
@undo_block = block
|
|
78
77
|
end
|
|
79
78
|
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
# `where`/`guard` were a second, pre-body skip mechanism with failure
|
|
80
|
+
# rules of their own (008 R-15). A step decides to skip itself from its
|
|
81
|
+
# body instead, so both stay only to name the replacement.
|
|
82
|
+
def where(*)
|
|
83
|
+
raise_removed_condition!(:where)
|
|
82
84
|
end
|
|
83
85
|
|
|
84
|
-
def guard(
|
|
85
|
-
|
|
86
|
+
def guard(*)
|
|
87
|
+
raise_removed_condition!(:guard)
|
|
86
88
|
end
|
|
87
89
|
|
|
88
90
|
def wait_for(*step_names)
|
|
@@ -130,20 +132,13 @@ module RubyReactor
|
|
|
130
132
|
)
|
|
131
133
|
end
|
|
132
134
|
|
|
133
|
-
def retries(max_attempts: 3, backoff: :exponential, base_delay: 1)
|
|
134
|
-
@retry_config = {
|
|
135
|
-
max_attempts: max_attempts,
|
|
136
|
-
backoff: backoff,
|
|
137
|
-
base_delay: base_delay
|
|
138
|
-
}
|
|
139
|
-
end
|
|
140
|
-
|
|
141
135
|
# `async_dispatch` marks a step whose work is dispatched as an independent
|
|
142
136
|
# unit rather than run inline — `:step` for `async_step`, `:reactor` for
|
|
143
137
|
# `async_reactor`. Nil for an ordinary step.
|
|
144
138
|
def build(async_dispatch: nil)
|
|
145
139
|
check_contract_conflicts!
|
|
146
140
|
check_coordination_conflicts!
|
|
141
|
+
check_retry_conflict!
|
|
147
142
|
warn_deprecated_rules
|
|
148
143
|
|
|
149
144
|
step_config = {
|
|
@@ -154,13 +149,11 @@ module RubyReactor
|
|
|
154
149
|
run_block: @run_block,
|
|
155
150
|
compensate_block: @compensate_block,
|
|
156
151
|
undo_block: @undo_block,
|
|
157
|
-
conditions: @conditions,
|
|
158
|
-
guards: @guards,
|
|
159
152
|
dependencies: @dependencies,
|
|
160
153
|
args_validator: @args_validator || build_args_validator(@arg_validations, @validate_args_input),
|
|
161
154
|
output_validator: @output_validator,
|
|
162
155
|
inline_contract: @inline_contract,
|
|
163
|
-
retry_config: @retry_config
|
|
156
|
+
retry_config: @retry_config,
|
|
164
157
|
lock_config: @lock_config,
|
|
165
158
|
semaphore_config: @semaphore_config,
|
|
166
159
|
rate_limit_config: @rate_limit_config,
|
|
@@ -173,6 +166,15 @@ module RubyReactor
|
|
|
173
166
|
|
|
174
167
|
private
|
|
175
168
|
|
|
169
|
+
def raise_removed_condition!(keyword)
|
|
170
|
+
raise RubyReactor::Error::DeprecatedDslError.new(
|
|
171
|
+
"`#{keyword}` inside a step block has been removed (`where`/`guard` are gone). To skip " \
|
|
172
|
+
":#{@name}, return `Skipped(value)` (or call `skip!(value)`) from its `run` body; the " \
|
|
173
|
+
"reactor continues with that value, exactly as for a Success.",
|
|
174
|
+
step: @name
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
|
|
176
178
|
# The same primitive declared BOTH inline and on the step class is
|
|
177
179
|
# ambiguous — two keys for one slot of the fixed acquisition order, and
|
|
178
180
|
# `StepConfig`'s readers would silently let the inline one win. Refuse it
|
|
@@ -190,6 +192,16 @@ module RubyReactor
|
|
|
190
192
|
end
|
|
191
193
|
end
|
|
192
194
|
|
|
195
|
+
# Same rule as the coordination macros: one retry policy per step.
|
|
196
|
+
def check_retry_conflict!
|
|
197
|
+
return unless @retry_config && @impl.respond_to?(:retry_config) && @impl.retry_config
|
|
198
|
+
|
|
199
|
+
raise Error::ValidationError,
|
|
200
|
+
"#{reactor_label} step :#{@name} declares `retries` inline, but #{@impl} declares it too. " \
|
|
201
|
+
"Keep ONE: drop the inline declaration to use #{@impl}'s, or remove it from #{@impl}. " \
|
|
202
|
+
"To vary the policy per workflow, subclass #{@impl} and declare `retries` there."
|
|
203
|
+
end
|
|
204
|
+
|
|
193
205
|
# A step that owns its input contract takes wiring only from the
|
|
194
206
|
# reactor: rules here would be a second, overlapping rule set.
|
|
195
207
|
def check_contract_conflicts!
|
|
@@ -233,11 +245,15 @@ module RubyReactor
|
|
|
233
245
|
end
|
|
234
246
|
|
|
235
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)
|
|
236
253
|
location = "#{site.path}:#{site.lineno}"
|
|
237
254
|
return unless StepBuilder.deprecation_sites.add?(location)
|
|
238
255
|
|
|
239
|
-
warn "[RubyReactor]
|
|
240
|
-
"Removal no earlier than the next MAJOR."
|
|
256
|
+
warn ["[RubyReactor]", prefix, location, reactor_label, message].compact.join(" ")
|
|
241
257
|
end
|
|
242
258
|
|
|
243
259
|
def owned_contract
|
|
@@ -250,8 +266,10 @@ module RubyReactor
|
|
|
250
266
|
end
|
|
251
267
|
|
|
252
268
|
class StepConfig
|
|
253
|
-
|
|
254
|
-
|
|
269
|
+
NO_RETRIES = { max_attempts: 1, backoff: :exponential, base_delay: 1 }.freeze
|
|
270
|
+
|
|
271
|
+
attr_reader :name, :impl, :arguments, :run_block, :compensate_block, :undo_block,
|
|
272
|
+
:dependencies, :args_validator, :output_validator, :async_dispatch,
|
|
255
273
|
:inline_contract
|
|
256
274
|
|
|
257
275
|
def initialize(config)
|
|
@@ -262,13 +280,11 @@ module RubyReactor
|
|
|
262
280
|
@run_block = config[:run_block]
|
|
263
281
|
@compensate_block = config[:compensate_block]
|
|
264
282
|
@undo_block = config[:undo_block]
|
|
265
|
-
@conditions = config[:conditions] || []
|
|
266
|
-
@guards = config[:guards] || []
|
|
267
283
|
@dependencies = config[:dependencies] || []
|
|
268
284
|
@args_validator = config[:args_validator]
|
|
269
285
|
@output_validator = config[:output_validator]
|
|
270
286
|
@inline_contract = config[:inline_contract]
|
|
271
|
-
@retry_config =
|
|
287
|
+
@retry_config = config[:retry_config]
|
|
272
288
|
@lock_config = config[:lock_config]
|
|
273
289
|
@semaphore_config = config[:semaphore_config]
|
|
274
290
|
@rate_limit_config = config[:rate_limit_config]
|
|
@@ -287,6 +303,21 @@ module RubyReactor
|
|
|
287
303
|
@lock_config || (impl.lock_config if impl.respond_to?(:lock_config))
|
|
288
304
|
end
|
|
289
305
|
|
|
306
|
+
# A step's EFFECTIVE retry policy, resolved like the coordination
|
|
307
|
+
# readers: its own (step block) declaration, else the class step's,
|
|
308
|
+
# else a single attempt. Never nil. The reactor is never consulted.
|
|
309
|
+
def retry_config
|
|
310
|
+
@retry_config || (impl.retry_config if impl.respond_to?(:retry_config)) || NO_RETRIES
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Where `retry_config` came from: :step_block, :step_class or :none.
|
|
314
|
+
def retry_source
|
|
315
|
+
return :step_block if @retry_config
|
|
316
|
+
return :step_class if impl.respond_to?(:retry_config) && impl.retry_config
|
|
317
|
+
|
|
318
|
+
:none
|
|
319
|
+
end
|
|
320
|
+
|
|
290
321
|
def semaphore_config
|
|
291
322
|
@semaphore_config || (impl.semaphore_config if impl.respond_to?(:semaphore_config))
|
|
292
323
|
end
|
|
@@ -337,13 +368,58 @@ module RubyReactor
|
|
|
337
368
|
input_contract ? input_contract.enforce!(args) : args
|
|
338
369
|
end
|
|
339
370
|
|
|
371
|
+
# The step's `argument` wiring resolved against `context`: each source,
|
|
372
|
+
# then its `transform`. The one copy every process uses (StepExecutor,
|
|
373
|
+
# StepWorker). Any raise becomes an `ArgumentResolutionError`, a
|
|
374
|
+
# never-started failure attributed to this step (008 R-06) — except a
|
|
375
|
+
# park signal (a worker waiting on an async result), which is not a failure.
|
|
376
|
+
def resolve_arguments(context)
|
|
377
|
+
arguments.to_h do |arg_name, arg_config|
|
|
378
|
+
value = arg_config[:source].resolve(context)
|
|
379
|
+
value = arg_config[:transform].call(value) if arg_config[:transform]
|
|
380
|
+
[arg_name, value]
|
|
381
|
+
end
|
|
382
|
+
rescue Error::ExecutionParked
|
|
383
|
+
raise
|
|
384
|
+
rescue Error::Rescuable => e
|
|
385
|
+
raise never_started(Error::ArgumentResolutionError, "could not resolve its arguments", e)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Rollback dispatch, the same wherever the step rolls back
|
|
389
|
+
# (CompensationManager, StepWorker): the inline block, else the class
|
|
390
|
+
# step, else Skipped — nothing defined, rollback continues. Coordination,
|
|
391
|
+
# trace and middleware stay with the caller.
|
|
392
|
+
def call_compensate(error, arguments, context)
|
|
393
|
+
catch(StepSignals::TAG) do
|
|
394
|
+
if compensate_block
|
|
395
|
+
compensate_block.call(error, wrap_inputs(arguments), context)
|
|
396
|
+
elsif has_impl?
|
|
397
|
+
impl.compensate(error, arguments, context)
|
|
398
|
+
else
|
|
399
|
+
RubyReactor.Skipped()
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def call_undo(result_value, arguments, context)
|
|
405
|
+
catch(StepSignals::TAG) do
|
|
406
|
+
if undo_block
|
|
407
|
+
undo_block.call(result_value, wrap_inputs(arguments), context)
|
|
408
|
+
elsif has_impl?
|
|
409
|
+
impl.undo(result_value, arguments, context)
|
|
410
|
+
else
|
|
411
|
+
RubyReactor.Skipped()
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
340
416
|
# The step's work as a reactor runs it. Coordination is NOT taken here:
|
|
341
417
|
# the caller (StepExecutor / StepWorker) takes this config's effective
|
|
342
418
|
# declarations — inline and class alike — in one fixed order around it.
|
|
343
419
|
def call_body(arguments, context)
|
|
344
420
|
catch(StepSignals::TAG) do
|
|
345
421
|
if has_run_block?
|
|
346
|
-
run_block.call(arguments, context)
|
|
422
|
+
run_block.call(wrap_inputs(arguments), context)
|
|
347
423
|
elsif impl.respond_to?(:run_without_coordination)
|
|
348
424
|
impl.run_without_coordination(arguments, context)
|
|
349
425
|
else
|
|
@@ -360,6 +436,20 @@ module RubyReactor
|
|
|
360
436
|
!@async_dispatch.nil?
|
|
361
437
|
end
|
|
362
438
|
|
|
439
|
+
# Whether a success of this step is recorded for undo. `async_step` /
|
|
440
|
+
# `async_reactor` dispatches are independent units of work with their
|
|
441
|
+
# own compensation flows: the parent rolling back must not "undo" a
|
|
442
|
+
# dispatch whose unit runs (and may still succeed) elsewhere (008 R-10).
|
|
443
|
+
def rollback_tracked?
|
|
444
|
+
!async_dispatch?
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Whether a run of this step cut short by an interruption is still
|
|
448
|
+
# tracked for undo: the construct's own definition decides (008 R-16).
|
|
449
|
+
def undoes_partial_run?
|
|
450
|
+
rollback_tracked? && has_impl? && impl.respond_to?(:undoes_partial_run?) && impl.undoes_partial_run?
|
|
451
|
+
end
|
|
452
|
+
|
|
363
453
|
def has_impl?
|
|
364
454
|
!@impl.nil?
|
|
365
455
|
end
|
|
@@ -373,18 +463,26 @@ module RubyReactor
|
|
|
373
463
|
!@run_block.nil?
|
|
374
464
|
end
|
|
375
465
|
|
|
376
|
-
|
|
377
|
-
|
|
466
|
+
# What an inline `run`/`undo`/`compensate` block receives as its inputs.
|
|
467
|
+
def wrap_inputs(arguments)
|
|
468
|
+
RubyReactor::Step::Inputs.new(arguments, contract: input_contract, owner: "step :#{name}")
|
|
378
469
|
end
|
|
379
470
|
|
|
380
|
-
def
|
|
381
|
-
|
|
382
|
-
@guards.all? { |guard| guard.call(context) }
|
|
471
|
+
def retryable?
|
|
472
|
+
(retry_config[:max_attempts] || 0) > 1
|
|
383
473
|
end
|
|
384
474
|
|
|
385
475
|
def interrupt?
|
|
386
476
|
false
|
|
387
477
|
end
|
|
478
|
+
|
|
479
|
+
private
|
|
480
|
+
|
|
481
|
+
def never_started(error_class, what, cause)
|
|
482
|
+
error = error_class.new("Step '#{name}' #{what}: #{cause.message}", step: name, original_error: cause)
|
|
483
|
+
error.set_backtrace(cause.backtrace)
|
|
484
|
+
error
|
|
485
|
+
end
|
|
388
486
|
end
|
|
389
487
|
end
|
|
390
488
|
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Error
|
|
5
|
+
# A step's `argument` source, `transform` or result path raised. The step's
|
|
6
|
+
# body never started, so it is never compensated; completed steps are
|
|
7
|
+
# undone (008 R-06). The same inputs fail the same way, so never retried.
|
|
8
|
+
class ArgumentResolutionError < Base
|
|
9
|
+
attr_reader :exception_class
|
|
10
|
+
|
|
11
|
+
def initialize(message, step:, original_error:, context: nil)
|
|
12
|
+
super(message, step: step, context: context, original_error: original_error)
|
|
13
|
+
@exception_class = original_error.class.name
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def retryable? = false
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Error
|
|
5
|
+
# `rescue Error::Rescuable` catches every exception reactor code can raise —
|
|
6
|
+
# standard or not (`NotImplementedError`, `SystemStackError`, a custom
|
|
7
|
+
# `Exception` subclass) — so it fails its step and rolls back (008 R-16).
|
|
8
|
+
#
|
|
9
|
+
# It lets through only interruptions: exceptions raised INTO running code
|
|
10
|
+
# from outside it. A signal (incl. `Interrupt`, `Sidekiq::Shutdown`), an
|
|
11
|
+
# exit, running out of memory, and the one an enclosing `Timeout.timeout`
|
|
12
|
+
# raises — rescuing that would stop the caller's timeout from ever firing.
|
|
13
|
+
# Running rollback code during any of them is unsafe; the executor marks
|
|
14
|
+
# the run `aborted` for a manual undo instead.
|
|
15
|
+
module Rescuable
|
|
16
|
+
INTERRUPTIONS = [SignalException, SystemExit, NoMemoryError].freeze
|
|
17
|
+
|
|
18
|
+
def self.===(exception)
|
|
19
|
+
exception.is_a?(Exception) && !interruption?(exception)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.interruption?(exception)
|
|
23
|
+
INTERRUPTIONS.any? { |klass| exception.is_a?(klass) } ||
|
|
24
|
+
(defined?(::Timeout::ExitException) && exception.is_a?(::Timeout::ExitException))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Error
|
|
5
|
+
# A step read an input it can't read (a typo, or a name it never
|
|
6
|
+
# declared). It fails the same way on every attempt, so it never retries.
|
|
7
|
+
class UndeclaredInputError < NoMethodError
|
|
8
|
+
def retryable?
|
|
9
|
+
false
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
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)
|
|
@@ -65,15 +67,26 @@ module RubyReactor
|
|
|
65
67
|
end
|
|
66
68
|
end
|
|
67
69
|
|
|
70
|
+
# A unit that compensates itself outside the executor loop (StepWorker's
|
|
71
|
+
# `async_step`): the same coordination re-take, trace, middleware events
|
|
72
|
+
# and `rollback_failures` as a step compensated here.
|
|
73
|
+
def compensate(step_config, error, arguments)
|
|
74
|
+
compensate_step(step_config, error, arguments)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Newest first. Each entry leaves the stack only once its undo returned,
|
|
78
|
+
# so an interruption mid-rollback leaves exactly the entries still to
|
|
79
|
+
# undo — the interrupted one included — for a manual undo (008 R-16).
|
|
68
80
|
def rollback_completed_steps
|
|
69
|
-
undo_stack.
|
|
81
|
+
until undo_stack.empty?
|
|
82
|
+
step_info = undo_stack.last
|
|
70
83
|
result = @context.with_step(step_info[:step].name) do
|
|
71
84
|
undo_step(step_info[:step], step_info[:result], step_info[:arguments])
|
|
72
85
|
end
|
|
86
|
+
undo_stack.pop
|
|
73
87
|
@undo_trace << { type: :undo, step: step_info[:step].name, result: result,
|
|
74
88
|
arguments: step_info[:arguments] }
|
|
75
89
|
end
|
|
76
|
-
undo_stack.clear
|
|
77
90
|
end
|
|
78
91
|
|
|
79
92
|
private
|
|
@@ -117,19 +130,18 @@ module RubyReactor
|
|
|
117
130
|
).around_rollback(&block)
|
|
118
131
|
end
|
|
119
132
|
|
|
133
|
+
# Under `with_step`, as `rollback_completed_steps` runs each undo: a
|
|
134
|
+
# construct (compose, map) reads `context.current_step` to find its own
|
|
135
|
+
# state during either rollback moment.
|
|
120
136
|
def compensate_step(step_config, error, arguments)
|
|
137
|
+
@context.with_step(step_config.name) { compensate_step_body(step_config, error, arguments) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def compensate_step_body(step_config, error, arguments)
|
|
121
141
|
middlewares.on(:start_compensation, step_config.name, error, arguments, @context)
|
|
122
142
|
begin
|
|
123
143
|
compensate_result = coordinated_rollback(step_config, arguments) do
|
|
124
|
-
|
|
125
|
-
if step_config.compensate_block
|
|
126
|
-
step_config.compensate_block.call(error, 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
|
|
144
|
+
step_config.call_compensate(error, arguments, @context)
|
|
133
145
|
end
|
|
134
146
|
|
|
135
147
|
@context.append_execution_trace(
|
|
@@ -152,7 +164,7 @@ module RubyReactor
|
|
|
152
164
|
end
|
|
153
165
|
|
|
154
166
|
compensate_result
|
|
155
|
-
rescue
|
|
167
|
+
rescue Error::Rescuable => e
|
|
156
168
|
record_rollback_failure(step_config.name, :compensate, e)
|
|
157
169
|
middlewares.on(:failed_compensation, step_config.name, e, @context)
|
|
158
170
|
# A raise is a compensation failure like a returned Failure: the
|
|
@@ -162,19 +174,11 @@ module RubyReactor
|
|
|
162
174
|
end
|
|
163
175
|
end
|
|
164
176
|
|
|
165
|
-
def undo_step(step_config, result, arguments)
|
|
177
|
+
def undo_step(step_config, result, arguments)
|
|
166
178
|
middlewares.on(:start_undo, step_config.name, result, arguments, @context)
|
|
167
179
|
begin
|
|
168
180
|
undo_result = coordinated_rollback(step_config, arguments) do
|
|
169
|
-
|
|
170
|
-
if step_config.undo_block
|
|
171
|
-
step_config.undo_block.call(result.value, 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
|
|
181
|
+
step_config.call_undo(result.value, arguments, @context)
|
|
178
182
|
end
|
|
179
183
|
|
|
180
184
|
@context.append_execution_trace(
|
|
@@ -196,7 +200,7 @@ module RubyReactor
|
|
|
196
200
|
end
|
|
197
201
|
|
|
198
202
|
undo_result
|
|
199
|
-
rescue
|
|
203
|
+
rescue Error::Rescuable => e
|
|
200
204
|
record_rollback_failure(step_config.name, :undo, e)
|
|
201
205
|
middlewares.on(:failed_undo, step_config.name, e, @context)
|
|
202
206
|
# Log undo failure but don't halt the rollback process
|