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.
@@ -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
- if (skipped = check_period_gate)
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 finalize_skipped(skipped)
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 => e
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
- save_context if persist_context?
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
- if first_run && (skipped = check_period_gate)
143
- completed = true
144
- return finalize_skipped(skipped)
145
- end
146
- check_rate_limit if first_run
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
- acquire_concurrency_primitives
175
+ @context.status = :running
176
+ check_rate_limit if first_run
149
177
 
150
- # Post-lock re-check (see execute) closes the period race for the
151
- # first run of a locked async reactor.
152
- if first_run && (skipped = check_period_gate)
153
- completed = true
154
- return finalize_skipped(skipped)
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
- prepare_for_resume
158
- save_context
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
- @result = if @context.current_step
161
- execute_current_step_and_continue
162
- else
163
- execute_remaining_steps
164
- end
191
+ prepare_for_resume
192
+ save_context
165
193
 
166
- update_context_status(@result)
167
- mark_period_on_success(@result)
194
+ @result = if @context.current_step
195
+ execute_current_step_and_continue
196
+ else
197
+ execute_remaining_steps
198
+ end
168
199
 
169
- handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
170
- completed = true
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
- if completed
187
- middlewares.on(:complete_reactor, reactor_class.name, @result, @context)
188
- else
189
- middlewares.on(:failed_reactor, reactor_class.name, $ERROR_INFO, @context)
190
- end
191
- end
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
@@ -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
- # Loop until no more jobs are being queued
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