solid_queue 1.1.3 → 1.1.5
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 +19 -5
- data/app/models/solid_queue/blocked_execution.rb +1 -1
- data/app/models/solid_queue/claimed_execution.rb +10 -3
- data/lib/solid_queue/dispatcher.rb +5 -2
- data/lib/solid_queue/engine.rb +0 -8
- data/lib/solid_queue/lifecycle_hooks.rb +11 -2
- data/lib/solid_queue/processes/base.rb +1 -1
- data/lib/solid_queue/processes/interruptible.rb +17 -17
- data/lib/solid_queue/processes/process_pruned_error.rb +1 -1
- data/lib/solid_queue/scheduler.rb +2 -1
- data/lib/solid_queue/supervisor.rb +2 -0
- data/lib/solid_queue/version.rb +1 -1
- data/lib/solid_queue/worker.rb +5 -3
- data/lib/solid_queue.rb +11 -21
- metadata +2 -3
- data/lib/solid_queue/processes/og_interruptible.rb +0 -41
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 86df23afff2a24d62b03768997b2544497806d78064f03fa40b34f830516eada
|
4
|
+
data.tar.gz: c62fc25c9715937f0326d5b23159e34222fe6d4055e18045c201739cc48861f0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 396da409f95384a6aacd42533865de189e7cb44a98e16c18fabde73d0addfb4086630a6affe7e5a49bbb9bc8e1f632fed0a86026d622c722ed940b61cac90aba
|
7
|
+
data.tar.gz: a602f595cd387355c090c4b0826c18c754943150e26e58de7ff05da1c34298c860845411a1399ac7659224e83b445188a7debea6a77da7fdac47d29e7c38c6b1
|
data/README.md
CHANGED
@@ -313,7 +313,7 @@ and then remove the paused ones. Pausing in general should be something rare, us
|
|
313
313
|
Do this:
|
314
314
|
|
315
315
|
```yml
|
316
|
-
queues: background, backend
|
316
|
+
queues: [ background, backend ]
|
317
317
|
```
|
318
318
|
|
319
319
|
instead of this:
|
@@ -379,6 +379,8 @@ And into two different points in the worker's, dispatcher's and scheduler's life
|
|
379
379
|
- `(worker|dispatcher|scheduler)_start`: after the worker/dispatcher/scheduler has finished booting and right before it starts the polling loop or loading the recurring schedule.
|
380
380
|
- `(worker|dispatcher|scheduler)_stop`: after receiving a signal (`TERM`, `INT` or `QUIT`) and right before starting graceful or immediate shutdown (which is just `exit!`).
|
381
381
|
|
382
|
+
Each of these hooks has an instance of the supervisor/worker/dispatcher/scheduler yielded to the block so that you may read its configuration for logging or metrics reporting purposes.
|
383
|
+
|
382
384
|
You can use the following methods with a block to do this:
|
383
385
|
```ruby
|
384
386
|
SolidQueue.on_start
|
@@ -396,8 +398,20 @@ SolidQueue.on_scheduler_stop
|
|
396
398
|
|
397
399
|
For example:
|
398
400
|
```ruby
|
399
|
-
SolidQueue.on_start
|
400
|
-
|
401
|
+
SolidQueue.on_start do |supervisor|
|
402
|
+
MyMetricsReporter.process_name = supervisor.name
|
403
|
+
|
404
|
+
start_metrics_server
|
405
|
+
end
|
406
|
+
|
407
|
+
SolidQueue.on_stop do |_supervisor|
|
408
|
+
stop_metrics_server
|
409
|
+
end
|
410
|
+
|
411
|
+
SolidQueue.on_worker_start do |worker|
|
412
|
+
MyMetricsReporter.process_name = worker.name
|
413
|
+
MyMetricsReporter.queues = worker.queues.join(',')
|
414
|
+
end
|
401
415
|
```
|
402
416
|
|
403
417
|
These can be called several times to add multiple hooks, but it needs to happen before Solid Queue is started. An initializer would be a good place to do this.
|
@@ -426,7 +440,7 @@ class MyJob < ApplicationJob
|
|
426
440
|
|
427
441
|
When a job includes these controls, we'll ensure that, at most, the number of jobs (indicated as `to`) that yield the same `key` will be performed concurrently, and this guarantee will last for `duration` for each job enqueued. Note that there's no guarantee about _the order of execution_, only about jobs being performed at the same time (overlapping).
|
428
442
|
|
429
|
-
The concurrency limits use the concept of semaphores when enqueuing, and work as follows: when a job is enqueued, we check if it specifies concurrency controls. If it does, we check the semaphore for the computed concurrency key. If the semaphore is open, we claim it and we set the job as _ready_. Ready means it can be picked up by workers for execution. When the job finishes executing (be it successfully or unsuccessfully, resulting in a failed execution), we signal the semaphore and try to unblock the next job with the same key, if any. Unblocking the next job doesn't mean running that job right away, but moving it from _blocked_ to _ready_. Since something can happen that prevents the first job from releasing the semaphore and unblocking the next job (for example, someone pulling a plug in the machine where the worker is running), we have the `duration` as a failsafe. Jobs that have been blocked for more than duration are candidates to be released, but only as many of them as the concurrency rules allow, as each one would need to go through the semaphore dance check. This means that the `duration` is not really about the job that's enqueued or being run, it's about the jobs that are blocked waiting.
|
443
|
+
The concurrency limits use the concept of semaphores when enqueuing, and work as follows: when a job is enqueued, we check if it specifies concurrency controls. If it does, we check the semaphore for the computed concurrency key. If the semaphore is open, we claim it and we set the job as _ready_. Ready means it can be picked up by workers for execution. When the job finishes executing (be it successfully or unsuccessfully, resulting in a failed execution), we signal the semaphore and try to unblock the next job with the same key, if any. Unblocking the next job doesn't mean running that job right away, but moving it from _blocked_ to _ready_. Since something can happen that prevents the first job from releasing the semaphore and unblocking the next job (for example, someone pulling a plug in the machine where the worker is running), we have the `duration` as a failsafe. Jobs that have been blocked for more than duration are candidates to be released, but only as many of them as the concurrency rules allow, as each one would need to go through the semaphore dance check. This means that the `duration` is not really about the job that's enqueued or being run, it's about the jobs that are blocked waiting. It's important to note that after one or more candidate jobs are unblocked (either because a job finishes or because `duration` expires and a semaphore is released), the `duration` timer for the still blocked jobs is reset. This happens indirectly via the expiration time of the semaphore, which is updated.
|
430
444
|
|
431
445
|
|
432
446
|
For example:
|
@@ -459,7 +473,7 @@ class Bundle::RebundlePostingsJob < ApplicationJob
|
|
459
473
|
|
460
474
|
In this case, if we have a `Box::MovePostingsByContactToDesignatedBoxJob` job enqueued for a contact record with id `123` and another `Bundle::RebundlePostingsJob` job enqueued simultaneously for a bundle record that references contact `123`, only one of them will be allowed to proceed. The other one will stay blocked until the first one finishes (or 15 minutes pass, whatever happens first).
|
461
475
|
|
462
|
-
Note that the `duration` setting depends indirectly on the value for `concurrency_maintenance_interval` that you set for your dispatcher(s), as that'd be the frequency with which blocked jobs are checked and unblocked. In general, you should set `duration` in a way that all your jobs would finish well under that duration and think of the concurrency maintenance task as a failsafe in case something goes wrong.
|
476
|
+
Note that the `duration` setting depends indirectly on the value for `concurrency_maintenance_interval` that you set for your dispatcher(s), as that'd be the frequency with which blocked jobs are checked and unblocked (at which point, only one job per concurrency key, at most, is unblocked). In general, you should set `duration` in a way that all your jobs would finish well under that duration and think of the concurrency maintenance task as a failsafe in case something goes wrong.
|
463
477
|
|
464
478
|
Jobs are unblocked in order of priority but queue order is not taken into account for unblocking jobs. That means that if you have a group of jobs that share a concurrency group but are in different queues, or jobs of the same class that you enqueue in different queues, the queue order you set for a worker is not taken into account when unblocking blocked ones. The reason is that a job that runs unblocks the next one, and the job itself doesn't know about a particular worker's queue order (you could even have different workers with different queue orders), it can only know about priority. Once blocked jobs are unblocked and available for polling, they'll be picked up by a worker following its queue order.
|
465
479
|
|
@@ -12,7 +12,7 @@ module SolidQueue
|
|
12
12
|
class << self
|
13
13
|
def unblock(limit)
|
14
14
|
SolidQueue.instrument(:release_many_blocked, limit: limit) do |payload|
|
15
|
-
expired.distinct.limit(limit).pluck(:concurrency_key).then do |concurrency_keys|
|
15
|
+
expired.order(:concurrency_key).distinct.limit(limit).pluck(:concurrency_key).then do |concurrency_keys|
|
16
16
|
payload[:size] = release_many releasable(concurrency_keys)
|
17
17
|
end
|
18
18
|
end
|
@@ -39,7 +39,10 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
|
|
39
39
|
def fail_all_with(error)
|
40
40
|
SolidQueue.instrument(:fail_many_claimed) do |payload|
|
41
41
|
includes(:job).tap do |executions|
|
42
|
-
executions.each
|
42
|
+
executions.each do |execution|
|
43
|
+
execution.failed_with(error)
|
44
|
+
execution.unblock_next_job
|
45
|
+
end
|
43
46
|
|
44
47
|
payload[:process_ids] = executions.map(&:process_id).uniq
|
45
48
|
payload[:job_ids] = executions.map(&:job_id).uniq
|
@@ -67,7 +70,7 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
|
|
67
70
|
raise result.error
|
68
71
|
end
|
69
72
|
ensure
|
70
|
-
|
73
|
+
unblock_next_job
|
71
74
|
end
|
72
75
|
|
73
76
|
def release
|
@@ -90,9 +93,13 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
|
|
90
93
|
end
|
91
94
|
end
|
92
95
|
|
96
|
+
def unblock_next_job
|
97
|
+
job.unblock_next_blocked_job
|
98
|
+
end
|
99
|
+
|
93
100
|
private
|
94
101
|
def execute
|
95
|
-
ActiveJob::Base.execute(job.arguments)
|
102
|
+
ActiveJob::Base.execute(job.arguments.merge("provider_job_id" => job.id))
|
96
103
|
Result.new(true, nil)
|
97
104
|
rescue Exception => e
|
98
105
|
Result.new(false, e)
|
@@ -3,12 +3,13 @@
|
|
3
3
|
module SolidQueue
|
4
4
|
class Dispatcher < Processes::Poller
|
5
5
|
include LifecycleHooks
|
6
|
-
|
6
|
+
attr_reader :batch_size
|
7
7
|
|
8
8
|
after_boot :run_start_hooks
|
9
9
|
after_boot :start_concurrency_maintenance
|
10
10
|
before_shutdown :stop_concurrency_maintenance
|
11
|
-
|
11
|
+
before_shutdown :run_stop_hooks
|
12
|
+
after_shutdown :run_exit_hooks
|
12
13
|
|
13
14
|
def initialize(**options)
|
14
15
|
options = options.dup.with_defaults(SolidQueue::Configuration::DISPATCHER_DEFAULTS)
|
@@ -25,6 +26,8 @@ module SolidQueue
|
|
25
26
|
end
|
26
27
|
|
27
28
|
private
|
29
|
+
attr_reader :concurrency_maintenance
|
30
|
+
|
28
31
|
def poll
|
29
32
|
batch = dispatch_next_batch
|
30
33
|
|
data/lib/solid_queue/engine.rb
CHANGED
@@ -37,13 +37,5 @@ module SolidQueue
|
|
37
37
|
include ActiveJob::ConcurrencyControls
|
38
38
|
end
|
39
39
|
end
|
40
|
-
|
41
|
-
initializer "solid_queue.include_interruptible_concern" do
|
42
|
-
if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.2")
|
43
|
-
SolidQueue::Processes::Base.include SolidQueue::Processes::Interruptible
|
44
|
-
else
|
45
|
-
SolidQueue::Processes::Base.include SolidQueue::Processes::OgInterruptible
|
46
|
-
end
|
47
|
-
end
|
48
40
|
end
|
49
41
|
end
|
@@ -5,7 +5,7 @@ module SolidQueue
|
|
5
5
|
extend ActiveSupport::Concern
|
6
6
|
|
7
7
|
included do
|
8
|
-
mattr_reader :lifecycle_hooks, default: { start: [], stop: [] }
|
8
|
+
mattr_reader :lifecycle_hooks, default: { start: [], stop: [], exit: [] }
|
9
9
|
end
|
10
10
|
|
11
11
|
class_methods do
|
@@ -17,7 +17,12 @@ module SolidQueue
|
|
17
17
|
self.lifecycle_hooks[:stop] << block
|
18
18
|
end
|
19
19
|
|
20
|
+
def on_exit(&block)
|
21
|
+
self.lifecycle_hooks[:exit] << block
|
22
|
+
end
|
23
|
+
|
20
24
|
def clear_hooks
|
25
|
+
self.lifecycle_hooks[:exit] = []
|
21
26
|
self.lifecycle_hooks[:start] = []
|
22
27
|
self.lifecycle_hooks[:stop] = []
|
23
28
|
end
|
@@ -32,9 +37,13 @@ module SolidQueue
|
|
32
37
|
run_hooks_for :stop
|
33
38
|
end
|
34
39
|
|
40
|
+
def run_exit_hooks
|
41
|
+
run_hooks_for :exit
|
42
|
+
end
|
43
|
+
|
35
44
|
def run_hooks_for(event)
|
36
45
|
self.class.lifecycle_hooks.fetch(event, []).each do |block|
|
37
|
-
|
46
|
+
block.call(self)
|
38
47
|
rescue Exception => exception
|
39
48
|
handle_thread_error(exception)
|
40
49
|
end
|
@@ -2,36 +2,36 @@
|
|
2
2
|
|
3
3
|
module SolidQueue::Processes
|
4
4
|
module Interruptible
|
5
|
-
include SolidQueue::AppExecutor
|
6
|
-
|
7
5
|
def wake_up
|
8
6
|
interrupt
|
9
7
|
end
|
10
8
|
|
11
9
|
private
|
10
|
+
SELF_PIPE_BLOCK_SIZE = 11
|
12
11
|
|
13
12
|
def interrupt
|
14
|
-
|
13
|
+
self_pipe[:writer].write_nonblock(".")
|
14
|
+
rescue Errno::EAGAIN, Errno::EINTR
|
15
|
+
# Ignore writes that would block and retry
|
16
|
+
# if another signal arrived while writing
|
17
|
+
retry
|
15
18
|
end
|
16
19
|
|
17
|
-
# Sleeps for 'time'. Can be interrupted asynchronously and return early via wake_up.
|
18
|
-
# @param time [Numeric, Duration] the time to sleep. 0 returns immediately.
|
19
20
|
def interruptible_sleep(time)
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
wrapped_exception = RuntimeError.new("Interruptible#interruptible_sleep - #{e.class}: #{e.message}")
|
26
|
-
wrapped_exception.set_backtrace(e.backtrace)
|
27
|
-
handle_thread_error(wrapped_exception)
|
28
|
-
end.value
|
21
|
+
if time > 0 && self_pipe[:reader].wait_readable(time)
|
22
|
+
loop { self_pipe[:reader].read_nonblock(SELF_PIPE_BLOCK_SIZE) }
|
23
|
+
end
|
24
|
+
rescue Errno::EAGAIN, Errno::EINTR
|
25
|
+
end
|
29
26
|
|
30
|
-
|
27
|
+
# Self-pipe for signal-handling (http://cr.yp.to/docs/selfpipe.html)
|
28
|
+
def self_pipe
|
29
|
+
@self_pipe ||= create_self_pipe
|
31
30
|
end
|
32
31
|
|
33
|
-
def
|
34
|
-
|
32
|
+
def create_self_pipe
|
33
|
+
reader, writer = IO.pipe
|
34
|
+
{ reader: reader, writer: writer }
|
35
35
|
end
|
36
36
|
end
|
37
37
|
end
|
@@ -4,7 +4,7 @@ module SolidQueue
|
|
4
4
|
module Processes
|
5
5
|
class ProcessPrunedError < RuntimeError
|
6
6
|
def initialize(last_heartbeat_at)
|
7
|
-
super("Process was found dead and pruned (last heartbeat at: #{last_heartbeat_at}")
|
7
|
+
super("Process was found dead and pruned (last heartbeat at: #{last_heartbeat_at})")
|
8
8
|
end
|
9
9
|
end
|
10
10
|
end
|
@@ -5,12 +5,13 @@ module SolidQueue
|
|
5
5
|
include Processes::Runnable
|
6
6
|
include LifecycleHooks
|
7
7
|
|
8
|
-
|
8
|
+
attr_reader :recurring_schedule
|
9
9
|
|
10
10
|
after_boot :run_start_hooks
|
11
11
|
after_boot :schedule_recurring_tasks
|
12
12
|
before_shutdown :unschedule_recurring_tasks
|
13
13
|
before_shutdown :run_stop_hooks
|
14
|
+
after_shutdown :run_exit_hooks
|
14
15
|
|
15
16
|
def initialize(recurring_tasks:, **options)
|
16
17
|
@recurring_schedule = RecurringSchedule.new(recurring_tasks)
|
data/lib/solid_queue/version.rb
CHANGED
data/lib/solid_queue/worker.rb
CHANGED
@@ -6,14 +6,16 @@ module SolidQueue
|
|
6
6
|
|
7
7
|
after_boot :run_start_hooks
|
8
8
|
before_shutdown :run_stop_hooks
|
9
|
+
after_shutdown :run_exit_hooks
|
9
10
|
|
10
|
-
|
11
|
-
attr_accessor :queues, :pool
|
11
|
+
attr_reader :queues, :pool
|
12
12
|
|
13
13
|
def initialize(**options)
|
14
14
|
options = options.dup.with_defaults(SolidQueue::Configuration::WORKER_DEFAULTS)
|
15
15
|
|
16
|
-
|
16
|
+
# Ensure that the queues array is deep frozen to prevent accidental modification
|
17
|
+
@queues = Array(options[:queues]).map(&:freeze).freeze
|
18
|
+
|
17
19
|
@pool = Pool.new(options[:threads], on_idle: -> { wake_up })
|
18
20
|
|
19
21
|
super(**options)
|
data/lib/solid_queue.rb
CHANGED
@@ -41,30 +41,20 @@ module SolidQueue
|
|
41
41
|
mattr_accessor :clear_finished_jobs_after, default: 1.day
|
42
42
|
mattr_accessor :default_concurrency_control_period, default: 3.minutes
|
43
43
|
|
44
|
-
delegate :on_start, :on_stop, to: Supervisor
|
44
|
+
delegate :on_start, :on_stop, :on_exit, to: Supervisor
|
45
45
|
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
def on_worker_stop(...)
|
51
|
-
Worker.on_stop(...)
|
52
|
-
end
|
53
|
-
|
54
|
-
def on_dispatcher_start(...)
|
55
|
-
Dispatcher.on_start(...)
|
56
|
-
end
|
57
|
-
|
58
|
-
def on_dispatcher_stop(...)
|
59
|
-
Dispatcher.on_stop(...)
|
60
|
-
end
|
46
|
+
[ Dispatcher, Scheduler, Worker ].each do |process|
|
47
|
+
define_singleton_method(:"on_#{process.name.demodulize.downcase}_start") do |&block|
|
48
|
+
process.on_start(&block)
|
49
|
+
end
|
61
50
|
|
62
|
-
|
63
|
-
|
64
|
-
|
51
|
+
define_singleton_method(:"on_#{process.name.demodulize.downcase}_stop") do |&block|
|
52
|
+
process.on_stop(&block)
|
53
|
+
end
|
65
54
|
|
66
|
-
|
67
|
-
|
55
|
+
define_singleton_method(:"on_#{process.name.demodulize.downcase}_exit") do |&block|
|
56
|
+
process.on_exit(&block)
|
57
|
+
end
|
68
58
|
end
|
69
59
|
|
70
60
|
def supervisor?
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: solid_queue
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.1.
|
4
|
+
version: 1.1.5
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Rosa Gutierrez
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-04-20 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -295,7 +295,6 @@ files:
|
|
295
295
|
- lib/solid_queue/processes/base.rb
|
296
296
|
- lib/solid_queue/processes/callbacks.rb
|
297
297
|
- lib/solid_queue/processes/interruptible.rb
|
298
|
-
- lib/solid_queue/processes/og_interruptible.rb
|
299
298
|
- lib/solid_queue/processes/poller.rb
|
300
299
|
- lib/solid_queue/processes/process_exit_error.rb
|
301
300
|
- lib/solid_queue/processes/process_missing_error.rb
|
@@ -1,41 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
# frozen_string_literal: true
|
4
|
-
|
5
|
-
module SolidQueue::Processes
|
6
|
-
# The original implementation of Interruptible that works
|
7
|
-
# with Ruby 3.1 and earlier
|
8
|
-
module OgInterruptible
|
9
|
-
def wake_up
|
10
|
-
interrupt
|
11
|
-
end
|
12
|
-
|
13
|
-
private
|
14
|
-
SELF_PIPE_BLOCK_SIZE = 11
|
15
|
-
|
16
|
-
def interrupt
|
17
|
-
self_pipe[:writer].write_nonblock(".")
|
18
|
-
rescue Errno::EAGAIN, Errno::EINTR
|
19
|
-
# Ignore writes that would block and retry
|
20
|
-
# if another signal arrived while writing
|
21
|
-
retry
|
22
|
-
end
|
23
|
-
|
24
|
-
def interruptible_sleep(time)
|
25
|
-
if time > 0 && self_pipe[:reader].wait_readable(time)
|
26
|
-
loop { self_pipe[:reader].read_nonblock(SELF_PIPE_BLOCK_SIZE) }
|
27
|
-
end
|
28
|
-
rescue Errno::EAGAIN, Errno::EINTR
|
29
|
-
end
|
30
|
-
|
31
|
-
# Self-pipe for signal-handling (http://cr.yp.to/docs/selfpipe.html)
|
32
|
-
def self_pipe
|
33
|
-
@self_pipe ||= create_self_pipe
|
34
|
-
end
|
35
|
-
|
36
|
-
def create_self_pipe
|
37
|
-
reader, writer = IO.pipe
|
38
|
-
{ reader: reader, writer: writer }
|
39
|
-
end
|
40
|
-
end
|
41
|
-
end
|