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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15044fdadc1f5170f909ebfeeb3f0d31e948bb51edb336aa040ad477b9d12545
4
- data.tar.gz: 566ab7ad221caa7c96ea1f755c8e5537ac4e37304afbb7ced6697eb1a120033b
3
+ metadata.gz: d55976c5a8d2a3e411cfb10099c0bbbeedc4652fcb02edfb8474b31b793c164c
4
+ data.tar.gz: 5a4a9ddffa329ef4ff6feb7fb642f18baaae637d8248f55bfa739d56a7f16824
5
5
  SHA512:
6
- metadata.gz: 0744c1361eddc92f7cc24f26d3ca6194c7a06474cc2e3a287b3935e86b6e4dbb88c595b70ef63327431f687b8578770805a68cb33729aecb1ef7ca895b3d8e07
7
- data.tar.gz: 2a40a741c8269c3a31baec31970addfc268d65f9d3bdc6130b75a1e908f22ecce2a0786b34c8dfd6c88cb92a024ca3fb84709f6d1c79e4f00022880ebd55056f
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 acquire_concurrency_lock
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
- self.expires_at = job.concurrency_duration.from_now
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
- class SolidQueue::ClaimedExecution < SolidQueue::Execution
4
- belongs_to :process
3
+ module SolidQueue
4
+ class ClaimedExecution < Execution
5
+ belongs_to :process
5
6
 
6
- scope :orphaned, -> { where.missing(:process) }
7
+ scope :orphaned, -> { where.missing(:process) }
7
8
 
8
- class Result < Struct.new(:success, :error)
9
- def success?
10
- success
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
- class << self
15
- def claiming(job_ids, process_id, &block)
16
- job_data = Array(job_ids).collect { |job_id| { job_id: job_id, process_id: process_id } }
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
- SolidQueue.instrument(:claim, process_id: process_id, job_ids: job_ids) do |payload|
19
- insert_all!(job_data)
20
- where(job_id: job_ids, process_id: process_id).load.tap do |claimed|
21
- block.call(claimed)
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
- payload[:size] = claimed.size
24
- payload[:claimed_job_ids] = claimed.map(&:job_id)
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
- def release_all
30
- SolidQueue.instrument(:release_many_claimed) do |payload|
31
- includes(:job).tap do |executions|
32
- executions.each(&:release)
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
- payload[:size] = executions.size
46
+ payload[:size] = executions.size
47
+ end
35
48
  end
36
49
  end
37
- end
38
50
 
39
- def fail_all_with(error)
40
- includes(:job).tap do |executions|
41
- return if executions.empty?
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
- SolidQueue.instrument(:fail_many_claimed) do |payload|
44
- executions.each do |execution|
45
- execution.failed_with(error)
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
- def discard_all_in_batches(*)
57
- raise UndiscardableError, "Can't discard jobs in progress"
58
- end
68
+ def discard_all_in_batches(*)
69
+ raise SolidQueue::Execution::UndiscardableError, "Can't discard jobs in progress"
70
+ end
59
71
 
60
- def discard_all_from_jobs(*)
61
- raise UndiscardableError, "Can't discard jobs in progress"
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
- def perform
66
- result = execute
77
+ def perform
78
+ result = execute
67
79
 
68
- if result.success?
69
- finished
70
- else
71
- failed_with(result.error)
72
- raise result.error
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
- def release
77
- SolidQueue.instrument(:release_claimed, job_id: job.id, process_id: process_id) do
78
- unless_already_finalized do
79
- job.dispatch_bypassing_concurrency_limits
80
- destroy!
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
- def discard
86
- raise UndiscardableError, "Can't discard a job in progress"
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 finished
102
- finalize { job.finished! }
101
+ def failed_with(error)
102
+ finalize { job.failed_with(error) }
103
103
  end
104
104
 
105
- def finalize
106
- finalized = unless_already_finalized do
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
- destroy!
109
- true
111
+ rescue => error
112
+ raise FinalizationError.new(self, cause: error) if still_claimed?
113
+
114
+ raise
110
115
  end
