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
@@ -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
 
@@ -2,17 +2,54 @@
2
2
 
3
3
  require_relative "rspec/helpers"
4
4
  require_relative "rspec/matchers"
5
+ require_relative "rspec/sidekiq_helpers"
6
+ require_relative "rspec/storage_reset"
5
7
  require_relative "rspec/test_subject"
6
8
 
7
9
  module RubyReactor
8
10
  module RSpec
11
+ # Examples opt into RubyReactor's RSpec setup (Sidekiq fake mode,
12
+ # storage wipe, snooze knob reset) by declaring `type: :reactor`.
13
+ REACTOR_METADATA = { type: :reactor }.freeze
14
+
15
+ DEFAULT_SNOOZE_BASE_DELAY = 5
16
+ DEFAULT_SNOOZE_JITTER = 5
17
+ DEFAULT_SNOOZE_MAX_ATTEMPTS = 20
18
+
9
19
  def self.configure(config)
10
20
  require_relative "rspec/step_executor_patch"
11
21
 
12
22
  config.include RubyReactor::RSpec::Helpers
13
23
  config.include RubyReactor::RSpec::Matchers
24
+ config.include RubyReactor::RSpec::SidekiqHelpers, REACTOR_METADATA
14
25
 
15
26
  ::RubyReactor::Executor::StepExecutor.prepend(RubyReactor::RSpec::StepExecutorPatch)
27
+ StorageReset.install!
28
+
29
+ config.before(:each, REACTOR_METADATA) do
30
+ RubyReactor::RSpec.prepare_example!
31
+ end
32
+ end
33
+
34
+ # Idempotent setup invoked before each `type: :reactor` example. Restores
35
+ # Sidekiq fake mode, clears the queues, wipes the storage adapter, and
36
+ # rolls back snooze knobs so cross-example bleed-through can't happen.
37
+ def self.prepare_example!
38
+ if defined?(::Sidekiq::Testing)
39
+ begin
40
+ ::Sidekiq::Testing.fake! unless ::Sidekiq::Testing.fake?
41
+ rescue ::Sidekiq::Testing::TestModeAlreadySetError
42
+ # Nested fake!/inline! block already active in this thread; leave it.
43
+ end
44
+ ::Sidekiq::Worker.clear_all
45
+ end
46
+
47
+ adapter = ::RubyReactor.configuration.storage_adapter
48
+ adapter.reset! if adapter.respond_to?(:reset!)
49
+
50
+ ::RubyReactor.configuration.lock_snooze_base_delay = DEFAULT_SNOOZE_BASE_DELAY
51
+ ::RubyReactor.configuration.lock_snooze_jitter = DEFAULT_SNOOZE_JITTER
52
+ ::RubyReactor.configuration.lock_snooze_max_attempts = DEFAULT_SNOOZE_MAX_ATTEMPTS
16
53
  end
17
54
  end
18
55
  end
@@ -48,18 +48,29 @@ module RubyReactor
48
48
  # Resume execution from the failed step
49
49
  executor = Executor.new(context.reactor_class, {}, context)
50
50
  executor.resume_execution
51
- executor.save_context
51
+ # Skip the post-run save when the executor deliberately suppressed
52
+ # persistence (stale-batch redelivery of an already-terminal context)
53
+ # — re-saving here would clobber the stored terminal record with this
54
+ # run's stale in-memory status.
55
+ executor.save_context unless executor.skip_context_persist?
52
56
 
53
57
  # Return the executor (which now has the result stored in it)
54
58
  executor
55
59
  rescue RubyReactor::Lock::AcquisitionError,
56
60
  RubyReactor::Semaphore::AcquisitionError,
57
- RubyReactor::RateLimit::ExceededError => e
58
- # Snooze on expected concurrency or rate contention. We avoid
59
- # Sidekiq's native retry path so this doesn't burn the job's retry
60
- # budget or appear as an error in dashboards. After the configured
61
- # cap is reached we escalate by marking the reactor as failed.
61
+ RubyReactor::RateLimit::ExceededError,
62
+ RubyReactor::OrderedLock::WaitError => e
63
+ # Snooze on expected concurrency, rate, or ordering contention.
64
+ # OrderedLock::WaitError carries a poison-pill-derived retry hint,
65
+ # consumed by compute_snooze_delay below. We avoid Sidekiq's native
66
+ # retry path so this doesn't burn the job's retry budget or appear
67
+ # as an error in dashboards. After the configured cap is reached we
68
+ # escalate by marking the reactor as failed.
62
69
  handle_snooze(serialized_context, reactor_class_name, context, snooze_count, e)
