solid_queue 1.3.2 → 1.5.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 +79 -7
- data/UPGRADING.md +11 -0
- data/app/models/solid_queue/blocked_execution.rb +3 -1
- data/app/models/solid_queue/claimed_execution.rb +23 -14
- data/app/models/solid_queue/job/executable.rb +6 -5
- data/app/models/solid_queue/job/schedulable.rb +2 -1
- data/app/models/solid_queue/queue.rb +1 -3
- data/app/models/solid_queue/queue_selector.rb +2 -2
- data/app/models/solid_queue/ready_execution.rb +2 -1
- data/app/models/solid_queue/record/distinct_values.rb +48 -0
- data/app/models/solid_queue/record.rb +10 -0
- data/app/models/solid_queue/recurring_task.rb +40 -5
- data/app/models/solid_queue/semaphore.rb +7 -1
- data/lib/active_job/queue_adapters/solid_queue_adapter.rb +9 -1
- data/lib/generators/solid_queue/install/templates/config/queue.yml +1 -1
- data/lib/solid_queue/cli.rb +6 -0
- data/lib/solid_queue/configuration.rb +69 -29
- data/lib/solid_queue/engine.rb +6 -0
- data/lib/solid_queue/processes/registrable.rb +4 -0
- data/lib/solid_queue/processes/runnable.rb +4 -2
- data/lib/solid_queue/scheduler/recurring_schedule.rb +68 -16
- data/lib/solid_queue/scheduler.rb +23 -4
- data/lib/solid_queue/supervisor/signals.rb +1 -1
- data/lib/solid_queue/supervisor.rb +2 -0
- data/lib/solid_queue/tasks.rb +6 -0
- data/lib/solid_queue/version.rb +1 -1
- data/lib/solid_queue.rb +17 -0
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d40f055103084c9aaa9efe6ded0e675ff967a88200bf9f5579b1287e29d48818
|
|
4
|
+
data.tar.gz: af6ab85d5c674dbceff32768b5aa7b4d87bc1aeea24476e3fb469d639c4fad46
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 926a7f82ebca8389d5d6d6116bd6539d65715704e739f66083f1e21411737da72972f4b643bd4291e95ef5bb107417cb11fde6c535d6e4013434ac589cc2fc6e
|
|
7
|
+
data.tar.gz: 684e5f32bbe7aa4ce0645687a92c2538016431151208156ec144b574e1d48e8d04144f28a2eacca86ff5bd4449a2c6bdd439bdcd74bdf72788ae4430abd284c2
|
data/README.md
CHANGED
|
@@ -17,11 +17,13 @@ Solid Queue can be used with SQL databases such as MySQL, PostgreSQL, or SQLite,
|
|
|
17
17
|
- [Workers, dispatchers, and scheduler](#workers-dispatchers-and-scheduler)
|
|
18
18
|
- [Fork vs. async mode](#fork-vs-async-mode)
|
|
19
19
|
- [Configuration](#configuration)
|
|
20
|
+
- [Optional scheduler configuration](#optional-scheduler-configuration)
|
|
20
21
|
- [Queue order and priorities](#queue-order-and-priorities)
|
|
21
22
|
- [Queues specification and performance](#queues-specification-and-performance)
|
|
22
23
|
- [Threads, processes, and signals](#threads-processes-and-signals)
|
|
23
24
|
- [Database configuration](#database-configuration)
|
|
24
25
|
- [Other configuration settings](#other-configuration-settings)
|
|
26
|
+
- [Validating the configuration](#validating-the-configuration)
|
|
25
27
|
- [Lifecycle hooks](#lifecycle-hooks)
|
|
26
28
|
- [Errors when enqueuing](#errors-when-enqueuing)
|
|
27
29
|
- [Concurrency controls](#concurrency-controls)
|
|
@@ -31,6 +33,7 @@ Solid Queue can be used with SQL databases such as MySQL, PostgreSQL, or SQLite,
|
|
|
31
33
|
- [Puma plugin](#puma-plugin)
|
|
32
34
|
- [Jobs and transactional integrity](#jobs-and-transactional-integrity)
|
|
33
35
|
- [Recurring tasks](#recurring-tasks)
|
|
36
|
+
- [Scheduling and unscheduling recurring tasks dynamically](#scheduling-and-unscheduling-recurring-tasks-dynamically)
|
|
34
37
|
- [Inspiration](#inspiration)
|
|
35
38
|
- [License](#license)
|
|
36
39
|
|
|
@@ -42,7 +45,7 @@ Solid Queue is configured by default in new Rails 8 applications. If you're runn
|
|
|
42
45
|
1. `bundle add solid_queue`
|
|
43
46
|
2. `bin/rails solid_queue:install`
|
|
44
47
|
|
|
45
|
-
(Note: The minimum supported version of Rails is 7.1 and Ruby is 3.
|
|
48
|
+
(Note: The minimum supported version of Rails is 7.1 and Ruby is 3.2.)
|
|
46
49
|
|
|
47
50
|
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.
|
|
48
51
|
|
|
@@ -209,7 +212,7 @@ By default, Solid Queue will try to find your configuration under `config/queue.
|
|
|
209
212
|
bin/jobs -c config/calendar.yml
|
|
210
213
|
```
|
|
211
214
|
|
|
212
|
-
You can also skip
|
|
215
|
+
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`.
|
|
213
216
|
|
|
214
217
|
This is what this configuration looks like:
|
|
215
218
|
|
|
@@ -227,6 +230,10 @@ production:
|
|
|
227
230
|
threads: 5
|
|
228
231
|
polling_interval: 0.1
|
|
229
232
|
processes: 3
|
|
233
|
+
scheduler:
|
|
234
|
+
dynamic_tasks_enabled: true
|
|
235
|
+
polling_interval: 5
|
|
236
|
+
|
|
230
237
|
```
|
|
231
238
|
|
|
232
239
|
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:
|
|
@@ -271,6 +278,19 @@ It is recommended to set this value less than or equal to the queue database's c
|
|
|
271
278
|
- `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.
|
|
272
279
|
|
|
273
280
|
|
|
281
|
+
### Optional scheduler configuration
|
|
282
|
+
|
|
283
|
+
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).
|
|
284
|
+
|
|
285
|
+
```yaml
|
|
286
|
+
scheduler:
|
|
287
|
+
dynamic_tasks_enabled: true
|
|
288
|
+
polling_interval: 5
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
- `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`.
|
|
292
|
+
- `polling_interval`: how frequently (in seconds) the scheduler checks for dynamic task changes. Defaults to `5`.
|
|
293
|
+
|
|
274
294
|
### Queue order and priorities
|
|
275
295
|
|
|
276
296
|
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`.
|
|
@@ -319,7 +339,7 @@ FROM solid_queue_ready_executions
|
|
|
319
339
|
WHERE queue_name LIKE 'beta%';
|
|
320
340
|
```
|
|
321
341
|
|
|
322
|
-
This type of `DISTINCT` query on a column that's the leftmost column in an index can be performed very fast in MySQL thanks to a technique called [Loose Index Scan](https://dev.mysql.com/doc/refman/8.0/en/group-by-optimization.html#loose-index-scan). PostgreSQL
|
|
342
|
+
This type of `DISTINCT` query on a column that's the leftmost column in an index can be performed very fast in MySQL thanks to a technique called [Loose Index Scan](https://dev.mysql.com/doc/refman/8.0/en/group-by-optimization.html#loose-index-scan). PostgreSQL doesn't implement this technique natively, so Solid Queue uses a [recursive CTE](https://www.postgresql.org/docs/current/queries-with.html#QUERIES-WITH-RECURSIVE) to emulate it, achieving similar performance by walking the B-tree index and jumping between distinct values. SQLite doesn't implement loose index scan either, but this is unlikely to be a problem since SQLite is typically used in development with small datasets.
|
|
323
343
|
|
|
324
344
|
Similarly to using prefixes, the same will happen if you have paused queues, because we need to get a list of all queues with a query like
|
|
325
345
|
```sql
|
|
@@ -353,6 +373,8 @@ The supervisor is in charge of managing these processes, and it responds to the
|
|
|
353
373
|
|
|
354
374
|
When receiving a `QUIT` signal, if workers still have jobs in-flight, these will be returned to the queue when the processes are deregistered.
|
|
355
375
|
|
|
376
|
+
On Windows, the `QUIT` signal can't be trapped, so the supervisor only responds to `TERM` and `INT` there.
|
|
377
|
+
|
|
356
378
|
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.
|
|
357
379
|
|
|
358
380
|
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.
|
|
@@ -389,6 +411,22 @@ There are several settings that control how Solid Queue works that you can set a
|
|
|
389
411
|
- `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.
|
|
390
412
|
- `default_concurrency_control_period`: the value to be used as the default for the `duration` parameter in [concurrency controls](#concurrency-controls). It defaults to 3 minutes.
|
|
391
413
|
|
|
414
|
+
### Validating the configuration
|
|
415
|
+
|
|
416
|
+
You can validate the Solid Queue configuration ahead of time, without starting any process. This is handy in deploy scripts or CI to catch mistakes—a typo in `recurring.yml`, no processes configured, and so on—before they cause a supervisor to boot into a broken state:
|
|
417
|
+
|
|
418
|
+
```bash
|
|
419
|
+
# Using the bin/jobs binstub
|
|
420
|
+
bin/jobs check
|
|
421
|
+
|
|
422
|
+
# Or via rake
|
|
423
|
+
bin/rails solid_queue:check
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
Both commands validate the configuration for the current Rails environment. On success they print `Solid Queue configuration is valid.` and exit `0`; otherwise they print the errors and exit non-zero. When the number of threads is larger than the [database connection pool](#database-configuration), they also print an advisory warning about it—the same one the supervisor logs on boot. They're tolerant of a missing database connection, so they can run on CI or deploy hosts without database credentials.
|
|
427
|
+
|
|
428
|
+
`bin/jobs check` accepts the same options as `bin/jobs start` (e.g. `--config_file`, `--recurring_schedule_file`, `--skip-recurring`). The rake task honors the same environment variables Solid Queue already uses: `SOLID_QUEUE_CONFIG`, `SOLID_QUEUE_RECURRING_SCHEDULE`, and `SOLID_QUEUE_SKIP_RECURRING`. To validate a specific environment's configuration, set `RAILS_ENV`, for example `RAILS_ENV=production bin/jobs check`.
|
|
429
|
+
|
|
392
430
|
|
|
393
431
|
## Lifecycle hooks
|
|
394
432
|
|
|
@@ -452,7 +490,7 @@ Solid Queue extends Active Job with concurrency controls, that allows you to lim
|
|
|
452
490
|
|
|
453
491
|
```ruby
|
|
454
492
|
class MyJob < ApplicationJob
|
|
455
|
-
limits_concurrency to: max_concurrent_executions, key: ->(arg1, arg2,
|
|
493
|
+
limits_concurrency to: max_concurrent_executions, key: ->(arg1, arg2, *) { ... }, duration: max_interval_to_guarantee_concurrency_limit, group: concurrency_group, on_conflict: on_conflict_behaviour
|
|
456
494
|
|
|
457
495
|
# ...
|
|
458
496
|
```
|
|
@@ -462,7 +500,7 @@ class MyJob < ApplicationJob
|
|
|
462
500
|
- `group` is used to control the concurrency of different job classes together. It defaults to the job class name.
|
|
463
501
|
- `on_conflict` controls behaviour when enqueuing a job that conflicts with the concurrency limits configured. It can be set to one of the following:
|
|
464
502
|
- (default) `:block`: the job is blocked and is dispatched when another job completes and unblocks it, or when the duration expires.
|
|
465
|
-
- `: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
|
|
503
|
+
- `: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.
|
|
466
504
|
|
|
467
505
|
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).
|
|
468
506
|
|
|
@@ -472,7 +510,7 @@ Since something can happen that prevents the first job from releasing the semaph
|
|
|
472
510
|
|
|
473
511
|
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.
|
|
474
512
|
|
|
475
|
-
When using `discard` as the behaviour to handle conflicts, you might have jobs discarded for
|
|
513
|
+
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.
|
|
476
514
|
|
|
477
515
|
|
|
478
516
|
For example:
|
|
@@ -686,7 +724,9 @@ production:
|
|
|
686
724
|
|
|
687
725
|
Tasks are specified as a hash/dictionary, where the key will be the task's key internally. Each task needs to either have a `class`, which will be the job class to enqueue, or a `command`, which will be eval'ed in the context of a job (`SolidQueue::RecurringJob`) that will be enqueued according to its schedule, in the `solid_queue_recurring` queue.
|
|
688
726
|
|
|
689
|
-
Each task needs to have also a schedule, which is parsed using [Fugit](https://github.com/floraison/fugit), so it accepts anything [that Fugit accepts as a cron](https://github.com/floraison/fugit?tab=readme-ov-file#fugitcron). You can
|
|
727
|
+
Each task needs to have also a schedule, which is parsed using [Fugit](https://github.com/floraison/fugit), so it accepts anything [that Fugit accepts as a cron](https://github.com/floraison/fugit?tab=readme-ov-file#fugitcron). Schedules can include a time zone (e.g. `0 9 * * * America/New_York` or `every day at 9am America/New_York`). When a schedule doesn't specify one, it's interpreted in the application's configured time zone (`config.time_zone`) by default. You can change or disable this default with `config.solid_queue.time_zone`; setting it to `nil` falls back to the system's local time.
|
|
728
|
+
|
|
729
|
+
You can optionally supply the following for each task:
|
|
690
730
|
- `args`: the arguments to be passed to the job, as a single argument, a hash, or an array of arguments that can also include kwargs as the last element in the array.
|
|
691
731
|
|
|
692
732
|
The job in the example configuration above will be enqueued every second as:
|
|
@@ -732,6 +772,38 @@ my_periodic_resque_job:
|
|
|
732
772
|
|
|
733
773
|
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.
|
|
734
774
|
|
|
775
|
+
### Scheduling and unscheduling recurring tasks dynamically
|
|
776
|
+
|
|
777
|
+
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).
|
|
778
|
+
|
|
779
|
+
```yaml
|
|
780
|
+
scheduler:
|
|
781
|
+
dynamic_tasks_enabled: true
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
Then you can use the following methods to add recurring tasks dynamically:
|
|
785
|
+
|
|
786
|
+
```ruby
|
|
787
|
+
SolidQueue.schedule_recurring_task(
|
|
788
|
+
"my_dynamic_task",
|
|
789
|
+
class: "MyJob",
|
|
790
|
+
args: [1, 2],
|
|
791
|
+
schedule: "every 10 minutes"
|
|
792
|
+
)
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
This accepts the same options as the YAML configuration: `class`, `args`, `command`, `schedule`, `queue`, `priority`, and `description`.
|
|
796
|
+
|
|
797
|
+
To remove a dynamically scheduled task:
|
|
798
|
+
|
|
799
|
+
```ruby
|
|
800
|
+
SolidQueue.unschedule_recurring_task("my_dynamic_task")
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
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.
|
|
804
|
+
|
|
805
|
+
Tasks scheduled like this persist between Solid Queue's restarts and won't stop running until you manually unschedule them.
|
|
806
|
+
|
|
735
807
|
## Inspiration
|
|
736
808
|
|
|
737
809
|
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.
|
data/UPGRADING.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
# Upgrading to version 1.5.x
|
|
2
|
+
Ruby 3.1 is no longer supported, as it reached end-of-life in March 2025. Solid Queue now requires Ruby 3.2 or newer. If you're still on Ruby 3.1, Bundler will continue to resolve solid_queue 1.4.x for you, but you won't receive any new versions until you upgrade Ruby.
|
|
3
|
+
|
|
4
|
+
Recurring schedules that don't specify a time zone are now interpreted in your application's configured time zone (`config.time_zone`) by default, instead of the system's local time. This only affects schedules without an explicit time zone (e.g. `every day at 9am`); schedules that already include one (e.g. `0 9 * * * America/New_York`) are unaffected.
|
|
5
|
+
|
|
6
|
+
If your `config.time_zone` differs from the system time where your processes run, recurring jobs may fire at a different wall-clock time than before. To keep the previous behavior, set:
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
config.solid_queue.time_zone = nil
|
|
10
|
+
```
|
|
11
|
+
|
|
1
12
|
# Upgrading to version 1.x
|
|
2
13
|
The value returned for `enqueue_after_transaction_commit?` has changed to `true`, and it's no longer configurable. If you want to change this, you need to use Active Job's configuration options.
|
|
3
14
|
|
|
@@ -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
|
|
@@ -43,7 +43,6 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
|
|
|
43
43
|
SolidQueue.instrument(:fail_many_claimed) do |payload|
|
|
44
44
|
executions.each do |execution|
|
|
45
45
|
execution.failed_with(error)
|
|
46
|
-
execution.unblock_next_job
|
|
47
46
|
end
|
|
48
47
|
|
|
49
48
|
payload[:process_ids] = executions.map(&:process_id).uniq
|
|
@@ -71,13 +70,11 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
|
|
|
71
70
|
failed_with(result.error)
|
|
72
71
|
raise result.error
|
|
73
72
|
end
|
|
74
|
-
ensure
|
|
75
|
-
unblock_next_job
|
|
76
73
|
end
|
|
77
74
|
|
|
78
75
|
def release
|
|
79
76
|
SolidQueue.instrument(:release_claimed, job_id: job.id, process_id: process_id) do
|
|
80
|
-
|
|
77
|
+
unless_already_finalized do
|
|
81
78
|
job.dispatch_bypassing_concurrency_limits
|
|
82
79
|
destroy!
|
|
83
80
|
end
|
|
@@ -89,14 +86,7 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
|
|
|
89
86
|
end
|
|
90
87
|
|
|
91
88
|
def failed_with(error)
|
|
92
|
-
|
|
93
|
-
job.failed_with(error)
|
|
94
|
-
destroy!
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def unblock_next_job
|
|
99
|
-
job.unblock_next_blocked_job
|
|
89
|
+
finalize { job.failed_with(error) }
|
|
100
90
|
end
|
|
101
91
|
|
|
102
92
|
private
|
|
@@ -108,9 +98,28 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
|
|
|
108
98
|
end
|
|
109
99
|
|
|
110
100
|
def finished
|
|
111
|
-
|
|
112
|
-
|
|
101
|
+
finalize { job.finished! }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def finalize
|
|
105
|
+
finalized = unless_already_finalized do
|
|
106
|
+
yield
|
|
113
107
|
destroy!
|
|
108
|
+
true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Unblock the next job outside the finalize transaction so a failure while
|
|
112
|
+
# releasing the concurrency lock or dispatching the next job can't roll back
|
|
113
|
+
# a job that already finished or failed. Only the actor that owned and
|
|
114
|
+
# finalized the claim gets here, so the lock is released exactly once.
|
|
115
|
+
job.unblock_next_blocked_job if finalized
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def unless_already_finalized
|
|
119
|
+
transaction do
|
|
120
|
+
return false unless self.class.unscoped.lock.find_by(id: id)
|
|
121
|
+
|
|
122
|
+
yield
|
|
114
123
|
end
|
|
115
124
|
end
|
|
116
125
|
end
|
|
@@ -41,15 +41,16 @@ module SolidQueue
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def successfully_dispatched(jobs)
|
|
44
|
-
|
|
44
|
+
jobs_by_id = jobs.index_by(&:id)
|
|
45
|
+
dispatched_and_ready(jobs_by_id) + dispatched_and_blocked(jobs_by_id)
|
|
45
46
|
end
|
|
46
47
|
|
|
47
|
-
def dispatched_and_ready(
|
|
48
|
-
|
|
48
|
+
def dispatched_and_ready(jobs_by_id)
|
|
49
|
+
ReadyExecution.where(job_id: jobs_by_id.keys).pluck(:job_id).map { |id| jobs_by_id[id] }
|
|
49
50
|
end
|
|
50
51
|
|
|
51
|
-
def dispatched_and_blocked(
|
|
52
|
-
|
|
52
|
+
def dispatched_and_blocked(jobs_by_id)
|
|
53
|
+
BlockedExecution.where(job_id: jobs_by_id.keys).pluck(:job_id).map { |id| jobs_by_id[id] }
|
|
53
54
|
end
|
|
54
55
|
end
|
|
55
56
|
|
|
@@ -23,7 +23,8 @@ module SolidQueue
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def successfully_scheduled(jobs)
|
|
26
|
-
|
|
26
|
+
jobs_by_id = jobs.index_by(&:id)
|
|
27
|
+
ScheduledExecution.where(job_id: jobs_by_id.keys).pluck(:job_id).map { |id| jobs_by_id[id] }
|
|
27
28
|
end
|
|
28
29
|
end
|
|
29
30
|
|
|
@@ -43,7 +43,7 @@ module SolidQueue
|
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def all_queues
|
|
46
|
-
relation.
|
|
46
|
+
relation.distinct_values_of(:queue_name)
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def exact_names
|
|
@@ -53,7 +53,7 @@ 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).
|
|
56
|
+
relation.where(([ "queue_name LIKE ?" ] * prefixes.count).join(" OR "), *prefixes).distinct_values_of(:queue_name)
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
59
|
|
|
@@ -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)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidQueue
|
|
4
|
+
class Record
|
|
5
|
+
module DistinctValues
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
# PostgreSQL has no native loose index scan, so a plain DISTINCT on a leading
|
|
9
|
+
# index column degrades to a full index scan on large tables. We emulate one
|
|
10
|
+
# with a recursive CTE that walks the index jumping between distinct values.
|
|
11
|
+
class_methods do
|
|
12
|
+
def distinct_values_of(column)
|
|
13
|
+
if loose_index_scan_emulation_needed?
|
|
14
|
+
loose_distinct_via_recursive_cte(column)
|
|
15
|
+
else
|
|
16
|
+
distinct.pluck(column)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
def loose_index_scan_emulation_needed?
|
|
22
|
+
connection.adapter_name == "PostgreSQL"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Emulates a loose index scan, honoring the current scope (e.g. LIKE prefixes)
|
|
26
|
+
# by building the anchor and the recursive step as scoped relations, whose
|
|
27
|
+
# #to_sql inlines any bind parameters so they can be embedded in the raw CTE.
|
|
28
|
+
def loose_distinct_via_recursive_cte(column)
|
|
29
|
+
col = connection.quote_column_name(column)
|
|
30
|
+
|
|
31
|
+
connection.select_values(<<~SQL.squish)
|
|
32
|
+
WITH RECURSIVE t AS (
|
|
33
|
+
(#{next_distinct_value(col, "#{col} IS NOT NULL")})
|
|
34
|
+
UNION ALL
|
|
35
|
+
SELECT (#{next_distinct_value(col, "#{col} > t.#{col}")}) FROM t WHERE t.#{col} IS NOT NULL
|
|
36
|
+
)
|
|
37
|
+
SELECT #{col} FROM t WHERE #{col} IS NOT NULL
|
|
38
|
+
SQL
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Smallest value of `col` within the current scope that matches `condition`.
|
|
42
|
+
def next_distinct_value(col, condition)
|
|
43
|
+
all.where(Arel.sql(condition)).reorder(Arel.sql(col)).limit(1).select(Arel.sql(col)).to_sql
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
module SolidQueue
|
|
4
4
|
class Record < ActiveRecord::Base
|
|
5
5
|
self.abstract_class = true
|
|
6
|
+
self.strict_loading_by_default = false
|
|
7
|
+
|
|
8
|
+
include DistinctValues
|
|
6
9
|
|
|
7
10
|
connects_to(**SolidQueue.connects_to) if SolidQueue.connects_to
|
|
8
11
|
|
|
@@ -20,6 +23,13 @@ module SolidQueue
|
|
|
20
23
|
connection.supports_insert_conflict_target?
|
|
21
24
|
end
|
|
22
25
|
end
|
|
26
|
+
|
|
27
|
+
# Pass index hints to the query optimizer using SQL comment hints.
|
|
28
|
+
# Uses MySQL 8 optimizer hint query comments, which SQLite and
|
|
29
|
+
# PostgreSQL ignore.
|
|
30
|
+
def use_index(*indexes)
|
|
31
|
+
optimizer_hints "INDEX(#{quoted_table_name} #{indexes.join(', ')})"
|
|
32
|
+
end
|
|
23
33
|
end
|
|
24
34
|
end
|
|
25
35
|
end
|
|
@@ -11,6 +11,7 @@ module SolidQueue
|
|
|
11
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,7 +33,15 @@ 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)
|
|
@@ -47,16 +56,17 @@ module SolidQueue
|
|
|
47
56
|
end
|
|
48
57
|
end
|
|
49
58
|
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
|
|
60
|
+
def next_time_after(time)
|
|
61
|
+
parsed_schedule_with_time_zone.next_time(time).utc
|
|
52
62
|
end
|
|
53
63
|
|
|
54
64
|
def next_time
|
|
55
|
-
|
|
65
|
+
parsed_schedule_with_time_zone.next_time.utc
|
|
56
66
|
end
|
|
57
67
|
|
|
58
68
|
def previous_time
|
|
59
|
-
|
|
69
|
+
parsed_schedule_with_time_zone.previous_time.utc
|
|
60
70
|
end
|
|
61
71
|
|
|
62
72
|
def last_enqueued_time
|
|
@@ -76,6 +86,7 @@ module SolidQueue
|
|
|
76
86
|
|
|
77
87
|
perform_later.tap do |job|
|
|
78
88
|
unless job.successfully_enqueued?
|
|
89
|
+
report_enqueue_error(job.enqueue_error, at: at)
|
|
79
90
|
payload[:enqueue_error] = job.enqueue_error&.message
|
|
80
91
|
end
|
|
81
92
|
end
|
|
@@ -88,6 +99,7 @@ module SolidQueue
|
|
|
88
99
|
payload[:skipped] = true
|
|
89
100
|
false
|
|
90
101
|
rescue Job::EnqueueError => error
|
|
102
|
+
report_enqueue_error(error, at: at)
|
|
91
103
|
payload[:enqueue_error] = error.message
|
|
92
104
|
false
|
|
93
105
|
end
|
|
@@ -159,11 +171,24 @@ module SolidQueue
|
|
|
159
171
|
end
|
|
160
172
|
end
|
|
161
173
|
|
|
174
|
+
def parsed_schedule_with_time_zone
|
|
175
|
+
@parsed_schedule_with_time_zone ||= apply_default_time_zone_to(parsed_schedule)
|
|
176
|
+
end
|
|
162
177
|
|
|
163
178
|
def parsed_schedule
|
|
164
179
|
@parsed_schedule ||= Fugit.parse(schedule, multi: :fail)
|
|
165
180
|
end
|
|
166
181
|
|
|
182
|
+
def apply_default_time_zone_to(schedule)
|
|
183
|
+
if schedule.respond_to?(:zone) && schedule.zone.nil? && default_time_zone.present?
|
|
184
|
+
Fugit.parse("#{schedule.to_cron_s} #{default_time_zone}", multi: :fail)
|
|
185
|
+
else
|
|
186
|
+
schedule
|
|
187
|
+
end
|
|
188
|
+
rescue ArgumentError
|
|
189
|
+
schedule
|
|
190
|
+
end
|
|
191
|
+
|
|
167
192
|
def job_class
|
|
168
193
|
@job_class ||= class_name.present? ? class_name.safe_constantize : self.class.default_job_class
|
|
169
194
|
end
|
|
@@ -171,5 +196,15 @@ module SolidQueue
|
|
|
171
196
|
def enqueue_options
|
|
172
197
|
{ queue: queue_name, priority: priority }.compact
|
|
173
198
|
end
|
|
199
|
+
|
|
200
|
+
def default_time_zone
|
|
201
|
+
SolidQueue.time_zone
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def report_enqueue_error(error, at:)
|
|
205
|
+
if error
|
|
206
|
+
Rails.error.report(error, handled: true, source: "application.solid_queue", context: { task: key, at: at })
|
|
207
|
+
end
|
|
208
|
+
end
|
|
174
209
|
end
|
|
175
210
|
end
|
|
@@ -32,7 +32,13 @@ module SolidQueue
|
|
|
32
32
|
|
|
33
33
|
class Proxy
|
|
34
34
|
def self.signal_all(jobs)
|
|
35
|
-
|
|
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
|
|
36
42
|
end
|
|
37
43
|
|
|
38
44
|
def initialize(job)
|
|
@@ -8,9 +8,17 @@ module ActiveJob
|
|
|
8
8
|
#
|
|
9
9
|
# Rails.application.config.active_job.queue_adapter = :solid_queue
|
|
10
10
|
class SolidQueueAdapter < (Rails::VERSION::MAJOR == 7 && Rails::VERSION::MINOR == 1 ? Object : AbstractAdapter)
|
|
11
|
-
class_attribute :stopping, default: false, instance_writer: false
|
|
11
|
+
class_attribute :stopping, default: false, instance_writer: false, instance_predicate: false
|
|
12
12
|
SolidQueue.on_worker_stop { self.stopping = true }
|
|
13
13
|
|
|
14
|
+
# Accept an optional job argument for compatibility with Rails main, which
|
|
15
|
+
# began passing the running job to +queue_adapter.stopping?+ so adapters can
|
|
16
|
+
# decide whether to checkpoint based on it. We rely solely on the worker
|
|
17
|
+
# shutdown flag, so the argument is ignored.
|
|
18
|
+
def stopping?(_job = nil)
|
|
19
|
+
self.class.stopping
|
|
20
|
+
end
|
|
21
|
+
|
|
14
22
|
def enqueue_after_transaction_commit?
|
|
15
23
|
true
|
|
16
24
|
end
|
data/lib/solid_queue/cli.rb
CHANGED
|
@@ -30,5 +30,11 @@ module SolidQueue
|
|
|
30
30
|
def start
|
|
31
31
|
SolidQueue::Supervisor.start(**options.symbolize_keys)
|
|
32
32
|
end
|
|
33
|
+
|
|
34
|
+
desc :check, "Validates the Solid Queue configuration for the current Rails env without starting anything. Exits non-zero on errors."
|
|
35
|
+
def check
|
|
36
|
+
configuration = SolidQueue::Configuration.new(**options.symbolize_keys)
|
|
37
|
+
exit 1 unless configuration.check
|
|
38
|
+
end
|
|
33
39
|
end
|
|
34
40
|
end
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
module SolidQueue
|
|
4
4
|
class Configuration
|
|
5
5
|
include ActiveModel::Model
|
|
6
|
+
include ActiveModel::Validations::Callbacks
|
|
6
7
|
|
|
7
|
-
validate :ensure_configured_processes
|
|
8
|
-
validate :
|
|
9
|
-
|
|
8
|
+
validate :ensure_configured_processes, :ensure_valid_recurring_tasks
|
|
9
|
+
validate :warn_about_incorrectly_sized_thread_pool, :warn_about_missing_config_files
|
|
10
|
+
|
|
11
|
+
before_validation { warnings.clear }
|
|
10
12
|
|
|
11
13
|
class Process < Struct.new(:kind, :attributes)
|
|
12
14
|
def instantiate
|
|
@@ -28,6 +30,11 @@ module SolidQueue
|
|
|
28
30
|
concurrency_maintenance_interval: 600
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
SCHEDULER_DEFAULTS = {
|
|
34
|
+
polling_interval: 5,
|
|
35
|
+
dynamic_tasks_enabled: false
|
|
36
|
+
}
|
|
37
|
+
|
|
31
38
|
DEFAULT_CONFIG_FILE_PATH = "config/queue.yml"
|
|
32
39
|
DEFAULT_RECURRING_SCHEDULE_FILE_PATH = "config/recurring.yml"
|
|
33
40
|
|
|
@@ -42,26 +49,32 @@ module SolidQueue
|
|
|
42
49
|
end
|
|
43
50
|
end
|
|
44
51
|
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
else
|
|
49
|
-
error_messages = invalid_tasks.map do |task|
|
|
50
|
-
all_messages = task.errors.full_messages.map { |msg| "\t#{msg}" }.join("\n")
|
|
51
|
-
"#{task.key}:\n#{all_messages}"
|
|
52
|
-
end
|
|
53
|
-
.join("\n")
|
|
52
|
+
def mode
|
|
53
|
+
options[:mode].to_s.inquiry
|
|
54
|
+
end
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
def standalone?
|
|
57
|
+
mode.fork? || options[:standalone]
|
|
57
58
|
end
|
|
58
59
|
|
|
59
|
-
def
|
|
60
|
-
@
|
|
60
|
+
def warnings
|
|
61
|
+
@warnings ||= ActiveModel::Errors.new(self)
|
|
61
62
|
end
|
|
62
63
|
|
|
63
|
-
def
|
|
64
|
-
|
|
64
|
+
def check
|
|
65
|
+
if valid?
|
|
66
|
+
warnings.full_messages.each { |warning| $stderr.puts warning }
|
|
67
|
+
$stdout.puts "Solid Queue configuration is valid."
|
|
68
|
+
|
|
69
|
+
true
|
|
70
|
+
else
|
|
71
|
+
$stderr.puts "Solid Queue configuration is invalid:"
|
|
72
|
+
(warnings.full_messages + errors.full_messages).each do |message|
|
|
73
|
+
message.each_line { |line| $stderr.puts " #{line.chomp}" }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
false
|
|
77
|
+
end
|
|
65
78
|
end
|
|
66
79
|
|
|
67
80
|
private
|
|
@@ -83,11 +96,26 @@ module SolidQueue
|
|
|
83
96
|
end
|
|
84
97
|
end
|
|
85
98
|
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
|
|
99
|
+
def warn_about_incorrectly_sized_thread_pool
|
|
100
|
+
db_pool_size = SolidQueue::Record.connection_pool&.size
|
|
101
|
+
|
|
102
|
+
if db_pool_size && db_pool_size < estimated_number_of_threads
|
|
103
|
+
warnings.add(:base, "Warning: Solid Queue is configured to use #{estimated_number_of_threads} threads but the " \
|
|
89
104
|
"database connection pool is #{db_pool_size}. Increase it in `config/database.yml`")
|
|
90
105
|
end
|
|
106
|
+
rescue ActiveRecord::ActiveRecordError
|
|
107
|
+
# No usable database connection. Skip the pool-size warning in that case.
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def warn_about_missing_config_files
|
|
111
|
+
files = [ options[:config_file] ]
|
|
112
|
+
files << options[:recurring_schedule_file] unless skip_recurring_tasks?
|
|
113
|
+
|
|
114
|
+
files.compact.each do |file|
|
|
115
|
+
unless Pathname.new(file).exist?
|
|
116
|
+
warnings.add(:base, "Warning: provided configuration file '#{file}' does not exist. Falling back to default configuration.")
|
|
117
|
+
end
|
|
118
|
+
end
|
|
91
119
|
end
|
|
92
120
|
|
|
93
121
|
def default_options
|
|
@@ -137,8 +165,10 @@ module SolidQueue
|
|
|
137
165
|
end
|
|
138
166
|
|
|
139
167
|
def schedulers
|
|
140
|
-
if
|
|
141
|
-
|
|
168
|
+
return [] if skip_recurring_tasks?
|
|
169
|
+
|
|
170
|
+
if recurring_tasks.any? || dynamic_recurring_tasks_enabled?
|
|
171
|
+
[ Process.new(:scheduler, { recurring_tasks: recurring_tasks, **scheduler_options.with_defaults(SCHEDULER_DEFAULTS) }) ]
|
|
142
172
|
else
|
|
143
173
|
[]
|
|
144
174
|
end
|
|
@@ -154,17 +184,29 @@ module SolidQueue
|
|
|
154
184
|
.map { |options| options.dup.symbolize_keys }
|
|
155
185
|
end
|
|
156
186
|
|
|
187
|
+
def scheduler_options
|
|
188
|
+
@scheduler_options ||= processes_config.fetch(:scheduler, {}).dup.symbolize_keys
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def dynamic_recurring_tasks_enabled?
|
|
192
|
+
scheduler_options.fetch(:dynamic_tasks_enabled, SCHEDULER_DEFAULTS[:dynamic_tasks_enabled])
|
|
193
|
+
end
|
|
194
|
+
|
|
157
195
|
def recurring_tasks
|
|
158
196
|
@recurring_tasks ||= recurring_tasks_config.map do |id, options|
|
|
159
|
-
RecurringTask.from_configuration(id, **options) if options&.has_key?(:schedule)
|
|
197
|
+
RecurringTask.from_configuration(id, **options.merge(static: true)) if options&.has_key?(:schedule)
|
|
160
198
|
end.compact
|
|
161
199
|
end
|
|
162
200
|
|
|
163
201
|
def processes_config
|
|
164
202
|
@processes_config ||= config_from \
|
|
165
|
-
options.slice(:workers, :dispatchers).presence || options[:config_file],
|
|
166
|
-
keys: [ :workers, :dispatchers ],
|
|
167
|
-
fallback: {
|
|
203
|
+
options.slice(:workers, :dispatchers, :scheduler).presence || options[:config_file],
|
|
204
|
+
keys: [ :workers, :dispatchers, :scheduler ],
|
|
205
|
+
fallback: {
|
|
206
|
+
workers: [ WORKER_DEFAULTS ],
|
|
207
|
+
dispatchers: [ DISPATCHER_DEFAULTS ],
|
|
208
|
+
scheduler: SCHEDULER_DEFAULTS
|
|
209
|
+
}
|
|
168
210
|
end
|
|
169
211
|
|
|
170
212
|
def recurring_tasks_config
|
|
@@ -173,7 +215,6 @@ module SolidQueue
|
|
|
173
215
|
end
|
|
174
216
|
end
|
|
175
217
|
|
|
176
|
-
|
|
177
218
|
def config_from(file_or_hash, keys: [], fallback: {}, env: Rails.env)
|
|
178
219
|
load_config_from(file_or_hash).then do |config|
|
|
179
220
|
config = config[env.to_sym] ? config[env.to_sym] : config
|
|
@@ -203,7 +244,6 @@ module SolidQueue
|
|
|
203
244
|
if file.exist?
|
|
204
245
|
ActiveSupport::ConfigurationFile.parse(file).deep_symbolize_keys
|
|
205
246
|
else
|
|
206
|
-
puts "[solid_queue] WARNING: Provided configuration file '#{file}' does not exist. Falling back to default configuration."
|
|
207
247
|
{}
|
|
208
248
|
end
|
|
209
249
|
end
|
data/lib/solid_queue/engine.rb
CHANGED
|
@@ -16,6 +16,12 @@ module SolidQueue
|
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
initializer "solid_queue.time_zone" do |app|
|
|
20
|
+
unless config.solid_queue.key?(:time_zone)
|
|
21
|
+
SolidQueue.time_zone = app.config.time_zone
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
19
25
|
initializer "solid_queue.app_executor", before: :run_prepare_callbacks do |app|
|
|
20
26
|
config.solid_queue.app_executor ||= app.executor
|
|
21
27
|
config.solid_queue.on_thread_error ||= ->(exception) { Rails.error.report(exception, handled: false) }
|
|
@@ -4,7 +4,9 @@ module SolidQueue::Processes
|
|
|
4
4
|
module Runnable
|
|
5
5
|
include Supervised
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
def mode=(value)
|
|
8
|
+
@mode = (value || DEFAULT_MODE).to_s.inquiry
|
|
9
|
+
end
|
|
8
10
|
|
|
9
11
|
def start
|
|
10
12
|
run_in_mode do
|
|
@@ -33,7 +35,7 @@ module SolidQueue::Processes
|
|
|
33
35
|
DEFAULT_MODE = :async
|
|
34
36
|
|
|
35
37
|
def mode
|
|
36
|
-
|
|
38
|
+
@mode ||= DEFAULT_MODE.to_s.inquiry
|
|
37
39
|
end
|
|
38
40
|
|
|
39
41
|
def run_in_mode(&block)
|
|
@@ -4,21 +4,28 @@ module SolidQueue
|
|
|
4
4
|
class Scheduler::RecurringSchedule
|
|
5
5
|
include AppExecutor
|
|
6
6
|
|
|
7
|
-
attr_reader :
|
|
7
|
+
attr_reader :scheduled_tasks
|
|
8
|
+
|
|
9
|
+
def initialize(static_tasks, dynamic_tasks_enabled: false)
|
|
10
|
+
@static_tasks = Array(static_tasks).map { |task| RecurringTask.wrap(task) }.select(&:valid?)
|
|
11
|
+
@dynamic_tasks_enabled = dynamic_tasks_enabled
|
|
8
12
|
|
|
9
|
-
def initialize(tasks)
|
|
10
|
-
@configured_tasks = Array(tasks).map { |task| SolidQueue::RecurringTask.wrap(task) }.select(&:valid?)
|
|
11
13
|
@scheduled_tasks = Concurrent::Hash.new
|
|
12
14
|
end
|
|
13
15
|
|
|
16
|
+
def configured_tasks
|
|
17
|
+
static_tasks + dynamic_tasks
|
|
18
|
+
end
|
|
19
|
+
|
|
14
20
|
def empty?
|
|
15
|
-
|
|
21
|
+
scheduled_tasks.empty? && dynamic_tasks.empty?
|
|
16
22
|
end
|
|
17
23
|
|
|
18
24
|
def schedule_tasks
|
|
19
25
|
wrap_in_app_executor do
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
persist_static_tasks
|
|
27
|
+
reload_static_tasks
|
|
28
|
+
reload_dynamic_tasks
|
|
22
29
|
end
|
|
23
30
|
|
|
24
31
|
configured_tasks.each do |task|
|
|
@@ -26,8 +33,8 @@ module SolidQueue
|
|
|
26
33
|
end
|
|
27
34
|
end
|
|
28
35
|
|
|
29
|
-
def schedule_task(task)
|
|
30
|
-
scheduled_tasks[task.key] = schedule(task)
|
|
36
|
+
def schedule_task(task, run_at: task.next_time)
|
|
37
|
+
scheduled_tasks[task.key] = schedule(task, run_at: run_at)
|
|
31
38
|
end
|
|
32
39
|
|
|
33
40
|
def unschedule_tasks
|
|
@@ -39,19 +46,64 @@ module SolidQueue
|
|
|
39
46
|
configured_tasks.map(&:key)
|
|
40
47
|
end
|
|
41
48
|
|
|
49
|
+
def reschedule_dynamic_tasks
|
|
50
|
+
wrap_in_app_executor do
|
|
51
|
+
reload_dynamic_tasks
|
|
52
|
+
schedule_created_dynamic_tasks
|
|
53
|
+
unschedule_deleted_dynamic_tasks
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
42
57
|
private
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
58
|
+
attr_reader :static_tasks
|
|
59
|
+
|
|
60
|
+
def static_task_keys
|
|
61
|
+
static_tasks.map(&:key)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def dynamic_tasks
|
|
65
|
+
@dynamic_tasks ||= load_dynamic_tasks
|
|
46
66
|
end
|
|
47
67
|
|
|
48
|
-
def
|
|
49
|
-
@
|
|
68
|
+
def dynamic_tasks_enabled?
|
|
69
|
+
@dynamic_tasks_enabled
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def schedule_created_dynamic_tasks
|
|
73
|
+
RecurringTask.dynamic.where.not(key: scheduled_tasks.keys).each do |task|
|
|
74
|
+
schedule_task(task)
|
|
75
|
+
end
|
|
50
76
|
end
|
|
51
77
|
|
|
52
|
-
def
|
|
53
|
-
|
|
54
|
-
|
|
78
|
+
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)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def persist_static_tasks
|
|
86
|
+
RecurringTask.static.where.not(key: static_task_keys).delete_all
|
|
87
|
+
RecurringTask.create_or_update_all static_tasks
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def reload_static_tasks
|
|
91
|
+
@static_tasks = RecurringTask.static.where(key: static_task_keys).to_a
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def reload_dynamic_tasks
|
|
95
|
+
@dynamic_tasks = load_dynamic_tasks
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def load_dynamic_tasks
|
|
99
|
+
dynamic_tasks_enabled? ? RecurringTask.dynamic.to_a : []
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def schedule(task, run_at: task.next_time)
|
|
103
|
+
delay = [ (run_at - Time.current).to_f, 0.1 ].max
|
|
104
|
+
|
|
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))
|
|
55
107
|
|
|
56
108
|
wrap_in_app_executor do
|
|
57
109
|
thread_task.enqueue(at: thread_task_run_at)
|
|
@@ -5,7 +5,7 @@ module SolidQueue
|
|
|
5
5
|
include Processes::Runnable
|
|
6
6
|
include LifecycleHooks
|
|
7
7
|
|
|
8
|
-
attr_reader :recurring_schedule
|
|
8
|
+
attr_reader :recurring_schedule, :polling_interval
|
|
9
9
|
|
|
10
10
|
after_boot :run_start_hooks
|
|
11
11
|
after_boot :schedule_recurring_tasks
|
|
@@ -14,7 +14,10 @@ module SolidQueue
|
|
|
14
14
|
after_shutdown :run_exit_hooks
|
|
15
15
|
|
|
16
16
|
def initialize(recurring_tasks:, **options)
|
|
17
|
-
|
|
17
|
+
options = options.dup.with_defaults(SolidQueue::Configuration::SCHEDULER_DEFAULTS)
|
|
18
|
+
@dynamic_tasks_enabled = options[:dynamic_tasks_enabled]
|
|
19
|
+
@polling_interval = options[:polling_interval]
|
|
20
|
+
@recurring_schedule = RecurringSchedule.new(recurring_tasks, dynamic_tasks_enabled: @dynamic_tasks_enabled)
|
|
18
21
|
|
|
19
22
|
super(**options)
|
|
20
23
|
end
|
|
@@ -24,13 +27,16 @@ module SolidQueue
|
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
private
|
|
27
|
-
|
|
30
|
+
|
|
31
|
+
STATIC_SLEEP_INTERVAL = 60
|
|
28
32
|
|
|
29
33
|
def run
|
|
30
34
|
loop do
|
|
31
35
|
break if shutting_down?
|
|
32
36
|
|
|
33
|
-
|
|
37
|
+
reload_dynamic_schedule if dynamic_tasks_enabled?
|
|
38
|
+
|
|
39
|
+
interruptible_sleep(sleep_interval)
|
|
34
40
|
end
|
|
35
41
|
ensure
|
|
36
42
|
SolidQueue.instrument(:shutdown_process, process: self) do
|
|
@@ -46,10 +52,23 @@ module SolidQueue
|
|
|
46
52
|
recurring_schedule.unschedule_tasks
|
|
47
53
|
end
|
|
48
54
|
|
|
55
|
+
def reload_dynamic_schedule
|
|
56
|
+
recurring_schedule.reschedule_dynamic_tasks
|
|
57
|
+
reload_metadata
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def dynamic_tasks_enabled?
|
|
61
|
+
@dynamic_tasks_enabled
|
|
62
|
+
end
|
|
63
|
+
|
|
49
64
|
def all_work_completed?
|
|
50
65
|
recurring_schedule.empty?
|
|
51
66
|
end
|
|
52
67
|
|
|
68
|
+
def sleep_interval
|
|
69
|
+
dynamic_tasks_enabled? ? polling_interval : STATIC_SLEEP_INTERVAL
|
|
70
|
+
end
|
|
71
|
+
|
|
53
72
|
def set_procline
|
|
54
73
|
procline "scheduling #{recurring_schedule.task_keys.join(",")}"
|
|
55
74
|
end
|
|
@@ -13,6 +13,8 @@ module SolidQueue
|
|
|
13
13
|
configuration = Configuration.new(**options)
|
|
14
14
|
|
|
15
15
|
if configuration.valid?
|
|
16
|
+
configuration.warnings.full_messages.each { |warning| SolidQueue.logger.warn(warning) }
|
|
17
|
+
|
|
16
18
|
klass = configuration.mode.fork? ? ForkSupervisor : AsyncSupervisor
|
|
17
19
|
klass.new(configuration).tap(&:start)
|
|
18
20
|
else
|
data/lib/solid_queue/tasks.rb
CHANGED
|
@@ -8,4 +8,10 @@ namespace :solid_queue do
|
|
|
8
8
|
task start: :environment do
|
|
9
9
|
SolidQueue::Supervisor.start
|
|
10
10
|
end
|
|
11
|
+
|
|
12
|
+
desc "validate the Solid Queue configuration for the current Rails env without starting any process"
|
|
13
|
+
task check: :environment do
|
|
14
|
+
configuration = SolidQueue::Configuration.new
|
|
15
|
+
exit 1 unless configuration.check
|
|
16
|
+
end
|
|
11
17
|
end
|
data/lib/solid_queue/version.rb
CHANGED
data/lib/solid_queue.rb
CHANGED
|
@@ -41,8 +41,25 @@ module SolidQueue
|
|
|
41
41
|
mattr_accessor :clear_finished_jobs_after, default: 1.day
|
|
42
42
|
mattr_accessor :default_concurrency_control_period, default: 3.minutes
|
|
43
43
|
|
|
44
|
+
mattr_reader :time_zone
|
|
45
|
+
|
|
46
|
+
def time_zone=(zone)
|
|
47
|
+
@@time_zone = if zone
|
|
48
|
+
resolved = zone.respond_to?(:tzinfo) ? zone : ActiveSupport::TimeZone[zone]
|
|
49
|
+
resolved&.tzinfo&.name || zone.to_s
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
44
53
|
delegate :on_start, :on_stop, :on_exit, to: Supervisor
|
|
45
54
|
|
|
55
|
+
def schedule_recurring_task(key, **options)
|
|
56
|
+
RecurringTask.create_dynamic_task(key, **options)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def unschedule_recurring_task(key)
|
|
60
|
+
RecurringTask.delete_dynamic_task(key)
|
|
61
|
+
end
|
|
62
|
+
|
|
46
63
|
[ Dispatcher, Scheduler, Worker ].each do |process|
|
|
47
64
|
define_singleton_method(:"on_#{process.name.demodulize.downcase}_start") do |&block|
|
|
48
65
|
process.on_start(&block)
|
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.
|
|
4
|
+
version: 1.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rosa Gutierrez
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-07-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -294,6 +294,7 @@ files:
|
|
|
294
294
|
- app/models/solid_queue/queue_selector.rb
|
|
295
295
|
- app/models/solid_queue/ready_execution.rb
|
|
296
296
|
- app/models/solid_queue/record.rb
|
|
297
|
+
- app/models/solid_queue/record/distinct_values.rb
|
|
297
298
|
- app/models/solid_queue/recurring_execution.rb
|
|
298
299
|
- app/models/solid_queue/recurring_task.rb
|
|
299
300
|
- app/models/solid_queue/recurring_task/arguments.rb
|
|
@@ -360,7 +361,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
360
361
|
requirements:
|
|
361
362
|
- - ">="
|
|
362
363
|
- !ruby/object:Gem::Version
|
|
363
|
-
version: '3.
|
|
364
|
+
version: '3.2'
|
|
364
365
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
365
366
|
requirements:
|
|
366
367
|
- - ">="
|