solid_queue 1.2.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +112 -32
  3. data/app/models/solid_queue/blocked_execution.rb +3 -1
  4. data/app/models/solid_queue/claimed_execution.rb +4 -2
  5. data/app/models/solid_queue/failed_execution.rb +6 -3
  6. data/app/models/solid_queue/job/concurrency_controls.rb +1 -1
  7. data/app/models/solid_queue/job/retryable.rb +10 -1
  8. data/app/models/solid_queue/job.rb +1 -0
  9. data/app/models/solid_queue/ready_execution.rb +2 -1
  10. data/app/models/solid_queue/record.rb +20 -5
  11. data/app/models/solid_queue/recurring_execution.rb +1 -1
  12. data/app/models/solid_queue/recurring_task.rb +27 -9
  13. data/app/models/solid_queue/semaphore.rb +2 -2
  14. data/lib/puma/plugin/solid_queue.rb +74 -14
  15. data/lib/solid_queue/app_executor.rb +10 -0
  16. data/lib/solid_queue/async_supervisor.rb +52 -0
  17. data/lib/solid_queue/cli.rb +5 -1
  18. data/lib/solid_queue/configuration.rb +42 -8
  19. data/lib/solid_queue/dispatcher.rb +1 -0
  20. data/lib/solid_queue/fork_supervisor.rb +68 -0
  21. data/lib/solid_queue/processes/registrable.rb +15 -9
  22. data/lib/solid_queue/processes/runnable.rb +25 -18
  23. data/lib/solid_queue/processes/thread_terminated_error.rb +11 -0
  24. data/lib/solid_queue/scheduler/recurring_schedule.rb +61 -11
  25. data/lib/solid_queue/scheduler.rb +23 -4
  26. data/lib/solid_queue/supervisor/maintenance.rb +11 -0
  27. data/lib/solid_queue/supervisor/signals.rb +2 -2
  28. data/lib/solid_queue/supervisor.rb +40 -82
  29. data/lib/solid_queue/timer.rb +3 -3
  30. data/lib/solid_queue/version.rb +1 -1
  31. data/lib/solid_queue.rb +8 -0
  32. metadata +25 -9
  33. data/Rakefile +0 -43
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9066bd5266e43075385bfd3365de2512400960f5cfa7f780dba69e4a3259c07a
4
- data.tar.gz: 0a7103f485e445563814874e3113b6ac6dca84c8333bad418c449ebaf3fac1c9
3
+ metadata.gz: fd0590f46160c60f3a496158cf8dc2412803025cd06d94ac423c32f0b688dd77
4
+ data.tar.gz: 8c892f457280b1974908d2de0c3e5be5229ff6bcb448ed61c2937ff4566da796
5
5
  SHA512:
6
- metadata.gz: 952b71b5cd59ebd79eb51c44f7ed509bf3d4959c010dc0441cff37c0a1bd2ccea97054007bd3a197b287a158342e8791318c841f0d1d9b3dd347986da68bb53a
7
- data.tar.gz: 1309ce242499f430667d9677b7ff8807e1b43af33873d9b00575c4c9e13be24824d9520ca24db432901da2616c4ba5a530db83fd29c09c47b820fff3586a75b7
6
+ metadata.gz: 0a86b46d35b0cc8aef4a235e401b4470a6f6e64ff8c44ebdefc06f597d6cbe67ea76c786fc1e85caee1c3fc4dd492180530348078890ef74af90461705150a60
7
+ data.tar.gz: efeadbeb7dc0d044c801915d6ba4d8c249b249ef0dde8726454a359d3f1a4ce9ad4e2edb6cd19f8c371967059081c0ef2904ea630a0ae620123939ce881f0fdb
data/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # Solid Queue
2
2
 