70
+ rescue RubyReactor::RateLimitRegistry::UnknownLimitError => e
71
+ # Permanent configuration error — snoozing or retrying the same job
72
+ # will keep failing. Mark the context failed immediately.
73
+ escalate_snooze(context, snooze_count, e)
63
74
  end
64
75
  end
65
76
 
@@ -69,7 +80,15 @@ module RubyReactor
69
80
  config = RubyReactor.configuration
70
81
  max = config.lock_snooze_max_attempts
71
82
 
72
- if max != :infinity && snooze_count >= max
83
+ # OrderedLock::WaitError bypasses the snooze cap. The gate's
84
+ # poison_pill_timeout is the only meaningful upper bound on how long a
85
+ # nonce can legitimately wait; capping snoozes would either fail jobs
86
+ # prematurely or strand the nonce in `assigned_at` until poison_pill
87
+ # eventually advances past it. Snooze until the gate passes (or poison
88
+ # auto-advance moves the cursor past us).
89
+ capped = !error.is_a?(RubyReactor::OrderedLock::WaitError)
90
+
91
+ if capped && max != :infinity && snooze_count >= max
73
92
  escalate_snooze(context, snooze_count, error)
74
93
  return
75
94
  end
@@ -82,17 +101,32 @@ module RubyReactor
82
101
  # (RateLimit::ExceededError carries the time until the bucket rolls);
83
102
  # otherwise fall back to the configured base + jitter for lock/semaphore
84
103
  # contention which has no precise hint.
104
+ #
105
+ # OrderedLock::WaitError is deliberately excluded from the hint path: its
106
+ # `retry_after_seconds` is the poison-pill window (the upper bound before
107
+ # a *dead* blocker is force-advanced), NOT how long the *live* blocker
108
+ # will take — which is usually milliseconds. Snoozing for the full window
109
+ # would make every out-of-order nonce sleep up to poison_pill_timeout even
110
+ # though its blocker finishes immediately, collapsing throughput. Re-poll
111
+ # at the base delay instead; poison auto-advance still clears a genuinely
112
+ # dead blocker on a later gate.
85
113
  def compute_snooze_delay(config, error)
86
114
  jitter = config.lock_snooze_jitter.to_f
87
115
  jitter_amount = jitter.positive? ? rand(0.0..jitter) : 0.0
88
116
 
89
- if error.respond_to?(:retry_after_seconds) && error.retry_after_seconds
117
+ if hinted_retry?(error)
90
118
  [error.retry_after_seconds.to_f, 0.1].max + jitter_amount
91
119
  else
92
120
  config.lock_snooze_base_delay.to_f + jitter_amount
93
121
  end
94
122
  end
95
123
 
124
+ def hinted_retry?(error)
125
+ return false if error.is_a?(RubyReactor::OrderedLock::WaitError)
126
+
127
+ error.respond_to?(:retry_after_seconds) && error.retry_after_seconds
128
+ end
129
+
96
130
  def escalate_snooze(context, snooze_count, error)
97
131
  RubyReactor.configuration.logger.warn(
98
132
  "RubyReactor snooze limit reached after #{snooze_count} attempts " \
@@ -113,6 +147,14 @@ module RubyReactor
113
147
  serialized,
114
148
  reactor_class_name
115
149
  )
150
+
151
+ # Escalation is a terminal Failure that never reaches the Executor's
152
+ # ensure path, so advance the ordered-lock cursor here. Without this
153
+ # the nonce stays stranded in assigned_at (successors stall for the
154
+ # full poison_pill_timeout) and, worse, the strict-mode chain marker
155
+ # is never recorded — successors would RUN instead of being skipped.
156
+ info = Executor::OrderedLockSupport.info_from(context)
157
+ Executor::OrderedLockSupport.advance_with_retry(info, failed: true) if info
116
158
  end
117
159
 
118
160
  def log_infrastructure_failure(msg, exception)
@@ -7,6 +7,7 @@ module RubyReactor
7
7
  module Storage
8
8
  class RedisAdapter < Adapter
9
9
  include RedisLocking
10
+ include RedisOrderedLocking
10
11
 
11
12
  def initialize(redis_config)
12
13
  super()