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
|
@@ -7,10 +7,13 @@ require_relative "executor/retry_manager"
|
|
|
7
7
|
require_relative "executor/compensation_manager"
|
|
8
8
|
require_relative "executor/result_handler"
|
|
9
9
|
require_relative "executor/step_executor"
|
|
10
|
+
require_relative "executor/ordered_lock_support"
|
|
10
11
|
|
|
11
12
|
module RubyReactor
|
|
12
13
|
# rubocop:disable Metrics/ClassLength
|
|
13
14
|
class Executor
|
|
15
|
+
include OrderedLockSupport
|
|
16
|
+
|
|
14
17
|
attr_reader :reactor_class, :context, :dependency_graph, :compensation_manager, :retry_manager, :result_handler,
|
|
15
18
|
:step_executor, :result, :middlewares
|
|
16
19
|
|
|
@@ -41,6 +44,8 @@ module RubyReactor
|
|
|
41
44
|
@result = nil
|
|
42
45
|
@acquired_lock = nil
|
|
43
46
|
@acquired_semaphore = nil
|
|
47
|
+
@contention_snooze = false
|
|
48
|
+
@skip_context_persist = false
|
|
44
49
|
end
|
|
45
50
|
|
|
46
51
|
def self.resolve_middlewares(reactor_class)
|
|
@@ -71,20 +76,32 @@ module RubyReactor
|
|
|
71
76
|
middlewares.on(:start_reactor, reactor_class.name, context.inputs, @context)
|
|
72
77
|
completed = false
|
|
73
78
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
enter_ordered_lock_scope
|
|
80
|
+
# short_circuit_result covers both the strict ordered-lock chain skip
|
|
81
|
+
# and the already-marked period bucket.
|
|
82
|
+
short = short_circuit_result
|
|
83
|
+
if short
|
|
79
84
|
completed = true
|
|
80
|
-
return
|
|
85
|
+
return short_circuit!(short)
|
|
81
86
|
end
|
|
82
87
|
|
|
83
|
-
|
|
84
|
-
|
|
88
|
+
# Validate inputs BEFORE consuming a rate-limit slot or grabbing a
|
|
89
|
+
# lock/semaphore: a run that can never start must not burn quota or
|
|
90
|
+
# briefly block other callers.
|
|
85
91
|
input_validator = InputValidator.new(@reactor_class, @context)
|
|
86
92
|
input_validator.validate!
|
|
87
93
|
|
|
94
|
+
acquire_locks_with_telemetry
|
|
95
|
+
|
|
96
|
+
# Re-check the period gate now that we hold the lock. The pre-lock check
|
|
97
|
+
# is a fast path; this one closes the race where two callers both passed
|
|
98
|
+
# it and then serialized on the lock — without it the second caller would
|
|
99
|
+
# re-run work the first already marked. (No-op when no lock is configured.)
|
|
100
|
+
if (skipped = check_period_gate)
|
|
101
|
+
completed = true
|
|
102
|
+
return finalize_skipped(skipped)
|
|
103
|
+
end
|
|
104
|
+
|
|
88
105
|
@context.status = :running
|
|
89
106
|
save_context
|
|
90
107
|
|
|
@@ -100,7 +117,10 @@ module RubyReactor
|
|
|
100
117
|
@result
|
|
101
118
|
rescue RubyReactor::Lock::AcquisitionError,
|
|
102
119
|
RubyReactor::Semaphore::AcquisitionError,
|
|
103
|
-
RubyReactor::RateLimit::ExceededError
|
|
120
|
+
RubyReactor::RateLimit::ExceededError,
|
|
121
|
+
RubyReactor::RateLimitRegistry::UnknownLimitError,
|
|
122
|
+
RubyReactor::OrderedLock::WaitError => e
|
|
123
|
+
@contention_snooze = true
|
|
104
124
|
raise e
|
|
105
125
|
rescue StandardError => e
|
|
106
126
|
@result = @result_handler.handle_execution_error(e)
|
|
@@ -109,56 +129,98 @@ module RubyReactor
|
|
|
109
129
|
@result
|
|
110
130
|
ensure
|
|
111
131
|
release_locks
|
|
112
|
-
|
|
132
|
+
leave_ordered_lock_scope
|
|
133
|
+
save_context if persist_context? && !skip_context_persist?
|
|
134
|
+
|
|
135
|
+
emit_lifecycle_completion(completed)
|
|
136
|
+
end
|
|
113
137
|
|
|
138
|
+
# Contention errors (lock/semaphore/rate-limit/ordered-lock wait) are
|
|
139
|
+
# expected "try again later" signals, not failures — the worker snoozes
|
|
140
|
+
# and re-runs. Emitting `failed_reactor` for them floods dashboards with
|
|
141
|
+
# phantom failures (one per snooze round), so route them to a distinct
|
|
142
|
+
# `snooze_reactor` event instead.
|
|
143
|
+
def emit_lifecycle_completion(completed)
|
|
114
144
|
if completed
|
|
115
145
|
middlewares.on(:complete_reactor, reactor_class.name, @result, @context)
|
|
146
|
+
elsif @contention_snooze
|
|
147
|
+
middlewares.on(:snooze_reactor, reactor_class.name, $ERROR_INFO, @context)
|
|
116
148
|
else
|
|
117
149
|
middlewares.on(:failed_reactor, reactor_class.name, $ERROR_INFO, @context)
|
|
118
150
|
end
|
|
119
151
|
end
|
|
120
152
|
|
|
121
|
-
def resume_execution
|
|
153
|
+
def resume_execution # rubocop:disable Metrics/MethodLength,Metrics/PerceivedComplexity
|
|
122
154
|
middlewares.on(:start_reactor, reactor_class.name, context.inputs, @context)
|
|
123
155
|
completed = false
|
|
124
|
-
begin
|
|
125
|
-
@context.status = :running
|
|
126
|
-
acquire_exclusive_lock if @reactor_class.respond_to?(:lock_config) && @reactor_class.lock_config
|
|
127
|
-
acquire_semaphore if @reactor_class.respond_to?(:semaphore_config) && @reactor_class.semaphore_config
|
|
128
|
-
prepare_for_resume
|
|
129
|
-
save_context
|
|
130
156
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
157
|
+
# A fresh async reactor run reaches the worker through resume_execution
|
|
158
|
+
# (it never calls execute), so the period and rate-limit gates that live
|
|
159
|
+
# in execute must be applied here too. Genuine resumes (a step already ran
|
|
160
|
+
# or we paused mid-flight, so current_step is set) must NOT re-gate: a
|
|
161
|
+
# paused reactor must not throttle or skip itself on the way back in.
|
|
162
|
+
first_run = first_execution?
|
|
163
|
+
|
|
164
|
+
enter_ordered_lock_scope
|
|
165
|
+
# ordered-lock skip applies on any run; the period gate only on a fresh
|
|
166
|
+
# first run (a genuine resume must not skip itself when its own marker
|
|
167
|
+
# eventually lands).
|
|
168
|
+
short = ordered_lock_short_circuit
|
|
169
|
+
short ||= check_period_gate if first_run
|
|
170
|
+
if short
|
|
171
|
+
completed = true
|
|
172
|
+
return short_circuit!(short)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
@context.status = :running
|
|
176
|
+
check_rate_limit if first_run
|
|
136
177
|
|
|
137
|
-
|
|
138
|
-
|
|
178
|
+
# Resumes intentionally skip check_rate_limit (a paused run must not
|
|
179
|
+
# block itself on resume), so acquire lock/semaphore directly rather
|
|
180
|
+
# than via acquire_locks.
|
|
181
|
+
acquire_exclusive_lock if @reactor_class.respond_to?(:lock_config) && @reactor_class.lock_config
|
|
182
|
+
acquire_semaphore if @reactor_class.respond_to?(:semaphore_config) && @reactor_class.semaphore_config
|
|
139
183
|
|
|
140
|
-
|
|
184
|
+
# Post-lock re-check (see execute) — closes the period race for the
|
|
185
|
+
# first run of a locked async reactor.
|
|
186
|
+
if first_run && (skipped = check_period_gate)
|
|
141
187
|
completed = true
|
|
142
|
-
|
|
143
|
-
rescue RubyReactor::Lock::AcquisitionError,
|
|
144
|
-
RubyReactor::Semaphore::AcquisitionError,
|
|
145
|
-
RubyReactor::RateLimit::ExceededError
|
|
146
|
-
raise
|
|
147
|
-
rescue StandardError => e
|
|
148
|
-
handle_resume_error(e)
|
|
149
|
-
update_context_status(@result)
|
|
150
|
-
completed = true
|
|
151
|
-
@result
|
|
152
|
-
ensure
|
|
153
|
-
release_locks
|
|
154
|
-
save_context
|
|
155
|
-
|
|
156
|
-
if completed
|
|
157
|
-
middlewares.on(:complete_reactor, reactor_class.name, @result, @context)
|
|
158
|
-
else
|
|
159
|
-
middlewares.on(:failed_reactor, reactor_class.name, $ERROR_INFO, @context)
|
|
160
|
-
end
|
|
188
|
+
return finalize_skipped(skipped)
|
|
161
189
|
end
|
|
190
|
+
|
|
191
|
+
prepare_for_resume
|
|
192
|
+
save_context
|
|
193
|
+
|
|
194
|
+
@result = if @context.current_step
|
|
195
|
+
execute_current_step_and_continue
|
|
196
|
+
else
|
|
197
|
+
execute_remaining_steps
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
update_context_status(@result)
|
|
201
|
+
mark_period_on_success(@result)
|
|
202
|
+
|
|
203
|
+
handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
|
|
204
|
+
completed = true
|
|
205
|
+
@result
|
|
206
|
+
rescue RubyReactor::Lock::AcquisitionError,
|
|
207
|
+
RubyReactor::Semaphore::AcquisitionError,
|
|
208
|
+
RubyReactor::RateLimit::ExceededError,
|
|
209
|
+
RubyReactor::RateLimitRegistry::UnknownLimitError,
|
|
210
|
+
RubyReactor::OrderedLock::WaitError => e
|
|
211
|
+
@contention_snooze = true
|
|
212
|
+
raise e
|
|
213
|
+
rescue StandardError => e
|
|
214
|
+
handle_resume_error(e)
|
|
215
|
+
update_context_status(@result)
|
|
216
|
+
completed = true
|
|
217
|
+
@result
|
|
218
|
+
ensure
|
|
219
|
+
release_locks
|
|
220
|
+
leave_ordered_lock_scope
|
|
221
|
+
save_context unless skip_context_persist?
|
|
222
|
+
|
|
223
|
+
emit_lifecycle_completion(completed)
|
|
162
224
|
end
|
|
163
225
|
|
|
164
226
|
def undo_all
|
|
@@ -196,6 +258,10 @@ module RubyReactor
|
|
|
196
258
|
|
|
197
259
|
def acquire_locks
|
|
198
260
|
check_rate_limit
|
|
261
|
+
acquire_concurrency_primitives
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def acquire_concurrency_primitives
|
|
199
265
|
acquire_exclusive_lock if @reactor_class.respond_to?(:lock_config) && @reactor_class.lock_config
|
|
200
266
|
acquire_semaphore if @reactor_class.respond_to?(:semaphore_config) && @reactor_class.semaphore_config
|
|
201
267
|
end
|
|
@@ -206,20 +272,49 @@ module RubyReactor
|
|
|
206
272
|
|
|
207
273
|
# Consume one slot from each configured rate-limit window. Raises
|
|
208
274
|
# `RubyReactor::RateLimit::ExceededError` (carrying a `retry_after_seconds`
|
|
209
|
-
# hint) if any window is full.
|
|
210
|
-
#
|
|
275
|
+
# hint) if any window is full. Consulted on the first execution only —
|
|
276
|
+
# `execute` for sync reactors, the first `resume_execution` pass for async
|
|
277
|
+
# reactors. Genuine resumes never re-check (a paused reactor must not block
|
|
278
|
+
# itself on resume).
|
|
211
279
|
def check_rate_limit
|
|
212
280
|
return unless @reactor_class.respond_to?(:rate_limit_config) && @reactor_class.rate_limit_config
|
|
213
281
|
|
|
214
282
|
config = @reactor_class.rate_limit_config
|
|
215
|
-
key_base = config[:key_proc].call(@context.inputs)
|
|
216
283
|
|
|
217
|
-
|
|
284
|
+
if config[:name]
|
|
285
|
+
# Named global limit: the name is the shared key base and the windows
|
|
286
|
+
# come from the registry (resolved lazily so config order doesn't matter).
|
|
287
|
+
key_base = config[:name].to_s
|
|
288
|
+
limits = RubyReactor.configuration.rate_limits.fetch(config[:name])
|
|
289
|
+
else
|
|
290
|
+
key_base = config[:key_proc].call(@context.inputs)
|
|
291
|
+
limits = config[:limits]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
RubyReactor::RateLimit.new(key_base, limits: limits).check_and_increment!
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# True when nothing has run yet for this context — the very first execution
|
|
298
|
+
# of the reactor, including an async reactor's first worker pass. A genuine
|
|
299
|
+
# resume (paused, async-handed-off, or retried step) always records a
|
|
300
|
+
# `current_step` before serializing, so it is never mistaken for a first run.
|
|
301
|
+
def first_execution?
|
|
302
|
+
@context.current_step.nil? && @context.intermediate_results.empty?
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Record and persist a Skipped result, then return it. Shared by the
|
|
306
|
+
# pre-lock and post-lock period gates in both execute and resume.
|
|
307
|
+
def finalize_skipped(skipped)
|
|
308
|
+
@result = skipped
|
|
309
|
+
update_context_status(@result)
|
|
310
|
+
save_context
|
|
311
|
+
@result
|
|
218
312
|
end
|
|
219
313
|
|
|
220
314
|
# Returns a Skipped result if the period bucket is already marked, else nil.
|
|
221
|
-
#
|
|
222
|
-
# must not skip itself when its own
|
|
315
|
+
# Consulted before AND after lock acquisition on a first execution; genuine
|
|
316
|
+
# resumes never re-check (a paused run must not skip itself when its own
|
|
317
|
+
# marker eventually appears).
|
|
223
318
|
def check_period_gate
|
|
224
319
|
return nil unless @reactor_class.respond_to?(:period_config) && @reactor_class.period_config
|
|
225
320
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
# Strict-ordering primitive. A monotonically increasing nonce is assigned at
|
|
5
|
+
# enqueue time; the worker can proceed only when its nonce equals
|
|
6
|
+
# `last_completed + 1`. Otherwise the worker raises {WaitError}, which the
|
|
7
|
+
# Sidekiq worker rescues and re-snoozes via `perform_in`.
|
|
8
|
+
#
|
|
9
|
+
# See `with_ordered_lock` for usage from a reactor.
|
|
10
|
+
class OrderedLock
|
|
11
|
+
# Raised by the gate check when the worker's nonce is ahead of
|
|
12
|
+
# `last_completed + 1`. Carries `retry_after_seconds`, a hint derived from
|
|
13
|
+
# the poison-pill timeout on the *blocker* nonce.
|
|
14
|
+
class WaitError < StandardError
|
|
15
|
+
attr_reader :retry_after_seconds, :key, :nonce, :last_completed
|
|
16
|
+
|
|
17
|
+
def initialize(key:, nonce:, last_completed:, retry_after_seconds:)
|
|
18
|
+
@key = key
|
|
19
|
+
@nonce = nonce
|
|
20
|
+
@last_completed = last_completed
|
|
21
|
+
@retry_after_seconds = retry_after_seconds
|
|
22
|
+
super("OrderedLock '#{key}' nonce #{nonce} waiting on #{last_completed + 1}")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Default poison-pill: if the blocker nonce was assigned more than
|
|
27
|
+
# `poison_pill_timeout` seconds ago and never advanced, the gate treats it
|
|
28
|
+
# as dead and advances past it. Prevents permanent head-of-line blocking
|
|
29
|
+
# from a crashed caller that INCRed but never enqueued.
|
|
30
|
+
DEFAULT_POISON_PILL_TIMEOUT = 600
|
|
31
|
+
|
|
32
|
+
# TTL on the Redis counter keys. Bumped on every assign so an active
|
|
33
|
+
# sequence never expires; only fully-drained ones GC themselves.
|
|
34
|
+
DEFAULT_TTL = 86_400
|
|
35
|
+
|
|
36
|
+
attr_reader :key, :nonce, :epoch, :poison_pill_timeout, :strict
|
|
37
|
+
|
|
38
|
+
def initialize(key, nonce: nil, epoch: nil, poison_pill_timeout: DEFAULT_POISON_PILL_TIMEOUT, # rubocop:disable Metrics/ParameterLists
|
|
39
|
+
ttl: DEFAULT_TTL, strict: true)
|
|
40
|
+
@key = key
|
|
41
|
+
@nonce = nonce
|
|
42
|
+
@epoch = epoch
|
|
43
|
+
@poison_pill_timeout = poison_pill_timeout
|
|
44
|
+
@ttl = ttl
|
|
45
|
+
@strict = strict
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Atomic INCR on the `next` counter. Caller-side; runs during
|
|
49
|
+
# `Reactor.run` BEFORE `perform_async`. Returns `[nonce, epoch]` — the nonce
|
|
50
|
+
# we own plus the generation it belongs to (used to fence stale stragglers).
|
|
51
|
+
def self.assign(key, ttl: DEFAULT_TTL)
|
|
52
|
+
adapter = RubyReactor.configuration.storage_adapter
|
|
53
|
+
adapter.ordered_lock_assign(key, ttl: ttl)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Gate check. Returns `:go`, `:drained_go`, `:skip_chain_failed`,
|
|
57
|
+
# `:stale_batch`, or raises {WaitError}.
|
|
58
|
+
# - `:go` — proceed to run steps.
|
|
59
|
+
# - `:drained_go` — the batch fully drained and GC'd while this caller slept.
|
|
60
|
+
# A genuine late straggler should run; a Sidekiq redelivery of an
|
|
61
|
+
# already-terminal context should be skipped. The executor disambiguates
|
|
62
|
+
# via the stored context status.
|
|
63
|
+
# - `:skip_chain_failed` — only in strict mode: an earlier nonce in this
|
|
64
|
+
# sequence terminated with a Failure, so this run is short-circuited
|
|
65
|
+
# with `Skipped(reason: :ordered_lock_chain_failed)` without executing.
|
|
66
|
+
# - `:stale_batch` — this run's epoch no longer matches the key's current
|
|
67
|
+
# generation: its batch fully drained and the numbering was reused by a
|
|
68
|
+
# newer batch. The run is short-circuited with
|
|
69
|
+
# `Skipped(reason: :ordered_lock_stale_batch)` and must not participate.
|
|
70
|
+
# - `:poison_advance` is collapsed to `:go` from the caller's perspective.
|
|
71
|
+
def check!
|
|
72
|
+
raise ArgumentError, "OrderedLock#check! requires a nonce" unless @nonce
|
|
73
|
+
|
|
74
|
+
state, retry_after, last_completed, first_failed = adapter.ordered_lock_can_proceed(
|
|
75
|
+
@key,
|
|
76
|
+
nonce: @nonce,
|
|
77
|
+
poison_pill_timeout: @poison_pill_timeout,
|
|
78
|
+
epoch: @epoch.to_i
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
case state
|
|
82
|
+
when "go", "poison_advance"
|
|
83
|
+
chain_failed?(first_failed) ? :skip_chain_failed : :go
|
|
84
|
+
when "drained_go"
|
|
85
|
+
# Batch fully drained and GC'd while this caller slept. A genuine late
|
|
86
|
+
# straggler may run (poison semantics); a redelivery of an
|
|
87
|
+
# already-terminal context must not. The executor disambiguates via the
|
|
88
|
+
# stored context status. (fail_key is GC'd here, so no chain check.)
|
|
89
|
+
:drained_go
|
|
90
|
+
when "stale"
|
|
91
|
+
:stale_batch
|
|
92
|
+
when "wait"
|
|
93
|
+
raise WaitError.new(
|
|
94
|
+
key: @key,
|
|
95
|
+
nonce: @nonce,
|
|
96
|
+
last_completed: last_completed,
|
|
97
|
+
retry_after_seconds: retry_after
|
|
98
|
+
)
|
|
99
|
+
else
|
|
100
|
+
raise "Unexpected OrderedLock state: #{state.inspect}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Move `last_completed` forward. Idempotent: only the nonce equal to
|
|
105
|
+
# `last_completed + 1` advances; others are no-ops (the poison-pill path
|
|
106
|
+
# may have already skipped us).
|
|
107
|
+
#
|
|
108
|
+
# Call on terminal status only (success, permanent failure, escalated skip).
|
|
109
|
+
# Retryable failures must NOT advance — the same nonce keeps owning until
|
|
110
|
+
# the job either succeeds or exhausts its retry budget.
|
|
111
|
+
#
|
|
112
|
+
# `failed:` records this nonce as the chain-failure marker (only the FIRST
|
|
113
|
+
# failure sticks). In strict mode the marker causes subsequent nonces to
|
|
114
|
+
# short-circuit with Skipped.
|
|
115
|
+
def advance!(failed: false)
|
|
116
|
+
raise ArgumentError, "OrderedLock#advance! requires a nonce" unless @nonce
|
|
117
|
+
|
|
118
|
+
adapter.ordered_lock_advance(@key, nonce: @nonce, failed: failed, epoch: @epoch.to_i, ttl: @ttl)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Restamp this nonce's `assigned_at` to "now" while its steps execute, so a
|
|
122
|
+
# successor does not poison-advance past a blocker that is merely slow (not
|
|
123
|
+
# dead). Called on an interval by a background heartbeat thread for the
|
|
124
|
+
# duration of step execution. No-op if the nonce's timer was already deleted
|
|
125
|
+
# by a terminal advance, or if the batch has gone stale (epoch fence).
|
|
126
|
+
def heartbeat!
|
|
127
|
+
return unless @nonce
|
|
128
|
+
|
|
129
|
+
adapter.ordered_lock_heartbeat(@key, nonce: @nonce, epoch: @epoch.to_i)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Read-only inspection. `{ next:, last_completed:, in_flight: [...] }`.
|
|
133
|
+
def self.peek(key)
|
|
134
|
+
RubyReactor.configuration.storage_adapter.ordered_lock_peek(key)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Manual ops escape hatch — force-advance past a stuck nonce.
|
|
138
|
+
def self.skip!(key, nonce:)
|
|
139
|
+
RubyReactor.configuration.storage_adapter.ordered_lock_skip(key, nonce: nonce)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Nuke all counters for a key. Ops only; concurrent enqueues during reset
|
|
143
|
+
# produce undefined ordering.
|
|
144
|
+
def self.reset!(key)
|
|
145
|
+
RubyReactor.configuration.storage_adapter.ordered_lock_reset(key)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def chain_failed?(first_failed)
|
|
151
|
+
@strict && first_failed.to_i.positive? && @nonce > first_failed.to_i
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def adapter
|
|
155
|
+
RubyReactor.configuration.storage_adapter
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -25,6 +25,34 @@ module RubyReactor
|
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# Normalize the user-facing window args into the internal spec array that
|
|
29
|
+
# `RateLimit#initialize` expects. Shared by the reactor DSL (`with_rate_limit`)
|
|
30
|
+
# and the global registry (`config.rate_limits.register`).
|
|
31
|
+
#
|
|
32
|
+
# Accepts either a single window (`limit:` + `period:`) or a hash of windows
|
|
33
|
+
# (`limits:`). Returns Array<Hash{period_seconds:, limit:, name:}>.
|
|
34
|
+
def self.normalize_specs(limit: nil, period: nil, limits: nil)
|
|
35
|
+
if limits
|
|
36
|
+
raise ArgumentError, "rate limit: use either :limits, or :limit + :period, not both" if limit || period
|
|
37
|
+
|
|
38
|
+
limits.map do |period_key, limit_val|
|
|
39
|
+
{
|
|
40
|
+
period_seconds: RubyReactor::Period.period_seconds(period_key),
|
|
41
|
+
limit: Integer(limit_val),
|
|
42
|
+
name: period_key.to_s
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
elsif limit && period
|
|
46
|
+
[{
|
|
47
|
+
period_seconds: RubyReactor::Period.period_seconds(period),
|
|
48
|
+
limit: Integer(limit),
|
|
49
|
+
name: period.to_s
|
|
50
|
+
}]
|
|
51
|
+
else
|
|
52
|
+
raise ArgumentError, "rate limit requires :limit + :period, or :limits"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
28
56
|
attr_reader :key_base, :limits
|
|
29
57
|
|
|
30
58
|
# @param key_base [String] caller-provided key (e.g. "stripe:account_42")
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
# Global registry of named rate limits, configured once via
|
|
5
|
+
# `RubyReactor.configure`. Lets multiple reactors share a single quota for an
|
|
6
|
+
# external service (e.g. all Stripe-calling reactors throttle against one
|
|
7
|
+
# `:stripe` bucket).
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# RubyReactor.configure do |config|
|
|
11
|
+
# config.rate_limits.register(:stripe, limit: 3, period: :second)
|
|
12
|
+
# config.rate_limits.register(:twilio, limits: { second: 10, minute: 100 })
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# class ChargeReactor < RubyReactor::Reactor
|
|
16
|
+
# with_rate_limit(:stripe)
|
|
17
|
+
# end
|
|
18
|
+
class RateLimitRegistry
|
|
19
|
+
# Raised when a reactor references a rate-limit name that was never
|
|
20
|
+
# registered. This is a configuration error, so it propagates out of
|
|
21
|
+
# `execute` rather than being swallowed into a step failure result.
|
|
22
|
+
class UnknownLimitError < StandardError; end
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@limits = {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Register a named rate limit. Same window args as the inline DSL form:
|
|
29
|
+
# a single window (`limit:` + `period:`) or layered windows (`limits:`).
|
|
30
|
+
def register(name, limit: nil, period: nil, limits: nil)
|
|
31
|
+
@limits[name.to_sym] = RubyReactor::RateLimit.normalize_specs(
|
|
32
|
+
limit: limit, period: period, limits: limits
|
|
33
|
+
)
|
|
34
|
+
self
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Return the normalized spec array for a registered name, or raise if the
|
|
38
|
+
# name was never registered (resolved lazily at execute time, so the error
|
|
39
|
+
# surfaces with a clear message instead of a nil dereference).
|
|
40
|
+
def fetch(name)
|
|
41
|
+
@limits.fetch(name.to_sym) do
|
|
42
|
+
raise UnknownLimitError, "Unknown rate limit #{name.inspect}. " \
|
|
43
|
+
"Register it with config.rate_limits.register(#{name.inspect}, ...)."
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def registered?(name)
|
|
48
|
+
@limits.key?(name.to_sym)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/ruby_reactor/reactor.rb
CHANGED
|
@@ -102,6 +102,11 @@ module RubyReactor
|
|
|
102
102
|
return validation_result
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
+
# Assign-at-enqueue: ordered_lock nonce is INCRed atomically here so
|
|
106
|
+
# the order matches the caller's order, not whichever worker happens to
|
|
107
|
+
# pick the job up first.
|
|
108
|
+
assign_ordered_lock_nonce!
|
|
109
|
+
|
|
105
110
|
if self.class.async? && !@context.inline_async_execution
|
|
106
111
|
# For async reactors, queue a job for the whole reactor
|
|
107
112
|
@context.status = :running
|
|
@@ -423,6 +428,42 @@ module RubyReactor
|
|
|
423
428
|
serialized_context = ContextSerializer.serialize(@context)
|
|
424
429
|
storage.store_context(@context.context_id, serialized_context, reactor_class_name)
|
|
425
430
|
end
|
|
431
|
+
|
|
432
|
+
def assign_ordered_lock_nonce!
|
|
433
|
+
return unless self.class.respond_to?(:ordered_lock_config) && self.class.ordered_lock_config
|
|
434
|
+
return if @context.private_data[:ordered_lock] || @context.private_data["ordered_lock"]
|
|
435
|
+
|
|
436
|
+
config = self.class.ordered_lock_config
|
|
437
|
+
key = config[:key_proc].call(@context.inputs)
|
|
438
|
+
|
|
439
|
+
# Synchronous nested `Reactor.run` of an ordered-lock reactor on the same
|
|
440
|
+
# key would deadlock: the outer nonce holds the slot, an inner nonce
|
|
441
|
+
# would never advance until the outer completes — but the outer is
|
|
442
|
+
# blocked waiting for the inner to return. Mirror the compose behavior:
|
|
443
|
+
# silently skip nonce assignment (the inner runs without gate/advance)
|
|
444
|
+
# and log a warning so this isn't invisible.
|
|
445
|
+
active = Executor::OrderedLockSupport.active_keys
|
|
446
|
+
if active.include?(key)
|
|
447
|
+
RubyReactor.configuration.logger.warn(
|
|
448
|
+
"RubyReactor: nested `Reactor.run` of #{self.class.name || "<anonymous>"} on " \
|
|
449
|
+
"ordered-lock key '#{key}' from inside another ordered-lock reactor on the same " \
|
|
450
|
+
"key — nonce assignment skipped, inner run executes without ordering enforcement. " \
|
|
451
|
+
"Use a different key or move the inner call to a top-level invocation if you need ordering."
|
|
452
|
+
)
|
|
453
|
+
return
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
nonce, epoch = RubyReactor::OrderedLock.assign(key, ttl: config[:ttl])
|
|
457
|
+
|
|
458
|
+
@context.private_data[:ordered_lock] = {
|
|
459
|
+
key: key,
|
|
460
|
+
nonce: nonce,
|
|
461
|
+
epoch: epoch,
|
|
462
|
+
poison_pill_timeout: config[:poison_pill_timeout],
|
|
463
|
+
ttl: config[:ttl],
|
|
464
|
+
strict: config.fetch(:strict, true)
|
|
465
|
+
}
|
|
466
|
+
end
|
|
426
467
|
end
|
|
427
468
|
# rubocop:enable Metrics/ClassLength
|
|
428
469
|
end
|
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module RubyReactor
|
|
4
4
|
module RSpec
|
|
5
|
+
# Globally-included helpers. Only methods whose names clearly belong to
|
|
6
|
+
# RubyReactor's test surface live here (`test_reactor`). Sidekiq-coupled
|
|
7
|
+
# helpers live in `SidekiqHelpers` and are scoped to `type: :reactor`.
|
|
5
8
|
module Helpers
|
|
9
|
+
# Build a `TestSubject` around a reactor invocation. Captures the run for
|
|
10
|
+
# later introspection via matchers; runs the reactor lazily on first
|
|
11
|
+
# query unless `.run` is called explicitly.
|
|
6
12
|
def test_reactor(reactor_class, inputs, context: {}, async: nil, process_jobs: true)
|
|
7
13
|
TestSubject.new(
|
|
8
14
|
reactor_class: reactor_class,
|
|
@@ -415,6 +415,72 @@ module RubyReactor
|
|
|
415
415
|
end
|
|
416
416
|
end
|
|
417
417
|
|
|
418
|
+
# Asserts the last-assigned ordered_lock nonce for a key. Subject is
|
|
419
|
+
# the user-provided ordered_lock key (without the `ordered_lock:` prefix).
|
|
420
|
+
#
|
|
421
|
+
# expect("orders:42").to have_ordered_lock_next(3)
|
|
422
|
+
::RSpec::Matchers.define :have_ordered_lock_next do |expected|
|
|
423
|
+
match { |key| Matchers.coordination_adapter.ordered_lock_peek(key)[:next] == expected }
|
|
424
|
+
|
|
425
|
+
failure_message do |key|
|
|
426
|
+
state = Matchers.coordination_adapter.ordered_lock_peek(key)
|
|
427
|
+
"expected ordered_lock '#{key}' next to be #{expected}, got #{state[:next]} " \
|
|
428
|
+
"(last_completed: #{state[:last_completed]}, in_flight: #{state[:in_flight].inspect})"
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Asserts the last-advanced cursor for an ordered_lock key.
|
|
433
|
+
#
|
|
434
|
+
# expect("orders:42").to have_ordered_lock_last_completed(2)
|
|
435
|
+
::RSpec::Matchers.define :have_ordered_lock_last_completed do |expected|
|
|
436
|
+
match do |key|
|
|
437
|
+
Matchers.coordination_adapter.ordered_lock_peek(key)[:last_completed] == expected
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
failure_message do |key|
|
|
441
|
+
state = Matchers.coordination_adapter.ordered_lock_peek(key)
|
|
442
|
+
"expected ordered_lock '#{key}' last_completed to be #{expected}, " \
|
|
443
|
+
"got #{state[:last_completed]} (next: #{state[:next]}, in_flight: #{state[:in_flight].inspect})"
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Asserts the exact set of in-flight nonces for an ordered_lock key.
|
|
448
|
+
# Order-insensitive — the matcher sorts both sides.
|
|
449
|
+
#
|
|
450
|
+
# expect("orders:42").to have_ordered_lock_in_flight(2, 3)
|
|
451
|
+
::RSpec::Matchers.define :have_ordered_lock_in_flight do |*expected|
|
|
452
|
+
match do |key|
|
|
453
|
+
actual = Matchers.coordination_adapter.ordered_lock_peek(key)[:in_flight].sort
|
|
454
|
+
actual == expected.flatten.map(&:to_i).sort
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
failure_message do |key|
|
|
458
|
+
state = Matchers.coordination_adapter.ordered_lock_peek(key)
|
|
459
|
+
"expected ordered_lock '#{key}' in_flight to be #{expected.flatten.sort.inspect}, " \
|
|
460
|
+
"got #{state[:in_flight].inspect} (next: #{state[:next]}, last_completed: #{state[:last_completed]})"
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Asserts an ordered_lock key has fully drained — counters GC'd, no
|
|
465
|
+
# in-flight nonces. After a clean drain `peek` returns all zeros.
|
|
466
|
+
#
|
|
467
|
+
# expect("orders:42").to be_ordered_lock_drained
|
|
468
|
+
::RSpec::Matchers.define :be_ordered_lock_drained do
|
|
469
|
+
match do |key|
|
|
470
|
+
state = Matchers.coordination_adapter.ordered_lock_peek(key)
|
|
471
|
+
state[:next].zero? && state[:last_completed].zero? && state[:in_flight].empty?
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
failure_message do |key|
|
|
475
|
+
state = Matchers.coordination_adapter.ordered_lock_peek(key)
|
|
476
|
+
"expected ordered_lock '#{key}' to be drained, but state is #{state.inspect}"
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
failure_message_when_negated do |key|
|
|
480
|
+
"expected ordered_lock '#{key}' not to be drained, but counters are all zero"
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
|
|
418
484
|
# Add more matchers as per plan
|
|
419
485
|
# rubocop:enable Metrics/BlockLength
|
|
420
486
|
end
|