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.
- checksums.yaml +4 -4
- data/README.md +112 -32
- data/app/models/solid_queue/blocked_execution.rb +3 -1
- data/app/models/solid_queue/claimed_execution.rb +4 -2
- data/app/models/solid_queue/failed_execution.rb +6 -3
- data/app/models/solid_queue/job/concurrency_controls.rb +1 -1
- data/app/models/solid_queue/job/retryable.rb +10 -1
- data/app/models/solid_queue/job.rb +1 -0
- data/app/models/solid_queue/ready_execution.rb +2 -1
- data/app/models/solid_queue/record.rb +20 -5
- data/app/models/solid_queue/recurring_execution.rb +1 -1
- data/app/models/solid_queue/recurring_task.rb +27 -9
- data/app/models/solid_queue/semaphore.rb +2 -2
- data/lib/puma/plugin/solid_queue.rb +74 -14
- data/lib/solid_queue/app_executor.rb +10 -0
- data/lib/solid_queue/async_supervisor.rb +52 -0
- data/lib/solid_queue/cli.rb +5 -1
- data/lib/solid_queue/configuration.rb +42 -8
- data/lib/solid_queue/dispatcher.rb +1 -0
- data/lib/solid_queue/fork_supervisor.rb +68 -0
- data/lib/solid_queue/processes/registrable.rb +15 -9
- data/lib/solid_queue/processes/runnable.rb +25 -18
- data/lib/solid_queue/processes/thread_terminated_error.rb +11 -0
- data/lib/solid_queue/scheduler/recurring_schedule.rb +61 -11
- data/lib/solid_queue/scheduler.rb +23 -4
- data/lib/solid_queue/supervisor/maintenance.rb +11 -0
- data/lib/solid_queue/supervisor/signals.rb +2 -2
- data/lib/solid_queue/supervisor.rb +40 -82
- data/lib/solid_queue/timer.rb +3 -3
- data/lib/solid_queue/version.rb +1 -1
- data/lib/solid_queue.rb +8 -0
- metadata +25 -9
- data/Rakefile +0 -43
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fd0590f46160c60f3a496158cf8dc2412803025cd06d94ac423c32f0b688dd77
|
|
4
|
+
data.tar.gz: 8c892f457280b1974908d2de0c3e5be5229ff6bcb448ed61c2937ff4566da796
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
- [
|
|
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.
|
|
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
|
|
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**:
|
|
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
|
-
+
|
|
96
|
-
+
|
|
97
|
-
+
|
|
98
|
-
+
|
|
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
|
-
+
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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::
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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,
|
|
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)
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
62
|
-
|
|
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
|
|
@@ -16,7 +16,16 @@ module SolidQueue
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def failed_with(exception)
|
|
19
|
-
FailedExecution.
|
|
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
|
|
@@ -30,7 +30,8 @@ module SolidQueue
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def select_candidates(queue_relation, limit)
|
|
33
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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 :
|
|
9
|
+
validate :ensure_schedule_supported
|
|
10
10
|
validate :ensure_command_or_class_present
|
|
11
|
-
validate :
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|