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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fb6ce5453b198c213a9de3fd6214ad410e158f1941a024fff5256f5895532523
4
- data.tar.gz: 8d9172270e0cbabbfde5c5084c4da52d615a787921acb1c837bccd494556d977
3
+ metadata.gz: d40f055103084c9aaa9efe6ded0e675ff967a88200bf9f5579b1287e29d48818
4
+ data.tar.gz: af6ab85d5c674dbceff32768b5aa7b4d87bc1aeea24476e3fb469d639c4fad46
5
5
  SHA512:
6
- metadata.gz: 3c9155032132f14e03132e651846fa4b2bec944f96eb62d988dc75a82abfcb5f4e3a1fde9f4bb4591866d8cb230bd188df4cb9f028b6adcdf09f161d9a48f2e0
7
- data.tar.gz: 0aecdd6b91de9f6026b9ea041a673709e348ddaac093ad6ebb57b5bdd98cd6b4f255e8e9350d790a8635703d0249407871f2c6258594593871c56b690fc8f3e7
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.1.6.)
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 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`.
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 and SQLite, however, don't implement this technique, which means that if your `solid_queue_ready_executions` table is very big because your queues get very deep, this query will get slow. Normally your `solid_queue_ready_executions` table will be small, but it can happen.
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, **) { ... }, duration: max_interval_to_guarantee_concurrency_limit, group: concurrency_group, on_conflict: on_conflict_behaviour
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 up to the interval defined by `duration` has elapsed.
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 up to the `duration` interval if something happens and a running job fails to release the semaphore.
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 optionally supply the following for each task:
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).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
@@ -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
- transaction do
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
- transaction do
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
- transaction do
112
- job.finished!
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
- dispatched_and_ready(jobs) + dispatched_and_blocked(jobs)
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(jobs)
48
- where(id: ReadyExecution.where(job_id: jobs.map(&:id)).pluck(:job_id))
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(jobs)
52
- where(id: BlockedExecution.where(job_id: jobs.map(&:id)).pluck(:job_id))
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
- where(id: ScheduledExecution.where(job_id: jobs.map(&:id)).pluck(:job_id))
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
 
@@ -6,9 +6,7 @@ module SolidQueue
6
6
 
7
7
  class << self
8
8
  def all
9
- Job.select(:queue_name).distinct.collect do |job|
10
- new(job.queue_name)
11
- end
9
+ Job.distinct_values_of(:queue_name).map { |name| new(name) }
12
10
  end
13
11
 
14
12
  def find_by_name(name)
@@ -43,7 +43,7 @@ module SolidQueue
43
43
  end
44
44
 
45
45
  def all_queues
46
- relation.distinct(:queue_name).pluck(:queue_name)
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).distinct(:queue_name).pluck(:queue_name)
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
- 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)
@@ -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
- def delay_from_now
51
- [ (next_time - Time.current).to_f, 0.1 ].max
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
- parsed_schedule.next_time.utc
65
+ parsed_schedule_with_time_zone.next_time.utc
56
66
  end
57
67
 
58
68
  def previous_time
59
- parsed_schedule.previous_time.utc
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
- Semaphore.where(key: jobs.map(&:concurrency_key)).update_all("value = value + 1")
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
@@ -6,7 +6,7 @@ default: &default
6
6
  - queues: "*"
7
7
  threads: 3
8
8
  processes: <%%= ENV.fetch("JOB_CONCURRENCY", 1) %>
9
- polling_interval: 0.1
9
+ polling_interval: 1
10
10
 
11
11
  development:
12
12
  <<: *default
@@ -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 :ensure_valid_recurring_tasks
9
- validate :ensure_correctly_sized_thread_pool
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 error_messages
46
- if configured_processes.none?
47
- "No workers or processed configured. Exiting..."
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
- "Invalid processes configured:\n#{error_messages}"
56
- end
56
+ def standalone?
57
+ mode.fork? || options[:standalone]
57
58
  end
58
59
 
59
- def mode
60
- @options[:mode].to_s.inquiry
60
+ def warnings
61
+ @warnings ||= ActiveModel::Errors.new(self)
61
62
  end
62
63
 
63
- def standalone?
64
- mode.fork? || @options[:standalone]
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 ensure_correctly_sized_thread_pool
87
- if (db_pool_size = SolidQueue::Record.connection_pool&.size) && db_pool_size < estimated_number_of_threads
88
- errors.add(:base, "Solid Queue is configured to use #{estimated_number_of_threads} threads but the " +
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 !skip_recurring_tasks? && recurring_tasks.any?
141
- [ Process.new(:scheduler, recurring_tasks: recurring_tasks) ]
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: { workers: [ WORKER_DEFAULTS ], dispatchers: [ DISPATCHER_DEFAULTS ] }
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
@@ -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) }
@@ -59,5 +59,9 @@ module SolidQueue::Processes
59
59
  self.process = nil
60
60
  wake_up
61
61
  end
62
+
63
+ def reload_metadata
64
+ wrap_in_app_executor { process&.update(metadata: metadata.compact) }
65
+ end
62
66
  end
63
67
  end
@@ -4,7 +4,9 @@ module SolidQueue::Processes
4
4
  module Runnable
5
5
  include Supervised
6
6
 
7
- attr_writer :mode
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
- (@mode || DEFAULT_MODE).to_s.inquiry
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 :configured_tasks, :scheduled_tasks
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
- configured_tasks.empty?
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
- persist_tasks
21
- reload_tasks
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
- def persist_tasks
44
- SolidQueue::RecurringTask.static.where.not(key: task_keys).delete_all
45
- SolidQueue::RecurringTask.create_or_update_all configured_tasks
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 reload_tasks
49
- @configured_tasks = SolidQueue::RecurringTask.where(key: task_keys).to_a
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 schedule(task)
53
- scheduled_task = Concurrent::ScheduledTask.new(task.delay_from_now, args: [ self, task, task.next_time ]) do |thread_schedule, thread_task, thread_task_run_at|
54
- thread_schedule.schedule_task(thread_task)
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
- @recurring_schedule = RecurringSchedule.new(recurring_tasks)
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
- SLEEP_INTERVAL = 60 # Right now it doesn't matter, can be set to 1 in the future for dynamic tasks
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
- interruptible_sleep(SLEEP_INTERVAL)
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
@@ -11,7 +11,7 @@ module SolidQueue
11
11
  end
12
12
 
13
13
  private
14
- SIGNALS = %i[ QUIT INT TERM ]
14
+ SIGNALS = Gem.win_platform? ? %i[ INT TERM ] : %i[ QUIT INT TERM ]
15
15
 
16
16
  def register_signal_handlers
17
17
  SIGNALS.each do |signal|
@@ -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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module SolidQueue
2
- VERSION = "1.3.2"
2
+ VERSION = "1.5.0"
3
3
  end
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.3.2
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-02-20 00:00:00.000000000 Z
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.1'
364
+ version: '3.2'
364
365
  required_rubygems_version: !ruby/object:Gem::Requirement
365
366
  requirements:
366
367
  - - ">="