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.
@@ -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
@@ -4,6 +4,7 @@ module RubyReactor
4
4
  # rubocop:disable Metrics/ClassLength
5
5
  class Reactor
6
6
  include RubyReactor::Dsl::Reactor
7
+ include RubyReactor::Dsl::Lockable
7
8
 
8
9
  attr_reader :context, :result, :undo_trace, :execution_trace
9
10
 
@@ -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
- else
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
- # Resume execution from the failed step
39
- executor = Executor.new(context.reactor_class, {}, context)
40
- executor.resume_execution
41
- executor.save_context
42
- executor.result
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
- ::Sidekiq.logger.error("RubyReactor infrastructure failure: #{exception.message}")
49
- ::Sidekiq.logger.error("Job details: #{msg.inspect}")
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
@@ -6,6 +6,8 @@ require "json"
6
6
  module RubyReactor
7
7
  module Storage
8
8
  class RedisAdapter < Adapter
9
+ include RedisLocking
10
+
9
11
  def initialize(redis_config)
10
12
  super()
11
13
  @redis = Redis.new(redis_config)