ruby_reactor 0.5.0 → 0.5.2
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/CHANGELOG.md +14 -0
- data/README.md +199 -30
- data/lib/ruby_reactor/configuration.rb +7 -0
- data/lib/ruby_reactor/dsl/compose_builder.rb +20 -0
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +18 -2
- data/lib/ruby_reactor/dsl/lockable.rb +60 -29
- data/lib/ruby_reactor/dsl/reactor.rb +38 -7
- data/lib/ruby_reactor/dsl/step_builder.rb +25 -39
- data/lib/ruby_reactor/dsl/validation_helpers.rb +34 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +307 -0
- data/lib/ruby_reactor/executor/result_handler.rb +35 -8
- data/lib/ruby_reactor/executor/step_executor.rb +10 -5
- data/lib/ruby_reactor/executor.rb +145 -50
- data/lib/ruby_reactor/ordered_lock.rb +158 -0
- data/lib/ruby_reactor/rate_limit.rb +28 -0
- data/lib/ruby_reactor/rate_limit_registry.rb +51 -0
- data/lib/ruby_reactor/reactor.rb +41 -0
- data/lib/ruby_reactor/rspec/helpers.rb +6 -0
- data/lib/ruby_reactor/rspec/matchers.rb +66 -0
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +70 -0
- data/lib/ruby_reactor/rspec/storage_reset.rb +23 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +14 -28
- data/lib/ruby_reactor/rspec.rb +37 -0
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +50 -8
- data/lib/ruby_reactor/storage/redis_adapter.rb +1 -0
- data/lib/ruby_reactor/storage/redis_ordered_locking.rb +382 -0
- data/lib/ruby_reactor/validation/base.rb +4 -1
- data/lib/ruby_reactor/validation/input_validator.rb +4 -2
- data/lib/ruby_reactor/validation/schema_builder.rb +82 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor.rb +1 -0
- metadata +7 -1
|
@@ -20,17 +20,23 @@ module RubyReactor
|
|
|
20
20
|
@conditions = []
|
|
21
21
|
@guards = []
|
|
22
22
|
@dependencies = []
|
|
23
|
+
@arg_validations = []
|
|
24
|
+
@validate_args_input = nil
|
|
23
25
|
@args_validator = nil
|
|
24
26
|
@output_validator = nil
|
|
25
27
|
@async = false
|
|
26
28
|
@retry_config = {}
|
|
27
29
|
end
|
|
28
30
|
|
|
29
|
-
def argument(name, source, transform: nil)
|
|
31
|
+
def argument(name, source, type = nil, transform: nil, **predicates)
|
|
30
32
|
@arguments[name] = {
|
|
31
33
|
source: source,
|
|
32
34
|
transform: transform
|
|
33
35
|
}
|
|
36
|
+
|
|
37
|
+
return unless type || predicates.any?
|
|
38
|
+
|
|
39
|
+
@arg_validations << [name, type, false, predicates]
|
|
34
40
|
end
|
|
35
41
|
|
|
36
42
|
def run(&block)
|
|
@@ -57,20 +63,26 @@ module RubyReactor
|
|
|
57
63
|
@dependencies.concat(step_names)
|
|
58
64
|
end
|
|
59
65
|
|
|
66
|
+
# Cross-field rules over the whole resolved argument hash. Composes with
|
|
67
|
+
# per-argument inline validations declared via `argument`; the block (or
|
|
68
|
+
# pre-built schema) is applied last and wins on conflicts.
|
|
60
69
|
def validate_args(schema_or_validator = nil, &block)
|
|
61
|
-
|
|
62
|
-
@args_validator = build_input_validator(block)
|
|
63
|
-
elsif schema_or_validator
|
|
64
|
-
@args_validator = build_input_validator(schema_or_validator)
|
|
65
|
-
end
|
|
70
|
+
@validate_args_input = block || schema_or_validator
|
|
66
71
|
end
|
|
67
72
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
# Scalar-aware output validation.
|
|
74
|
+
# validate_output :integer, gteq?: 0 # single value
|
|
75
|
+
# validate_output do ... end # hash output
|
|
76
|
+
# validate_output SomeSchema # pre-built schema
|
|
77
|
+
def validate_output(type = nil, **predicates, &block)
|
|
78
|
+
@output_validator =
|
|
79
|
+
if block
|
|
80
|
+
create_input_validator(block)
|
|
81
|
+
elsif type.is_a?(Symbol) || type.is_a?(Module) || predicates.any?
|
|
82
|
+
build_scalar_validator(type, predicates)
|
|
83
|
+
elsif type
|
|
84
|
+
create_input_validator(type)
|
|
85
|
+
end
|
|
74
86
|
end
|
|
75
87
|
|
|
76
88
|
def async(async = true)
|
|
@@ -96,7 +108,7 @@ module RubyReactor
|
|
|
96
108
|
conditions: @conditions,
|
|
97
109
|
guards: @guards,
|
|
98
110
|
dependencies: @dependencies,
|
|
99
|
-
args_validator: @args_validator,
|
|
111
|
+
args_validator: @args_validator || build_args_validator(@arg_validations, @validate_args_input),
|
|
100
112
|
output_validator: @output_validator,
|
|
101
113
|
async: @async,
|
|
102
114
|
retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
|
|
@@ -104,32 +116,6 @@ module RubyReactor
|
|
|
104
116
|
|
|
105
117
|
RubyReactor::Dsl::StepConfig.new(step_config)
|
|
106
118
|
end
|
|
107
|
-
|
|
108
|
-
private
|
|
109
|
-
|
|
110
|
-
def build_input_validator(schema_or_block)
|
|
111
|
-
check_dry_validation_available!
|
|
112
|
-
|
|
113
|
-
schema = case schema_or_block
|
|
114
|
-
when Proc
|
|
115
|
-
build_validation_schema(&schema_or_block)
|
|
116
|
-
else
|
|
117
|
-
schema_or_block
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
RubyReactor::Validation::InputValidator.new(schema)
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def build_validation_schema(&block)
|
|
124
|
-
RubyReactor::Validation::SchemaBuilder.build_from_block(&block)
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def check_dry_validation_available!
|
|
128
|
-
return if defined?(Dry::Schema)
|
|
129
|
-
|
|
130
|
-
raise LoadError,
|
|
131
|
-
"dry-validation gem is required for validation features. Add 'gem \"dry-validation\"' to your Gemfile."
|
|
132
|
-
end
|
|
133
119
|
end
|
|
134
120
|
|
|
135
121
|
class StepConfig
|
|
@@ -22,6 +22,40 @@ module RubyReactor
|
|
|
22
22
|
RubyReactor::Validation::InputValidator.new(schema)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# Form 1 / 1b — inline scalar or class type for a single named value.
|
|
26
|
+
def build_inline_validator(name, type, optional, predicates)
|
|
27
|
+
check_dry_validation_available!
|
|
28
|
+
schema = RubyReactor::Validation::SchemaBuilder.build_inline(name, type, optional, predicates)
|
|
29
|
+
RubyReactor::Validation::InputValidator.new(schema)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Form 2 — block bound to the value's macro (`required`/`optional`).
|
|
33
|
+
def build_macro_validator(name, optional, &block)
|
|
34
|
+
check_dry_validation_available!
|
|
35
|
+
schema = RubyReactor::Validation::SchemaBuilder.build_macro(name, optional, &block)
|
|
36
|
+
RubyReactor::Validation::InputValidator.new(schema)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Compose per-argument inline rules with an optional `validate_args`
|
|
40
|
+
# block / pre-built schema. Returns nil when there is nothing to validate.
|
|
41
|
+
def build_args_validator(inline_rules, validate_input)
|
|
42
|
+
return nil if inline_rules.empty? && validate_input.nil?
|
|
43
|
+
|
|
44
|
+
check_dry_validation_available!
|
|
45
|
+
schema = RubyReactor::Validation::SchemaBuilder.build_args(inline_rules, validate_input)
|
|
46
|
+
return nil unless schema
|
|
47
|
+
|
|
48
|
+
RubyReactor::Validation::InputValidator.new(schema)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Scalar-aware single-value validator (used by `validate_output`). The
|
|
52
|
+
# value is wrapped under `:value` before validation.
|
|
53
|
+
def build_scalar_validator(type, predicates)
|
|
54
|
+
check_dry_validation_available!
|
|
55
|
+
schema = RubyReactor::Validation::SchemaBuilder.build_inline(:value, type, false, predicates)
|
|
56
|
+
RubyReactor::Validation::InputValidator.new(schema, wrap_key: :value)
|
|
57
|
+
end
|
|
58
|
+
|
|
25
59
|
private
|
|
26
60
|
|
|
27
61
|
def check_dry_validation_available!
|
|
@@ -4,6 +4,10 @@ module RubyReactor
|
|
|
4
4
|
module Error
|
|
5
5
|
class InputValidationError < Base
|
|
6
6
|
attr_reader :field_errors
|
|
7
|
+
# Step attribution, set at the raise site when the failure happened at a
|
|
8
|
+
# step boundary (argument or output validation) rather than at reactor
|
|
9
|
+
# input validation. Nil for reactor-level input failures.
|
|
10
|
+
attr_accessor :step_name, :step_arguments
|
|
7
11
|
|
|
8
12
|
def initialize(field_errors)
|
|
9
13
|
@field_errors = field_errors
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
class Executor
|
|
5
|
+
# Gate check and terminal-advance logic for `with_ordered_lock`. Mixed
|
|
6
|
+
# into Executor to keep that class under the length limit. All methods
|
|
7
|
+
# read from `@context.private_data[:ordered_lock]`, which Reactor#run
|
|
8
|
+
# populates at enqueue time.
|
|
9
|
+
module OrderedLockSupport
|
|
10
|
+
# Thread-local stack of ordered-lock keys whose steps are currently
|
|
11
|
+
# running in this thread. Used to detect a synchronous `Reactor.run`
|
|
12
|
+
# nested under another ordered-lock reactor on the same key — which
|
|
13
|
+
# would deadlock since the outer holds the slot and the inner can never
|
|
14
|
+
# advance.
|
|
15
|
+
THREAD_LOCAL_ACTIVE_KEYS = :ruby_reactor_active_ordered_locks
|
|
16
|
+
|
|
17
|
+
# Minimum interval between liveness heartbeats; protects very small
|
|
18
|
+
# poison_pill_timeouts (mirrors Lock::MIN_EXTEND_INTERVAL).
|
|
19
|
+
HEARTBEAT_MIN_INTERVAL = 1.0
|
|
20
|
+
|
|
21
|
+
def self.active_keys
|
|
22
|
+
Thread.current[THREAD_LOCAL_ACTIVE_KEYS] ||= []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Parse the ordered-lock stash from a context's private_data, surviving
|
|
26
|
+
# the JSON round-trip (symbol or string keys). Module-level so the
|
|
27
|
+
# Sidekiq worker can advance the nonce on escalation paths that never
|
|
28
|
+
# construct an Executor.
|
|
29
|
+
def self.info_from(context)
|
|
30
|
+
data = context.private_data[:ordered_lock] || context.private_data["ordered_lock"]
|
|
31
|
+
return nil unless data
|
|
32
|
+
|
|
33
|
+
strict_raw = data[:strict]
|
|
34
|
+
strict_raw = data["strict"] if strict_raw.nil?
|
|
35
|
+
strict_raw = true if strict_raw.nil?
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
key: data[:key] || data["key"],
|
|
39
|
+
nonce: (data[:nonce] || data["nonce"]).to_i,
|
|
40
|
+
epoch: (data[:epoch] || data["epoch"]).to_i,
|
|
41
|
+
poison_pill_timeout: (data[:poison_pill_timeout] || data["poison_pill_timeout"] ||
|
|
42
|
+
OrderedLock::DEFAULT_POISON_PILL_TIMEOUT).to_i,
|
|
43
|
+
ttl: (data[:ttl] || data["ttl"] || OrderedLock::DEFAULT_TTL).to_i,
|
|
44
|
+
strict: [true, "true"].include?(strict_raw)
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Strict-ordering gate. Runs BEFORE rate-limit / lock / semaphore so a
|
|
49
|
+
# waiting nonce never holds any other primitive — preventing
|
|
50
|
+
# hold-and-wait deadlocks when `with_lock` and `with_ordered_lock`
|
|
51
|
+
# share inputs. Raises {OrderedLock::WaitError}; the Sidekiq worker
|
|
52
|
+
# rescues and snoozes.
|
|
53
|
+
def check_ordered_lock_gate
|
|
54
|
+
info = ordered_lock_info
|
|
55
|
+
return :go unless info
|
|
56
|
+
|
|
57
|
+
OrderedLock.new(
|
|
58
|
+
info.fetch(:key),
|
|
59
|
+
nonce: info.fetch(:nonce),
|
|
60
|
+
epoch: info.fetch(:epoch),
|
|
61
|
+
poison_pill_timeout: info.fetch(:poison_pill_timeout),
|
|
62
|
+
strict: info.fetch(:strict)
|
|
63
|
+
).check!
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Combined gate-check + thread-local push. Call at the top of
|
|
67
|
+
# `execute` / `resume_execution`. Pair with `leave_ordered_lock_scope`
|
|
68
|
+
# in `ensure`.
|
|
69
|
+
#
|
|
70
|
+
# The strict-mode chain-skip only fires on a *fresh* start (no step
|
|
71
|
+
# has run yet on this context). This lets an in-flight run that paused
|
|
72
|
+
# (Interrupt / AsyncResult) complete on resume regardless of chain
|
|
73
|
+
# failures that landed while it was parked, while still applying
|
|
74
|
+
# strict to a fresh Sidekiq job (which enters via `resume_execution`
|
|
75
|
+
# but has no prior step state).
|
|
76
|
+
def enter_ordered_lock_scope
|
|
77
|
+
gate = check_ordered_lock_gate
|
|
78
|
+
# A stale-batch run never participates regardless of fresh/resume state —
|
|
79
|
+
# its numbering belongs to a drained generation. Chain-skip stays gated
|
|
80
|
+
# on a fresh start so an in-flight paused run still completes on resume.
|
|
81
|
+
@ordered_lock_stale_batch = gate == :stale_batch
|
|
82
|
+
@ordered_lock_chain_skip = fresh_ordered_lock_start? && gate == :skip_chain_failed
|
|
83
|
+
|
|
84
|
+
# Drained-batch gate: the batch GC'd while this caller slept. A genuine
|
|
85
|
+
# late straggler runs (poison semantics); a Sidekiq redelivery of an
|
|
86
|
+
# ALREADY-terminal context must not re-execute its steps. Only the
|
|
87
|
+
# latter — confirmed by a terminal stored status — is short-circuited.
|
|
88
|
+
@ordered_lock_drained_replay = gate == :drained_go && stored_status_terminal?
|
|
89
|
+
|
|
90
|
+
info = ordered_lock_info
|
|
91
|
+
return unless info
|
|
92
|
+
|
|
93
|
+
OrderedLockSupport.active_keys << info[:key]
|
|
94
|
+
|
|
95
|
+
# Only a run that will actually execute steps needs a heartbeat. A
|
|
96
|
+
# short-circuiting run (stale batch / strict chain skip / drained
|
|
97
|
+
# redelivery) does no work and terminally advances immediately, so
|
|
98
|
+
# starting a thread for it is pointless churn.
|
|
99
|
+
return if @ordered_lock_stale_batch || @ordered_lock_chain_skip || @ordered_lock_drained_replay
|
|
100
|
+
|
|
101
|
+
start_ordered_lock_heartbeat(info)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def fresh_ordered_lock_start?
|
|
105
|
+
@context.intermediate_results.empty? && @context.current_step.nil?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def ordered_lock_chain_skip?
|
|
109
|
+
@ordered_lock_chain_skip == true
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def ordered_lock_stale_batch?
|
|
113
|
+
@ordered_lock_stale_batch == true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def ordered_lock_drained_replay?
|
|
117
|
+
@ordered_lock_drained_replay == true
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Terminal Skipped result when the ordered-lock gate short-circuits this
|
|
121
|
+
# run (stale batch, strict chain failure, or a drained-batch redelivery of
|
|
122
|
+
# an already-terminal context), or nil to continue. Shared by `execute`
|
|
123
|
+
# and `resume_execution`.
|
|
124
|
+
def ordered_lock_short_circuit
|
|
125
|
+
return RubyReactor::Skipped.new(reason: :ordered_lock_stale_batch) if ordered_lock_stale_batch?
|
|
126
|
+
return RubyReactor::Skipped.new(reason: :ordered_lock_drained_replay) if ordered_lock_drained_replay?
|
|
127
|
+
return RubyReactor::Skipped.new(reason: :ordered_lock_chain_failed) if ordered_lock_chain_skip?
|
|
128
|
+
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Pre-step short-circuit: ordered-lock gate skip or already-marked
|
|
133
|
+
# period bucket. Returns a terminal result or nil.
|
|
134
|
+
def short_circuit_result
|
|
135
|
+
ordered_lock_short_circuit || check_period_gate
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def short_circuit!(result)
|
|
139
|
+
@result = result
|
|
140
|
+
|
|
141
|
+
# A stale-batch or drained-batch-redelivery skip means this run's epoch
|
|
142
|
+
# belongs to a drained generation — typically a slow straggler or a
|
|
143
|
+
# Sidekiq at-least-once redelivery. If the redelivery is of a job that
|
|
144
|
+
# ALREADY reached a terminal status, its stored context is the source of
|
|
145
|
+
# truth; writing :skipped over a :completed/:failed record would silently
|
|
146
|
+
# corrupt the outcome. Return the skip to the worker (so it stops)
|
|
147
|
+
# without saving. The `@skip_context_persist` flag also suppresses the
|
|
148
|
+
# ensure-block save in execute / resume_execution, which would otherwise
|
|
149
|
+
# clobber the stored terminal record with this run's stale in-memory
|
|
150
|
+
# status.
|
|
151
|
+
if redelivery_of_terminal?(result)
|
|
152
|
+
@skip_context_persist = true
|
|
153
|
+
return @result
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
update_context_status(@result)
|
|
157
|
+
save_context
|
|
158
|
+
@result
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def skip_context_persist?
|
|
162
|
+
@skip_context_persist == true
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# True when the skip is one of the drained-generation reasons (stale batch
|
|
166
|
+
# or drained-batch replay) AND the stored context already reached a
|
|
167
|
+
# terminal status — i.e. this is a redelivery of an already-finished run
|
|
168
|
+
# whose record must not be overwritten. The drained-replay flag is only
|
|
169
|
+
# set when the status was terminal, but re-checking keeps both paths
|
|
170
|
+
# uniform and self-guarding.
|
|
171
|
+
def redelivery_of_terminal?(result)
|
|
172
|
+
return false unless result.is_a?(RubyReactor::Skipped)
|
|
173
|
+
return false unless %i[ordered_lock_stale_batch ordered_lock_drained_replay].include?(result.reason)
|
|
174
|
+
|
|
175
|
+
stored_status_terminal?
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def stored_status_terminal?
|
|
179
|
+
%w[completed failed skipped].include?(stored_context_status)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def stored_context_status
|
|
183
|
+
reactor_class_name = @reactor_class.name || "AnonymousReactor-#{@reactor_class.object_id}"
|
|
184
|
+
data = RubyReactor.configuration.storage_adapter.retrieve_context(@context.context_id, reactor_class_name)
|
|
185
|
+
return nil unless data
|
|
186
|
+
|
|
187
|
+
(data["status"] || data[:status]).to_s
|
|
188
|
+
rescue StandardError
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Combined terminal-advance + thread-local pop. Idempotent: safe to call
|
|
193
|
+
# in `ensure` even if `enter_ordered_lock_scope` never pushed (gate
|
|
194
|
+
# raised, or no ordered_lock configured).
|
|
195
|
+
def leave_ordered_lock_scope
|
|
196
|
+
# Stop (and join) the heartbeat BEFORE advancing: the advance HDELs this
|
|
197
|
+
# nonce's assigned_at, and a heartbeat racing that HDEL could restamp it.
|
|
198
|
+
# The HEARTBEAT_SCRIPT's hexists guard makes a late restamp a harmless
|
|
199
|
+
# no-op, but joining first keeps the ordering deterministic.
|
|
200
|
+
stop_ordered_lock_heartbeat
|
|
201
|
+
advance_ordered_lock_if_terminal
|
|
202
|
+
info = ordered_lock_info
|
|
203
|
+
return unless info
|
|
204
|
+
|
|
205
|
+
stack = OrderedLockSupport.active_keys
|
|
206
|
+
idx = stack.rindex(info[:key])
|
|
207
|
+
stack.delete_at(idx) if idx
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Background thread that restamps this nonce's assigned_at every pp/3
|
|
211
|
+
# seconds (floored at HEARTBEAT_MIN_INTERVAL) while its steps run, so a
|
|
212
|
+
# legitimately slow blocker is not poison-passed by a successor. Mirrors
|
|
213
|
+
# the Lock auto-extend thread. The thread sleeps FIRST, so a job that
|
|
214
|
+
# finishes faster than one interval never touches Redis.
|
|
215
|
+
def start_ordered_lock_heartbeat(info)
|
|
216
|
+
return if @ordered_lock_heartbeat_running
|
|
217
|
+
|
|
218
|
+
pp = info[:poison_pill_timeout].to_f
|
|
219
|
+
interval = [pp / 3.0, HEARTBEAT_MIN_INTERVAL].max
|
|
220
|
+
@ordered_lock_heartbeat_running = true
|
|
221
|
+
lock = OrderedLock.new(
|
|
222
|
+
info.fetch(:key), nonce: info.fetch(:nonce), epoch: info.fetch(:epoch)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
@ordered_lock_heartbeat = Thread.new do
|
|
226
|
+
while @ordered_lock_heartbeat_running
|
|
227
|
+
sleep interval
|
|
228
|
+
break unless @ordered_lock_heartbeat_running
|
|
229
|
+
|
|
230
|
+
begin
|
|
231
|
+
lock.heartbeat!
|
|
232
|
+
rescue StandardError => e
|
|
233
|
+
RubyReactor.configuration.logger.warn(
|
|
234
|
+
"RubyReactor ordered_lock heartbeat failed for '#{info[:key]}' " \
|
|
235
|
+
"nonce #{info[:nonce]}: #{e.message}"
|
|
236
|
+
)
|
|
237
|
+
break
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def stop_ordered_lock_heartbeat
|
|
244
|
+
return unless @ordered_lock_heartbeat_running
|
|
245
|
+
|
|
246
|
+
@ordered_lock_heartbeat_running = false
|
|
247
|
+
thread = @ordered_lock_heartbeat
|
|
248
|
+
@ordered_lock_heartbeat = nil
|
|
249
|
+
return unless thread
|
|
250
|
+
|
|
251
|
+
thread.wakeup if thread.alive?
|
|
252
|
+
thread.join(0.1)
|
|
253
|
+
rescue StandardError
|
|
254
|
+
# Best-effort shutdown; never let heartbeat teardown break the ensure chain.
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Advance the cursor when this run reached a *terminal* status.
|
|
258
|
+
# Retry-queued, interrupted, or async-handed-off results keep the same
|
|
259
|
+
# nonce owning the slot — a Sidekiq retry must not double-advance. A
|
|
260
|
+
# terminal `Failure` is also recorded as the chain's poison marker
|
|
261
|
+
# (only the FIRST such failure sticks).
|
|
262
|
+
def advance_ordered_lock_if_terminal
|
|
263
|
+
info = ordered_lock_info
|
|
264
|
+
return unless info
|
|
265
|
+
return unless terminal_for_ordered_lock?(@result)
|
|
266
|
+
|
|
267
|
+
OrderedLockSupport.advance_with_retry(info, failed: @result.is_a?(RubyReactor::Failure))
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# A missed advance on a terminal result stalls every successor for up to
|
|
271
|
+
# poison_pill_timeout with only a warn line as evidence, so one transient
|
|
272
|
+
# Redis blip is worth absorbing here before giving up.
|
|
273
|
+
def self.advance_with_retry(info, failed:)
|
|
274
|
+
attempts = 0
|
|
275
|
+
begin
|
|
276
|
+
attempts += 1
|
|
277
|
+
OrderedLock.new(
|
|
278
|
+
info.fetch(:key), nonce: info.fetch(:nonce), epoch: info.fetch(:epoch), ttl: info.fetch(:ttl)
|
|
279
|
+
).advance!(failed: failed)
|
|
280
|
+
rescue StandardError => e
|
|
281
|
+
retry if attempts < 2
|
|
282
|
+
|
|
283
|
+
RubyReactor.configuration.logger.warn(
|
|
284
|
+
"RubyReactor failed to advance ordered_lock '#{info[:key]}' nonce #{info[:nonce]} " \
|
|
285
|
+
"after #{attempts} attempts: #{e.message} — successors will stall until " \
|
|
286
|
+
"poison_pill_timeout (#{info[:poison_pill_timeout]}s) expires"
|
|
287
|
+
)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
private
|
|
292
|
+
|
|
293
|
+
def ordered_lock_info
|
|
294
|
+
OrderedLockSupport.info_from(@context)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def terminal_for_ordered_lock?(result)
|
|
298
|
+
case result
|
|
299
|
+
when RubyReactor::AsyncResult, RubyReactor::InterruptResult, RetryQueuedResult
|
|
300
|
+
false
|
|
301
|
+
when RubyReactor::Success, RubyReactor::Failure
|
|
302
|
+
true
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
@@ -33,8 +33,11 @@ module RubyReactor
|
|
|
33
33
|
when Error::StepFailureError
|
|
34
34
|
handle_step_failure_error(error)
|
|
35
35
|
when Error::InputValidationError
|
|
36
|
-
#
|
|
37
|
-
|
|
36
|
+
# Unified validation failure shape (inputs, step args, step output).
|
|
37
|
+
# Roll back any completed steps so saga semantics hold for mid-reactor
|
|
38
|
+
# validation failures (a no-op for input validation at reactor start).
|
|
39
|
+
@compensation_manager.rollback_completed_steps
|
|
40
|
+
build_validation_failure(error)
|
|
38
41
|
when Error::Base
|
|
39
42
|
# Other errors need rollback
|
|
40
43
|
@compensation_manager.rollback_completed_steps
|
|
@@ -56,6 +59,25 @@ module RubyReactor
|
|
|
56
59
|
|
|
57
60
|
private
|
|
58
61
|
|
|
62
|
+
# Failure for a validation error (reactor inputs, step arguments, or
|
|
63
|
+
# step output), carrying both the structured field errors and the step/
|
|
64
|
+
# reactor attribution stamped at the raise site (nil step_name for
|
|
65
|
+
# reactor-level input failures).
|
|
66
|
+
def build_validation_failure(error)
|
|
67
|
+
redact_inputs = []
|
|
68
|
+
redact_inputs = @context.reactor_class.inputs.select { |_, c| c[:redact] }.keys if @context.reactor_class
|
|
69
|
+
|
|
70
|
+
RubyReactor.Failure(
|
|
71
|
+
error,
|
|
72
|
+
validation_errors: error.field_errors,
|
|
73
|
+
step_name: error.step_name,
|
|
74
|
+
step_arguments: error.step_arguments || {},
|
|
75
|
+
inputs: @context.inputs,
|
|
76
|
+
redact_inputs: redact_inputs,
|
|
77
|
+
reactor_name: @context.reactor_class&.name
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
59
81
|
# A step returned `RubyReactor.Skipped(...)`. Halt cleanly: record the
|
|
60
82
|
# event in the trace, do NOT push to the undo stack (so existing
|
|
61
83
|
# completed steps stay as-is — no compensation), and stamp the step
|
|
@@ -180,12 +202,17 @@ module RubyReactor
|
|
|
180
202
|
output_validation_result = step_config.output_validator.call(value)
|
|
181
203
|
return if output_validation_result.success?
|
|
182
204
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
205
|
+
error = output_validation_result.error
|
|
206
|
+
error.step_name = step_config.name
|
|
207
|
+
error.step_arguments = resolved_arguments
|
|
208
|
+
|
|
209
|
+
# The step DID run — its side effect exists even though its output is
|
|
210
|
+
# invalid. Treat it like a step failure: run the step's own
|
|
211
|
+
# compensation and roll back prior steps, so the side effect is not
|
|
212
|
+
# orphaned. Then surface the structured validation error (the later
|
|
213
|
+
# rollback in handle_execution_error is a no-op — stack already clear).
|
|
214
|
+
@compensation_manager.handle_step_failure(step_config, error, resolved_arguments)
|
|
215
|
+
raise error
|
|
189
216
|
end
|
|
190
217
|
|
|
191
218
|
def extract_location(backtrace)
|
|
@@ -128,6 +128,10 @@ module RubyReactor
|
|
|
128
128
|
def safe_execute_step_sync(step_config, resolved_arguments = nil)
|
|
129
129
|
resolved_arguments ||= resolve_arguments(step_config)
|
|
130
130
|
execute_step_sync_without_result_handling(step_config, resolved_arguments)
|
|
131
|
+
rescue Error::InputValidationError
|
|
132
|
+
# Validation failures are not retryable and must surface as a structured
|
|
133
|
+
# InputValidationError (with field_errors), so let them propagate.
|
|
134
|
+
raise
|
|
131
135
|
rescue StandardError => e
|
|
132
136
|
# Identify redacted inputs
|
|
133
137
|
redact_inputs = @reactor_class.inputs.select { |_, config| config[:redact] }.keys
|
|
@@ -240,11 +244,12 @@ module RubyReactor
|
|
|
240
244
|
validation_result = step_config.args_validator.call(resolved_arguments)
|
|
241
245
|
return if validation_result.success?
|
|
242
246
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
247
|
+
# Stamp step attribution so the resulting Failure can say WHERE the
|
|
248
|
+
# validation failed, not just what was invalid.
|
|
249
|
+
error = validation_result.error
|
|
250
|
+
error.step_name = step_config.name
|
|
251
|
+
error.step_arguments = resolved_arguments
|
|
252
|
+
raise error
|
|
248
253
|
end
|
|
249
254
|
|
|
250
255
|
def resolve_arguments(step_config)
|