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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +14 -0
- data/README.md +199 -30
- data/lib/ruby_reactor/configuration.rb +7 -0
- data/lib/ruby_reactor/dsl/compose_builder.rb +20 -0
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +18 -2
- data/lib/ruby_reactor/dsl/lockable.rb +60 -29
- data/lib/ruby_reactor/dsl/reactor.rb +38 -7
- data/lib/ruby_reactor/dsl/step_builder.rb +25 -39
- data/lib/ruby_reactor/dsl/validation_helpers.rb +34 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +307 -0
- data/lib/ruby_reactor/executor/result_handler.rb +35 -8
- data/lib/ruby_reactor/executor/step_executor.rb +10 -5
- data/lib/ruby_reactor/executor.rb +145 -50
- data/lib/ruby_reactor/ordered_lock.rb +158 -0
- data/lib/ruby_reactor/rate_limit.rb +28 -0
- data/lib/ruby_reactor/rate_limit_registry.rb +51 -0
- data/lib/ruby_reactor/reactor.rb +41 -0
- data/lib/ruby_reactor/rspec/helpers.rb +6 -0
- data/lib/ruby_reactor/rspec/matchers.rb +66 -0
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +70 -0
- data/lib/ruby_reactor/rspec/storage_reset.rb +23 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +14 -28
- data/lib/ruby_reactor/rspec.rb +37 -0
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +50 -8
- data/lib/ruby_reactor/storage/redis_adapter.rb +1 -0
- data/lib/ruby_reactor/storage/redis_ordered_locking.rb +382 -0
- data/lib/ruby_reactor/validation/base.rb +4 -1
- data/lib/ruby_reactor/validation/input_validator.rb +4 -2
- data/lib/ruby_reactor/validation/schema_builder.rb +82 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor.rb +1 -0
- 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
|
-
|
|
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
|
|
data/lib/ruby_reactor/rspec.rb
CHANGED
|
@@ -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
|
|
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
|
|
58
|
-
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
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
|
-
|
|
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
|
|
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)
|