ruby_reactor 0.3.1 → 0.3.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/README.md +114 -5
- data/documentation/README.md +20 -8
- data/documentation/async_reactors.md +46 -34
- data/documentation/core_concepts.md +75 -61
- data/documentation/examples/inventory_management.md +2 -3
- data/documentation/examples/order_processing.md +92 -77
- data/documentation/examples/payment_processing.md +28 -117
- data/documentation/getting_started.md +112 -94
- data/documentation/interrupts.md +9 -7
- data/documentation/locks_and_semaphores.md +459 -0
- data/documentation/retry_configuration.md +19 -14
- data/documentation/testing.md +182 -0
- data/lib/ruby_reactor/configuration.rb +18 -1
- data/lib/ruby_reactor/dsl/lockable.rb +130 -0
- data/lib/ruby_reactor/executor/result_handler.rb +19 -0
- data/lib/ruby_reactor/executor/step_executor.rb +5 -0
- data/lib/ruby_reactor/executor.rb +145 -2
- data/lib/ruby_reactor/lock.rb +92 -0
- data/lib/ruby_reactor/period.rb +67 -0
- data/lib/ruby_reactor/rate_limit.rb +74 -0
- data/lib/ruby_reactor/reactor.rb +1 -0
- data/lib/ruby_reactor/rspec/matchers.rb +171 -4
- data/lib/ruby_reactor/semaphore.rb +58 -0
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +70 -8
- data/lib/ruby_reactor/storage/redis_adapter.rb +2 -0
- data/lib/ruby_reactor/storage/redis_locking.rb +251 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor.rb +49 -0
- metadata +9 -2
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
class Lock
|
|
5
|
+
class AcquisitionError < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Minimum interval between auto-extend pings; protects very small TTLs.
|
|
8
|
+
MIN_EXTEND_INTERVAL = 1.0
|
|
9
|
+
|
|
10
|
+
attr_reader :key, :owner, :ttl, :wait, :auto_extend
|
|
11
|
+
|
|
12
|
+
def initialize(key, owner:, ttl: 60, wait: 0, auto_extend: true)
|
|
13
|
+
@key = "lock:#{key}"
|
|
14
|
+
@owner = owner
|
|
15
|
+
@ttl = ttl
|
|
16
|
+
@wait = wait
|
|
17
|
+
@auto_extend = auto_extend
|
|
18
|
+
@extender = nil
|
|
19
|
+
@extender_running = false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def acquire
|
|
23
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
24
|
+
|
|
25
|
+
loop do
|
|
26
|
+
if adapter.lock_acquire(@key, @owner, @ttl)
|
|
27
|
+
start_extender if @auto_extend
|
|
28
|
+
return true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if @wait.zero? || (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) >= @wait
|
|
32
|
+
raise AcquisitionError, "Could not acquire lock '#{@key}' for owner '#{@owner}'"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
sleep 0.1
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def release
|
|
40
|
+
stop_extender
|
|
41
|
+
adapter.lock_release(@key, @owner)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def synchronize
|
|
45
|
+
acquire
|
|
46
|
+
yield
|
|
47
|
+
ensure
|
|
48
|
+
release
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def start_extender
|
|
54
|
+
return if @extender&.alive?
|
|
55
|
+
|
|
56
|
+
interval = [@ttl / 3.0, MIN_EXTEND_INTERVAL].max
|
|
57
|
+
@extender_running = true
|
|
58
|
+
|
|
59
|
+
@extender = Thread.new do
|
|
60
|
+
while @extender_running
|
|
61
|
+
sleep interval
|
|
62
|
+
break unless @extender_running
|
|
63
|
+
|
|
64
|
+
begin
|
|
65
|
+
adapter.lock_extend(@key, @owner, @ttl)
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
RubyReactor.configuration.logger.warn(
|
|
68
|
+
"Lock auto-extend failed for '#{@key}': #{e.message}"
|
|
69
|
+
)
|
|
70
|
+
break
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def stop_extender
|
|
77
|
+
@extender_running = false
|
|
78
|
+
thread = @extender
|
|
79
|
+
@extender = nil
|
|
80
|
+
return unless thread
|
|
81
|
+
|
|
82
|
+
thread.wakeup if thread.alive?
|
|
83
|
+
thread.join(0.1)
|
|
84
|
+
rescue StandardError
|
|
85
|
+
# Best-effort shutdown; never let extender teardown break release.
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def adapter
|
|
89
|
+
RubyReactor.configuration.storage_adapter
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
# Calendar-aligned bucket helpers for `with_period` dedup gating.
|
|
5
|
+
#
|
|
6
|
+
# A *bucket* is a deterministic string derived from the current UTC time and
|
|
7
|
+
# the configured period. Two calls in the same bucket dedup to the same
|
|
8
|
+
# Redis marker key; calls that cross a calendar boundary land in different
|
|
9
|
+
# buckets and run again.
|
|
10
|
+
module Period
|
|
11
|
+
SYMBOLIC_PERIODS = {
|
|
12
|
+
second: 1,
|
|
13
|
+
minute: 60,
|
|
14
|
+
hour: 60 * 60,
|
|
15
|
+
day: 60 * 60 * 24,
|
|
16
|
+
week: 60 * 60 * 24 * 7,
|
|
17
|
+
month: 60 * 60 * 24 * 31,
|
|
18
|
+
year: 60 * 60 * 24 * 366
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# Build the bucket id for a given period at a given moment. UTC, calendar
|
|
22
|
+
# aligned for symbolic periods, index-based for integer seconds.
|
|
23
|
+
def self.bucket_id(every, now: Time.now.utc)
|
|
24
|
+
case every
|
|
25
|
+
when :second then now.strftime("%Y-%m-%dT%H-%M-%S")
|
|
26
|
+
when :minute then now.strftime("%Y-%m-%dT%H-%M")
|
|
27
|
+
when :hour then now.strftime("%Y-%m-%dT%H")
|
|
28
|
+
when :day then now.strftime("%Y-%m-%d")
|
|
29
|
+
when :week then now.strftime("%G-W%V")
|
|
30
|
+
when :month then now.strftime("%Y-%m")
|
|
31
|
+
when :year then now.strftime("%Y")
|
|
32
|
+
when Integer
|
|
33
|
+
raise ArgumentError, "Period seconds must be positive" unless every.positive?
|
|
34
|
+
|
|
35
|
+
"i#{now.to_i / every}"
|
|
36
|
+
else
|
|
37
|
+
raise ArgumentError, "Unknown period: #{every.inspect}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# TTL for the marker. Twice the period length so the marker survives clock
|
|
42
|
+
# skew across the boundary and reliably dedups the very next attempt.
|
|
43
|
+
def self.ttl_seconds(every)
|
|
44
|
+
base = period_seconds(every)
|
|
45
|
+
base * 2
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.period_seconds(every)
|
|
49
|
+
case every
|
|
50
|
+
when Symbol
|
|
51
|
+
SYMBOLIC_PERIODS.fetch(every) do
|
|
52
|
+
raise ArgumentError, "Unknown period: #{every.inspect}"
|
|
53
|
+
end
|
|
54
|
+
when Integer
|
|
55
|
+
raise ArgumentError, "Period seconds must be positive" unless every.positive?
|
|
56
|
+
|
|
57
|
+
every
|
|
58
|
+
else
|
|
59
|
+
raise ArgumentError, "Unknown period: #{every.inspect}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.key(base, every, now: Time.now.utc)
|
|
64
|
+
"period:#{base}:#{bucket_id(every, now: now)}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
# Distributed rate limiter (fixed-window counter, multi-window aware).
|
|
5
|
+
#
|
|
6
|
+
# A `RateLimit` is configured with one or more (period, limit) tuples.
|
|
7
|
+
# `check_and_increment!` atomically verifies every window has headroom and,
|
|
8
|
+
# if so, increments all of them. The check uses a single Lua script so
|
|
9
|
+
# nothing slips through between read and write.
|
|
10
|
+
#
|
|
11
|
+
# When any window is over-limit the call raises `ExceededError` carrying a
|
|
12
|
+
# `retry_after_seconds` hint (time until the tightest failing bucket rolls).
|
|
13
|
+
# The Sidekiq worker uses this hint to schedule a precise snooze.
|
|
14
|
+
class RateLimit
|
|
15
|
+
class ExceededError < StandardError
|
|
16
|
+
attr_reader :retry_after_seconds, :key_base, :limit, :period_seconds, :period_name
|
|
17
|
+
|
|
18
|
+
def initialize(message, retry_after_seconds:, key_base:, limit:, period_seconds:, period_name:)
|
|
19
|
+
super(message)
|
|
20
|
+
@retry_after_seconds = retry_after_seconds
|
|
21
|
+
@key_base = key_base
|
|
22
|
+
@limit = limit
|
|
23
|
+
@period_seconds = period_seconds
|
|
24
|
+
@period_name = period_name
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
attr_reader :key_base, :limits
|
|
29
|
+
|
|
30
|
+
# @param key_base [String] caller-provided key (e.g. "stripe:account_42")
|
|
31
|
+
# @param limits [Array<Hash>] each hash needs :period_seconds, :limit,
|
|
32
|
+
# and :name (used in the Redis bucket key and the error message)
|
|
33
|
+
def initialize(key_base, limits:)
|
|
34
|
+
@key_base = key_base
|
|
35
|
+
@limits = limits
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def check_and_increment!
|
|
39
|
+
now = Time.now.to_i
|
|
40
|
+
keys = @limits.map { |spec| bucket_key(spec, now) }
|
|
41
|
+
argv = [now]
|
|
42
|
+
@limits.each do |spec|
|
|
43
|
+
argv << spec[:period_seconds]
|
|
44
|
+
argv << spec[:limit]
|
|
45
|
+
argv << spec[:period_seconds] * 2 # TTL: generous, auto-cleans stale buckets
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
allowed, retry_after, failed_index = adapter.rate_limit_check_and_increment(keys, argv)
|
|
49
|
+
return true if allowed == 1
|
|
50
|
+
|
|
51
|
+
failed = @limits[failed_index - 1]
|
|
52
|
+
raise ExceededError.new(
|
|
53
|
+
"Rate limit '#{@key_base}' exceeded (#{failed[:limit]}/#{failed[:name]}); " \
|
|
54
|
+
"retry in #{retry_after}s",
|
|
55
|
+
retry_after_seconds: retry_after,
|
|
56
|
+
key_base: @key_base,
|
|
57
|
+
limit: failed[:limit],
|
|
58
|
+
period_seconds: failed[:period_seconds],
|
|
59
|
+
period_name: failed[:name]
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def bucket_key(spec, now)
|
|
66
|
+
bucket_id = now / spec[:period_seconds]
|
|
67
|
+
"rate:#{@key_base}:#{spec[:name]}:#{bucket_id}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def adapter
|
|
71
|
+
RubyReactor.configuration.storage_adapter
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/ruby_reactor/reactor.rb
CHANGED
|
@@ -2,20 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
module RubyReactor
|
|
4
4
|
module RSpec
|
|
5
|
+
# rubocop:disable Metrics/ModuleLength
|
|
5
6
|
module Matchers
|
|
6
7
|
# rubocop:disable Metrics/BlockLength
|
|
7
8
|
::RSpec::Matchers.define :be_success do
|
|
8
9
|
match do |subject|
|
|
9
|
-
subject.ensure_executed!
|
|
10
|
+
subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
|
|
10
11
|
subject.success?
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
failure_message do |subject|
|
|
14
|
-
result = subject.result
|
|
15
|
+
result = subject.respond_to?(:result) ? subject.result : subject
|
|
15
16
|
if result&.failure?
|
|
16
17
|
format_failure_message(result)
|
|
17
|
-
|
|
18
|
+
elsif subject.respond_to?(:reactor_instance)
|
|
18
19
|
"expected reactor to be success, but failed (Status: #{subject.reactor_instance.context.status})"
|
|
20
|
+
else
|
|
21
|
+
"expected #{subject.inspect} to be success"
|
|
19
22
|
end
|
|
20
23
|
end
|
|
21
24
|
|
|
@@ -62,7 +65,7 @@ module RubyReactor
|
|
|
62
65
|
|
|
63
66
|
::RSpec::Matchers.define :be_failure do
|
|
64
67
|
match do |subject|
|
|
65
|
-
subject.ensure_executed!
|
|
68
|
+
subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
|
|
66
69
|
subject.failure?
|
|
67
70
|
end
|
|
68
71
|
|
|
@@ -249,8 +252,172 @@ module RubyReactor
|
|
|
249
252
|
end
|
|
250
253
|
end
|
|
251
254
|
|
|
255
|
+
# ---------------------------------------------------------------------
|
|
256
|
+
# Lock / Semaphore / Rate-limit / Period state matchers
|
|
257
|
+
# ---------------------------------------------------------------------
|
|
258
|
+
#
|
|
259
|
+
# These assert against the live Redis state via the configured storage
|
|
260
|
+
# adapter, so they work for any test that has actually exercised the
|
|
261
|
+
# reactor (or interacted with the primitives directly).
|
|
262
|
+
|
|
263
|
+
def self.coordination_adapter
|
|
264
|
+
RubyReactor.configuration.storage_adapter
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Distinguishes `RubyReactor::Skipped` from a plain `Success`. Works on
|
|
268
|
+
# any object with a `skipped?` predicate.
|
|
269
|
+
#
|
|
270
|
+
# Examples:
|
|
271
|
+
# expect(result).to be_skipped
|
|
272
|
+
# expect(result).to be_skipped.because(:period)
|
|
273
|
+
# expect(result).to be_skipped.at_step(:second)
|
|
274
|
+
::RSpec::Matchers.define :be_skipped do
|
|
275
|
+
match do |subject|
|
|
276
|
+
subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
|
|
277
|
+
actual = subject.respond_to?(:result) ? subject.result : subject
|
|
278
|
+
next false unless actual.respond_to?(:skipped?) && actual.skipped?
|
|
279
|
+
next false if @expected_reason && actual.reason != @expected_reason
|
|
280
|
+
next false if @expected_step && actual.step_name != @expected_step
|
|
281
|
+
|
|
282
|
+
true
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
chain :because do |reason|
|
|
286
|
+
@expected_reason = reason
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
chain :at_step do |step|
|
|
290
|
+
@expected_step = step
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
failure_message do |subject|
|
|
294
|
+
actual = subject.respond_to?(:result) ? subject.result : subject
|
|
295
|
+
if !actual.respond_to?(:skipped?) || !actual.skipped?
|
|
296
|
+
"expected result to be Skipped, got #{actual.class}"
|
|
297
|
+
elsif @expected_reason && actual.reason != @expected_reason
|
|
298
|
+
"expected Skipped reason #{@expected_reason.inspect}, got #{actual.reason.inspect}"
|
|
299
|
+
else
|
|
300
|
+
"expected Skipped at_step #{@expected_step.inspect}, got #{actual.step_name.inspect}"
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
failure_message_when_negated do
|
|
305
|
+
"expected result not to be Skipped"
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Asserts that an exclusive lock is currently held in Redis. Subject is
|
|
310
|
+
# the user-provided lock key (without the "lock:" prefix).
|
|
311
|
+
#
|
|
312
|
+
# expect("order:42").to be_locked
|
|
313
|
+
# expect("order:42").to be_locked.by("ctx-abc")
|
|
314
|
+
::RSpec::Matchers.define :be_locked do
|
|
315
|
+
match do |key|
|
|
316
|
+
info = Matchers.coordination_adapter.lock_info("lock:#{key}")
|
|
317
|
+
next false unless info
|
|
318
|
+
next true unless @expected_owner
|
|
319
|
+
|
|
320
|
+
info[:owner] == @expected_owner
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
chain :by do |owner|
|
|
324
|
+
@expected_owner = owner
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
failure_message do |key|
|
|
328
|
+
info = Matchers.coordination_adapter.lock_info("lock:#{key}")
|
|
329
|
+
if info.nil?
|
|
330
|
+
"expected lock 'lock:#{key}' to be held, but it is free"
|
|
331
|
+
else
|
|
332
|
+
"expected lock 'lock:#{key}' to be held by #{@expected_owner.inspect}, " \
|
|
333
|
+
"but is held by #{info[:owner].inspect}"
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
failure_message_when_negated do |key|
|
|
338
|
+
info = Matchers.coordination_adapter.lock_info("lock:#{key}")
|
|
339
|
+
"expected lock 'lock:#{key}' not to be held, but is held by #{info[:owner].inspect}"
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Asserts the number of unallocated semaphore tokens. Subject is the
|
|
344
|
+
# user-provided semaphore name (without the "semaphore:" prefix).
|
|
345
|
+
#
|
|
346
|
+
# expect("api_limit").to have_available_tokens(3)
|
|
347
|
+
::RSpec::Matchers.define :have_available_tokens do |expected|
|
|
348
|
+
match do |name|
|
|
349
|
+
Matchers.coordination_adapter.semaphore_state(name)[:available] == expected
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
failure_message do |name|
|
|
353
|
+
state = Matchers.coordination_adapter.semaphore_state(name)
|
|
354
|
+
"expected semaphore '#{name}' to have #{expected} available tokens, " \
|
|
355
|
+
"got #{state[:available]} (held: #{state[:held]}, limit: #{state[:limit]})"
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Asserts the number of currently-checked-out semaphore tokens.
|
|
360
|
+
#
|
|
361
|
+
# expect("api_limit").to have_held_tokens(2)
|
|
362
|
+
::RSpec::Matchers.define :have_held_tokens do |expected|
|
|
363
|
+
match do |name|
|
|
364
|
+
Matchers.coordination_adapter.semaphore_state(name)[:held] == expected
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
failure_message do |name|
|
|
368
|
+
state = Matchers.coordination_adapter.semaphore_state(name)
|
|
369
|
+
"expected semaphore '#{name}' to have #{expected} held tokens, " \
|
|
370
|
+
"got #{state[:held]} (available: #{state[:available]}, limit: #{state[:limit]})"
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Asserts the current rate-limit counter for a (key_base, period) pair.
|
|
375
|
+
# Use `.for(period_unit)` to specify which window.
|
|
376
|
+
#
|
|
377
|
+
# expect("stripe:42").to have_rate_limit_count(3).for(:second)
|
|
378
|
+
::RSpec::Matchers.define :have_rate_limit_count do |expected|
|
|
379
|
+
match do |key_base|
|
|
380
|
+
raise ArgumentError, "have_rate_limit_count requires .for(period)" unless @period
|
|
381
|
+
|
|
382
|
+
Matchers.coordination_adapter.rate_limit_count(key_base, @period) == expected
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
chain :for do |period|
|
|
386
|
+
@period = period
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
failure_message do |key_base|
|
|
390
|
+
actual = Matchers.coordination_adapter.rate_limit_count(key_base, @period)
|
|
391
|
+
"expected rate-limit '#{key_base}' (#{@period}) count to be #{expected}, got #{actual}"
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Asserts that a `with_period` bucket has been marked. Use `.for(period)`.
|
|
396
|
+
#
|
|
397
|
+
# expect("daily_report:7").to be_period_marked.for(:day)
|
|
398
|
+
::RSpec::Matchers.define :be_period_marked do
|
|
399
|
+
match do |key_base|
|
|
400
|
+
raise ArgumentError, "be_period_marked requires .for(period)" unless @period
|
|
401
|
+
|
|
402
|
+
Matchers.coordination_adapter.period_marker?(key_base, @period)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
chain :for do |period|
|
|
406
|
+
@period = period
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
failure_message do |key_base|
|
|
410
|
+
"expected period bucket #{RubyReactor::Period.key(key_base, @period).inspect} to be marked, but it is not"
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
failure_message_when_negated do |key_base|
|
|
414
|
+
"expected period bucket #{RubyReactor::Period.key(key_base, @period).inspect} not to be marked, but it is"
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
252
418
|
# Add more matchers as per plan
|
|
253
419
|
# rubocop:enable Metrics/BlockLength
|
|
254
420
|
end
|
|
421
|
+
# rubocop:enable Metrics/ModuleLength
|
|
255
422
|
end
|
|
256
423
|
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
class Semaphore
|
|
5
|
+
class AcquisitionError < StandardError; end
|
|
6
|
+
|
|
7
|
+
attr_reader :key, :limit, :wait, :token
|
|
8
|
+
|
|
9
|
+
def initialize(key, limit: 1, wait: 0)
|
|
10
|
+
@key = "semaphore:#{key}"
|
|
11
|
+
@limit = limit
|
|
12
|
+
@wait = wait
|
|
13
|
+
@token = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def acquire # rubocop:disable Naming/PredicateMethod
|
|
17
|
+
ensure_initialized
|
|
18
|
+
|
|
19
|
+
token = adapter.semaphore_acquire(@key, timeout: @wait)
|
|
20
|
+
raise AcquisitionError, "Could not acquire semaphore '#{@key}' within #{@wait} seconds" unless token
|
|
21
|
+
|
|
22
|
+
@token = token
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns true if a held token was returned to the pool.
|
|
27
|
+
# Idempotent: a second release (or one without a prior acquire) is a no-op.
|
|
28
|
+
def release
|
|
29
|
+
return false unless @token
|
|
30
|
+
|
|
31
|
+
result = adapter.semaphore_release(@key, @token, @limit)
|
|
32
|
+
@token = nil
|
|
33
|
+
result
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def synchronize
|
|
37
|
+
acquire
|
|
38
|
+
yield
|
|
39
|
+
ensure
|
|
40
|
+
release
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def ensure_initialized
|
|
46
|
+
# Double-checked locking pattern optimized for Redis
|
|
47
|
+
# 1. Optimistic check (if key exists)
|
|
48
|
+
return if adapter.semaphore_exists?(@key)
|
|
49
|
+
|
|
50
|
+
# 2. Try to init
|
|
51
|
+
adapter.semaphore_init(@key, @limit)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def adapter
|
|
55
|
+
RubyReactor.configuration.storage_adapter
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -17,7 +17,7 @@ module RubyReactor
|
|
|
17
17
|
# Handle infrastructure failures (network, Redis, etc.)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def perform(serialized_context, reactor_class_name = nil)
|
|
20
|
+
def perform(serialized_context, reactor_class_name = nil, snooze_count = 0)
|
|
21
21
|
context = ContextSerializer.deserialize(serialized_context)
|
|
22
22
|
|
|
23
23
|
# If reactor_class_name is provided, use it to get the reactor class
|
|
@@ -35,18 +35,80 @@ module RubyReactor
|
|
|
35
35
|
# Mark that we're executing inline to prevent nested async calls
|
|
36
36
|
context.inline_async_execution = true
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
begin
|
|
39
|
+
# Resume execution from the failed step
|
|
40
|
+
executor = Executor.new(context.reactor_class, {}, context)
|
|
41
|
+
executor.resume_execution
|
|
42
|
+
executor.save_context
|
|
43
|
+
|
|
44
|
+
# Return the executor (which now has the result stored in it)
|
|
45
|
+
executor
|
|
46
|
+
rescue RubyReactor::Lock::AcquisitionError,
|
|
47
|
+
RubyReactor::Semaphore::AcquisitionError,
|
|
48
|
+
RubyReactor::RateLimit::ExceededError => e
|
|
49
|
+
# Snooze on expected concurrency or rate contention. We avoid
|
|
50
|
+
# Sidekiq's native retry path so this doesn't burn the job's retry
|
|
51
|
+
# budget or appear as an error in dashboards. After the configured
|
|
52
|
+
# cap is reached we escalate by marking the reactor as failed.
|
|
53
|
+
handle_snooze(serialized_context, reactor_class_name, context, snooze_count, e)
|
|
54
|
+
end
|
|
43
55
|
end
|
|
44
56
|
|
|
45
57
|
private
|
|
46
58
|
|
|
59
|
+
def handle_snooze(serialized_context, reactor_class_name, context, snooze_count, error)
|
|
60
|
+
config = RubyReactor.configuration
|
|
61
|
+
max = config.lock_snooze_max_attempts
|
|
62
|
+
|
|
63
|
+
if max != :infinity && snooze_count >= max
|
|
64
|
+
escalate_snooze(context, snooze_count, error)
|
|
65
|
+
return
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
delay = compute_snooze_delay(config, error)
|
|
69
|
+
self.class.perform_in(delay, serialized_context, reactor_class_name, snooze_count + 1)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Use the error's `retry_after_seconds` hint when available
|
|
73
|
+
# (RateLimit::ExceededError carries the time until the bucket rolls);
|
|
74
|
+
# otherwise fall back to the configured base + jitter for lock/semaphore
|
|
75
|
+
# contention which has no precise hint.
|
|
76
|
+
def compute_snooze_delay(config, error)
|
|
77
|
+
jitter = config.lock_snooze_jitter.to_f
|
|
78
|
+
jitter_amount = jitter.positive? ? rand(0.0..jitter) : 0.0
|
|
79
|
+
|
|
80
|
+
if error.respond_to?(:retry_after_seconds) && error.retry_after_seconds
|
|
81
|
+
[error.retry_after_seconds.to_f, 0.1].max + jitter_amount
|
|
82
|
+
else
|
|
83
|
+
config.lock_snooze_base_delay.to_f + jitter_amount
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def escalate_snooze(context, snooze_count, error)
|
|
88
|
+
RubyReactor.configuration.logger.warn(
|
|
89
|
+
"RubyReactor snooze limit reached after #{snooze_count} attempts " \
|
|
90
|
+
"for context #{context.context_id}: #{error.message}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
context.status = :failed
|
|
94
|
+
context.failure_reason = {
|
|
95
|
+
message: error.message,
|
|
96
|
+
exception_class: error.class.name,
|
|
97
|
+
snooze_attempts: snooze_count
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
serialized = ContextSerializer.serialize(context)
|
|
101
|
+
reactor_class_name = context.reactor_class&.name || "AnonymousReactor"
|
|
102
|
+
RubyReactor.configuration.storage_adapter.store_context(
|
|
103
|
+
context.context_id,
|
|
104
|
+
serialized,
|
|
105
|
+
reactor_class_name
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
47
109
|
def log_infrastructure_failure(msg, exception)
|
|
48
|
-
|
|
49
|
-
|
|
110
|
+
RubyReactor.configuration.logger.error("RubyReactor infrastructure failure: #{exception.message}")
|
|
111
|
+
RubyReactor.configuration.logger.error("Job details: #{msg.inspect}")
|
|
50
112
|
end
|
|
51
113
|
end
|
|
52
114
|
end
|