ruby_reactor 0.5.1 → 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 +7 -0
- data/README.md +54 -14
- data/lib/ruby_reactor/dsl/compose_builder.rb +20 -0
- data/lib/ruby_reactor/dsl/lockable.rb +41 -1
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +307 -0
- data/lib/ruby_reactor/executor.rb +82 -50
- data/lib/ruby_reactor/ordered_lock.rb +158 -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 +46 -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/version.rb +1 -1
- data/lib/ruby_reactor.rb +1 -0
- metadata +6 -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,9 +76,13 @@ module RubyReactor
|
|
|
71
76
|
middlewares.on(:start_reactor, reactor_class.name, context.inputs, @context)
|
|
72
77
|
completed = false
|
|
73
78
|
|
|
74
|
-
|
|
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
|
|
75
84
|
completed = true
|
|
76
|
-
return
|
|
85
|
+
return short_circuit!(short)
|
|
77
86
|
end
|
|
78
87
|
|
|
79
88
|
# Validate inputs BEFORE consuming a rate-limit slot or grabbing a
|
|
@@ -109,7 +118,9 @@ module RubyReactor
|
|
|
109
118
|
rescue RubyReactor::Lock::AcquisitionError,
|
|
110
119
|
RubyReactor::Semaphore::AcquisitionError,
|
|
111
120
|
RubyReactor::RateLimit::ExceededError,
|
|
112
|
-
RubyReactor::RateLimitRegistry::UnknownLimitError
|
|
121
|
+
RubyReactor::RateLimitRegistry::UnknownLimitError,
|
|
122
|
+
RubyReactor::OrderedLock::WaitError => e
|
|
123
|
+
@contention_snooze = true
|
|
113
124
|
raise e
|
|
114
125
|
rescue StandardError => e
|
|
115
126
|
@result = @result_handler.handle_execution_error(e)
|
|
@@ -118,77 +129,98 @@ module RubyReactor
|
|
|
118
129
|
@result
|
|
119
130
|
ensure
|
|
120
131
|
release_locks
|
|
121
|
-
|
|
132
|
+
leave_ordered_lock_scope
|
|
133
|
+
save_context if persist_context? && !skip_context_persist?
|
|
122
134
|
|
|
135
|
+
emit_lifecycle_completion(completed)
|
|
136
|
+
end
|
|
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)
|
|
123
144
|
if completed
|
|
124
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)
|
|
125
148
|
else
|
|
126
149
|
middlewares.on(:failed_reactor, reactor_class.name, $ERROR_INFO, @context)
|
|
127
150
|
end
|
|
128
151
|
end
|
|
129
152
|
|
|
130
|
-
def resume_execution # rubocop:disable Metrics/MethodLength
|
|
153
|
+
def resume_execution # rubocop:disable Metrics/MethodLength,Metrics/PerceivedComplexity
|
|
131
154
|
middlewares.on(:start_reactor, reactor_class.name, context.inputs, @context)
|
|
132
155
|
completed = false
|
|
156
|
+
|
|
133
157
|
# A fresh async reactor run reaches the worker through resume_execution
|
|
134
158
|
# (it never calls execute), so the period and rate-limit gates that live
|
|
135
159
|
# in execute must be applied here too. Genuine resumes (a step already ran
|
|
136
160
|
# or we paused mid-flight, so current_step is set) must NOT re-gate: a
|
|
137
161
|
# paused reactor must not throttle or skip itself on the way back in.
|
|
138
162
|
first_run = first_execution?
|
|
139
|
-
begin
|
|
140
|
-
@context.status = :running
|
|
141
163
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
147
174
|
|
|
148
|
-
|
|
175
|
+
@context.status = :running
|
|
176
|
+
check_rate_limit if first_run
|
|
149
177
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
end
|
|
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
|
|
156
183
|
|
|
157
|
-
|
|
158
|
-
|
|
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)
|
|
187
|
+
completed = true
|
|
188
|
+
return finalize_skipped(skipped)
|
|
189
|
+
end
|
|
159
190
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
else
|
|
163
|
-
execute_remaining_steps
|
|
164
|
-
end
|
|
191
|
+
prepare_for_resume
|
|
192
|
+
save_context
|
|
165
193
|
|
|
166
|
-
|
|
167
|
-
|
|
194
|
+
@result = if @context.current_step
|
|
195
|
+
execute_current_step_and_continue
|
|
196
|
+
else
|
|
197
|
+
execute_remaining_steps
|
|
198
|
+
end
|
|
168
199
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
@result
|
|
172
|
-
rescue RubyReactor::Lock::AcquisitionError,
|
|
173
|
-
RubyReactor::Semaphore::AcquisitionError,
|
|
174
|
-
RubyReactor::RateLimit::ExceededError,
|
|
175
|
-
RubyReactor::RateLimitRegistry::UnknownLimitError
|
|
176
|
-
raise
|
|
177
|
-
rescue StandardError => e
|
|
178
|
-
handle_resume_error(e)
|
|
179
|
-
update_context_status(@result)
|
|
180
|
-
completed = true
|
|
181
|
-
@result
|
|
182
|
-
ensure
|
|
183
|
-
release_locks
|
|
184
|
-
save_context
|
|
200
|
+
update_context_status(@result)
|
|
201
|
+
mark_period_on_success(@result)
|
|
185
202
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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)
|
|
192
224
|
end
|
|
193
225
|
|
|
194
226
|
def undo_all
|
|
@@ -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
|
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
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module RSpec
|
|
5
|
+
# Async-job manipulation helpers. Names like `drain_async_jobs` are too
|
|
6
|
+
# generic to live in the global spec namespace, so this module is only
|
|
7
|
+
# auto-included into examples tagged `type: :reactor`. Specs that need it
|
|
8
|
+
# outside that tag should `include RubyReactor::RSpec::SidekiqHelpers`
|
|
9
|
+
# explicitly.
|
|
10
|
+
module SidekiqHelpers
|
|
11
|
+
# Drain every queued async job across all RubyReactor worker classes
|
|
12
|
+
# until the queues are empty. Recursive — handles jobs that re-enqueue
|
|
13
|
+
# themselves (e.g. ordered_lock snoozes) and worker chains that queue
|
|
14
|
+
# additional jobs.
|
|
15
|
+
def drain_async_jobs(max_iterations: 100)
|
|
16
|
+
SidekiqHelpers.drain_async_jobs(max_iterations: max_iterations)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# All currently-pending async jobs, wrapped in `PendingJob` so callers
|
|
20
|
+
# can perform individual jobs out-of-order (e.g. to assert
|
|
21
|
+
# ordered_lock's snooze behavior) without touching Sidekiq internals.
|
|
22
|
+
def pending_async_jobs
|
|
23
|
+
SidekiqHelpers.pending_async_jobs
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
PendingJob = Struct.new(:worker_class, :raw) do
|
|
27
|
+
def perform!
|
|
28
|
+
worker_class.jobs.delete(raw)
|
|
29
|
+
worker_class.new.perform(*raw["args"])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def args
|
|
33
|
+
raw["args"]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.worker_classes
|
|
38
|
+
@worker_classes ||= [
|
|
39
|
+
RubyReactor::SidekiqWorkers::Worker,
|
|
40
|
+
RubyReactor::SidekiqWorkers::MapElementWorker,
|
|
41
|
+
RubyReactor::SidekiqWorkers::MapCollectorWorker
|
|
42
|
+
]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.drain_async_jobs(max_iterations: 100)
|
|
46
|
+
return unless defined?(Sidekiq::Testing)
|
|
47
|
+
|
|
48
|
+
max_iterations.times do
|
|
49
|
+
processed_any = false
|
|
50
|
+
worker_classes.each do |worker_class|
|
|
51
|
+
while (job = worker_class.jobs.shift)
|
|
52
|
+
worker_class.new.perform(*job["args"])
|
|
53
|
+
processed_any = true
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
break unless processed_any
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.pending_async_jobs
|
|
62
|
+
return [] unless defined?(Sidekiq::Testing)
|
|
63
|
+
|
|
64
|
+
worker_classes.flat_map do |worker_class|
|
|
65
|
+
worker_class.jobs.map { |raw| PendingJob.new(worker_class, raw) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module RSpec
|
|
5
|
+
# Test-only `reset!` impls layered onto storage adapters at framework
|
|
6
|
+
# load time. Kept out of `lib/ruby_reactor/storage/*` so production code
|
|
7
|
+
# never gains a "wipe everything" entry point.
|
|
8
|
+
module StorageReset
|
|
9
|
+
module RedisAdapterReset
|
|
10
|
+
def reset!
|
|
11
|
+
@redis.flushdb
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.install!
|
|
16
|
+
return if @installed
|
|
17
|
+
|
|
18
|
+
::RubyReactor::Storage::RedisAdapter.prepend(RedisAdapterReset)
|
|
19
|
+
@installed = true
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -246,6 +246,8 @@ module RubyReactor
|
|
|
246
246
|
end
|
|
247
247
|
end
|
|
248
248
|
RubyReactor::Success.new(val)
|
|
249
|
+
when "skipped"
|
|
250
|
+
skipped_result(ctx)
|
|
249
251
|
when "running"
|
|
250
252
|
# Try to determine if it is truly running or if we just missed the completion
|
|
251
253
|
if @process_jobs && defined?(Sidekiq::Testing)
|
|
@@ -269,6 +271,17 @@ module RubyReactor
|
|
|
269
271
|
end
|
|
270
272
|
end
|
|
271
273
|
|
|
274
|
+
# A clean halt: either a `with_period` gate or a step returning
|
|
275
|
+
# `RubyReactor.Skipped(...)`. The sync run already produced the exact
|
|
276
|
+
# Skipped (reason/step intact) — surface it. For async runs the worker
|
|
277
|
+
# swallows the return value, so rebuild from the trace.
|
|
278
|
+
def skipped_result(ctx)
|
|
279
|
+
return @run_result if @run_result.is_a?(RubyReactor::Skipped)
|
|
280
|
+
|
|
281
|
+
entry = ctx.execution_trace.reverse.find { |t| t[:type].to_s == "skipped" }
|
|
282
|
+
RubyReactor::Skipped.new(reason: entry&.dig(:reason), step_name: entry&.dig(:step))
|
|
283
|
+
end
|
|
284
|
+
|
|
272
285
|
def success?
|
|
273
286
|
ensure_executed!
|
|
274
287
|
@reactor_instance.context.status.to_s == "completed"
|
|
@@ -419,34 +432,7 @@ module RubyReactor
|
|
|
419
432
|
def process_pending_jobs
|
|
420
433
|
return unless defined?(Sidekiq::Testing)
|
|
421
434
|
|
|
422
|
-
|
|
423
|
-
# This handles batched map execution where jobs queue more jobs
|
|
424
|
-
max_iterations = 100
|
|
425
|
-
iterations = 0
|
|
426
|
-
|
|
427
|
-
while iterations < max_iterations
|
|
428
|
-
iterations += 1
|
|
429
|
-
jobs_processed = false
|
|
430
|
-
|
|
431
|
-
# Known worker classes to check
|
|
432
|
-
worker_classes = [
|
|
433
|
-
RubyReactor::SidekiqWorkers::Worker,
|
|
434
|
-
RubyReactor::SidekiqWorkers::MapElementWorker,
|
|
435
|
-
RubyReactor::SidekiqWorkers::MapCollectorWorker
|
|
436
|
-
]
|
|
437
|
-
|
|
438
|
-
worker_classes.each do |worker_class|
|
|
439
|
-
while worker_class.jobs.any?
|
|
440
|
-
job = worker_class.jobs.shift
|
|
441
|
-
worker_class.new.perform(*job["args"])
|
|
442
|
-
jobs_processed = true
|
|
443
|
-
end
|
|
444
|
-
end
|
|
445
|
-
|
|
446
|
-
break unless jobs_processed
|
|
447
|
-
end
|
|
448
|
-
|
|
449
|
-
# Final reload
|
|
435
|
+
SidekiqHelpers.drain_async_jobs
|
|
450
436
|
@reactor_instance = @reactor_class.find(@reactor_instance.context.context_id)
|
|
451
437
|
end
|
|
452
438
|
|