solid_queue 1.7.0 → 1.7.1
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 +21 -1
- data/app/models/solid_queue/batch_execution.rb +3 -0
- data/app/models/solid_queue/blocked_execution.rb +8 -2
- data/app/models/solid_queue/claimed_execution.rb +120 -86
- data/app/models/solid_queue/execution.rb +4 -2
- data/app/models/solid_queue/failed_execution.rb +46 -17
- data/app/models/solid_queue/job/concurrency_controls.rb +7 -7
- data/app/models/solid_queue/job/executable.rb +6 -2
- data/app/models/solid_queue/job/schedulable.rb +1 -1
- data/app/models/solid_queue/job.rb +9 -0
- data/app/models/solid_queue/process.rb +6 -2
- data/app/models/solid_queue/queue_selector.rb +7 -9
- data/app/models/solid_queue/ready_execution.rb +1 -1
- data/app/models/solid_queue/semaphore.rb +0 -14
- data/lib/puma/plugin/solid_queue.rb +14 -0
- data/lib/solid_queue/async_supervisor.rb +15 -2
- data/lib/solid_queue/fiber_pool.rb +2 -1
- data/lib/solid_queue/fork_supervisor.rb +2 -12
- data/lib/solid_queue/log_subscriber.rb +1 -1
- data/lib/solid_queue/pool.rb +18 -4
- data/lib/solid_queue/processes/registrable.rb +20 -0
- data/lib/solid_queue/processes/supervised.rb +8 -0
- data/lib/solid_queue/processes/unrecoverable_error.rb +9 -0
- data/lib/solid_queue/scheduler/recurring_schedule.rb +31 -19
- data/lib/solid_queue/supervisor/maintenance.rb +10 -0
- data/lib/solid_queue/thread_pool.rb +1 -0
- data/lib/solid_queue/version.rb +1 -1
- data/lib/solid_queue/worker.rb +10 -1
- data/lib/solid_queue.rb +2 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d55976c5a8d2a3e411cfb10099c0bbbeedc4652fcb02edfb8474b31b793c164c
|
|
4
|
+
data.tar.gz: 5a4a9ddffa329ef4ff6feb7fb642f18baaae637d8248f55bfa739d56a7f16824
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8301a9c5150cc8f0a99b47755fd2e22e41832a913618fffe3280bd61be27c7f84a570ba2ade570a8228b6d07b4587b914fa61b626b24e87b4d57f8cd702e9949
|
|
7
|
+
data.tar.gz: af764dc8c6749f5a88a50993693b36450a0dfe158ea04a6dd267fd23476208c858e96a17cf767152be75eb6bbc0bfb82d1cdfb3863091b5cfc4d8430eec72997
|
data/README.md
CHANGED
|
@@ -401,6 +401,8 @@ The supervisor is in charge of managing these processes, and it responds to the
|
|
|
401
401
|
|
|
402
402
|
When receiving a `QUIT` signal, if workers still have jobs in-flight, these will be returned to the queue when the processes are deregistered.
|
|
403
403
|
|
|
404
|
+
In `async` mode, workers, dispatchers and the scheduler are threads inside the supervisor's process, so they can't be signaled one by one. On `TERM` or `INT`, the supervisor stops them and waits up to `SolidQueue.shutdown_timeout`. If any are still running by then, it deregisters itself, which returns their in-flight jobs to the queue, and exits. On `QUIT`, it exits immediately without deregistering anything, so in-flight jobs remain claimed until another supervisor prunes these processes and marks the jobs as failed with a `SolidQueue::Processes::ProcessPrunedError`.
|
|
405
|
+
|
|
404
406
|
On Windows, the `QUIT` signal can't be trapped, so the supervisor only responds to `TERM` and `INT` there.
|
|
405
407
|
|
|
406
408
|
If processes have no chance of cleaning up before exiting (e.g. if someone pulls a cable somewhere), in-flight jobs might remain claimed by the processes executing them. Processes send heartbeats, and the supervisor checks and prunes processes with expired heartbeats. Jobs that were claimed by processes with an expired heartbeat will be marked as failed with a `SolidQueue::Processes::ProcessPrunedError`. You can configure both the frequency of heartbeats and the threshold to consider a process dead. See the section below for this.
|
|
@@ -418,6 +420,16 @@ You can configure the database used by Solid Queue via the `config.solid_queue.c
|
|
|
418
420
|
|
|
419
421
|
All the options available to Active Record for multiple databases can be used here.
|
|
420
422
|
|
|
423
|
+
If you use MySQL or MariaDB, consider running the queue database with the `READ COMMITTED` transaction isolation level. Under the default `REPEATABLE READ`, InnoDB takes gap locks on the indexes Solid Queue polls, which under heavy load can occasionally deadlock jobs being enqueued against jobs being claimed or dispatched. `READ COMMITTED` avoids these gap locks and is perfectly safe for Solid Queue's own tables, and it's how we run it ourselves. You can set it per connection in your `database.yml`:
|
|
424
|
+
|
|
425
|
+
```yaml
|
|
426
|
+
queue:
|
|
427
|
+
<<: *default
|
|
428
|
+
database: my_app_queue
|
|
429
|
+
variables:
|
|
430
|
+
transaction_isolation: READ-COMMITTED
|
|
431
|
+
```
|
|
432
|
+
|
|
421
433
|
### Other configuration settings
|
|
422
434
|
|
|
423
435
|
_Note_: The settings in this section should be set in your `config/application.rb` or your environment config like this: `config.solid_queue.silence_polling = true`
|
|
@@ -438,7 +450,7 @@ There are several settings that control how Solid Queue works that you can set a
|
|
|
438
450
|
- `process_alive_threshold`: how long to wait until a process is considered dead after its last heartbeat—defaults to 5 minutes.
|
|
439
451
|
- `fork_boot_timeout`: how long a forked process can take to finish booting before the supervisor replaces it—defaults to 5 minutes. It only applies in the default `fork` mode.
|
|
440
452
|
- `shutdown_timeout`: time the supervisor will wait since it sent the `TERM` signal to its supervised processes before sending a `QUIT` version to them requesting immediate termination—defaults to 5 seconds.
|
|
441
|
-
- `silence_polling`: whether to silence Active Record logs emitted when polling for both workers and dispatchers—defaults to `true`.
|
|
453
|
+
- `silence_polling`: whether to silence Active Record logs emitted when polling for both workers and dispatchers—defaults to `true`. On Rails 8.2 and later, you can go further and disable SQL notifications for the whole queue database connection by setting `sql_notifications: false` on it in your `database.yml`. This silences not only polling but also heartbeats, semaphores, maintenance queries, and everything else Solid Queue does on that connection, both in the logs and for any subscribers to `sql.active_record`.
|
|
442
454
|
- `supervisor_pidfile`: path to a pidfile that the supervisor will create when booting to prevent running more than one supervisor in the same host, or in case you want to use it for a health check. It's `nil` by default.
|
|
443
455
|
- `preserve_finished_jobs`: whether to keep finished jobs in the `solid_queue_jobs` table—defaults to `true`.
|
|
444
456
|
- `clear_finished_jobs_after`: period to keep finished jobs around, in case `preserve_finished_jobs` is true — defaults to 1 day. When installing Solid Queue, [a recurring job](#recurring-tasks) is automatically configured to clear finished jobs every hour on the 12th minute in batches. You can edit the `recurring.yml` configuration to change this as you see fit.
|
|
@@ -545,6 +557,8 @@ It's important to note that after one or more candidate jobs are unblocked (eith
|
|
|
545
557
|
|
|
546
558
|
When using `discard` as the behaviour to handle conflicts, you might have jobs discarded for until the `duration` interval if something happens and a running job fails to release the semaphore.
|
|
547
559
|
|
|
560
|
+
If a job's class no longer exists by the time its concurrency controls are checked—say it was renamed or removed in a deploy while jobs referencing it were still in the queue—the job is marked as failed with a `SolidQueue::Job::ClassMissingError`, so it shows up in [failed jobs](#failed-jobs-and-retries), where it can be retried once the class is back, or discarded. Jobs with a missing class picked up by a worker fail with the same error.
|
|
561
|
+
|
|
548
562
|
|
|
549
563
|
For example:
|
|
550
564
|
```ruby
|
|
@@ -674,6 +688,8 @@ class ApplicationMailer < ActionMailer::Base
|
|
|
674
688
|
|
|
675
689
|
When a process dies without a clean shutdown (for example, `SIGKILL`ed by the OS or the container runtime because of memory limits), the jobs it was running can't be released back to their queues. Once another process notices the missing heartbeats and prunes the dead process's registration, its in-flight jobs are marked as failed with `SolidQueue::Processes::ProcessPrunedError`. Solid Queue deliberately doesn't retry these automatically: the job itself might be what's killing the process (for example, a job that exhausts the container's memory), and retrying it blindly would just kill the next worker too.
|
|
676
690
|
|
|
691
|
+
Note that Active Job's `retry_on` and `rescue_from` have no effect on these errors: they only intercept exceptions raised while your job's `perform` method runs, and `ProcessPrunedError` (as well as `ProcessExitError` and `ProcessMissingError`) is never raised inside the job. The process that was running the job is gone by then — the error is recorded directly as a failed execution by a *different* process, after the fact, so there's no job execution left for Active Job's retry machinery to hook into. To retry these jobs, act on the failed executions from the outside instead: manually, via [Mission Control — Jobs](https://github.com/rails/mission_control-jobs), or automatically, with a subscription like the one below.
|
|
692
|
+
|
|
677
693
|
If you know your jobs are idempotent and want to implement your own recovery policy, you can subscribe to the `fail_many_claimed.solid_queue` event, which includes the error and the affected job IDs in its payload:
|
|
678
694
|
|
|
679
695
|
```ruby
|
|
@@ -812,6 +828,8 @@ that you set in production only. This is what Rails 8's default Puma config look
|
|
|
812
828
|
|
|
813
829
|
**Note**: phased restarts are not supported currently because the plugin requires [app preloading](https://github.com/puma/puma?tab=readme-ov-file#cluster-mode) to work.
|
|
814
830
|
|
|
831
|
+
The plugin ties the two processes together: if Puma goes away, the supervisor stops, and if the supervisor exits, the plugin stops Puma so that whatever manages Puma (systemd, Kamal, your container orchestrator...) restarts both. In particular, the queue database needs to be reachable when the supervisor boots, as it registers itself and cleans up after previous runs at that point; if it isn't, the supervisor exits, and Puma with it. Once running, a transient database outage doesn't bring the supervisor down: workers and dispatchers will fail and be replaced until the database is back, and then pick up where they left off.
|
|
832
|
+
|
|
815
833
|
### Running as a fork or asynchronously
|
|
816
834
|
|
|
817
835
|
By default, the Puma plugin will fork additional processes for each worker and dispatcher so that they run in different processes. This provides the best isolation and performance, but can have additional memory usage.
|
|
@@ -911,6 +929,8 @@ It's possible to run multiple schedulers with the same `recurring_tasks` configu
|
|
|
911
929
|
|
|
912
930
|
**Note**: a single recurring schedule is supported, so you can have multiple schedulers using the same schedule, but not multiple schedulers using different configurations.
|
|
913
931
|
|
|
932
|
+
When a scheduler starts, it removes any static recurring tasks that are no longer in its configuration. If you remove all of them, though, no scheduler starts, so the old tasks stay in the `solid_queue_recurring_tasks` table, and in Mission Control, even though nothing runs them. You can delete them from a console with `SolidQueue::RecurringTask.static.delete_all`.
|
|
933
|
+
|
|
914
934
|
Finally, it's possible to configure jobs that aren't handled by Solid Queue. That is, you can have a job like this in your app:
|
|
915
935
|
```ruby
|
|
916
936
|
class MyResqueJob < ApplicationJob
|
|
@@ -47,6 +47,9 @@ module SolidQueue
|
|
|
47
47
|
if batch = Batch.select(:id, :finished_at, :enqueued_at).find_by(id: batch_id)
|
|
48
48
|
batch.finish
|
|
49
49
|
end
|
|
50
|
+
rescue => error
|
|
51
|
+
# The job is already committed; let the sweeper retry batch completion.
|
|
52
|
+
SolidQueue.instrument(:batch_progress_error, batch_id: batch_id, job_id: job_id, error: error)
|
|
50
53
|
end
|
|
51
54
|
end
|
|
52
55
|
end
|
|
@@ -46,7 +46,12 @@ module SolidQueue
|
|
|
46
46
|
def release
|
|
47
47
|
SolidQueue.instrument(:release_blocked, job_id: job.id, concurrency_key: concurrency_key, released: false) do |payload|
|
|
48
48
|
transaction do
|
|
49
|
-
if
|
|
49
|
+
if job.job_class.nil?
|
|
50
|
+
job.failed_with(Job::ClassMissingError.for(job))
|
|
51
|
+
destroy!
|
|
52
|
+
|
|
53
|
+
payload[:failed] = true
|
|
54
|
+
elsif acquire_concurrency_lock
|
|
50
55
|
promote_to_ready
|
|
51
56
|
destroy!
|
|
52
57
|
|
|
@@ -58,7 +63,8 @@ module SolidQueue
|
|
|
58
63
|
|
|
59
64
|
private
|
|
60
65
|
def set_expires_at
|
|
61
|
-
|
|
66
|
+
duration = job.job_class ? job.concurrency_duration : SolidQueue.default_concurrency_control_period
|
|
67
|
+
self.expires_at = duration.from_now
|
|
62
68
|
end
|
|
63
69
|
|
|
64
70
|
def acquire_concurrency_lock
|
|
@@ -1,126 +1,160 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
module SolidQueue
|
|
4
|
+
class ClaimedExecution < Execution
|
|
5
|
+
belongs_to :process
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
scope :orphaned, -> { where.missing(:process) }
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
class Result < Struct.new(:success, :error)
|
|
10
|
+
def success?
|
|
11
|
+
success
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Raised when a job has already run (or failed) but we couldn't update its
|
|
16
|
+
# claim/finished state because of a transient error. The claim is still held
|
|
17
|
+
# by a living worker, so it won't be recovered as orphaned unless the worker
|
|
18
|
+
# is stopped and replaced.
|
|
19
|
+
class FinalizationError < Processes::UnrecoverableError
|
|
20
|
+
def initialize(claimed_execution, cause:)
|
|
21
|
+
super("Failed to finalize claimed execution #{claimed_execution.id} (job #{claimed_execution.job_id}): #{cause.class}: #{cause.message}")
|
|
22
|
+
set_backtrace(cause.backtrace) if cause.backtrace
|
|
23
|
+
end
|
|
11
24
|
end
|
|
12
|
-
end
|
|
13
25
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
26
|
+
class << self
|
|
27
|
+
def claiming(job_ids, process_id, &block)
|
|
28
|
+
job_data = Array(job_ids).collect { |job_id| { job_id: job_id, process_id: process_id } }
|
|
17
29
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
30
|
+
SolidQueue.instrument(:claim, process_id: process_id, job_ids: job_ids) do |payload|
|
|
31
|
+
insert_all!(job_data)
|
|
32
|
+
where(job_id: job_ids, process_id: process_id).load.tap do |claimed|
|
|
33
|
+
block.call(claimed)
|
|
22
34
|
|
|
23
|
-
|
|
24
|
-
|
|
35
|
+
payload[:size] = claimed.size
|
|
36
|
+
payload[:claimed_job_ids] = claimed.map(&:job_id)
|
|
37
|
+
end
|
|
25
38
|
end
|
|
26
39
|
end
|
|
27
|
-
end
|
|
28
40
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
41
|
+
def release_all
|
|
42
|
+
SolidQueue.instrument(:release_many_claimed) do |payload|
|
|
43
|
+
includes(:job).tap do |executions|
|
|
44
|
+
executions.each(&:release)
|
|
33
45
|
|
|
34
|
-
|
|
46
|
+
payload[:size] = executions.size
|
|
47
|
+
end
|
|
35
48
|
end
|
|
36
49
|
end
|
|
37
|
-
end
|
|
38
50
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
51
|
+
def fail_all_with(error)
|
|
52
|
+
includes(:job).tap do |executions|
|
|
53
|
+
return if executions.empty?
|
|
54
|
+
|
|
55
|
+
SolidQueue.instrument(:fail_many_claimed) do |payload|
|
|
56
|
+
executions.each do |execution|
|
|
57
|
+
execution.failed_with(error)
|
|
58
|
+
end
|
|
42
59
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
60
|
+
payload[:process_ids] = executions.map(&:process_id).uniq
|
|
61
|
+
payload[:job_ids] = executions.map(&:job_id).uniq
|
|
62
|
+
payload[:size] = executions.size
|
|
63
|
+
payload[:error] = error
|
|
46
64
|
end
|
|
47
|
-
|
|
48
|
-
payload[:process_ids] = executions.map(&:process_id).uniq
|
|
49
|
-
payload[:job_ids] = executions.map(&:job_id).uniq
|
|
50
|
-
payload[:size] = executions.size
|
|
51
|
-
payload[:error] = error
|
|
52
65
|
end
|
|
53
66
|
end
|
|
54
|
-
end
|
|
55
67
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
68
|
+
def discard_all_in_batches(*)
|
|
69
|
+
raise SolidQueue::Execution::UndiscardableError, "Can't discard jobs in progress"
|
|
70
|
+
end
|
|
59
71
|
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
def discard_all_from_jobs(*)
|
|
73
|
+
raise SolidQueue::Execution::UndiscardableError, "Can't discard jobs in progress"
|
|
74
|
+
end
|
|
62
75
|
end
|
|
63
|
-
end
|
|
64
76
|
|
|
65
|
-
|
|
66
|
-
|
|
77
|
+
def perform
|
|
78
|
+
result = execute
|
|
67
79
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
80
|
+
if result.success?
|
|
81
|
+
finalizing { finished }
|
|
82
|
+
else
|
|
83
|
+
finalizing { failed_with(result.error) }
|
|
84
|
+
raise result.error
|
|
85
|
+
end
|
|
73
86
|
end
|
|
74
|
-
end
|
|
75
87
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
88
|
+
def release
|
|
89
|
+
SolidQueue.instrument(:release_claimed, job_id: job.id, process_id: process_id) do
|
|
90
|
+
unless_already_finalized do
|
|
91
|
+
job.dispatch_bypassing_concurrency_limits
|
|
92
|
+
destroy!
|
|
93
|
+
end
|
|
81
94
|
end
|
|
82
95
|
end
|
|
83
|
-
end
|
|
84
96
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def failed_with(error)
|
|
90
|
-
finalize { job.failed_with(error) }
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
private
|
|
94
|
-
def execute
|
|
95
|
-
ActiveJob::Base.execute(job.arguments.merge("provider_job_id" => job.id))
|
|
96
|
-
Result.new(true, nil)
|
|
97
|
-
rescue Exception => e
|
|
98
|
-
Result.new(false, e)
|
|
97
|
+
def discard
|
|
98
|
+
raise UndiscardableError, "Can't discard a job in progress"
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
-
def
|
|
102
|
-
finalize { job.
|
|
101
|
+
def failed_with(error)
|
|
102
|
+
finalize { job.failed_with(error) }
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
private
|
|
106
|
+
# A failure here means the job already ran but we couldn't record the
|
|
107
|
+
# outcome, and the claim is still held by this living worker, where no
|
|
108
|
+
# recovery can reach it: it's a process problem, not a job problem
|
|
109
|
+
def finalizing
|
|
107
110
|
yield
|
|
108
|
-
|
|
109
|
-
|
|
111
|
+
rescue => error
|
|
112
|
+
raise FinalizationError.new(self, cause: error) if still_claimed?
|
|
113
|
+
|
|
114
|
+
raise
|
|
110
115
|
end
|
|
111
116
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
# a job that already finished or failed. Only the actor that owned and
|
|
115
|
-
# finalized the claim gets here, so the lock is released exactly once.
|
|
116
|
-
job.unblock_next_blocked_job if finalized
|
|
117
|
-
end
|
|
117
|
+
def execute
|
|
118
|
+
raise Job::ClassMissingError.for(job) if job.job_class.nil?
|
|
118
119
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
ActiveJob::Base.execute(job.arguments.merge("provider_job_id" => job.id))
|
|
121
|
+
Result.new(true, nil)
|
|
122
|
+
rescue Exception => e
|
|
123
|
+
Result.new(false, e)
|
|
124
|
+
end
|
|
122
125
|
|
|
123
|
-
|
|
126
|
+
def finished
|
|
127
|
+
finalize { job.finished! }
|
|
124
128
|
end
|
|
125
|
-
|
|
129
|
+
|
|
130
|
+
def finalize
|
|
131
|
+
finalized = unless_already_finalized do
|
|
132
|
+
yield
|
|
133
|
+
destroy!
|
|
134
|
+
true
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Unblock the next job outside the finalize transaction so a failure while
|
|
138
|
+
# releasing the concurrency lock or dispatching the next job can't roll back
|
|
139
|
+
# a job that already finished or failed. Only the actor that owned and
|
|
140
|
+
# finalized the claim gets here, so the lock is released exactly once.
|
|
141
|
+
job.unblock_next_blocked_job if finalized
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def unless_already_finalized
|
|
145
|
+
transaction do
|
|
146
|
+
return false unless self.class.unscoped.lock.find_by(id: id)
|
|
147
|
+
|
|
148
|
+
yield
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def still_claimed?
|
|
153
|
+
self.class.exists?(id)
|
|
154
|
+
rescue
|
|
155
|
+
# If we can't check because the DB is unavailable, assume the claim is
|
|
156
|
+
# still held so the worker can be stopped and replaced.
|
|
157
|
+
true
|
|
158
|
+
end
|
|
159
|
+
end
|
|
126
160
|
end
|
|
@@ -28,13 +28,15 @@ module SolidQueue
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def discard_all_in_batches(batch_size: 500)
|
|
31
|
-
|
|
31
|
+
# Newly unblocked executions must survive later discard batches.
|
|
32
|
+
last_id = maximum(:id) || 0
|
|
33
|
+
pending = where(id: ..last_id).count
|
|
32
34
|
discarded = 0
|
|
33
35
|
|
|
34
36
|
SolidQueue.instrument(:discard_all, batch_size: batch_size, status: type, batches: 0, size: 0) do |payload|
|
|
35
37
|
loop do
|
|
36
38
|
transaction do
|
|
37
|
-
job_ids = limit(batch_size).order(:job_id).lock.pluck(:job_id)
|
|
39
|
+
job_ids = where(id: ..last_id).limit(batch_size).order(:job_id).lock.pluck(:job_id)
|
|
38
40
|
discarded = discard_jobs job_ids
|
|
39
41
|
|
|
40
42
|
where(job_id: job_ids).delete_all
|
|
@@ -13,7 +13,10 @@ module SolidQueue
|
|
|
13
13
|
def self.retry_all(jobs)
|
|
14
14
|
SolidQueue.instrument(:retry_all, jobs_size: jobs.size) do |payload|
|
|
15
15
|
transaction do
|
|
16
|
-
|
|
16
|
+
job_ids = lock_all_from_jobs(jobs)
|
|
17
|
+
Job.where(id: job_ids).find_each(&:reset_execution_counters)
|
|
18
|
+
|
|
19
|
+
payload[:size] = dispatch_jobs(job_ids)
|
|
17
20
|
end
|
|
18
21
|
end
|
|
19
22
|
end
|
|
@@ -33,11 +36,11 @@ module SolidQueue
|
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
private
|
|
36
|
-
|
|
39
|
+
TRUNCATED_MESSAGE_OMISSION = "... (truncated)"
|
|
37
40
|
|
|
38
41
|
def expand_error_details_from_exception
|
|
39
42
|
if exception
|
|
40
|
-
self.error =
|
|
43
|
+
self.error = fit_into_error_column(exception_class: exception_class_name, message: exception_message, backtrace: exception_backtrace)
|
|
41
44
|
end
|
|
42
45
|
end
|
|
43
46
|
|
|
@@ -50,32 +53,58 @@ module SolidQueue
|
|
|
50
53
|
end
|
|
51
54
|
|
|
52
55
|
def exception_backtrace
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
exception.backtrace
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Columns with a size limit, like MySQL's TEXT, reject (or truncate into invalid JSON)
|
|
60
|
+
# errors that don't fit. Drop backtrace lines first and, if that's not enough, cut the
|
|
61
|
+
# message, measuring the error as it'll be stored, after JSON escaping.
|
|
62
|
+
def fit_into_error_column(error)
|
|
63
|
+
if (limit = error_column_limit) && serialized_error_size(error) > limit
|
|
64
|
+
error[:backtrace] = truncate_backtrace(error, limit) if error[:backtrace]
|
|
65
|
+
error[:message] = truncate_message(error, limit) if serialized_error_size(error) > limit
|
|
57
66
|
end
|
|
67
|
+
|
|
68
|
+
error
|
|
58
69
|
end
|
|
59
70
|
|
|
60
|
-
def
|
|
71
|
+
def error_column_limit
|
|
61
72
|
column = self.class.connection_pool.with_connection do |connection|
|
|
62
73
|
connection.schema_cache.columns_hash(self.class.table_name)["error"]
|
|
63
74
|
end
|
|
64
75
|
|
|
65
|
-
|
|
66
|
-
|
|
76
|
+
column&.limit
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def serialized_error_size(error)
|
|
80
|
+
self.class.type_for_attribute(:error).serialize(error).bytesize
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def truncate_backtrace(error, limit)
|
|
84
|
+
# Each kept line adds its own encoded size plus a separating comma
|
|
85
|
+
available = limit - serialized_error_size(error.merge(backtrace: [])) + 1
|
|
86
|
+
|
|
87
|
+
error[:backtrace].take_while do |line|
|
|
88
|
+
(available -= serialized_error_size(line) + 1) >= 0
|
|
67
89
|
end
|
|
68
90
|
end
|
|
69
91
|
|
|
70
|
-
def
|
|
71
|
-
[].
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
92
|
+
def truncate_message(error, limit)
|
|
93
|
+
message = error[:message].to_s
|
|
94
|
+
|
|
95
|
+
# Every byte cut from the message shrinks its encoded size by at least one byte,
|
|
96
|
+
# so cutting the overflow converges in very few iterations, even with escaping
|
|
97
|
+
while message.present? && (overflow = serialized_error_size(error.merge(message: message)) - limit) > 0
|
|
98
|
+
truncate_to = message.bytesize - overflow
|
|
99
|
+
|
|
100
|
+
message = if truncate_to > TRUNCATED_MESSAGE_OMISSION.bytesize
|
|
101
|
+
message.truncate_bytes(truncate_to, omission: TRUNCATED_MESSAGE_OMISSION)
|
|
102
|
+
else
|
|
103
|
+
""
|
|
77
104
|
end
|
|
78
105
|
end
|
|
106
|
+
|
|
107
|
+
message
|
|
79
108
|
end
|
|
80
109
|
end
|
|
81
110
|
end
|
|
@@ -14,8 +14,8 @@ module SolidQueue
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
class_methods do
|
|
17
|
-
def
|
|
18
|
-
|
|
17
|
+
def unblock_next_blocked_jobs(jobs)
|
|
18
|
+
jobs.find_each(&:unblock_next_blocked_job)
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
|
|
@@ -33,6 +33,10 @@ module SolidQueue
|
|
|
33
33
|
blocked_execution.present?
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
def job_class
|
|
37
|
+
@job_class ||= class_name.safe_constantize
|
|
38
|
+
end
|
|
39
|
+
|
|
36
40
|
private
|
|
37
41
|
def concurrency_on_conflict
|
|
38
42
|
job_class.concurrency_on_conflict.to_s.inquiry
|
|
@@ -59,17 +63,13 @@ module SolidQueue
|
|
|
59
63
|
end
|
|
60
64
|
|
|
61
65
|
def block
|
|
62
|
-
BlockedExecution.create_or_find_by!(
|
|
66
|
+
BlockedExecution.create_or_find_by!(job: self)
|
|
63
67
|
end
|
|
64
68
|
|
|
65
69
|
def release_next_blocked_job
|
|
66
70
|
BlockedExecution.release_one(concurrency_key)
|
|
67
71
|
end
|
|
68
72
|
|
|
69
|
-
def job_class
|
|
70
|
-
@job_class ||= class_name.safe_constantize
|
|
71
|
-
end
|
|
72
|
-
|
|
73
73
|
def execution
|
|
74
74
|
super || blocked_execution
|
|
75
75
|
end
|
|
@@ -39,8 +39,12 @@ module SolidQueue
|
|
|
39
39
|
ReadyExecution.create_all_from_jobs jobs
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# Each job acquires its concurrency lock here, and holds it until the
|
|
43
|
+
# transaction dispatching the whole set ends. Going through them by
|
|
44
|
+
# concurrency key makes everyone dispatching at the same time acquire
|
|
45
|
+
# the locks they share in the same order, so they can't deadlock.
|
|
42
46
|
def dispatch_all_one_by_one(jobs)
|
|
43
|
-
jobs.each(&:dispatch)
|
|
47
|
+
jobs.sort_by { |job| [ job.concurrency_key, job.id ] }.each(&:dispatch)
|
|
44
48
|
end
|
|
45
49
|
|
|
46
50
|
def successfully_dispatched(jobs)
|
|
@@ -106,7 +110,7 @@ module SolidQueue
|
|
|
106
110
|
|
|
107
111
|
private
|
|
108
112
|
def ready
|
|
109
|
-
ReadyExecution.create_or_find_by!(
|
|
113
|
+
ReadyExecution.create_or_find_by!(job: self)
|
|
110
114
|
end
|
|
111
115
|
|
|
112
116
|
def execution
|
|
@@ -4,6 +4,15 @@ module SolidQueue
|
|
|
4
4
|
class Job < Record
|
|
5
5
|
class EnqueueError < StandardError; end
|
|
6
6
|
|
|
7
|
+
# Raised when a job's class can't be resolved anymore, typically because it
|
|
8
|
+
# was renamed or removed in a deploy while jobs referencing it were in
|
|
9
|
+
# flight. It subclasses NameError, which is what resolving the class raises.
|
|
10
|
+
class ClassMissingError < NameError
|
|
11
|
+
def self.for(job)
|
|
12
|
+
new("Job class #{job.class_name.inspect} could not be resolved")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
7
16
|
include Executable, Clearable, Recurrable, Batchable
|
|
8
17
|
|
|
9
18
|
serialize :arguments, coder: JSON
|
|
@@ -21,10 +21,14 @@ class SolidQueue::Process < SolidQueue::Record
|
|
|
21
21
|
|
|
22
22
|
def heartbeat
|
|
23
23
|
# Clear any previous changes before locking, for example, in case a previous heartbeat
|
|
24
|
-
# failed because of a DB issue
|
|
25
|
-
# is not rare) and we still have the unpersisted value
|
|
24
|
+
# failed because of a DB issue and we still have the unpersisted value
|
|
26
25
|
restore_attributes
|
|
27
26
|
with_lock { touch(:last_heartbeat_at) }
|
|
27
|
+
rescue
|
|
28
|
+
# touch writes the attribute before persisting; don't let a failed
|
|
29
|
+
# update leave this object claiming a heartbeat that was never persisted
|
|
30
|
+
restore_attributes
|
|
31
|
+
raise
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
def deregister(pruned: false)
|
|
@@ -34,7 +34,7 @@ module SolidQueue
|
|
|
34
34
|
def eligible_queues
|
|
35
35
|
if include_all_queues? then all_queues
|
|
36
36
|
else
|
|
37
|
-
in_raw_order(exact_names
|
|
37
|
+
in_raw_order(exact_names | prefixed_names)
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
|
|
@@ -53,12 +53,12 @@ module SolidQueue
|
|
|
53
53
|
def prefixed_names
|
|
54
54
|
if prefixes.empty? then []
|
|
55
55
|
else
|
|
56
|
-
relation.where(([ "queue_name LIKE ?" ] * prefixes.count).join(" OR "), *prefixes).distinct_values_of(:queue_name)
|
|
56
|
+
relation.where(([ "queue_name LIKE ? ESCAPE '!'" ] * prefixes.count).join(" OR "), *prefixes).distinct_values_of(:queue_name)
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def prefixes
|
|
61
|
-
@prefixes ||= raw_queues.select { |queue| prefixed_name?(queue) }.map { |queue| queue.tr("*", "%") }
|
|
61
|
+
@prefixes ||= raw_queues.select { |queue| prefixed_name?(queue) }.map { |queue| relation.sanitize_sql_like(queue, "!").tr("*", "%") }
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def exact_name?(queue)
|
|
@@ -74,9 +74,9 @@ module SolidQueue
|
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
def in_raw_order(queues)
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
if
|
|
77
|
+
# Exact names are already in configuration order. Prefix matches always
|
|
78
|
+
# go through the literal filter so a single false LIKE hit is not returned.
|
|
79
|
+
if prefixes.empty?
|
|
80
80
|
queues
|
|
81
81
|
else
|
|
82
82
|
queues = queues.dup
|
|
@@ -89,9 +89,7 @@ module SolidQueue
|
|
|
89
89
|
queues.delete(raw_queue)
|
|
90
90
|
elsif prefixed_name?(raw_queue)
|
|
91
91
|
prefix = raw_queue.tr("*", "")
|
|
92
|
-
queues.
|
|
93
|
-
queues -= matches
|
|
94
|
-
end
|
|
92
|
+
queues.extract! { |queue| queue.start_with?(prefix) }
|
|
95
93
|
end
|
|
96
94
|
end
|
|
97
95
|
end
|
|
@@ -14,10 +14,6 @@ module SolidQueue
|
|
|
14
14
|
Proxy.new(job).signal
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def signal_all(jobs)
|
|
18
|
-
Proxy.signal_all(jobs)
|
|
19
|
-
end
|
|
20
|
-
|
|
21
17
|
# Requires a unique index on key
|
|
22
18
|
def create_unique_by(attributes)
|
|
23
19
|
if supports_insert_conflict_target?
|
|
@@ -31,16 +27,6 @@ module SolidQueue
|
|
|
31
27
|
end
|
|
32
28
|
|
|
33
29
|
class Proxy
|
|
34
|
-
def self.signal_all(jobs)
|
|
35
|
-
# Guard against incrementing a semaphore's value beyond its limit. Jobs can
|
|
36
|
-
# have different limits, so group them and cap each group with `value < limit`.
|
|
37
|
-
jobs.group_by { |job| job.concurrency_limit || 1 }.each do |limit, grouped_jobs|
|
|
38
|
-
Semaphore.where(key: grouped_jobs.map(&:concurrency_key))
|
|
39
|
-
.where(value: ...limit)
|
|
40
|
-
.update_all("value = value + 1")
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
|
|
44
30
|
def initialize(job)
|
|
45
31
|
@job = job
|
|
46
32
|
end
|
|
@@ -33,6 +33,11 @@ Puma::Plugin.create do
|
|
|
33
33
|
@solid_queue_pid = fork do
|
|
34
34
|
Thread.new { monitor_puma }
|
|
35
35
|
SolidQueue::Supervisor.start(mode: :fork)
|
|
36
|
+
|
|
37
|
+
# Same as Processes::Supervised#create_fork: skip at-exit
|
|
38
|
+
# finalization, which can deadlock on database handles when a
|
|
39
|
+
# thread was killed while inside a query
|
|
40
|
+
exit!(0)
|
|
36
41
|
end
|
|
37
42
|
end
|
|
38
43
|
|
|
@@ -43,6 +48,11 @@ Puma::Plugin.create do
|
|
|
43
48
|
@solid_queue_pid = fork do
|
|
44
49
|
Thread.new { monitor_puma }
|
|
45
50
|
start_solid_queue(mode: :fork)
|
|
51
|
+
|
|
52
|
+
# Same as Processes::Supervised#create_fork: skip at-exit
|
|
53
|
+
# finalization, which can deadlock on database handles when a
|
|
54
|
+
# thread was killed while inside a query
|
|
55
|
+
exit!(0)
|
|
46
56
|
end
|
|
47
57
|
end
|
|
48
58
|
|
|
@@ -129,5 +139,9 @@ Puma::Plugin.create do
|
|
|
129
139
|
|
|
130
140
|
def log(...)
|
|
131
141
|
log_writer.log(...)
|
|
142
|
+
rescue Errno::EIO, Errno::EPIPE, Errno::EBADF
|
|
143
|
+
# The controlling terminal can disappear, or the output pipe close, before
|
|
144
|
+
# the monitor thread shuts down the child process. Keep shutdown moving
|
|
145
|
+
# even when logging cannot.
|
|
132
146
|
end
|
|
133
147
|
end
|
|
@@ -27,8 +27,7 @@ module SolidQueue
|
|
|
27
27
|
if (instance = process_instances.delete(thread_id))
|
|
28
28
|
payload[:thread] = instance
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
release_claimed_jobs_by(instance, with_error: error)
|
|
30
|
+
attempt_to_release_claimed_jobs_by(instance, with_error: Processes::ThreadTerminatedError.new(instance.name))
|
|
32
31
|
|
|
33
32
|
start_process(configured_processes.delete(thread_id))
|
|
34
33
|
end
|
|
@@ -39,12 +38,26 @@ module SolidQueue
|
|
|
39
38
|
process_instances.values.each(&:stop)
|
|
40
39
|
|
|
41
40
|
Timer.wait_until(SolidQueue.shutdown_timeout, -> { all_processes_terminated? })
|
|
41
|
+
|
|
42
|
+
# The exit! that follows skips the shutdown callbacks, so release the jobs
|
|
43
|
+
# of the threads still running now instead of leaving them to be pruned
|
|
44
|
+
attempt_to_deregister unless all_processes_terminated?
|
|
42
45
|
end
|
|
43
46
|
|
|
44
47
|
def perform_immediate_termination
|
|
45
48
|
exit!
|
|
46
49
|
end
|
|
47
50
|
|
|
51
|
+
def attempt_to_deregister
|
|
52
|
+
wrap_in_app_executor do
|
|
53
|
+
# Not our own copy: it shares the threads' registrations in memory, and
|
|
54
|
+
# those of threads that already deregistered are destroyed and frozen
|
|
55
|
+
SolidQueue::Process.find_by(id: process_id)&.deregister
|
|
56
|
+
end
|
|
57
|
+
rescue StandardError => error
|
|
58
|
+
handle_thread_error(error)
|
|
59
|
+
end
|
|
60
|
+
|
|
48
61
|
def all_processes_terminated?
|
|
49
62
|
process_instances.values.none?(&:alive?)
|
|
50
63
|
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidQueue
|
|
4
4
|
class FiberPool < Pool
|
|
5
|
-
def initialize(size, on_idle: nil)
|
|
5
|
+
def initialize(size, on_idle: nil, on_unrecoverable_error: nil)
|
|
6
6
|
super
|
|
7
7
|
|
|
8
8
|
@state_mutex = Mutex.new
|
|
@@ -108,6 +108,7 @@ module SolidQueue
|
|
|
108
108
|
handle_thread_error(error)
|
|
109
109
|
register_fatal_error(error)
|
|
110
110
|
rescue Exception => error
|
|
111
|
+
handle_unrecoverable_error(error)
|
|
111
112
|
handle_thread_error(error)
|
|
112
113
|
ensure
|
|
113
114
|
restore_capacity
|
|
@@ -57,7 +57,7 @@ module SolidQueue
|
|
|
57
57
|
terminated_fork.mark_as_reaped
|
|
58
58
|
|
|
59
59
|
if !status.exited? || status.exitstatus.to_i > 0
|
|
60
|
-
attempt_to_release_claimed_jobs_by(terminated_fork, status)
|
|
60
|
+
attempt_to_release_claimed_jobs_by(terminated_fork, with_error: Processes::ProcessExitError.new(status))
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
63
|
|
|
@@ -73,23 +73,13 @@ module SolidQueue
|
|
|
73
73
|
terminated_fork.mark_as_reaped
|
|
74
74
|
payload[:fork] = terminated_fork
|
|
75
75
|
|
|
76
|
-
attempt_to_release_claimed_jobs_by(terminated_fork, status)
|
|
76
|
+
attempt_to_release_claimed_jobs_by(terminated_fork, with_error: Processes::ProcessExitError.new(status))
|
|
77
77
|
|
|
78
78
|
start_process(configured_processes.delete(pid))
|
|
79
79
|
end
|
|
80
80
|
end
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
-
# The database may be unreachable — likely the same reason the fork
|
|
84
|
-
# terminated. Neither starting a replacement nor shutting down can depend
|
|
85
|
-
# on it: the jobs claimed by the terminated fork will be failed when its
|
|
86
|
-
# stale registration is pruned once the database is back.
|
|
87
|
-
def attempt_to_release_claimed_jobs_by(terminated_fork, status)
|
|
88
|
-
release_claimed_jobs_by(terminated_fork, with_error: Processes::ProcessExitError.new(status))
|
|
89
|
-
rescue StandardError => error
|
|
90
|
-
handle_thread_error(error)
|
|
91
|
-
end
|
|
92
|
-
|
|
93
83
|
def all_processes_terminated?
|
|
94
84
|
process_instances.empty?
|
|
95
85
|
end
|
|
@@ -59,7 +59,7 @@ class SolidQueue::LogSubscriber < ActiveSupport::LogSubscriber
|
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
def release_blocked(event)
|
|
62
|
-
debug formatted_event(event, action: "Release blocked job", **event.payload.slice(:job_id, :concurrency_key, :released))
|
|
62
|
+
debug formatted_event(event, action: "Release blocked job", **event.payload.slice(:job_id, :concurrency_key, :released, :failed))
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def enqueue_recurring_task(event)
|
data/lib/solid_queue/pool.rb
CHANGED
|
@@ -4,15 +4,20 @@ module SolidQueue
|
|
|
4
4
|
class Pool
|
|
5
5
|
include AppExecutor
|
|
6
6
|
|
|
7
|
-
def self.build(type:, size:, on_idle: nil)
|
|
8
|
-
SolidQueue.const_get("#{type.to_s.camelize}Pool").new(
|
|
7
|
+
def self.build(type:, size:, on_idle: nil, on_unrecoverable_error: nil)
|
|
8
|
+
SolidQueue.const_get("#{type.to_s.camelize}Pool").new(
|
|
9
|
+
size,
|
|
10
|
+
on_idle: on_idle,
|
|
11
|
+
on_unrecoverable_error: on_unrecoverable_error
|
|
12
|
+
)
|
|
9
13
|
end
|
|
10
14
|
|
|
11
15
|
attr_reader :size
|
|
12
16
|
|
|
13
|
-
def initialize(size, on_idle: nil)
|
|
17
|
+
def initialize(size, on_idle: nil, on_unrecoverable_error: nil)
|
|
14
18
|
@size = size
|
|
15
19
|
@on_idle = on_idle
|
|
20
|
+
@on_unrecoverable_error = on_unrecoverable_error
|
|
16
21
|
@available_capacity = size
|
|
17
22
|
@mutex = Mutex.new
|
|
18
23
|
end
|
|
@@ -41,7 +46,7 @@ module SolidQueue
|
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
private
|
|
44
|
-
attr_reader :mutex, :on_idle
|
|
49
|
+
attr_reader :mutex, :on_idle, :on_unrecoverable_error
|
|
45
50
|
|
|
46
51
|
def schedule(execution)
|
|
47
52
|
raise NotImplementedError
|
|
@@ -50,11 +55,20 @@ module SolidQueue
|
|
|
50
55
|
def perform_execution(execution)
|
|
51
56
|
wrap_in_app_executor { execution.perform }
|
|
52
57
|
rescue Exception => error
|
|
58
|
+
handle_unrecoverable_error(error)
|
|
53
59
|
handle_thread_error(error)
|
|
54
60
|
ensure
|
|
55
61
|
restore_capacity
|
|
56
62
|
end
|
|
57
63
|
|
|
64
|
+
def handle_unrecoverable_error(error)
|
|
65
|
+
return unless error.is_a?(Processes::UnrecoverableError)
|
|
66
|
+
|
|
67
|
+
# Only signal shutdown — do not join the worker from this pool thread,
|
|
68
|
+
# or wait_for_termination during worker shutdown would deadlock.
|
|
69
|
+
on_unrecoverable_error&.call
|
|
70
|
+
end
|
|
71
|
+
|
|
58
72
|
def reserve_capacity!
|
|
59
73
|
mutex.synchronize do
|
|
60
74
|
raise RuntimeError, "Execution pool is at capacity" if @available_capacity <= 0
|
|
@@ -56,6 +56,26 @@ module SolidQueue::Processes
|
|
|
56
56
|
def heartbeat
|
|
57
57
|
process&.heartbeat
|
|
58
58
|
rescue ActiveRecord::RecordNotFound
|
|
59
|
+
# Our registration is gone: a supervisor pruned it
|
|
60
|
+
stop_to_be_replaced
|
|
61
|
+
rescue => error
|
|
62
|
+
# Errors like a dropped database connection prevent the
|
|
63
|
+
# heartbeat from going through, and even from finding out whether the
|
|
64
|
+
# registration is still there
|
|
65
|
+
stop_to_be_replaced if presumed_dead?
|
|
66
|
+
raise error
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Whether this process's registration is prunable: if the last heartbeat that
|
|
70
|
+
# we were able to persist is older than the alive threshold, supervisors
|
|
71
|
+
# consider this one dead and would have pruned its registration by now
|
|
72
|
+
def presumed_dead?
|
|
73
|
+
process && process.last_heartbeat_at <= SolidQueue.process_alive_threshold.ago
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Deregister locally and wake the run loop, which stops when
|
|
77
|
+
# unregistered, so the supervisor replaces this process
|
|
78
|
+
def stop_to_be_replaced
|
|
59
79
|
self.process = nil
|
|
60
80
|
wake_up
|
|
61
81
|
end
|
|
@@ -29,6 +29,14 @@ module SolidQueue::Processes
|
|
|
29
29
|
fork do
|
|
30
30
|
register_signal_handlers
|
|
31
31
|
block.call
|
|
32
|
+
|
|
33
|
+
# Exit skipping at-exit hooks and finalizers, like Puma's cluster
|
|
34
|
+
# workers do: Ruby would finalize every object still alive in the
|
|
35
|
+
# fork, including SQLite database handles whose mutex can be left
|
|
36
|
+
# locked when a thread is killed while waiting in SQLite's busy
|
|
37
|
+
# handler, deadlocking the exit. Everything the process needs to do
|
|
38
|
+
# on shutdown has already run by now.
|
|
39
|
+
exit!(0)
|
|
32
40
|
end
|
|
33
41
|
end
|
|
34
42
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidQueue
|
|
4
|
+
module Processes
|
|
5
|
+
# Errors that mean the process raising them can no longer account for the
|
|
6
|
+
# work it holds, so it should stop and let its supervisor replace it
|
|
7
|
+
class UnrecoverableError < RuntimeError; end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -10,6 +10,7 @@ module SolidQueue
|
|
|
10
10
|
@static_tasks = Array(static_tasks).map { |task| RecurringTask.wrap(task) }.select(&:valid?)
|
|
11
11
|
@dynamic_tasks_enabled = dynamic_tasks_enabled
|
|
12
12
|
|
|
13
|
+
@schedule_lock = Mutex.new
|
|
13
14
|
@scheduled_tasks = Concurrent::Hash.new
|
|
14
15
|
end
|
|
15
16
|
|
|
@@ -28,18 +29,16 @@ module SolidQueue
|
|
|
28
29
|
reload_dynamic_tasks
|
|
29
30
|
end
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
schedule_task(task)
|
|
32
|
+
schedule_lock.synchronize do
|
|
33
|
+
configured_tasks.each { |task| schedule_task(task) }
|
|
33
34
|
end
|
|
34
35
|
end
|
|
35
36
|
|
|
36
|
-
def schedule_task(task, run_at: task.next_time)
|
|
37
|
-
scheduled_tasks[task.key] = schedule(task, run_at: run_at)
|
|
38
|
-
end
|
|
39
|
-
|
|
40
37
|
def unschedule_tasks
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
schedule_lock.synchronize do
|
|
39
|
+
scheduled_tasks.values.each(&:cancel)
|
|
40
|
+
scheduled_tasks.clear
|
|
41
|
+
end
|
|
43
42
|
end
|
|
44
43
|
|
|
45
44
|
def task_keys
|
|
@@ -49,13 +48,16 @@ module SolidQueue
|
|
|
49
48
|
def reschedule_dynamic_tasks
|
|
50
49
|
wrap_in_app_executor do
|
|
51
50
|
reload_dynamic_tasks
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
|
|
52
|
+
schedule_lock.synchronize do
|
|
53
|
+
schedule_created_dynamic_tasks
|
|
54
|
+
unschedule_deleted_dynamic_tasks
|
|
55
|
+
end
|
|
54
56
|
end
|
|
55
57
|
end
|
|
56
58
|
|
|
57
59
|
private
|
|
58
|
-
attr_reader :static_tasks
|
|
60
|
+
attr_reader :static_tasks, :schedule_lock
|
|
59
61
|
|
|
60
62
|
def static_task_keys
|
|
61
63
|
static_tasks.map(&:key)
|
|
@@ -69,19 +71,22 @@ module SolidQueue
|
|
|
69
71
|
@dynamic_tasks_enabled
|
|
70
72
|
end
|
|
71
73
|
|
|
74
|
+
# Scheduled tasks are tracked by record id, so a dynamic task that's deleted and
|
|
75
|
+
# created again with the same key is unscheduled and scheduled as a new one.
|
|
72
76
|
def schedule_created_dynamic_tasks
|
|
73
|
-
|
|
74
|
-
schedule_task(task)
|
|
75
|
-
end
|
|
77
|
+
dynamic_tasks.reject { |task| scheduled_tasks.key?(task.id) }.each { |task| schedule_task(task) }
|
|
76
78
|
end
|
|
77
79
|
|
|
78
80
|
def unschedule_deleted_dynamic_tasks
|
|
79
|
-
(scheduled_tasks.keys -
|
|
80
|
-
scheduled_tasks
|
|
81
|
-
scheduled_tasks.delete(key)
|
|
81
|
+
(scheduled_tasks.keys - static_tasks.map(&:id) - dynamic_tasks.map(&:id)).each do |id|
|
|
82
|
+
scheduled_tasks.delete(id).cancel
|
|
82
83
|
end
|
|
83
84
|
end
|
|
84
85
|
|
|
86
|
+
def schedule_task(task, run_at: task.next_time)
|
|
87
|
+
scheduled_tasks[task.id] = schedule(task, run_at: run_at)
|
|
88
|
+
end
|
|
89
|
+
|
|
85
90
|
def persist_static_tasks
|
|
86
91
|
RecurringTask.static.where.not(key: static_task_keys).delete_all
|
|
87
92
|
RecurringTask.create_or_update_all static_tasks
|
|
@@ -102,8 +107,8 @@ module SolidQueue
|
|
|
102
107
|
def schedule(task, run_at: task.next_time)
|
|
103
108
|
delay = [ (run_at - Time.current).to_f, 0.1 ].max
|
|
104
109
|
|
|
105
|
-
scheduled_task = Concurrent::ScheduledTask.new(delay, args: [
|
|
106
|
-
|
|
110
|
+
scheduled_task = Concurrent::ScheduledTask.new(delay, args: [ task, run_at ]) do |thread_task, thread_task_run_at|
|
|
111
|
+
schedule_next_run(thread_task, run_at: thread_task.next_time_after(thread_task_run_at))
|
|
107
112
|
|
|
108
113
|
wrap_in_app_executor do
|
|
109
114
|
thread_task.enqueue(at: thread_task_run_at)
|
|
@@ -118,5 +123,12 @@ module SolidQueue
|
|
|
118
123
|
|
|
119
124
|
scheduled_task.tap(&:execute)
|
|
120
125
|
end
|
|
126
|
+
|
|
127
|
+
# Only schedule the next run if the task wasn't unscheduled in the meantime
|
|
128
|
+
def schedule_next_run(task, run_at:)
|
|
129
|
+
schedule_lock.synchronize do
|
|
130
|
+
schedule_task(task, run_at: run_at) if scheduled_tasks.key?(task.id)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
121
133
|
end
|
|
122
134
|
end
|
|
@@ -43,5 +43,15 @@ module SolidQueue
|
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
45
|
end
|
|
46
|
+
|
|
47
|
+
# The database may be unreachable — possibly the same reason the
|
|
48
|
+
# supervised process died. Neither starting a replacement nor shutting
|
|
49
|
+
# down can depend on it: the jobs claimed by the dead process will be
|
|
50
|
+
# failed when its stale registration is pruned once the database is back.
|
|
51
|
+
def attempt_to_release_claimed_jobs_by(terminated_process, with_error:)
|
|
52
|
+
release_claimed_jobs_by(terminated_process, with_error: with_error)
|
|
53
|
+
rescue StandardError => error
|
|
54
|
+
handle_thread_error(error)
|
|
55
|
+
end
|
|
46
56
|
end
|
|
47
57
|
end
|
|
@@ -17,6 +17,7 @@ module SolidQueue
|
|
|
17
17
|
end.on_rejection! do |error|
|
|
18
18
|
# Backstop for errors raised outside perform_execution's own rescue,
|
|
19
19
|
# such as when restoring capacity or waking up the worker
|
|
20
|
+
handle_unrecoverable_error(error)
|
|
20
21
|
handle_thread_error(error)
|
|
21
22
|
end
|
|
22
23
|
end
|
data/lib/solid_queue/version.rb
CHANGED
data/lib/solid_queue/worker.rb
CHANGED
|
@@ -22,7 +22,8 @@ module SolidQueue
|
|
|
22
22
|
@pool = Pool.build \
|
|
23
23
|
type: execution_pool_type,
|
|
24
24
|
size: execution_pool_size,
|
|
25
|
-
on_idle: -> { wake_up }
|
|
25
|
+
on_idle: -> { wake_up },
|
|
26
|
+
on_unrecoverable_error: -> { request_termination }
|
|
26
27
|
|
|
27
28
|
super(**options)
|
|
28
29
|
end
|
|
@@ -48,6 +49,14 @@ module SolidQueue
|
|
|
48
49
|
end
|
|
49
50
|
end
|
|
50
51
|
|
|
52
|
+
def request_termination
|
|
53
|
+
# Signal the poller to shut down without joining from the pool thread.
|
|
54
|
+
# Runnable#stop joins when unsupervised, which would deadlock once
|
|
55
|
+
# shutdown waits for this pool thread to finish.
|
|
56
|
+
@stopped = true
|
|
57
|
+
wake_up
|
|
58
|
+
end
|
|
59
|
+
|
|
51
60
|
def shutdown
|
|
52
61
|
pool.shutdown
|
|
53
62
|
pool.wait_for_termination(SolidQueue.shutdown_timeout)
|
data/lib/solid_queue.rb
CHANGED
|
@@ -16,6 +16,8 @@ loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
|
|
|
16
16
|
loader.ignore("#{__dir__}/solid_queue/tasks.rb")
|
|
17
17
|
loader.ignore("#{__dir__}/generators")
|
|
18
18
|
loader.ignore("#{__dir__}/puma")
|
|
19
|
+
# Only bin/jobs uses the CLI, and requires it. Eager loading it would load Thor in every process.
|
|
20
|
+
loader.do_not_eager_load("#{__dir__}/solid_queue/cli.rb")
|
|
19
21
|
loader.setup
|
|
20
22
|
|
|
21
23
|
module SolidQueue
|
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.7.
|
|
4
|
+
version: 1.7.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rosa Gutierrez
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-11-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -361,6 +361,7 @@ files:
|
|
|
361
361
|
- lib/solid_queue/processes/runnable.rb
|
|
362
362
|
- lib/solid_queue/processes/supervised.rb
|
|
363
363
|
- lib/solid_queue/processes/thread_terminated_error.rb
|
|
364
|
+
- lib/solid_queue/processes/unrecoverable_error.rb
|
|
364
365
|
- lib/solid_queue/scheduler.rb
|
|
365
366
|
- lib/solid_queue/scheduler/recurring_schedule.rb
|
|
366
367
|
- lib/solid_queue/supervisor.rb
|