ruby_reactor 0.8.2 → 0.8.4
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 +94 -0
- data/CLAUDE.md +2 -2
- data/README.md +128 -93
- 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/async_reactor_builder.rb +3 -6
- data/lib/ruby_reactor/dsl/compose_builder.rb +3 -10
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +14 -0
- data/lib/ruby_reactor/dsl/lockable.rb +76 -21
- 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 +150 -13
- 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/error/undeclared_input_error.rb +13 -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 +34 -13
- data/lib/ruby_reactor/executor/retry_manager.rb +20 -13
- 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 +9 -5
- data/lib/ruby_reactor/step/async_reactor_step.rb +46 -26
- data/lib/ruby_reactor/step/compose_step.rb +22 -7
- 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 +32 -22
- data/lib/ruby_reactor/step.rb +59 -11
- 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 +30 -6
- data/specs/{004-inheritable-step-class → 006-step-retry-declarations}/checklists/requirements.md +10 -9
- 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/future_improvements.md +250 -0
- data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
- metadata +20 -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/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
|
@@ -10,6 +10,7 @@ module RubyReactor
|
|
|
10
10
|
# ordering nonce.
|
|
11
11
|
class AsyncReactorBuilder
|
|
12
12
|
include RubyReactor::Dsl::TemplateHelpers
|
|
13
|
+
include RubyReactor::Dsl::Retryable
|
|
13
14
|
|
|
14
15
|
attr_accessor :name, :child_reactor_class, :argument_mappings
|
|
15
16
|
|
|
@@ -18,17 +19,13 @@ module RubyReactor
|
|
|
18
19
|
@child_reactor_class = child_reactor_class
|
|
19
20
|
@reactor = reactor
|
|
20
21
|
@argument_mappings = {}
|
|
21
|
-
@retry_config =
|
|
22
|
+
@retry_config = nil
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
def argument(child_input_name, source)
|
|
25
26
|
@argument_mappings[child_input_name] = source
|
|
26
27
|
end
|
|
27
28
|
|
|
28
|
-
def retries(max_attempts: 3, backoff: :exponential, base_delay: 1)
|
|
29
|
-
@retry_config = { max_attempts: max_attempts, backoff: backoff, base_delay: base_delay }
|
|
30
|
-
end
|
|
31
|
-
|
|
32
29
|
def build
|
|
33
30
|
RubyReactor::Dsl::StepConfig.new(
|
|
34
31
|
async_dispatch: :reactor,
|
|
@@ -49,7 +46,7 @@ module RubyReactor
|
|
|
49
46
|
dependencies: dependencies_from_mappings,
|
|
50
47
|
args_validator: nil,
|
|
51
48
|
output_validator: nil,
|
|
52
|
-
retry_config: @retry_config
|
|
49
|
+
retry_config: @retry_config
|
|
53
50
|
)
|
|
54
51
|
end
|
|
55
52
|
|
|
@@ -4,6 +4,7 @@ module RubyReactor
|
|
|
4
4
|
module Dsl
|
|
5
5
|
class ComposeBuilder
|
|
6
6
|
include RubyReactor::Dsl::TemplateHelpers
|
|
7
|
+
include RubyReactor::Dsl::Retryable
|
|
7
8
|
|
|
8
9
|
attr_accessor :name, :composed_reactor_class, :argument_mappings
|
|
9
10
|
|
|
@@ -20,7 +21,7 @@ module RubyReactor
|
|
|
20
21
|
end
|
|
21
22
|
@reactor = reactor
|
|
22
23
|
@argument_mappings = {}
|
|
23
|
-
@retry_config =
|
|
24
|
+
@retry_config = nil
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
def argument(composed_input_name, source)
|
|
@@ -44,14 +45,6 @@ module RubyReactor
|
|
|
44
45
|
)
|
|
45
46
|
end
|
|
46
47
|
|
|
47
|
-
def retries(max_attempts: 3, backoff: :exponential, base_delay: 1)
|
|
48
|
-
@retry_config = {
|
|
49
|
-
max_attempts: max_attempts,
|
|
50
|
-
backoff: backoff,
|
|
51
|
-
base_delay: base_delay
|
|
52
|
-
}
|
|
53
|
-
end
|
|
54
|
-
|
|
55
48
|
def build
|
|
56
49
|
warn_if_child_has_ordered_lock!
|
|
57
50
|
dependencies = extract_dependencies_from_mappings
|
|
@@ -71,7 +64,7 @@ module RubyReactor
|
|
|
71
64
|
dependencies: dependencies,
|
|
72
65
|
args_validator: nil,
|
|
73
66
|
output_validator: nil,
|
|
74
|
-
retry_config: @retry_config
|
|
67
|
+
retry_config: @retry_config
|
|
75
68
|
}
|
|
76
69
|
|
|
77
70
|
RubyReactor::Dsl::StepConfig.new(step_config)
|
|
@@ -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
|
|
@@ -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
|
|
@@ -5,9 +5,16 @@ 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
|
+
include RubyReactor::Dsl::Retryable
|
|
10
|
+
|
|
11
|
+
COORDINATION_MACROS = {
|
|
12
|
+
lock_config: "with_lock", semaphore_config: "with_semaphore", rate_limit_config: "with_rate_limit",
|
|
13
|
+
period_config: "with_period", ordered_lock_config: "with_ordered_lock"
|
|
14
|
+
}.freeze
|
|
8
15
|
|
|
9
16
|
attr_accessor :name, :impl, :arguments, :run_block, :compensate_block, :undo_block, :conditions, :guards,
|
|
10
|
-
:dependencies, :args_validator, :output_validator
|
|
17
|
+
:dependencies, :args_validator, :output_validator
|
|
11
18
|
|
|
12
19
|
def initialize(name, impl = nil, reactor = nil)
|
|
13
20
|
@name = name
|
|
@@ -24,7 +31,7 @@ module RubyReactor
|
|
|
24
31
|
@validate_args_input = nil
|
|
25
32
|
@args_validator = nil
|
|
26
33
|
@output_validator = nil
|
|
27
|
-
@retry_config =
|
|
34
|
+
@retry_config = nil
|
|
28
35
|
@inline_contract = nil
|
|
29
36
|
@rule_sites = []
|
|
30
37
|
end
|
|
@@ -124,19 +131,13 @@ module RubyReactor
|
|
|
124
131
|
)
|
|
125
132
|
end
|
|
126
133
|
|
|
127
|
-
def retries(max_attempts: 3, backoff: :exponential, base_delay: 1)
|
|
128
|
-
@retry_config = {
|
|
129
|
-
max_attempts: max_attempts,
|
|
130
|
-
backoff: backoff,
|
|
131
|
-
base_delay: base_delay
|
|
132
|
-
}
|
|
133
|
-
end
|
|
134
|
-
|
|
135
134
|
# `async_dispatch` marks a step whose work is dispatched as an independent
|
|
136
135
|
# unit rather than run inline — `:step` for `async_step`, `:reactor` for
|
|
137
136
|
# `async_reactor`. Nil for an ordinary step.
|
|
138
137
|
def build(async_dispatch: nil)
|
|
139
138
|
check_contract_conflicts!
|
|
139
|
+
check_coordination_conflicts!
|
|
140
|
+
check_retry_conflict!
|
|
140
141
|
warn_deprecated_rules
|
|
141
142
|
|
|
142
143
|
step_config = {
|
|
@@ -153,7 +154,12 @@ module RubyReactor
|
|
|
153
154
|
args_validator: @args_validator || build_args_validator(@arg_validations, @validate_args_input),
|
|
154
155
|
output_validator: @output_validator,
|
|
155
156
|
inline_contract: @inline_contract,
|
|
156
|
-
retry_config: @retry_config
|
|
157
|
+
retry_config: @retry_config,
|
|
158
|
+
lock_config: @lock_config,
|
|
159
|
+
semaphore_config: @semaphore_config,
|
|
160
|
+
rate_limit_config: @rate_limit_config,
|
|
161
|
+
period_config: @period_config,
|
|
162
|
+
ordered_lock_config: @ordered_lock_config
|
|
157
163
|
}
|
|
158
164
|
|
|
159
165
|
RubyReactor::Dsl::StepConfig.new(step_config)
|
|
@@ -161,6 +167,33 @@ module RubyReactor
|
|
|
161
167
|
|
|
162
168
|
private
|
|
163
169
|
|
|
170
|
+
# The same primitive declared BOTH inline and on the step class is
|
|
171
|
+
# ambiguous — two keys for one slot of the fixed acquisition order, and
|
|
172
|
+
# `StepConfig`'s readers would silently let the inline one win. Refuse it
|
|
173
|
+
# at class-definition time rather than pick a winner silently.
|
|
174
|
+
def check_coordination_conflicts!
|
|
175
|
+
return unless @impl
|
|
176
|
+
|
|
177
|
+
COORDINATION_MACROS.each do |reader, macro|
|
|
178
|
+
next unless instance_variable_get(:"@#{reader}")
|
|
179
|
+
next unless @impl.respond_to?(reader) && @impl.public_send(reader)
|
|
180
|
+
|
|
181
|
+
raise Error::ValidationError,
|
|
182
|
+
"#{reactor_label} step :#{@name} declares `#{macro}` inline, but #{@impl} declares it too. " \
|
|
183
|
+
"Keep ONE: drop the inline declaration to use #{@impl}'s, or remove it from #{@impl}."
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Same rule as the coordination macros: one retry policy per step.
|
|
188
|
+
def check_retry_conflict!
|
|
189
|
+
return unless @retry_config && @impl.respond_to?(:retry_config) && @impl.retry_config
|
|
190
|
+
|
|
191
|
+
raise Error::ValidationError,
|
|
192
|
+
"#{reactor_label} step :#{@name} declares `retries` inline, but #{@impl} declares it too. " \
|
|
193
|
+
"Keep ONE: drop the inline declaration to use #{@impl}'s, or remove it from #{@impl}. " \
|
|
194
|
+
"To vary the policy per workflow, subclass #{@impl} and declare `retries` there."
|
|
195
|
+
end
|
|
196
|
+
|
|
164
197
|
# A step that owns its input contract takes wiring only from the
|
|
165
198
|
# reactor: rules here would be a second, overlapping rule set.
|
|
166
199
|
def check_contract_conflicts!
|
|
@@ -221,8 +254,10 @@ module RubyReactor
|
|
|
221
254
|
end
|
|
222
255
|
|
|
223
256
|
class StepConfig
|
|
257
|
+
NO_RETRIES = { max_attempts: 1, backoff: :exponential, base_delay: 1 }.freeze
|
|
258
|
+
|
|
224
259
|
attr_reader :name, :impl, :arguments, :run_block, :compensate_block, :undo_block, :conditions, :guards,
|
|
225
|
-
:dependencies, :args_validator, :output_validator, :
|
|
260
|
+
:dependencies, :args_validator, :output_validator, :async_dispatch,
|
|
226
261
|
:inline_contract
|
|
227
262
|
|
|
228
263
|
def initialize(config)
|
|
@@ -239,7 +274,104 @@ module RubyReactor
|
|
|
239
274
|
@args_validator = config[:args_validator]
|
|
240
275
|
@output_validator = config[:output_validator]
|
|
241
276
|
@inline_contract = config[:inline_contract]
|
|
242
|
-
@retry_config =
|
|
277
|
+
@retry_config = config[:retry_config]
|
|
278
|
+
@lock_config = config[:lock_config]
|
|
279
|
+
@semaphore_config = config[:semaphore_config]
|
|
280
|
+
@rate_limit_config = config[:rate_limit_config]
|
|
281
|
+
@period_config = config[:period_config]
|
|
282
|
+
@ordered_lock_config = config[:ordered_lock_config]
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# A step's EFFECTIVE coordination: its own (inline) declaration if it has
|
|
286
|
+
# one, else the class step's (`impl`). Every consumer — forward
|
|
287
|
+
# execution, rollback, the dispatch guard, the dashboard — reads only
|
|
288
|
+
# these five readers, so a step mixing inline and class declarations is
|
|
289
|
+
# acquired by ONE `StepCoordination` in one global order. The two sources
|
|
290
|
+
# can never both carry the SAME primitive (`check_coordination_conflicts!`
|
|
291
|
+
# rejects that at class-definition time), so this is a union.
|
|
292
|
+
def lock_config
|
|
293
|
+
@lock_config || (impl.lock_config if impl.respond_to?(:lock_config))
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# A step's EFFECTIVE retry policy, resolved like the coordination
|
|
297
|
+
# readers: its own (step block) declaration, else the class step's,
|
|
298
|
+
# else a single attempt. Never nil. The reactor is never consulted.
|
|
299
|
+
def retry_config
|
|
300
|
+
@retry_config || (impl.retry_config if impl.respond_to?(:retry_config)) || NO_RETRIES
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Where `retry_config` came from: :step_block, :step_class or :none.
|
|
304
|
+
def retry_source
|
|
305
|
+
return :step_block if @retry_config
|
|
306
|
+
return :step_class if impl.respond_to?(:retry_config) && impl.retry_config
|
|
307
|
+
|
|
308
|
+
:none
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def semaphore_config
|
|
312
|
+
@semaphore_config || (impl.semaphore_config if impl.respond_to?(:semaphore_config))
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def rate_limit_config
|
|
316
|
+
@rate_limit_config || (impl.rate_limit_config if impl.respond_to?(:rate_limit_config))
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def period_config
|
|
320
|
+
@period_config || (impl.period_config if impl.respond_to?(:period_config))
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def ordered_lock_config
|
|
324
|
+
@ordered_lock_config || (impl.ordered_lock_config if impl.respond_to?(:ordered_lock_config))
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def coordination_declarations
|
|
328
|
+
{
|
|
329
|
+
lock: lock_config,
|
|
330
|
+
semaphore: semaphore_config,
|
|
331
|
+
rate_limit: rate_limit_config,
|
|
332
|
+
period: period_config,
|
|
333
|
+
ordered_lock: ordered_lock_config
|
|
334
|
+
}.compact
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def declares_coordination?
|
|
338
|
+
!coordination_declarations.empty?
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# The ONE derivation of what a step's body — and therefore every
|
|
342
|
+
# coordination key — receives: an inline step with no `argument` wiring
|
|
343
|
+
# gets the reactor's inputs, and the step's contract applies its
|
|
344
|
+
# defaults. Forward execution (`body_arguments`), rollback, the async
|
|
345
|
+
# dispatch guard and the dashboard all go through here, so a key can
|
|
346
|
+
# never be computed from different values in different places.
|
|
347
|
+
# Never raises — `enforce!` returns exactly `apply_defaults(args)` when
|
|
348
|
+
# the arguments are valid, and rollback must not fail on invalid ones.
|
|
349
|
+
def coordination_arguments(resolved, inputs)
|
|
350
|
+
args = has_run_block? && resolved.empty? ? inputs : resolved
|
|
351
|
+
input_contract && args.is_a?(Hash) ? input_contract.apply_defaults(args) : args
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# `coordination_arguments`, validated: raises InputValidationError
|
|
355
|
+
# BEFORE any coordination is taken (Finding 8).
|
|
356
|
+
def body_arguments(resolved, inputs)
|
|
357
|
+
args = has_run_block? && resolved.empty? ? inputs : resolved
|
|
358
|
+
input_contract ? input_contract.enforce!(args) : args
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# The step's work as a reactor runs it. Coordination is NOT taken here:
|
|
362
|
+
# the caller (StepExecutor / StepWorker) takes this config's effective
|
|
363
|
+
# declarations — inline and class alike — in one fixed order around it.
|
|
364
|
+
def call_body(arguments, context)
|
|
365
|
+
catch(StepSignals::TAG) do
|
|
366
|
+
if has_run_block?
|
|
367
|
+
run_block.call(wrap_inputs(arguments), context)
|
|
368
|
+
elsif impl.respond_to?(:run_without_coordination)
|
|
369
|
+
impl.run_without_coordination(arguments, context)
|
|
370
|
+
else
|
|
371
|
+
# A duck-typed impl (any `.run(args, ctx)`) never coordinates itself.
|
|
372
|
+
impl.run(arguments, context)
|
|
373
|
+
end
|
|
374
|
+
end
|
|
243
375
|
end
|
|
244
376
|
|
|
245
377
|
# True for `async_step` / `async_reactor` — the step's work leaves this
|
|
@@ -262,6 +394,11 @@ module RubyReactor
|
|
|
262
394
|
!@run_block.nil?
|
|
263
395
|
end
|
|
264
396
|
|
|
397
|
+
# What an inline `run`/`undo`/`compensate` block receives as its inputs.
|
|
398
|
+
def wrap_inputs(arguments)
|
|
399
|
+
RubyReactor::Step::Inputs.new(arguments, contract: input_contract, owner: "step :#{name}")
|
|
400
|
+
end
|
|
401
|
+
|
|
265
402
|
def retryable?
|
|
266
403
|
(retry_config[:max_attempts] || 0) > 1
|
|
267
404
|
end
|
|
@@ -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
|
|
@@ -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
|