3
- Solid Queue is a DB-based queuing backend for [Active Job](https://edgeguides.rubyonrails.org/active_job_basics.html), designed with simplicity and performance in mind.
3
+ Solid Queue is a database-based queuing backend for [Active Job](https://edgeguides.rubyonrails.org/active_job_basics.html), designed with simplicity and performance in mind.
4
4
 
5
- Besides regular job enqueuing and processing, Solid Queue supports delayed jobs, concurrency controls, recurring jobs, pausing queues, numeric priorities per job, priorities by queue order, and bulk enqueuing (`enqueue_all` for Active Job's `perform_all_later`).
5
+ In addition to regular job enqueuing and processing, Solid Queue supports delayed jobs, concurrency controls, recurring jobs, pausing queues, numeric priorities per job, priorities by queue order, and bulk enqueuing (`enqueue_all` for Active Job's `perform_all_later`).
6
6
 
7
- Solid Queue can be used with SQL databases such as MySQL, PostgreSQL or SQLite, and it leverages the `FOR UPDATE SKIP LOCKED` clause, if available, to avoid blocking and waiting on locks when polling jobs. It relies on Active Job for retries, discarding, error handling, serialization, or delays, and it's compatible with Ruby on Rails's multi-threading.
7
+ Solid Queue can be used with SQL databases such as MySQL, PostgreSQL, or SQLite, and it leverages the `FOR UPDATE SKIP LOCKED` clause, if available, to avoid blocking and waiting on locks when polling jobs. It relies on Active Job for retries, discarding, error handling, serialization, and delays, and it's compatible with Ruby on Rails's multi-threading.
8
8
 
9
- ## Table of contents
9
+ ## Table of Contents
10
10
 
11
11
  - [Installation](#installation)
12
12
  - [Usage in development and other non-production environments](#usage-in-development-and-other-non-production-environments)
@@ -14,11 +14,13 @@ Solid Queue can be used with SQL databases such as MySQL, PostgreSQL or SQLite,
14
14
  - [Dashboard UI Setup](#dashboard-ui-setup)
15
15
  - [Incremental adoption](#incremental-adoption)
16
16
  - [High performance requirements](#high-performance-requirements)
17
+ - [Workers, dispatchers, and scheduler](#workers-dispatchers-and-scheduler)
18
+ - [Fork vs. async mode](#fork-vs-async-mode)
17
19
  - [Configuration](#configuration)
18
- - [Workers, dispatchers and scheduler](#workers-dispatchers-and-scheduler)
20
+ - [Optional scheduler configuration](#optional-scheduler-configuration)
19
21
  - [Queue order and priorities](#queue-order-and-priorities)
20
22
  - [Queues specification and performance](#queues-specification-and-performance)
21
- - [Threads, processes and signals](#threads-processes-and-signals)
23
+ - [Threads, processes, and signals](#threads-processes-and-signals)
22
24
  - [Database configuration](#database-configuration)
23
25
  - [Other configuration settings](#other-configuration-settings)
24
26
  - [Lifecycle hooks](#lifecycle-hooks)
@@ -30,13 +32,14 @@ Solid Queue can be used with SQL databases such as MySQL, PostgreSQL or SQLite,
30
32
  - [Puma plugin](#puma-plugin)
31
33
  - [Jobs and transactional integrity](#jobs-and-transactional-integrity)
32
34
  - [Recurring tasks](#recurring-tasks)
35
+ - [Scheduling and unscheduling recurring tasks dynamically](#scheduling-and-unscheduling-recurring-tasks-dynamically)
33
36
  - [Inspiration](#inspiration)
34
37
  - [License](#license)
35
38
 
36
39
 
37
40
  ## Installation
38
41
 
39
- Solid Queue is configured by default in new Rails 8 applications. But if you're running an earlier version, you can add it manually following these steps:
42
+ Solid Queue is configured by default in new Rails 8 applications. If you're running an earlier version, you can add it manually following these steps:
40
43
 
41
44
  1. `bundle add solid_queue`
42
45
  2. `bin/rails solid_queue:install`
@@ -45,7 +48,7 @@ Solid Queue is configured by default in new Rails 8 applications. But if you're
45
48
 
46
49
  This will configure Solid Queue as the production Active Job backend, create the configuration files `config/queue.yml` and `config/recurring.yml`, and create the `db/queue_schema.rb`. It'll also create a `bin/jobs` executable wrapper that you can use to start Solid Queue.
47
50
 
48
- Once you've done that, you will then have to add the configuration for the queue database in `config/database.yml`. If you're using SQLite, it'll look like this:
51
+ Once you've done that, you will have to add the configuration for the queue database in `config/database.yml`. If you're using SQLite, it'll look like this:
49
52
 
50
53
  ```yaml
51
54
  production:
@@ -79,7 +82,7 @@ Now you're ready to start processing jobs by running `bin/jobs` on the server th
79
82
 
80
83
  For small projects, you can run Solid Queue on the same machine as your webserver. When you're ready to scale, Solid Queue supports horizontal scaling out-of-the-box. You can run Solid Queue on a separate server from your webserver, or even run `bin/jobs` on multiple machines at the same time. Depending on the configuration, you can designate some machines to run only dispatchers or only workers. See the [configuration](#configuration) section for more details on this.
81
84
 
82
- **Note**: future changes to the schema will come in the form of regular migrations.
85
+ **Note**: Future changes to the schema will come in the form of regular migrations.
83
86
 
84
87
  ### Usage in development and other non-production environments
85
88
 
@@ -92,10 +95,10 @@ development:
92
95
  + primary:
93
96
  <<: *default
94
97
  database: storage/development.sqlite3
95
- + queue:
96
- + <<: *default
97
- + database: storage/development_queue.sqlite3
98
- + migrations_paths: db/queue_migrate
98
+ + queue:
99
+ + <<: *default
100
+ + database: storage/development_queue.sqlite3
101
+ + migrations_paths: db/queue_migrate
99
102
  ```
100
103
 
101
104
  Next, add the following to `development.rb`
@@ -127,7 +130,7 @@ In `config/cable.yml`
127
130
  ```diff
128
131
  development:
129
132
  - adapter: async
130
- + adapter: solid_cable
133
+ + adapter: solid_cable
131
134
  + connects_to:
132
135
  + database:
133
136
  + writing: cable
@@ -150,7 +153,7 @@ development:
150
153
 
151
154
  ### Single database configuration
152
155
 
153
- Running Solid Queue in a separate database is recommended, but it's also possible to use one single database for both the app and the queue. Just follow these steps:
156
+ Running Solid Queue in a separate database is recommended, but it's also possible to use one single database for both the app and the queue. Follow these steps:
154
157
 
155
158
  1. Copy the contents of `db/queue_schema.rb` into a normal migration and delete `db/queue_schema.rb`
156
159
  2. Remove `config.solid_queue.connects_to` from `production.rb`
@@ -158,7 +161,7 @@ Running Solid Queue in a separate database is recommended, but it's also possibl
158
161
 
159
162
  You won't have multiple databases, so `database.yml` doesn't need to have primary and queue database.
160
163
 
161
- ### Dashboard ui setup
164
+ ### Dashboard UI Setup
162
165
 
163
166
  For viewing information about your jobs via a UI, we recommend taking a look at [mission_control-jobs](https://github.com/rails/mission_control-jobs), a dashboard where, among other things, you can examine and retry/discard failed jobs.
164
167
 
@@ -177,11 +180,9 @@ end
177
180
 
178
181
  ### High performance requirements
179
182
 
180
- Solid Queue was designed for the highest throughput when used with MySQL 8+ or PostgreSQL 9.5+, as they support `FOR UPDATE SKIP LOCKED`. You can use it with older versions, but in that case, you might run into lock waits if you run multiple workers for the same queue. You can also use it with SQLite on smaller applications.
181
-
182
- ## Configuration
183
+ Solid Queue was designed for the highest throughput when used with MySQL 8+, MariaDB 10.6+, or PostgreSQL 9.5+, as they support `FOR UPDATE SKIP LOCKED`. You can use it with older versions, but in that case, you might run into lock waits if you run multiple workers for the same queue. You can also use it with SQLite on smaller applications.
183
184
 
184
- ### Workers, dispatchers and scheduler
185
+ ## Workers, dispatchers, and scheduler
185
186
 
186
187
  We have several types of actors in Solid Queue:
187
188
 
@@ -190,7 +191,19 @@ We have several types of actors in Solid Queue:
190
191
  - The _scheduler_ manages [recurring tasks](#recurring-tasks), enqueuing jobs for them when they're due.
191
192
  - The _supervisor_ runs workers and dispatchers according to the configuration, controls their heartbeats, and stops and starts them when needed.
192
193
 
193
- Solid Queue's supervisor will fork a separate process for each supervised worker/dispatcher/scheduler.
194
+ ### Fork vs. async mode
195
+
196
+ By default, Solid Queue runs in `fork` mode. This means the supervisor will fork a separate process for each supervised worker/dispatcher/scheduler. This provides the best isolation and performance, but can have additional memory usage and might not work with some Ruby implementations. As an alternative, you can run all workers, dispatchers and schedulers in the same process as the supervisor, in different threads, with an `async` mode. You can choose this mode by running `bin/jobs` as:
197
+
198
+ ```
199
+ bin/jobs --mode async
200
+ ```
201
+
202
+ Or you can also set the environment variable `SOLID_QUEUE_SUPERVISOR_MODE` to `async`. If you use the `async` mode, the `processes` option in the configuration described below will be ignored.
203
+
204
+ **The recommended and default mode is `fork`. Only use `async` if you know what you're doing and have strong reasons to**
205
+
206
+ ## Configuration
194
207
 
195
208
  By default, Solid Queue will try to find your configuration under `config/queue.yml`, but you can set a different path using the environment variable `SOLID_QUEUE_CONFIG` or by using the `-c/--config_file` option with `bin/jobs`, like this:
196
209
 
@@ -198,7 +211,7 @@ By default, Solid Queue will try to find your configuration under `config/queue.
198
211
  bin/jobs -c config/calendar.yml
199
212
  ```
200
213
 
201
- You can also skip all recurring tasks by setting the environment variable `SOLID_QUEUE_SKIP_RECURRING=true`. This is useful for environments like staging, review apps, or development where you don't want any recurring jobs to run. This is equivalent to using the `--skip-recurring` option with `bin/jobs`.
214
+ You can also skip the scheduler process by setting the environment variable `SOLID_QUEUE_SKIP_RECURRING=true`. This is useful for environments like staging, review apps, or development where you don't want any recurring jobs to run. This is equivalent to using the `--skip-recurring` option with `bin/jobs`.
202
215
 
203
216
  This is what this configuration looks like:
204
217
 
@@ -216,6 +229,10 @@ production:
216
229
  threads: 5
217
230
  polling_interval: 0.1
218
231
  processes: 3
232
+ scheduler:
233
+ dynamic_tasks_enabled: true
234
+ polling_interval: 5
235
+
219
236
  ```
220
237
 
221
238
  Everything is optional. If no configuration at all is provided, Solid Queue will run with one dispatcher and one worker with default settings. If you want to run only dispatchers or workers, you just need to include that section alone in the configuration. For example, with the following configuration:
@@ -247,6 +264,8 @@ Here's an overview of the different options:
247
264
  ```
248
265
 
249
266
  This will create a worker fetching jobs from all queues starting with `staging`. The wildcard `*` is only allowed on its own or at the end of a queue name; you can't specify queue names such as `*_some_queue`. These will be ignored.
267
+
268
+ Also, if a wildcard (*) is included alongside explicit queue names, for example: `queues: [default, backend, *]`, then it would behave like `queues: *`
250
269
 
251
270
  Finally, you can combine prefixes with exact names, like `[ staging*, background ]`, and the behaviour with respect to order will be the same as with only exact names.
252
271
 
@@ -254,10 +273,23 @@ Here's an overview of the different options:
254
273
 
255
274
  - `threads`: this is the max size of the thread pool that each worker will have to run jobs. Each worker will fetch this number of jobs from their queue(s), at most and will post them to the thread pool to be run. By default, this is `3`. Only workers have this setting.
256
275
  It is recommended to set this value less than or equal to the queue database's connection pool size minus 2, as each worker thread uses one connection, and two additional connections are reserved for polling and heartbeat.
257
- - `processes`: this is the number of worker processes that will be forked by the supervisor with the settings given. By default, this is `1`, just a single process. This setting is useful if you want to dedicate more than one CPU core to a queue or queues with the same configuration. Only workers have this setting.
276
+ - `processes`: this is the number of worker processes that will be forked by the supervisor with the settings given. By default, this is `1`, just a single process. This setting is useful if you want to dedicate more than one CPU core to a queue or queues with the same configuration. Only workers have this setting. **Note**: this option will be ignored if [running in `async` mode](#fork-vs-async-mode).
258
277
  - `concurrency_maintenance`: whether the dispatcher will perform the concurrency maintenance work. This is `true` by default, and it's useful if you don't use any [concurrency controls](#concurrency-controls) and want to disable it or if you run multiple dispatchers and want some of them to just dispatch jobs without doing anything else.
259
278
 
260
279
 
280
+ ### Optional scheduler configuration
281
+
282
+ Optionally, you can configure the scheduler process under the `scheduler` section in your `config/queue.yml` if you'd like to [schedule recurring tasks dynamically](#scheduling-and-unscheduling-recurring-tasks-dynamically).
283
+
284
+ ```yaml
285
+ scheduler:
286
+ dynamic_tasks_enabled: true
287
+ polling_interval: 5
288
+ ```
289
+
290
+ - `dynamic_tasks_enabled`: whether the scheduler should poll for [dynamically scheduled recurring tasks](#scheduling-and-unscheduling-recurring-tasks-dynamically). This is `false` by default. When enabled, the scheduler will poll the database at the given `polling_interval` to pick up tasks scheduled via `SolidQueue.schedule_recurring_task`.
291
+ - `polling_interval`: how frequently (in seconds) the scheduler checks for dynamic task changes. Defaults to `5`.
292
+
261
293
  ### Queue order and priorities
262
294
 
263
295
  As mentioned above, if you specify a list of queues for a worker, these will be polled in the order given, such as for the list `real_time,background`, no jobs will be taken from `background` unless there aren't any more jobs waiting in `real_time`.
@@ -330,11 +362,11 @@ queues: back*
330
362
  ```
331
363
 
332
364
 
333
- ### Threads, processes and signals
365
+ ### Threads, processes, and signals
334
366
 
335
367
  Workers in Solid Queue use a thread pool to run work in multiple threads, configurable via the `threads` parameter above. Besides this, parallelism can be achieved via multiple processes on one machine (configurable via different workers or the `processes` parameter above) or by horizontal scaling.
336
368
 
337
- The supervisor is in charge of managing these processes, and it responds to the following signals:
369
+ The supervisor is in charge of managing these processes, and it responds to the following signals when running in its own process via `bin/jobs` or with [the Puma plugin](#puma-plugin) with the default `fork` mode:
338
370
  - `TERM`, `INT`: starts graceful termination. The supervisor will send a `TERM` signal to its supervised processes, and it'll wait up to `SolidQueue.shutdown_timeout` time until they're done. If any supervised processes are still around by then, it'll send a `QUIT` signal to them to indicate they must exit.
339
371
  - `QUIT`: starts immediate termination. The supervisor will send a `QUIT` signal to its supervised processes, causing them to exit immediately.
340
372
 
@@ -342,7 +374,7 @@ When receiving a `QUIT` signal, if workers still have jobs in-flight, these will
342
374
 
343
375
  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.
344
376
 
345
- In a similar way, if a worker is terminated in any other way not initiated by the above signals (e.g. a worker is sent a `KILL` signal), jobs in progress will be marked as failed so that they can be inspected, with a `SolidQueue::Processes::Process::ProcessExitError`. Sometimes a job in particular is responsible for this, for example, if it has a memory leak and you have a mechanism to kill processes over a certain memory threshold, so this will help identifying this kind of situation.
377
+ In a similar way, if a worker is terminated in any other way not initiated by the above signals (e.g. a worker is sent a `KILL` signal), jobs in progress will be marked as failed so that they can be inspected, with a `SolidQueue::Processes::ProcessExitError`. Sometimes a job in particular is responsible for this, for example, if it has a memory leak and you have a mechanism to kill processes over a certain memory threshold, so this will help identifying this kind of situation.
346
378
 
347
379
 
348
380
  ### Database configuration
@@ -366,7 +398,7 @@ There are several settings that control how Solid Queue works that you can set a
366
398
 
367
399
  **This is not used for errors raised within a job execution**. Errors happening in jobs are handled by Active Job's `retry_on` or `discard_on`, and ultimately will result in [failed jobs](#failed-jobs-and-retries). This is for errors happening within Solid Queue itself.
368
400
 
369
- - `use_skip_locked`: whether to use `FOR UPDATE SKIP LOCKED` when performing locking reads. This will be automatically detected in the future, and for now, you'd only need to set this to `false` if your database doesn't support it. For MySQL, that'd be versions < 8, and for PostgreSQL, versions < 9.5. If you use SQLite, this has no effect, as writes are sequential.
401
+ - `use_skip_locked`: whether to use `FOR UPDATE SKIP LOCKED` when performing locking reads. This will be automatically detected in the future, and for now, you only need to set this to `false` if your database doesn't support it. For MySQL, that'd be versions < 8; for MariaDB, versions < 10.6; and for PostgreSQL, versions < 9.5. If you use SQLite, this has no effect, as writes are sequential.
370
402
  - `process_heartbeat_interval`: the heartbeat interval that all processes will follow—defaults to 60 seconds.
371
403
  - `process_alive_threshold`: how long to wait until a process is considered dead after its last heartbeat—defaults to 5 minutes.
372
404
  - `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.
@@ -449,7 +481,7 @@ class MyJob < ApplicationJob
449
481
  - `group` is used to control the concurrency of different job classes together. It defaults to the job class name.
450
482
  - `on_conflict` controls behaviour when enqueuing a job that conflicts with the concurrency limits configured. It can be set to one of the following:
451
483
  - (default) `:block`: the job is blocked and is dispatched when another job completes and unblocks it, or when the duration expires.
452
- - `:discard`: the job is discarded. When you choose this option, bear in mind that if a job runs and fails to remove the concurrency lock (or _semaphore_, read below to know more about this), all jobs conflicting with it will be discarded up to the interval defined by `duration` has elapsed.
484
+ - `:discard`: the job is discarded. When you choose this option, bear in mind that if a job runs and fails to remove the concurrency lock (or _semaphore_, read below to know more about this), all jobs conflicting with it will be discarded until the interval defined by `duration` has elapsed.
453
485
 
454
486
  When a job includes these controls, we'll ensure that, at most, the number of jobs (indicated as `to`) that yield the same `key` will be performed concurrently, and this guarantee will last for `duration` for each job enqueued. Note that there's no guarantee about _the order of execution_, only about jobs being performed at the same time (overlapping).
455
487
 
@@ -459,7 +491,7 @@ Since something can happen that prevents the first job from releasing the semaph
459
491
 
460
492
  It's important to note that after one or more candidate jobs are unblocked (either because a job finishes or because `duration` expires and a semaphore is released), the `duration` timer for the still blocked jobs is reset. This happens indirectly via the expiration time of the semaphore, which is updated.
461
493
 
462
- When using `discard` as the behaviour to handle conflicts, you might have jobs discarded for up to the `duration` interval if something happens and a running job fails to release the semaphore.
494
+ 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.
463
495
 
464
496
 
465
497
  For example:
@@ -519,11 +551,11 @@ DeliverAnnouncementToContactJob.set(wait: 30.minutes).perform_later(contact)
519
551
 
520
552
  The 3 jobs will go into the scheduled queue and will wait there until they're due. Then, 10 minutes after, the first two jobs will be enqueued and the second one most likely will be blocked because the first one will be running first. Then, assuming the jobs are fast and finish in a few seconds, when the third job is due, it'll be enqueued normally.
521
553
 
522
- Normally scheduled jobs are enqueued in batches, but with concurrency controls, jobs need to be enqueued one by one. This has an impact on performance, similarly to the impact of concurrency controls in bulk enqueuing. Read below for more details. I'd generally advise against mixing concurrency controls with waiting/scheduling in the future.
554
+ Normally scheduled jobs are enqueued in batches, but with concurrency controls, jobs need to be enqueued one by one. This has an impact on performance, similarly to the impact of concurrency controls in bulk enqueuing. Read below for more details. We generally advise against mixing concurrency controls with waiting/scheduling in the future.
523
555
 
524
556
  ### Performance considerations
525
557
 
526
- Concurrency controls introduce significant overhead (blocked executions need to be created and promoted to ready, semaphores need to be created and updated) so you should consider carefully whether you need them. For throttling purposes, where you plan to have `limit` significantly larger than 1, I'd encourage relying on a limited number of workers per queue instead. For example:
558
+ Concurrency controls introduce significant overhead (blocked executions need to be created and promoted to ready, semaphores need to be created and updated) so you should consider carefully whether you need them. For throttling purposes, where you plan to have `limit` significantly larger than 1, we encourage relying on a limited number of workers per queue instead. For example:
527
559
 
528
560
  ```ruby
529
561
  class ThrottledJob < ApplicationJob
@@ -603,6 +635,22 @@ that you set in production only. This is what Rails 8's default Puma config look
603
635
 
604
636
  **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.
605
637
 
638
+ ### Running as a fork or asynchronously
639
+
640
+ 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.
641
+
642
+ Alternatively, workers and dispatchers can be run within the same Puma process(s). To do so just configure the plugin as:
643
+
644
+ ```ruby
645
+ plugin :solid_queue
646
+ solid_queue_mode :async
647
+ ```
648
+
649
+ Note that in this case, the `processes` configuration option will be ignored. See also [Fork vs. async mode](#fork-vs-async-mode).
650
+
651
+ **The recommended and default mode is `fork`. Only use `async` if you know what you're doing and have strong reasons to**
652
+
653
+
606
654
  ## Jobs and transactional integrity
607
655
  :warning: Having your jobs in the same ACID-compliant database as your application data enables a powerful yet sharp tool: taking advantage of transactional integrity to ensure some action in your app is not committed unless your job is also committed and vice versa, and ensuring that your job won't be enqueued until the transaction within which you're enqueuing it is committed. This can be very powerful and useful, but it can also backfire if you base some of your logic on this behaviour, and in the future, you move to another active job backend, or if you simply move Solid Queue to its own database, and suddenly the behaviour changes under you. Because this can be quite tricky and many people shouldn't need to worry about it, by default Solid Queue is configured in a different database as the main app.
608
656
 
@@ -616,7 +664,7 @@ end
616
664
 
617
665
  Using this option, you can also use Solid Queue in the same database as your app but not rely on transactional integrity.
618
666
 
619
- If you don't set this option but still want to make sure you're not inadvertently on transactional integrity, you can make sure that:
667
+ If you don't set this option but still want to make sure you're not inadvertently relying on transactional integrity, you can make sure that:
620
668
  - Your jobs relying on specific data are always enqueued on [`after_commit` callbacks](https://guides.rubyonrails.org/active_record_callbacks.html#after-commit-and-after-rollback) or otherwise from a place where you're certain that whatever data the job will use has been committed to the database before the job is enqueued.
621
669
  - Or, you configure a different database for Solid Queue, even if it's the same as your app, ensuring that a different connection on the thread handling requests or running jobs for your app will be used to enqueue jobs. For example:
622
670
 
@@ -703,6 +751,38 @@ my_periodic_resque_job:
703
751
 
704
752
  and the job will be enqueued via `perform_later` so it'll run in Resque. However, in this case we won't track any `solid_queue_recurring_execution` record for it and there won't be any guarantees that the job is enqueued only once each time.
705
753
 
754
+ ### Scheduling and unscheduling recurring tasks dynamically
755
+
756
+ You can schedule and unschedule recurring tasks at runtime, without editing the configuration file. To enable this, you need to set `dynamic_tasks_enabled: true` in the `scheduler` section of your `config/queue.yml`, [as explained earlier](#optional-scheduler-configuration).
757
+
758
+ ```yaml
759
+ scheduler:
760
+ dynamic_tasks_enabled: true
761
+ ```
762
+
763
+ Then you can use the following methods to add recurring tasks dynamically:
764
+
765
+ ```ruby
766
+ SolidQueue.schedule_recurring_task(
767
+ "my_dynamic_task",
768
+ class: "MyJob",
769
+ args: [1, 2],
770
+ schedule: "every 10 minutes"
771
+ )
772
+ ```
773
+
774
+ This accepts the same options as the YAML configuration: `class`, `args`, `command`, `schedule`, `queue`, `priority`, and `description`.
775
+
776
+ To remove a dynamically scheduled task:
777
+
778
+ ```ruby
779
+ SolidQueue.unschedule_recurring_task("my_dynamic_task")
780
+ ```
781
+
782
+ Only dynamic tasks can be unscheduled at runtime. Attempting to unschedule a static task (defined in `config/recurring.yml`) will raise an `ActiveRecord::RecordNotFound` error.
783
+
784
+ Tasks scheduled like this persist between Solid Queue's restarts and won't stop running until you manually unschedule them.
785
+
706
786
  ## Inspiration
707
787
 
708
788
  Solid Queue has been inspired by [resque](https://github.com/resque/resque) and [GoodJob](https://github.com/bensheldon/good_job). We recommend checking out these projects as they're great examples from which we've learnt a lot.
@@ -26,7 +26,9 @@ module SolidQueue
26
26
 
27
27
  def release_one(concurrency_key)
28
28
  transaction do
29
- if execution = ordered.where(concurrency_key: concurrency_key).limit(1).non_blocking_lock.first
29
+ if execution = ordered.where(concurrency_key: concurrency_key).limit(1)
30
+ .use_index(:index_solid_queue_blocked_executions_for_release)
31
+ .non_blocking_lock.first
30
32
  execution.release
31
33
  end
32
34
  end
@@ -37,8 +37,10 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
37
37
  end
38
38
 
39
39
  def fail_all_with(error)
40
- SolidQueue.instrument(:fail_many_claimed) do |payload|
41
- includes(:job).tap do |executions|
40
+ includes(:job).tap do |executions|
41
+ return if executions.empty?
42
+
43
+ SolidQueue.instrument(:fail_many_claimed) do |payload|
42
44
  executions.each do |execution|
43
45
  execution.failed_with(error)
44
46
  execution.unblock_next_job
@@ -6,7 +6,7 @@ module SolidQueue
6
6
 
7
7
  serialize :error, coder: JSON
8
8
 
9
- before_create :expand_error_details_from_exception
9
+ before_save :expand_error_details_from_exception, if: :exception
10
10
 
11
11
  attr_accessor :exception
12
12
 
@@ -58,8 +58,11 @@ module SolidQueue
58
58
  end
59
59
 
60
60
  def determine_backtrace_size_limit
61
- column = self.class.connection.schema_cache.columns_hash(self.class.table_name)["error"]
62
- if column.limit.present?
61
+ column = self.class.connection_pool.with_connection do |connection|
62
+ connection.schema_cache.columns_hash(self.class.table_name)["error"]
63
+ end
64
+
65
+ if column && column.limit.present?
63
66
  column.limit - exception_class_name.bytesize - exception_message.bytesize - JSON_OVERHEAD
64
67
  end
65
68
  end
@@ -26,7 +26,7 @@ module SolidQueue
26
26
  end
27
27
 
28
28
  def concurrency_limited?
29
- concurrency_key.present?
29
+ concurrency_key.present? && job_class.present?
30
30
  end
31
31
 
32
32
  def blocked?
@@ -16,7 +16,16 @@ module SolidQueue
16
16
  end
17
17
 
18
18
  def failed_with(exception)
19
- FailedExecution.create_or_find_by!(job_id: id, exception: exception)
19
+ FailedExecution.transaction(requires_new: true) do
20
+ FailedExecution.create!(job_id: id, exception: exception)
21
+ end
22
+ rescue ActiveRecord::RecordNotUnique
23
+ if (failed_execution = FailedExecution.find_by(job_id: id))
24
+ failed_execution.exception = exception
25
+ failed_execution.save!
26
+ else
27
+ retry
28
+ end
20
29
  end
21
30
 
22
31
  def reset_execution_counters
@@ -10,6 +10,7 @@ module SolidQueue
10
10
 
11
11
  class << self
12
12
  def enqueue_all(active_jobs)
13
+ active_jobs.each { |job| job.scheduled_at ||= Time.current }
13
14
  active_jobs_by_job_id = active_jobs.index_by(&:job_id)
14
15
 
15
16
  transaction do
@@ -30,7 +30,8 @@ module SolidQueue
30
30
  end
31
31
 
32
32
  def select_candidates(queue_relation, limit)
33
- queue_relation.ordered.limit(limit).non_blocking_lock.select(:id, :job_id)
33
+ # Force query execution here with #to_a to avoid unintended FOR UPDATE query executions
34
+ queue_relation.ordered.limit(limit).non_blocking_lock.select(:id, :job_id).to_a
34
35
  end
35
36
 
36
37
  def lock_candidates(executions, process_id)
@@ -6,11 +6,26 @@ module SolidQueue
6
6
 
7
7
  connects_to(**SolidQueue.connects_to) if SolidQueue.connects_to
8
8
 
9
- def self.non_blocking_lock
10
- if SolidQueue.use_skip_locked
11
- lock(Arel.sql("FOR UPDATE SKIP LOCKED"))
12
- else
13
- lock
9
+ class << self
10
+ def non_blocking_lock
11
+ if SolidQueue.use_skip_locked
12
+ lock(Arel.sql("FOR UPDATE SKIP LOCKED"))
13
+ else
14
+ lock
15
+ end
16
+ end
17
+
18
+ def supports_insert_conflict_target?
19
+ connection_pool.with_connection do |connection|
20
+ connection.supports_insert_conflict_target?
21
+ end
22
+ end
23
+
24
+ # Pass index hints to the query optimizer using SQL comment hints.
25
+ # Uses MySQL 8 optimizer hint query comments, which SQLite and
26
+ # PostgreSQL ignore.
27
+ def use_index(*indexes)
28
+ optimizer_hints "INDEX(#{quoted_table_name} #{indexes.join(', ')})"
14
29
  end
15
30
  end
16
31
  end
@@ -8,7 +8,7 @@ module SolidQueue
8
8
 
9
9
  class << self
10
10
  def create_or_insert!(**attributes)
11
- if connection.supports_insert_conflict_target?
11
+ if supports_insert_conflict_target?
12
12
  # PostgreSQL fails and aborts the current transaction when it hits a duplicate key conflict
13
13
  # during two concurrent INSERTs for the same value of an unique index. We need to explicitly
14
14
  # indicate unique_by to ignore duplicate rows by this value when inserting
@@ -6,11 +6,12 @@ module SolidQueue
6
6
  class RecurringTask < Record
7
7
  serialize :arguments, coder: Arguments, default: []
8
8
 
9
- validate :supported_schedule
9
+ validate :ensure_schedule_supported
10
10
  validate :ensure_command_or_class_present
11
- validate :existing_job_class
11
+ validate :ensure_existing_job_class
12
12
 
13
13
  scope :static, -> { where(static: true) }
14
+ scope :dynamic, -> { where(static: false) }
14
15
 
15
16
  has_many :recurring_executions, foreign_key: :task_key, primary_key: :key
16
17
 
@@ -32,11 +33,19 @@ module SolidQueue
32
33
  queue_name: options[:queue].presence,
33
34
  priority: options[:priority].presence,
34
35
  description: options[:description],
35
- static: true
36
+ static: options.fetch(:static, true)
37
+ end
38
+
39
+ def create_dynamic_task(key, **options)
40
+ from_configuration(key, **options.merge(static: false)).save!
41
+ end
42
+
43
+ def delete_dynamic_task(key)
44
+ RecurringTask.dynamic.find_by!(key: key).destroy
36
45
  end
37
46
 
38
47
  def create_or_update_all(tasks)
39
- if connection.supports_insert_conflict_target?
48
+ if supports_insert_conflict_target?
40
49
  # PostgreSQL fails and aborts the current transaction when it hits a duplicate key conflict
41
50
  # during two concurrent INSERTs for the same value of an unique index. We need to explicitly
42
51
  # indicate unique_by to ignore duplicate rows by this value when inserting
@@ -48,7 +57,7 @@ module SolidQueue
48
57
  end
49
58
 
50
59
  def delay_from_now
51
- [ (next_time - Time.current).to_f, 0 ].max
60
+ [ (next_time - Time.current).to_f, 0.1 ].max
52
61
  end
53
62
 
54
63
  def next_time
@@ -102,19 +111,28 @@ module SolidQueue
102
111
  end
103
112
 
104
113
  private
105
- def supported_schedule
114
+ def ensure_schedule_supported
106
115
  unless parsed_schedule.instance_of?(Fugit::Cron)
107
116
  errors.add :schedule, :unsupported, message: "is not a supported recurring schedule"
108
117
  end
118
+ rescue ArgumentError => error
119
+ message = if error.message.include?("multiple crons")
120
+ "generates multiple cron schedules. Please use separate recurring tasks for each schedule, " +
121
+ "or use explicit cron syntax (e.g., '40 0,15 * * *' for multiple times with the same minutes)"
122
+ else
123
+ error.message
124
+ end
125
+
126
+ errors.add :schedule, :unsupported, message: message
109
127
  end
110
128
 
111
129
  def ensure_command_or_class_present
112
130
  unless command.present? || class_name.present?
113
- errors.add :base, :command_and_class_blank, message: "either command or class_name must be present"
131
+ errors.add :base, :command_and_class_blank, message: "either command or class must be present"
114
132
  end
115
133
  end
116
134
 
117
- def existing_job_class
135
+ def ensure_existing_job_class
118
136
  if class_name.present? && job_class.nil?
119
137
  errors.add :class_name, :undefined, message: "doesn't correspond to an existing class"
120
138
  end
@@ -152,7 +170,7 @@ module SolidQueue
152
170
 
153
171
 
154
172
  def parsed_schedule
155
- @parsed_schedule ||= Fugit.parse(schedule)
173
+ @parsed_schedule ||= Fugit.parse(schedule, multi: :fail)
156
174
  end
157
175
 
158
176
  def job_class
@@ -20,7 +20,7 @@ module SolidQueue
20
20
 
21
21
  # Requires a unique index on key
22
22
  def create_unique_by(attributes)
23
- if connection.supports_insert_conflict_target?
23
+ if supports_insert_conflict_target?
24
24
  insert({ **attributes }, unique_by: :key).any?
25
25
  else
26
26
  create!(**attributes)
@@ -40,7 +40,7 @@ module SolidQueue
40
40
  end
41
41
 
42
42
  def wait
43
- if semaphore = Semaphore.find_by(key: key)
43
+ if semaphore = Semaphore.lock.find_by(key: key)
44
44
  semaphore.value > 0 && attempt_decrement
45
45
  else
46
46
  attempt_creation