ruby_reactor 0.8.2 → 0.8.3
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/speckit-review/SKILL.md +324 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/extensions.yml +10 -0
- data/.specify/feature.json +1 -1
- data/.specify/workflows/speckit/workflow.yml +13 -1
- data/.specify/workflows/workflow-registry.json +2 -2
- data/CHANGELOG.md +82 -0
- data/CLAUDE.md +2 -2
- data/README.md +35 -2
- data/lib/ruby_reactor/adapters/active_job/router.rb +19 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -0
- data/lib/ruby_reactor/context.rb +26 -0
- data/lib/ruby_reactor/context_serializer.rb +4 -2
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +14 -0
- data/lib/ruby_reactor/dsl/lockable.rb +76 -21
- data/lib/ruby_reactor/dsl/step_builder.rb +112 -1
- data/lib/ruby_reactor/error/async_result_pending.rb +1 -1
- data/lib/ruby_reactor/error/execution_parked.rb +16 -0
- data/lib/ruby_reactor/error/reactor_contention_park.rb +26 -0
- data/lib/ruby_reactor/error/step_contention_park.rb +26 -0
- data/lib/ruby_reactor/executor/async_step_dispatch.rb +109 -3
- data/lib/ruby_reactor/executor/compensation_manager.rb +99 -17
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +76 -44
- data/lib/ruby_reactor/executor/result_handler.rb +31 -11
- data/lib/ruby_reactor/executor/retry_manager.rb +9 -1
- data/lib/ruby_reactor/executor/step_coordination.rb +788 -0
- data/lib/ruby_reactor/executor/step_executor.rb +115 -11
- data/lib/ruby_reactor/executor.rb +90 -20
- data/lib/ruby_reactor/map/element_executor.rb +24 -2
- data/lib/ruby_reactor/map/helpers.rb +35 -11
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +2 -2
- data/lib/ruby_reactor/open_telemetry.rb +61 -24
- data/lib/ruby_reactor/retry_context.rb +31 -2
- data/lib/ruby_reactor/rspec/helpers.rb +15 -0
- data/lib/ruby_reactor/rspec/matchers.rb +92 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +7 -1
- data/lib/ruby_reactor/step/async_reactor_step.rb +40 -24
- data/lib/ruby_reactor/step/compose_step.rb +14 -3
- data/lib/ruby_reactor/step.rb +49 -7
- data/lib/ruby_reactor/step_sweeper.rb +29 -1
- data/lib/ruby_reactor/step_worker.rb +260 -37
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +72 -7
- data/lib/ruby_reactor/web/coordination_serializer.rb +120 -2
- data/lib/ruby_reactor/web/public/assets/{index-Dw4KV4QY.js → index-CeZU-ESu.js} +9 -9
- data/lib/ruby_reactor/web/public/index.html +1 -1
- data/lib/ruby_reactor/worker.rb +56 -30
- data/lib/ruby_reactor.rb +27 -5
- data/specs/future_improvements.md +250 -0
- metadata +8 -28
- data/specs/002-step-input-contracts/checklists/requirements.md +0 -49
- data/specs/002-step-input-contracts/contracts/dsl-surface.md +0 -193
- data/specs/002-step-input-contracts/data-model.md +0 -115
- data/specs/002-step-input-contracts/plan.md +0 -165
- data/specs/002-step-input-contracts/quickstart.md +0 -170
- data/specs/002-step-input-contracts/research.md +0 -233
- data/specs/002-step-input-contracts/spec.md +0 -359
- data/specs/002-step-input-contracts/tasks.md +0 -367
- data/specs/004-inheritable-step-class/checklists/requirements.md +0 -40
- data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +0 -85
- data/specs/004-inheritable-step-class/data-model.md +0 -116
- data/specs/004-inheritable-step-class/plan.md +0 -174
- data/specs/004-inheritable-step-class/quickstart.md +0 -112
- data/specs/004-inheritable-step-class/research.md +0 -308
- data/specs/004-inheritable-step-class/spec.md +0 -316
- data/specs/004-inheritable-step-class/tasks.md +0 -258
- data/specs/active_job.md +0 -259
- data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +0 -51
- data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +0 -154
- data/specs/deferred-003-step-lock-declarations/data-model.md +0 -131
- data/specs/deferred-003-step-lock-declarations/plan.md +0 -166
- data/specs/deferred-003-step-lock-declarations/quickstart.md +0 -169
- data/specs/deferred-003-step-lock-declarations/research.md +0 -196
- data/specs/deferred-003-step-lock-declarations/spec.md +0 -447
- data/specs/deferred-003-step-lock-declarations/tasks.md +0 -572
- data/specs/possible_feature.md +0 -22
data/lib/ruby_reactor/context.rb
CHANGED
|
@@ -29,6 +29,19 @@ module RubyReactor
|
|
|
29
29
|
:cancelled, :cancellation_reason, :parent_context_id, :retried_from_id, :status, :failure_reason,
|
|
30
30
|
:middlewares
|
|
31
31
|
|
|
32
|
+
# Transient, NOT serialized (absent from `to_h`, `serialize_for_retry`,
|
|
33
|
+
# and `deserialize_from_retry` below — confirmed by
|
|
34
|
+
# reentrancy_spec.rb US4-16). Overrides `StepCoordination#owner` for
|
|
35
|
+
# everything coordinated with this context (research D5): set by the
|
|
36
|
+
# `async_step` worker to a per-job id so re-entrancy never crosses a
|
|
37
|
+
# process hand-off (US4-5).
|
|
38
|
+
attr_accessor :coordination_owner
|
|
39
|
+
|
|
40
|
+
# Transient, NOT serialized: the step whose coordination hook is firing
|
|
41
|
+
# right now (set by `StepCoordination#emit` for the duration of the hook),
|
|
42
|
+
# so a middleware can tell a step-level hold from a reactor-level one.
|
|
43
|
+
attr_accessor :coordinating_step
|
|
44
|
+
|
|
32
45
|
def initialize(inputs = {}, reactor_class = nil)
|
|
33
46
|
@context_id = SecureRandom.uuid
|
|
34
47
|
@inputs = inputs
|
|
@@ -95,6 +108,19 @@ module RubyReactor
|
|
|
95
108
|
@intermediate_results[step_name.to_sym] = value
|
|
96
109
|
end
|
|
97
110
|
|
|
111
|
+
# Set once this execution's reactor-level gates (rate limit, period,
|
|
112
|
+
# post-lock period re-check) have passed, and never unset — it rides
|
|
113
|
+
# `private_data`, so it survives parks, retries, pauses and redeliveries.
|
|
114
|
+
# "Is this a fresh run?" reads it instead of inferring from
|
|
115
|
+
# `current_step`, which is only the resume cursor (005 R-03).
|
|
116
|
+
def admitted?
|
|
117
|
+
!!(private_data[:admitted] || private_data["admitted"])
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def admit!
|
|
121
|
+
private_data[:admitted] = true
|
|
122
|
+
end
|
|
123
|
+
|
|
98
124
|
def with_step(step_name)
|
|
99
125
|
old_step = @current_step
|
|
100
126
|
@current_step = step_name
|
|
@@ -57,7 +57,8 @@ module RubyReactor
|
|
|
57
57
|
"file_path" => value.file_path,
|
|
58
58
|
"line_number" => value.line_number,
|
|
59
59
|
"code_snippet" => serialize_value(value.code_snippet),
|
|
60
|
-
"validation_errors" => serialize_value(value.validation_errors)
|
|
60
|
+
"validation_errors" => serialize_value(value.validation_errors),
|
|
61
|
+
"rollback_failures" => serialize_value(value.rollback_failures)
|
|
61
62
|
}
|
|
62
63
|
when RubyReactor::Context
|
|
63
64
|
{ "_type" => "Context", "value" => value.serialize_for_retry }
|
|
@@ -140,7 +141,8 @@ module RubyReactor
|
|
|
140
141
|
file_path: value["file_path"],
|
|
141
142
|
line_number: value["line_number"],
|
|
142
143
|
code_snippet: deserialize_value(value["code_snippet"]),
|
|
143
|
-
validation_errors: deserialize_value(value["validation_errors"])
|
|
144
|
+
validation_errors: deserialize_value(value["validation_errors"]),
|
|
145
|
+
rollback_failures: deserialize_value(value["rollback_failures"])
|
|
144
146
|
)
|
|
145
147
|
when "Context"
|
|
146
148
|
Context.deserialize_from_retry(value["value"])
|
|
@@ -42,6 +42,20 @@ module RubyReactor
|
|
|
42
42
|
"`validate_payload`."
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# Step-level coordination is refused on an interrupt (research D6): its
|
|
46
|
+
# body is split across a pause, so a hold taken before the pause would
|
|
47
|
+
# span the gap — held by nothing while the workflow waits, potentially
|
|
48
|
+
# forever. `StepBuilder` (the superclass) gains the five macros once
|
|
49
|
+
# `Lockable` is hosted there; these overrides make sure an interrupt
|
|
50
|
+
# never inherits them silently (Finding 3).
|
|
51
|
+
%i[with_lock with_semaphore with_rate_limit with_period with_ordered_lock].each do |macro|
|
|
52
|
+
define_method(macro) do |*, **|
|
|
53
|
+
raise RubyReactor::Error::ValidationError,
|
|
54
|
+
"interrupt :#{@name} cannot declare step-level coordination: its body is split across a pause, " \
|
|
55
|
+
"so a hold would span the gap. Declare it on the reactor (with_lock etc.) instead."
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
45
59
|
# Deprecated alias for {#validate_payload}.
|
|
46
60
|
def validate(schema = nil, &block)
|
|
47
61
|
unless @warned_validate
|
|
@@ -10,6 +10,23 @@ module RubyReactor
|
|
|
10
10
|
module ClassMethods
|
|
11
11
|
attr_reader :lock_config, :semaphore_config, :period_config, :rate_limit_config, :ordered_lock_config
|
|
12
12
|
|
|
13
|
+
# The five configs, nils compacted. Reactors get this too (additive);
|
|
14
|
+
# steps use it to decide whether `StepCoordination` needs building at
|
|
15
|
+
# all (`StepCoordination.none?`).
|
|
16
|
+
def coordination_declarations
|
|
17
|
+
{
|
|
18
|
+
lock: lock_config,
|
|
19
|
+
semaphore: semaphore_config,
|
|
20
|
+
rate_limit: rate_limit_config,
|
|
21
|
+
period: period_config,
|
|
22
|
+
ordered_lock: ordered_lock_config
|
|
23
|
+
}.compact
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def declares_coordination?
|
|
27
|
+
!coordination_declarations.empty?
|
|
28
|
+
end
|
|
29
|
+
|
|
13
30
|
# Propagate lock/semaphore/period/rate-limit config to subclasses;
|
|
14
31
|
# without this a subclass of a configured reactor would silently lose
|
|
15
32
|
# those settings.
|
|
@@ -22,39 +39,65 @@ module RubyReactor
|
|
|
22
39
|
subclass.instance_variable_set(:@ordered_lock_config, @ordered_lock_config) if @ordered_lock_config
|
|
23
40
|
end
|
|
24
41
|
|
|
25
|
-
# Configure locking for this reactor
|
|
42
|
+
# Configure locking for this reactor or step
|
|
26
43
|
# @param ttl [Integer] Time to live in seconds (default: 60)
|
|
27
44
|
# @param wait [Integer] Time to wait for lock in seconds (default: 0)
|
|
28
45
|
# @param auto_extend [Boolean] When true (default), a background thread
|
|
29
46
|
# refreshes the lock TTL every ttl/3 seconds while the reactor runs,
|
|
30
47
|
# protecting steps that may legitimately outlast `ttl`. Pass `false`
|
|
31
48
|
# to disable and rely solely on `ttl` for expiry.
|
|
32
|
-
# @
|
|
33
|
-
|
|
49
|
+
# @param rollback_wait [Numeric, nil] STEP only (accepted and ignored on
|
|
50
|
+
# a reactor, whose holds are not re-taken for rollback): how long the
|
|
51
|
+
# step's `undo`/`compensate` waits to re-take this lock. Defaults to
|
|
52
|
+
# `ttl` — a forward holder either finishes or expires within it.
|
|
53
|
+
# Rollback never parks: in a worker the wait blocks the thread. An
|
|
54
|
+
# undo that cannot re-take the key in time is reported on
|
|
55
|
+
# `Failure#rollback_failures`.
|
|
56
|
+
# @yield [inputs] Block that returns the lock key string. On a reactor,
|
|
57
|
+
# `inputs` is the reactor's inputs; on a step, it is the step's own
|
|
58
|
+
# resolved arguments (contract defaults applied).
|
|
59
|
+
def with_lock(ttl: 60, wait: 0, auto_extend: true, rollback_wait: nil, &block)
|
|
60
|
+
validate_rollback_wait!(rollback_wait)
|
|
34
61
|
@lock_config = {
|
|
35
62
|
ttl: ttl,
|
|
36
63
|
wait: wait,
|
|
37
64
|
auto_extend: auto_extend,
|
|
65
|
+
rollback_wait: rollback_wait,
|
|
38
66
|
key_proc: block
|
|
39
67
|
}
|
|
40
68
|
end
|
|
41
69
|
|
|
42
|
-
# Configure semaphore for this reactor
|
|
70
|
+
# Configure semaphore for this reactor or step
|
|
43
71
|
# @param limit [Integer] Maximum concurrent executions
|
|
44
72
|
# @param wait [Integer] Time to wait for a token in seconds (default: 0)
|
|
45
|
-
# @
|
|
46
|
-
|
|
73
|
+
# @param rollback_wait [Numeric, nil] STEP only, as for `with_lock`.
|
|
74
|
+
# Defaults to 60 seconds: a semaphore slot has no hold expiry.
|
|
75
|
+
# @yield [inputs] Block that returns the semaphore key string. On a
|
|
76
|
+
# reactor, `inputs` is the reactor's inputs; on a step, it is the
|
|
77
|
+
# step's own resolved arguments.
|
|
78
|
+
def with_semaphore(limit:, wait: 0, rollback_wait: nil, &block)
|
|
79
|
+
validate_rollback_wait!(rollback_wait)
|
|
47
80
|
@semaphore_config = {
|
|
48
81
|
limit: limit,
|
|
49
82
|
wait: wait,
|
|
83
|
+
rollback_wait: rollback_wait,
|
|
50
84
|
key_proc: block
|
|
51
85
|
}
|
|
52
86
|
end
|
|
53
87
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
88
|
+
def validate_rollback_wait!(value)
|
|
89
|
+
return if value.nil? || (value.is_a?(Numeric) && value >= 0)
|
|
90
|
+
|
|
91
|
+
raise ArgumentError, "rollback_wait must be a number of seconds >= 0 (got #{value.inspect})"
|
|
92
|
+
end
|
|
93
|
+
private :validate_rollback_wait!
|
|
94
|
+
|
|
95
|
+
# Configure a calendar-aligned dedup window for this reactor or step.
|
|
96
|
+
# On a reactor, a hit returns `RubyReactor::Halt` without executing
|
|
97
|
+
# any steps. On a STEP, a hit instead skips just that step
|
|
98
|
+
# (`RubyReactor.Skipped(reason: :period)`) — halting the whole
|
|
99
|
+
# workflow over one deduplicated step would defeat the point of
|
|
100
|
+
# declaring it at step level; the rest of the workflow runs normally.
|
|
58
101
|
#
|
|
59
102
|
# Note: `with_period` is *dedup*, not *concurrency*. Two concurrent
|
|
60
103
|
# racers can both see no marker and both run. Pair with `with_lock`
|
|
@@ -64,7 +107,9 @@ module RubyReactor
|
|
|
64
107
|
# :month / :year, or an integer number of seconds for a sliding
|
|
65
108
|
# bucket (index = `time.to_i / every`).
|
|
66
109
|
# @yield [inputs] Block that returns the period key base. The final
|
|
67
|
-
# Redis marker key is `period:<base>:<bucket_id>`.
|
|
110
|
+
# Redis marker key is `period:<base>:<bucket_id>`. On a reactor,
|
|
111
|
+
# `inputs` is the reactor's inputs; on a step, it is the step's own
|
|
112
|
+
# resolved arguments.
|
|
68
113
|
def with_period(every:, &block)
|
|
69
114
|
# Validate eagerly so misconfiguration surfaces at class load time.
|
|
70
115
|
RubyReactor::Period.period_seconds(every)
|
|
@@ -75,11 +120,13 @@ module RubyReactor
|
|
|
75
120
|
}
|
|
76
121
|
end
|
|
77
122
|
|
|
78
|
-
# Configure strict-ordering nonce gating for this reactor.
|
|
79
|
-
# monotonically increasing nonce is assigned at enqueue
|
|
80
|
-
# worker can only proceed when its nonce equals
|
|
81
|
-
# Otherwise the worker raises
|
|
82
|
-
# worker snoozes via
|
|
123
|
+
# Configure strict-ordering nonce gating for this reactor or step. On
|
|
124
|
+
# a reactor, a monotonically increasing nonce is assigned at enqueue
|
|
125
|
+
# time; the worker can only proceed when its nonce equals
|
|
126
|
+
# `last_completed + 1`. Otherwise the worker raises
|
|
127
|
+
# {OrderedLock::WaitError} and the Sidekiq worker snoozes via
|
|
128
|
+
# `perform_in`. On a step, the nonce is assigned on first arrival
|
|
129
|
+
# instead — see the `@yield` note below.
|
|
83
130
|
#
|
|
84
131
|
# Counters reset to 0 once the sequence fully drains (last_completed
|
|
85
132
|
# catches up to next). Re-entrancy is NOT supported — a nested reactor
|
|
@@ -101,7 +148,13 @@ module RubyReactor
|
|
|
101
148
|
# check only applies to a fresh `execute`; an already-started run
|
|
102
149
|
# that paused (InterruptResult/DispatchResult) completes on resume even
|
|
103
150
|
# if the chain failed in the meantime.
|
|
104
|
-
# @yield [inputs] Block that returns the ordered-lock key string.
|
|
151
|
+
# @yield [inputs] Block that returns the ordered-lock key string. On a
|
|
152
|
+
# STEP, the position is assigned when the execution first REACHES
|
|
153
|
+
# the step (its key reads step arguments, which do not exist until
|
|
154
|
+
# then), so executions are ordered by ARRIVAL at that step, not by
|
|
155
|
+
# enqueue — identical to the reactor form only when the step is
|
|
156
|
+
# first in its reactor. The deeper the step, the weaker the
|
|
157
|
+
# promise (research D8, contract §1).
|
|
105
158
|
def with_ordered_lock(poison_pill_timeout: OrderedLock::DEFAULT_POISON_PILL_TIMEOUT,
|
|
106
159
|
ttl: OrderedLock::DEFAULT_TTL,
|
|
107
160
|
strict: true,
|
|
@@ -114,9 +167,9 @@ module RubyReactor
|
|
|
114
167
|
}
|
|
115
168
|
end
|
|
116
169
|
|
|
117
|
-
# Configure rate limiting for this reactor (fixed-window
|
|
118
|
-
# Pass either a single window via `limit:` + `period:`, or
|
|
119
|
-
# windows via `limits:` for layered API quotas.
|
|
170
|
+
# Configure rate limiting for this reactor or step (fixed-window
|
|
171
|
+
# counter). Pass either a single window via `limit:` + `period:`, or
|
|
172
|
+
# a hash of windows via `limits:` for layered API quotas.
|
|
120
173
|
#
|
|
121
174
|
# @example Single window
|
|
122
175
|
# with_rate_limit(limit: 3, period: :second) { |i| "stripe:#{i[:account_id]}" }
|
|
@@ -138,7 +191,9 @@ module RubyReactor
|
|
|
138
191
|
# :week / :month / :year, or integer seconds (single-window form)
|
|
139
192
|
# @param limits [Hash{Symbol,Integer => Integer}] mapping of period
|
|
140
193
|
# unit to limit (multi-window form)
|
|
141
|
-
# @yield [inputs] Block returning the rate-limit key base (inline
|
|
194
|
+
# @yield [inputs] Block returning the rate-limit key base (inline
|
|
195
|
+
# forms). On a reactor, `inputs` is the reactor's inputs; on a
|
|
196
|
+
# step, it is the step's own resolved arguments.
|
|
142
197
|
def with_rate_limit(name = nil, limit: nil, period: nil, limits: nil, &block)
|
|
143
198
|
if name
|
|
144
199
|
if limit || period || limits || block
|
|
@@ -5,6 +5,12 @@ module RubyReactor
|
|
|
5
5
|
class StepBuilder
|
|
6
6
|
include RubyReactor::Dsl::TemplateHelpers
|
|
7
7
|
include RubyReactor::Dsl::ValidationHelpers
|
|
8
|
+
include RubyReactor::Dsl::Lockable::ClassMethods
|
|
9
|
+
|
|
10
|
+
COORDINATION_MACROS = {
|
|
11
|
+
lock_config: "with_lock", semaphore_config: "with_semaphore", rate_limit_config: "with_rate_limit",
|
|
12
|
+
period_config: "with_period", ordered_lock_config: "with_ordered_lock"
|
|
13
|
+
}.freeze
|
|
8
14
|
|
|
9
15
|
attr_accessor :name, :impl, :arguments, :run_block, :compensate_block, :undo_block, :conditions, :guards,
|
|
10
16
|
:dependencies, :args_validator, :output_validator, :retry_config
|
|
@@ -137,6 +143,7 @@ module RubyReactor
|
|
|
137
143
|
# `async_reactor`. Nil for an ordinary step.
|
|
138
144
|
def build(async_dispatch: nil)
|
|
139
145
|
check_contract_conflicts!
|
|
146
|
+
check_coordination_conflicts!
|
|
140
147
|
warn_deprecated_rules
|
|
141
148
|
|
|
142
149
|
step_config = {
|
|
@@ -153,7 +160,12 @@ module RubyReactor
|
|
|
153
160
|
args_validator: @args_validator || build_args_validator(@arg_validations, @validate_args_input),
|
|
154
161
|
output_validator: @output_validator,
|
|
155
162
|
inline_contract: @inline_contract,
|
|
156
|
-
retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
|
|
163
|
+
retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config,
|
|
164
|
+
lock_config: @lock_config,
|
|
165
|
+
semaphore_config: @semaphore_config,
|
|
166
|
+
rate_limit_config: @rate_limit_config,
|
|
167
|
+
period_config: @period_config,
|
|
168
|
+
ordered_lock_config: @ordered_lock_config
|
|
157
169
|
}
|
|
158
170
|
|
|
159
171
|
RubyReactor::Dsl::StepConfig.new(step_config)
|
|
@@ -161,6 +173,23 @@ module RubyReactor
|
|
|
161
173
|
|
|
162
174
|
private
|
|
163
175
|
|
|
176
|
+
# The same primitive declared BOTH inline and on the step class is
|
|
177
|
+
# ambiguous — two keys for one slot of the fixed acquisition order, and
|
|
178
|
+
# `StepConfig`'s readers would silently let the inline one win. Refuse it
|
|
179
|
+
# at class-definition time rather than pick a winner silently.
|
|
180
|
+
def check_coordination_conflicts!
|
|
181
|
+
return unless @impl
|
|
182
|
+
|
|
183
|
+
COORDINATION_MACROS.each do |reader, macro|
|
|
184
|
+
next unless instance_variable_get(:"@#{reader}")
|
|
185
|
+
next unless @impl.respond_to?(reader) && @impl.public_send(reader)
|
|
186
|
+
|
|
187
|
+
raise Error::ValidationError,
|
|
188
|
+
"#{reactor_label} step :#{@name} declares `#{macro}` inline, but #{@impl} declares it too. " \
|
|
189
|
+
"Keep ONE: drop the inline declaration to use #{@impl}'s, or remove it from #{@impl}."
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
164
193
|
# A step that owns its input contract takes wiring only from the
|
|
165
194
|
# reactor: rules here would be a second, overlapping rule set.
|
|
166
195
|
def check_contract_conflicts!
|
|
@@ -240,6 +269,88 @@ module RubyReactor
|
|
|
240
269
|
@output_validator = config[:output_validator]
|
|
241
270
|
@inline_contract = config[:inline_contract]
|
|
242
271
|
@retry_config = { max_attempts: 1 }.merge(config[:retry_config] || {})
|
|
272
|
+
@lock_config = config[:lock_config]
|
|
273
|
+
@semaphore_config = config[:semaphore_config]
|
|
274
|
+
@rate_limit_config = config[:rate_limit_config]
|
|
275
|
+
@period_config = config[:period_config]
|
|
276
|
+
@ordered_lock_config = config[:ordered_lock_config]
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# A step's EFFECTIVE coordination: its own (inline) declaration if it has
|
|
280
|
+
# one, else the class step's (`impl`). Every consumer — forward
|
|
281
|
+
# execution, rollback, the dispatch guard, the dashboard — reads only
|
|
282
|
+
# these five readers, so a step mixing inline and class declarations is
|
|
283
|
+
# acquired by ONE `StepCoordination` in one global order. The two sources
|
|
284
|
+
# can never both carry the SAME primitive (`check_coordination_conflicts!`
|
|
285
|
+
# rejects that at class-definition time), so this is a union.
|
|
286
|
+
def lock_config
|
|
287
|
+
@lock_config || (impl.lock_config if impl.respond_to?(:lock_config))
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def semaphore_config
|
|
291
|
+
@semaphore_config || (impl.semaphore_config if impl.respond_to?(:semaphore_config))
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def rate_limit_config
|
|
295
|
+
@rate_limit_config || (impl.rate_limit_config if impl.respond_to?(:rate_limit_config))
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def period_config
|
|
299
|
+
@period_config || (impl.period_config if impl.respond_to?(:period_config))
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def ordered_lock_config
|
|
303
|
+
@ordered_lock_config || (impl.ordered_lock_config if impl.respond_to?(:ordered_lock_config))
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def coordination_declarations
|
|
307
|
+
{
|
|
308
|
+
lock: lock_config,
|
|
309
|
+
semaphore: semaphore_config,
|
|
310
|
+
rate_limit: rate_limit_config,
|
|
311
|
+
period: period_config,
|
|
312
|
+
ordered_lock: ordered_lock_config
|
|
313
|
+
}.compact
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def declares_coordination?
|
|
317
|
+
!coordination_declarations.empty?
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# The ONE derivation of what a step's body — and therefore every
|
|
321
|
+
# coordination key — receives: an inline step with no `argument` wiring
|
|
322
|
+
# gets the reactor's inputs, and the step's contract applies its
|
|
323
|
+
# defaults. Forward execution (`body_arguments`), rollback, the async
|
|
324
|
+
# dispatch guard and the dashboard all go through here, so a key can
|
|
325
|
+
# never be computed from different values in different places.
|
|
326
|
+
# Never raises — `enforce!` returns exactly `apply_defaults(args)` when
|
|
327
|
+
# the arguments are valid, and rollback must not fail on invalid ones.
|
|
328
|
+
def coordination_arguments(resolved, inputs)
|
|
329
|
+
args = has_run_block? && resolved.empty? ? inputs : resolved
|
|
330
|
+
input_contract && args.is_a?(Hash) ? input_contract.apply_defaults(args) : args
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# `coordination_arguments`, validated: raises InputValidationError
|
|
334
|
+
# BEFORE any coordination is taken (Finding 8).
|
|
335
|
+
def body_arguments(resolved, inputs)
|
|
336
|
+
args = has_run_block? && resolved.empty? ? inputs : resolved
|
|
337
|
+
input_contract ? input_contract.enforce!(args) : args
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# The step's work as a reactor runs it. Coordination is NOT taken here:
|
|
341
|
+
# the caller (StepExecutor / StepWorker) takes this config's effective
|
|
342
|
+
# declarations — inline and class alike — in one fixed order around it.
|
|
343
|
+
def call_body(arguments, context)
|
|
344
|
+
catch(StepSignals::TAG) do
|
|
345
|
+
if has_run_block?
|
|
346
|
+
run_block.call(arguments, context)
|
|
347
|
+
elsif impl.respond_to?(:run_without_coordination)
|
|
348
|
+
impl.run_without_coordination(arguments, context)
|
|
349
|
+
else
|
|
350
|
+
# A duck-typed impl (any `.run(args, ctx)`) never coordinates itself.
|
|
351
|
+
impl.run(arguments, context)
|
|
352
|
+
end
|
|
353
|
+
end
|
|
243
354
|
end
|
|
244
355
|
|
|
245
356
|
# True for `async_step` / `async_reactor` — the step's work leaves this
|
|
@@ -9,7 +9,7 @@ module RubyReactor
|
|
|
9
9
|
# re-enqueues itself via the snooze path, and the wait resumes on
|
|
10
10
|
# redelivery. Bounded by `Configuration#async_park_timeout`, enforced at
|
|
11
11
|
# the wait site before this is raised.
|
|
12
|
-
class AsyncResultPending <
|
|
12
|
+
class AsyncResultPending < ExecutionParked
|
|
13
13
|
attr_reader :channel
|
|
14
14
|
|
|
15
15
|
def initialize(message, channel: nil)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Error
|
|
5
|
+
# Base for the internal "this execution parks" signals raised inside a
|
|
6
|
+
# worker (`inline_async_execution`). Never persisted, never reaches a
|
|
7
|
+
# synchronous caller.
|
|
8
|
+
#
|
|
9
|
+
# Contract: every rescue between the raise site and the final handler
|
|
10
|
+
# (`Worker#perform`, or `Map::ElementExecutor` for a map element) either
|
|
11
|
+
# re-raises it untouched, or parks its own holds and then re-raises. The
|
|
12
|
+
# requeue happens once, at the top, after every executor on the stack has
|
|
13
|
+
# parked and saved.
|
|
14
|
+
class ExecutionParked < Base; end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Error
|
|
5
|
+
# A composed child's OWN reactor-level lock, semaphore or rate limit was
|
|
6
|
+
# contended inside a worker, before the child was admitted. Nothing between
|
|
7
|
+
# the child and the worker snoozes a nested executor's contention error, so
|
|
8
|
+
# the child raises this park signal instead: every executor above it keeps
|
|
9
|
+
# its holds, and the worker requeues the job. Carries the contention error,
|
|
10
|
+
# and delegates `original`/`retry_after_seconds` to it so `Worker.snooze_delay`
|
|
11
|
+
# and `Worker.hinted_retry?` treat it exactly like the error it wraps (as
|
|
12
|
+
# `StepContentionPark` does).
|
|
13
|
+
class ReactorContentionPark < ExecutionParked
|
|
14
|
+
attr_reader :original
|
|
15
|
+
|
|
16
|
+
def initialize(original)
|
|
17
|
+
super(original.message)
|
|
18
|
+
@original = original
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def retry_after_seconds
|
|
22
|
+
original.retry_after_seconds if original.respond_to?(:retry_after_seconds)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Error
|
|
5
|
+
# A step's own coordination was contended inside a worker: park the
|
|
6
|
+
# execution at that step. Carries the `StepCoordination::Contended`, and
|
|
7
|
+
# delegates `original`/`retry_after_seconds` to it so `Worker.snooze_delay`
|
|
8
|
+
# and `Worker.hinted_retry?` treat it exactly like the contention it wraps.
|
|
9
|
+
class StepContentionPark < ExecutionParked
|
|
10
|
+
attr_reader :contended
|
|
11
|
+
|
|
12
|
+
def initialize(contended)
|
|
13
|
+
super(contended.message)
|
|
14
|
+
@contended = contended
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def original
|
|
18
|
+
contended.original
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def retry_after_seconds
|
|
22
|
+
contended.retry_after_seconds
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -28,6 +28,14 @@ module RubyReactor
|
|
|
28
28
|
return RubyReactor.Success(nil)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
deadlock = check_async_step_deadlock(step_config)
|
|
32
|
+
# Through the normal step-failure handler, never returned bare: a bare
|
|
33
|
+
# Failure goes straight back to `Executor#execute`, which marks the
|
|
34
|
+
# context failed WITHOUT rolling back — leaving an earlier step's side
|
|
35
|
+
# effect uncompensated. `handle_step_result` compensates and raises the
|
|
36
|
+
# `StepFailureError` the executor's own rescue chain expects.
|
|
37
|
+
return @result_handler.handle_step_result(step_config, deadlock, {}) if deadlock
|
|
38
|
+
|
|
31
39
|
record_async_step_dispatch(step_config)
|
|
32
40
|
enqueue_async_step(step_config)
|
|
33
41
|
|
|
@@ -43,6 +51,101 @@ module RubyReactor
|
|
|
43
51
|
!storage.retrieve_step_result(@context.context_id, step_config.name, async_step_class_name).nil?
|
|
44
52
|
end
|
|
45
53
|
|
|
54
|
+
# US4/T034: an `async_step` whose step class declares a key this
|
|
55
|
+
# execution currently holds would deadlock exactly like `async_reactor`
|
|
56
|
+
# dispatching into one — refuse before the durable record is written or
|
|
57
|
+
# the job is enqueued, naming the key (Step::AsyncReactorStep's guard
|
|
58
|
+
# already covers `async_reactor`; this reuses its message and registry).
|
|
59
|
+
def check_async_step_deadlock(step_config)
|
|
60
|
+
held = Step::AsyncReactorStep.held_lock_keys(@context)
|
|
61
|
+
return nil if held.empty?
|
|
62
|
+
|
|
63
|
+
# Only lock and a limit-1 semaphore have the circular-wait shape this
|
|
64
|
+
# guard defends against (same registry `with_lock`/limit-1
|
|
65
|
+
# `with_semaphore` push to, T032). Rate limit, period, and the
|
|
66
|
+
# ordered lock never enter `held_lock_keys`, so they cannot deadlock
|
|
67
|
+
# a hand-off and are deliberately not checked here.
|
|
68
|
+
lock_config = step_config.lock_config
|
|
69
|
+
semaphore_config = step_config.semaphore_config
|
|
70
|
+
return nil unless lock_config || (semaphore_config && semaphore_config[:limit] == 1)
|
|
71
|
+
|
|
72
|
+
args = resolve_args_for_deadlock_check(step_config)
|
|
73
|
+
return args if args.is_a?(RubyReactor::Failure) # KeyError or "skip, can't resolve without blocking"
|
|
74
|
+
return nil if args.nil? # skipped — non-blocking resolution was not possible
|
|
75
|
+
|
|
76
|
+
collision = async_step_lock_keys(lock_config, semaphore_config, args, step_config)
|
|
77
|
+
.find { |key| held.include?(key) }
|
|
78
|
+
return nil unless collision
|
|
79
|
+
|
|
80
|
+
# A never-started error, not a bare message: the step was neither
|
|
81
|
+
# dispatched nor run, so rollback must not compensate it.
|
|
82
|
+
message = Step::AsyncReactorStep.deadlock_message(collision, "#{@reactor_class&.name}##{step_config.name}",
|
|
83
|
+
@context, kind: "async_step")
|
|
84
|
+
never_started_failure(Executor::StepCoordination::DispatchRefused.new(message, step: step_config.name),
|
|
85
|
+
step_config)
|
|
86
|
+
rescue Executor::StepCoordination::KeyError => e
|
|
87
|
+
# A guard key that cannot be computed is the same non-retryable step
|
|
88
|
+
# failure the real acquisition would raise (FR-007) — never a generic
|
|
89
|
+
# execution error, which would skip rollback of the earlier steps.
|
|
90
|
+
never_started_failure(e, step_config)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Through `StepCoordination.resolve_key`, so a nil/empty or raising key
|
|
94
|
+
# proc fails here exactly as it would at acquisition time.
|
|
95
|
+
def async_step_lock_keys(lock_config, semaphore_config, args, step_config)
|
|
96
|
+
keys = []
|
|
97
|
+
keys << Executor::StepCoordination.resolve_key(lock_config, args, step_config.name) if lock_config
|
|
98
|
+
if semaphore_config && semaphore_config[:limit] == 1
|
|
99
|
+
keys << Executor::StepCoordination.resolve_key(semaphore_config, args, step_config.name)
|
|
100
|
+
end
|
|
101
|
+
keys.compact
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def never_started_failure(error, step_config)
|
|
105
|
+
RubyReactor::Failure(error, step_name: step_config.name, reactor_name: @reactor_class&.name,
|
|
106
|
+
retryable: false)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Finding 5: `async_step` defers argument resolution to the worker, so
|
|
110
|
+
# computing the key here means resolving early — safe UNLESS an
|
|
111
|
+
# argument reads a still-pending async result, which would BLOCK (or
|
|
112
|
+
# park) this dispatching step just to run a guard check. Detect that
|
|
113
|
+
# case without calling `.resolve` at all, skip the guard, and log it.
|
|
114
|
+
def resolve_args_for_deadlock_check(step_config)
|
|
115
|
+
if step_config.arguments.values.any? { |cfg| pending_async_source?(cfg[:source]) }
|
|
116
|
+
log_guard_skipped(step_config)
|
|
117
|
+
return nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
resolved = {}
|
|
121
|
+
step_config.arguments.each do |name, cfg|
|
|
122
|
+
value = cfg[:source].resolve(@context)
|
|
123
|
+
value = cfg[:transform].call(value) if cfg[:transform]
|
|
124
|
+
resolved[name] = value
|
|
125
|
+
end
|
|
126
|
+
# The worker keys off `coordination_arguments` of these same values;
|
|
127
|
+
# anything else here would let a self-deadlocking job through.
|
|
128
|
+
step_config.coordination_arguments(resolved, @context.inputs)
|
|
129
|
+
rescue Executor::StepCoordination::KeyError => e
|
|
130
|
+
never_started_failure(e, step_config)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def pending_async_source?(source)
|
|
134
|
+
return false unless source.is_a?(RubyReactor::Template::Result)
|
|
135
|
+
return false if @context.intermediate_results.key?(source.step_name.to_sym) ||
|
|
136
|
+
@context.intermediate_results.key?(source.step_name.to_s)
|
|
137
|
+
|
|
138
|
+
ref = @context.composed_contexts[source.step_name] || @context.composed_contexts[source.step_name.to_s]
|
|
139
|
+
ref.is_a?(Hash) && %i[async_step_ref async_reactor_ref].include?(ref[:type]&.to_sym)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def log_guard_skipped(step_config)
|
|
143
|
+
configuration.logger.info(
|
|
144
|
+
"event=\"ruby_reactor.step_coordination.guard_skipped\" reactor=#{@reactor_class&.name.inspect} " \
|
|
145
|
+
"step=#{step_config.name.inspect} execution_id=#{@context.context_id.inspect}"
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
46
149
|
def record_async_step_dispatch(step_config)
|
|
47
150
|
root = @context.root_context || @context
|
|
48
151
|
@context.composed_contexts[step_config.name] = {
|
|
@@ -98,11 +201,14 @@ module RubyReactor
|
|
|
98
201
|
end
|
|
99
202
|
|
|
100
203
|
# One machine-parseable line per hand-off / dispatch, carrying the
|
|
101
|
-
# three identifiers needed to correlate it with everything else
|
|
102
|
-
|
|
204
|
+
# three identifiers needed to correlate it with everything else, plus
|
|
205
|
+
# any extra key=value fields (e.g. a park's key/primitive/attempt),
|
|
206
|
+
# inserted before execution_id so it always trails the line.
|
|
207
|
+
def log_async_event(event, step_name, **fields)
|
|
208
|
+
extra = fields.map { |k, v| " #{k}=#{v.inspect}" }.join
|
|
103
209
|
configuration.logger.info(
|
|
104
210
|
"event=\"ruby_reactor.#{event}\" reactor=#{@reactor_class&.name.inspect} " \
|
|
105
|
-
"step=#{step_name.inspect} execution_id=#{@context.context_id.inspect}"
|
|
211
|
+
"step=#{step_name.inspect}#{extra} execution_id=#{@context.context_id.inspect}"
|
|
106
212
|
)
|
|
107
213
|
end
|
|
108
214
|
end
|