111
116
 
112
- # Unblock the next job outside the finalize transaction so a failure while
113
- # releasing the concurrency lock or dispatching the next job can't roll back
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
- def unless_already_finalized
120
- transaction do
121
- return false unless self.class.unscoped.lock.find_by(id: id)
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
- yield
126
+ def finished
127
+ finalize { job.finished! }
124
128
  end
125
- end
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
- pending = count
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
- payload[:size] = dispatch_jobs lock_all_from_jobs(jobs)
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
- JSON_OVERHEAD = 256
39
+ TRUNCATED_MESSAGE_OMISSION = "... (truncated)"
37
40
 
38
41
  def expand_error_details_from_exception
39
42
  if exception
40
- self.error = { exception_class: exception_class_name, message: exception_message, backtrace: exception_backtrace }
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
- if (limit = determine_backtrace_size_limit) && exception.backtrace.to_json.bytesize > limit
54
- truncate_backtrace(exception.backtrace, limit)
55
- else
56
- exception.backtrace
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 determine_backtrace_size_limit
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
- if column && column.limit.present?
66
- column.limit - exception_class_name.bytesize - exception_message.bytesize - JSON_OVERHEAD
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 truncate_backtrace(lines, limit)
71
- [].tap do |truncated_backtrace|
72
- lines.each do |line|
73
- if (truncated_backtrace << line).to_json.bytesize > limit
74
- truncated_backtrace.pop
75
- break
76
- end
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 release_all_concurrency_locks(jobs)
18
- Semaphore.signal_all(jobs.select(&:concurrency_limited?))
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!(job_id: id)
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!(job_id: id)
113
+ ReadyExecution.create_or_find_by!(job: self)
110
114
  end
111
115
 
112
116
  def execution
@@ -38,7 +38,7 @@ module SolidQueue
38
38
 
39
39
  private
40
40
  def schedule
41
- ScheduledExecution.create_or_find_by!(job_id: id)
41
+ ScheduledExecution.create_or_find_by!(job: self)
42
42
  end
43
43
 
44
44
  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 (with SQLite depending on configuration, a BusyException
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 + prefixed_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
- # Only need to sort if we have prefixes and more than one queue name.
78
- # Exact names are selected in the same order as they're found
79
- if queues.one? || prefixes.empty?
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.select { |queue| queue.start_with?(prefix) }.tap do |matches|
93
- queues -= matches
94
- end
92
+ queues.extract! { |queue| queue.start_with?(prefix) }
95
93
  end
96
94
  end
97
95
  end
@@ -45,7 +45,7 @@ module SolidQueue
45
45
 
46
46
 
47
47
  def discard_jobs(job_ids)
48
- Job.release_all_concurrency_locks Job.where(id: job_ids)
48
+ Job.unblock_next_blocked_jobs Job.where(id: job_ids)
49
49
  super
50
50
  end
51
51
  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
- error = Processes::ThreadTerminatedError.new(instance.name)
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)
@@ -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(size, on_idle: on_idle)
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
- configured_tasks.each do |task|
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
- scheduled_tasks.values.each(&:cancel)
42
- scheduled_tasks.clear
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
- schedule_created_dynamic_tasks
53
- unschedule_deleted_dynamic_tasks
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
- RecurringTask.dynamic.where.not(key: scheduled_tasks.keys).each do |task|
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 - RecurringTask.pluck(:key)).each do |key|
80
- scheduled_tasks[key].cancel
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: [ self, task, run_at ]) do |thread_schedule, thread_task, thread_task_run_at|
106
- thread_schedule.schedule_task(thread_task, run_at: thread_task.next_time_after(thread_task_run_at))
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
@@ -1,5 +1,5 @@
1
1
  module SolidQueue
2
- VERSION = "1.7.0"
2
+ VERSION = "1.7.1"
3
3
 
4
4
  def self.next_major_version
5
5
  Gem::Version.new(VERSION).segments.first + 1
@@ -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.0
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-08-21 00:00:00.000000000 Z
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