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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +14 -0
  4. data/README.md +199 -30
  5. data/lib/ruby_reactor/configuration.rb +7 -0
  6. data/lib/ruby_reactor/dsl/compose_builder.rb +20 -0
  7. data/lib/ruby_reactor/dsl/interrupt_builder.rb +18 -2
  8. data/lib/ruby_reactor/dsl/lockable.rb +60 -29
  9. data/lib/ruby_reactor/dsl/reactor.rb +38 -7
  10. data/lib/ruby_reactor/dsl/step_builder.rb +25 -39
  11. data/lib/ruby_reactor/dsl/validation_helpers.rb +34 -0
  12. data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
  13. data/lib/ruby_reactor/executor/ordered_lock_support.rb +307 -0
  14. data/lib/ruby_reactor/executor/result_handler.rb +35 -8
  15. data/lib/ruby_reactor/executor/step_executor.rb +10 -5
  16. data/lib/ruby_reactor/executor.rb +145 -50
  17. data/lib/ruby_reactor/ordered_lock.rb +158 -0
  18. data/lib/ruby_reactor/rate_limit.rb +28 -0
  19. data/lib/ruby_reactor/rate_limit_registry.rb +51 -0
  20. data/lib/ruby_reactor/reactor.rb +41 -0
  21. data/lib/ruby_reactor/rspec/helpers.rb +6 -0
  22. data/lib/ruby_reactor/rspec/matchers.rb +66 -0
  23. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +70 -0
  24. data/lib/ruby_reactor/rspec/storage_reset.rb +23 -0
  25. data/lib/ruby_reactor/rspec/test_subject.rb +14 -28
  26. data/lib/ruby_reactor/rspec.rb +37 -0
  27. data/lib/ruby_reactor/sidekiq_workers/worker.rb +50 -8
  28. data/lib/ruby_reactor/storage/redis_adapter.rb +1 -0
  29. data/lib/ruby_reactor/storage/redis_ordered_locking.rb +382 -0
  30. data/lib/ruby_reactor/validation/base.rb +4 -1
  31. data/lib/ruby_reactor/validation/input_validator.rb +4 -2
  32. data/lib/ruby_reactor/validation/schema_builder.rb +82 -0
  33. data/lib/ruby_reactor/version.rb +1 -1
  34. data/lib/ruby_reactor.rb +1 -0
  35. 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
- skipped = check_period_gate
75
- if skipped
76
- @result = skipped
77
- update_context_status(@result)
78
- save_context
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 @result
85
+ return short_circuit!(short)
81
86
  end
82
87
 
83
- acquire_locks_with_telemetry
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 => e
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
- save_context if persist_context?
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
- @result = if @context.current_step
132
- execute_current_step_and_continue
133
- else
134
- execute_remaining_steps
135
- end
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
- update_context_status(@result)
138
- mark_period_on_success(@result)
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
- handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
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
- @result
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. Only consulted on initial `execute`; resumes
210
- # never re-check (a paused reactor must not block itself on resume).
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
- RubyReactor::RateLimit.new(key_base, limits: config[:limits]).check_and_increment!
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
- # Only consulted on initial `execute`; resumes never re-check (a paused run
222
- # must not skip itself when its own marker eventually appears).
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
@@ -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