solid_queue 1.3.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fb6ce5453b198c213a9de3fd6214ad410e158f1941a024fff5256f5895532523
4
- data.tar.gz: 8d9172270e0cbabbfde5c5084c4da52d615a787921acb1c837bccd494556d977
3
+ metadata.gz: fd0590f46160c60f3a496158cf8dc2412803025cd06d94ac423c32f0b688dd77
4
+ data.tar.gz: 8c892f457280b1974908d2de0c3e5be5229ff6bcb448ed61c2937ff4566da796
5
5
  SHA512:
6
- metadata.gz: 3c9155032132f14e03132e651846fa4b2bec944f96eb62d988dc75a82abfcb5f4e3a1fde9f4bb4591866d8cb230bd188df4cb9f028b6adcdf09f161d9a48f2e0
7
- data.tar.gz: 0aecdd6b91de9f6026b9ea041a673709e348ddaac093ad6ebb57b5bdd98cd6b4f255e8e9350d790a8635703d0249407871f2c6258594593871c56b690fc8f3e7
6
+ metadata.gz: 0a86b46d35b0cc8aef4a235e401b4470a6f6e64ff8c44ebdefc06f597d6cbe67ea76c786fc1e85caee1c3fc4dd492180530348078890ef74af90461705150a60
7
+ data.tar.gz: efeadbeb7dc0d044c801915d6ba4d8c249b249ef0dde8726454a359d3f1a4ce9ad4e2edb6cd19f8c371967059081c0ef2904ea630a0ae620123939ce881f0fdb
data/README.md CHANGED
@@ -17,6 +17,7 @@ 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)
@@ -31,6 +32,7 @@ Solid Queue can be used with SQL databases such as MySQL, PostgreSQL, or SQLite,
31
32
  - [Puma plugin](#puma-plugin)
32
33
  - [Jobs and transactional integrity](#jobs-and-transactional-integrity)
33
34
  - [Recurring tasks](#recurring-tasks)
35
+ - [Scheduling and unscheduling recurring tasks dynamically](#scheduling-and-unscheduling-recurring-tasks-dynamically)
34
36
  - [Inspiration](#inspiration)
35
37
  - [License](#license)
36
38
 
@@ -209,7 +211,7 @@ By default, Solid Queue will try to find your configuration under `config/queue.
209
211
  bin/jobs -c config/calendar.yml
210
212
  ```
211
213
 
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`.
214
+ You can also skip the scheduler process by setting the environment variable `SOLID_QUEUE_SKIP_RECURRING=true`. This is useful for environments like staging, review apps, or development where you don't want any recurring jobs to run. This is equivalent to using the `--skip-recurring` option with `bin/jobs`.
213
215
 
214
216
  This is what this configuration looks like:
215
217
 
@@ -227,6 +229,10 @@ production:
227
229
  threads: 5
228
230
  polling_interval: 0.1
229
231
  processes: 3
232
+ scheduler:
233
+ dynamic_tasks_enabled: true
234
+ polling_interval: 5
235
+
230
236
  ```
231
237
 
232
238
  Everything is optional. If no configuration at all is provided, Solid Queue will run with one dispatcher and one worker with default settings. If you want to run only dispatchers or workers, you just need to include that section alone in the configuration. For example, with the following configuration:
@@ -271,6 +277,19 @@ It is recommended to set this value less than or equal to the queue database's c
271
277
  - `concurrency_maintenance`: whether the dispatcher will perform the concurrency maintenance work. This is `true` by default, and it's useful if you don't use any [concurrency controls](#concurrency-controls) and want to disable it or if you run multiple dispatchers and want some of them to just dispatch jobs without doing anything else.
272
278
 
273
279
 
280
+ ### Optional scheduler configuration
281
+
282
+ Optionally, you can configure the scheduler process under the `scheduler` section in your `config/queue.yml` if you'd like to [schedule recurring tasks dynamically](#scheduling-and-unscheduling-recurring-tasks-dynamically).
283
+
284
+ ```yaml
285
+ scheduler:
286
+ dynamic_tasks_enabled: true
287
+ polling_interval: 5
288
+ ```
289
+
290
+ - `dynamic_tasks_enabled`: whether the scheduler should poll for [dynamically scheduled recurring tasks](#scheduling-and-unscheduling-recurring-tasks-dynamically). This is `false` by default. When enabled, the scheduler will poll the database at the given `polling_interval` to pick up tasks scheduled via `SolidQueue.schedule_recurring_task`.
291
+ - `polling_interval`: how frequently (in seconds) the scheduler checks for dynamic task changes. Defaults to `5`.
292
+
274
293
  ### Queue order and priorities
275
294
 
276
295
  As mentioned above, if you specify a list of queues for a worker, these will be polled in the order given, such as for the list `real_time,background`, no jobs will be taken from `background` unless there aren't any more jobs waiting in `real_time`.
@@ -462,7 +481,7 @@ class MyJob < ApplicationJob
462
481
  - `group` is used to control the concurrency of different job classes together. It defaults to the job class name.
463
482
  - `on_conflict` controls behaviour when enqueuing a job that conflicts with the concurrency limits configured. It can be set to one of the following:
464
483
  - (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.
484
+ - `:discard`: the job is discarded. When you choose this option, bear in mind that if a job runs and fails to remove the concurrency lock (or _semaphore_, read below to know more about this), all jobs conflicting with it will be discarded until the interval defined by `duration` has elapsed.
466
485
 
467
486
  When a job includes these controls, we'll ensure that, at most, the number of jobs (indicated as `to`) that yield the same `key` will be performed concurrently, and this guarantee will last for `duration` for each job enqueued. Note that there's no guarantee about _the order of execution_, only about jobs being performed at the same time (overlapping).
468
487
 
@@ -472,7 +491,7 @@ Since something can happen that prevents the first job from releasing the semaph
472
491
 
473
492
  It's important to note that after one or more candidate jobs are unblocked (either because a job finishes or because `duration` expires and a semaphore is released), the `duration` timer for the still blocked jobs is reset. This happens indirectly via the expiration time of the semaphore, which is updated.
474
493
 
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.
494
+ When using `discard` as the behaviour to handle conflicts, you might have jobs discarded for until the `duration` interval if something happens and a running job fails to release the semaphore.
476
495
 
477
496
 
478
497
  For example:
@@ -732,6 +751,38 @@ my_periodic_resque_job:
732
751
 
733
752
  and the job will be enqueued via `perform_later` so it'll run in Resque. However, in this case we won't track any `solid_queue_recurring_execution` record for it and there won't be any guarantees that the job is enqueued only once each time.
734
753
 
754
+ ### Scheduling and unscheduling recurring tasks dynamically
755
+
756
+ You can schedule and unschedule recurring tasks at runtime, without editing the configuration file. To enable this, you need to set `dynamic_tasks_enabled: true` in the `scheduler` section of your `config/queue.yml`, [as explained earlier](#optional-scheduler-configuration).
757
+
758
+ ```yaml
759
+ scheduler:
760
+ dynamic_tasks_enabled: true
761
+ ```
762
+
763
+ Then you can use the following methods to add recurring tasks dynamically:
764
+
765
+ ```ruby
766
+ SolidQueue.schedule_recurring_task(
767
+ "my_dynamic_task",
768
+ class: "MyJob",
769
+ args: [1, 2],
770
+ schedule: "every 10 minutes"
771
+ )
772
+ ```
773
+
774
+ This accepts the same options as the YAML configuration: `class`, `args`, `command`, `schedule`, `queue`, `priority`, and `description`.
775
+
776
+ To remove a dynamically scheduled task:
777
+
778
+ ```ruby
779
+ SolidQueue.unschedule_recurring_task("my_dynamic_task")
780
+ ```
781
+
782
+ Only dynamic tasks can be unscheduled at runtime. Attempting to unschedule a static task (defined in `config/recurring.yml`) will raise an `ActiveRecord::RecordNotFound` error.
783
+
784
+ Tasks scheduled like this persist between Solid Queue's restarts and won't stop running until you manually unschedule them.
785
+
735
786
  ## Inspiration
736
787
 
737
788
  Solid Queue has been inspired by [resque](https://github.com/resque/resque) and [GoodJob](https://github.com/bensheldon/good_job). We recommend checking out these projects as they're great examples from which we've learnt a lot.
@@ -26,7 +26,9 @@ module SolidQueue
26
26
 
27
27
  def release_one(concurrency_key)
28
28
  transaction do
29
- if execution = ordered.where(concurrency_key: concurrency_key).limit(1).non_blocking_lock.first
29
+ if execution = ordered.where(concurrency_key: concurrency_key).limit(1)
30
+ .use_index(:index_solid_queue_blocked_executions_for_release)
31
+ .non_blocking_lock.first
30
32
  execution.release
31
33
  end
32
34
  end
@@ -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)
@@ -20,6 +20,13 @@ module SolidQueue
20
20
  connection.supports_insert_conflict_target?
21
21
  end
22
22
  end
23
+
24
+ # Pass index hints to the query optimizer using SQL comment hints.
25
+ # Uses MySQL 8 optimizer hint query comments, which SQLite and
26
+ # PostgreSQL ignore.
27
+ def use_index(*indexes)
28
+ optimizer_hints "INDEX(#{quoted_table_name} #{indexes.join(', ')})"
29
+ end
23
30
  end
24
31
  end
25
32
  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)
@@ -28,6 +28,11 @@ module SolidQueue
28
28
  concurrency_maintenance_interval: 600
29
29
  }
30
30
 
31
+ SCHEDULER_DEFAULTS = {
32
+ polling_interval: 5,
33
+ dynamic_tasks_enabled: false
34
+ }
35
+
31
36
  DEFAULT_CONFIG_FILE_PATH = "config/queue.yml"
32
37
  DEFAULT_RECURRING_SCHEDULE_FILE_PATH = "config/recurring.yml"
33
38
 
@@ -137,8 +142,10 @@ module SolidQueue
137
142
  end
138
143
 
139
144
  def schedulers
140
- if !skip_recurring_tasks? && recurring_tasks.any?
141
- [ Process.new(:scheduler, recurring_tasks: recurring_tasks) ]
145
+ return [] if skip_recurring_tasks?
146
+
147
+ if recurring_tasks.any? || dynamic_recurring_tasks_enabled?
148
+ [ Process.new(:scheduler, { recurring_tasks: recurring_tasks, **scheduler_options.with_defaults(SCHEDULER_DEFAULTS) }) ]
142
149
  else
143
150
  []
144
151
  end
@@ -154,17 +161,29 @@ module SolidQueue
154
161
  .map { |options| options.dup.symbolize_keys }
155
162
  end
156
163
 
164
+ def scheduler_options
165
+ @scheduler_options ||= processes_config.fetch(:scheduler, {}).dup.symbolize_keys
166
+ end
167
+
168
+ def dynamic_recurring_tasks_enabled?
169
+ scheduler_options.fetch(:dynamic_tasks_enabled, SCHEDULER_DEFAULTS[:dynamic_tasks_enabled])
170
+ end
171
+
157
172
  def recurring_tasks
158
173
  @recurring_tasks ||= recurring_tasks_config.map do |id, options|
159
- RecurringTask.from_configuration(id, **options) if options&.has_key?(:schedule)
174
+ RecurringTask.from_configuration(id, **options.merge(static: true)) if options&.has_key?(:schedule)
160
175
  end.compact
161
176
  end
162
177
 
163
178
  def processes_config
164
179
  @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 ] }
180
+ options.slice(:workers, :dispatchers, :scheduler).presence || options[:config_file],
181
+ keys: [ :workers, :dispatchers, :scheduler ],
182
+ fallback: {
183
+ workers: [ WORKER_DEFAULTS ],
184
+ dispatchers: [ DISPATCHER_DEFAULTS ],
185
+ scheduler: SCHEDULER_DEFAULTS
186
+ }
168
187
  end
169
188
 
170
189
  def recurring_tasks_config
@@ -173,7 +192,6 @@ module SolidQueue
173
192
  end
174
193
  end
175
194
 
176
-
177
195
  def config_from(file_or_hash, keys: [], fallback: {}, env: Rails.env)
178
196
  load_config_from(file_or_hash).then do |config|
179
197
  config = config[env.to_sym] ? config[env.to_sym] : config
@@ -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,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|
@@ -39,14 +46,57 @@ 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
66
+ end
67
+
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
76
+ end
77
+
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
46
96
  end
47
97
 
48
- def reload_tasks
49
- @configured_tasks = SolidQueue::RecurringTask.where(key: task_keys).to_a
98
+ def load_dynamic_tasks
99
+ dynamic_tasks_enabled? ? RecurringTask.dynamic.to_a : []
50
100
  end
51
101
 
52
102
  def schedule(task)
@@ -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
@@ -1,3 +1,3 @@
1
1
  module SolidQueue
2
- VERSION = "1.3.2"
2
+ VERSION = "1.4.0"
3
3
  end
data/lib/solid_queue.rb CHANGED
@@ -43,6 +43,14 @@ module SolidQueue
43
43
 
44
44
  delegate :on_start, :on_stop, :on_exit, to: Supervisor
45
45
 
46
+ def schedule_recurring_task(key, **options)
47
+ RecurringTask.create_dynamic_task(key, **options)
48
+ end
49
+
50
+ def unschedule_recurring_task(key)
51
+ RecurringTask.delete_dynamic_task(key)
52
+ end
53
+
46
54
  [ Dispatcher, Scheduler, Worker ].each do |process|
47
55
  define_singleton_method(:"on_#{process.name.demodulize.downcase}_start") do |&block|
48
56
  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.4.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-03-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord