solid_queue 1.2.4 → 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: 5450063206508cf94195d16dfe5be8db7609b4b959a9e2e36ca7cc102b90e04b
4
- data.tar.gz: 6b10b7ddc67fdff01b31a78e486a4ea4f351277e7f851071406237300177d614
3
+ metadata.gz: fd0590f46160c60f3a496158cf8dc2412803025cd06d94ac423c32f0b688dd77
4
+ data.tar.gz: 8c892f457280b1974908d2de0c3e5be5229ff6bcb448ed61c2937ff4566da796
5
5
  SHA512:
6
- metadata.gz: 71a4c19d255e551e3a44aa1cdc508c5cb8dee3d66ae27c5b67a7fe2aaa31f958226e2a947bba773738274f717e5f635e412da1c471a03608657fc3deead4a961
7
- data.tar.gz: 602c99609a6b65d3edbd29c8b8c26e9eed040830a115f207c0cebe139da80c6e43ccd2c1d583f4920fc0550c546747055f7da2fa73d858b9d20b4c7e37e674a9
6
+ metadata.gz: 0a86b46d35b0cc8aef4a235e401b4470a6f6e64ff8c44ebdefc06f597d6cbe67ea76c786fc1e85caee1c3fc4dd492180530348078890ef74af90461705150a60
7
+ data.tar.gz: efeadbeb7dc0d044c801915d6ba4d8c249b249ef0dde8726454a359d3f1a4ce9ad4e2edb6cd19f8c371967059081c0ef2904ea630a0ae620123939ce881f0fdb
data/README.md CHANGED
@@ -14,8 +14,10 @@ Solid Queue can be used with SQL databases such as MySQL, PostgreSQL, or SQLite,
14
14
  - [Dashboard UI Setup](#dashboard-ui-setup)
15
15
  - [Incremental adoption](#incremental-adoption)
16
16
  - [High performance requirements](#high-performance-requirements)
17
+ - [Workers, dispatchers, and scheduler](#workers-dispatchers-and-scheduler)
18
+ - [Fork vs. async mode](#fork-vs-async-mode)
17
19
  - [Configuration](#configuration)
18
- - [Workers, dispatchers, and scheduler](#workers-dispatchers-and-scheduler)
20
+ - [Optional scheduler configuration](#optional-scheduler-configuration)
19
21
  - [Queue order and priorities](#queue-order-and-priorities)
20
22
  - [Queues specification and performance](#queues-specification-and-performance)
21
23
  - [Threads, processes, and signals](#threads-processes-and-signals)
@@ -30,6 +32,7 @@ Solid Queue can be used with SQL databases such as MySQL, PostgreSQL, or SQLite,
30
32
  - [Puma plugin](#puma-plugin)
31
33
  - [Jobs and transactional integrity](#jobs-and-transactional-integrity)
32
34
  - [Recurring tasks](#recurring-tasks)
35
+ - [Scheduling and unscheduling recurring tasks dynamically](#scheduling-and-unscheduling-recurring-tasks-dynamically)
33
36
  - [Inspiration](#inspiration)
34
37
  - [License](#license)
35
38
 
@@ -92,10 +95,10 @@ development:
92
95
  + primary:
93
96
  <<: *default
94
97
  database: storage/development.sqlite3
95
- + queue:
96
- + <<: *default
97
- + database: storage/development_queue.sqlite3
98
- + migrations_paths: db/queue_migrate
98
+ + queue:
99
+ + <<: *default
100
+ + database: storage/development_queue.sqlite3
101
+ + migrations_paths: db/queue_migrate
99
102
  ```
100
103
 
101
104
  Next, add the following to `development.rb`
@@ -127,7 +130,7 @@ In `config/cable.yml`
127
130
  ```diff
128
131
  development:
129
132
  - adapter: async
130
- + adapter: solid_cable
133
+ + adapter: solid_cable
131
134
  + connects_to:
132
135
  + database:
133
136
  + writing: cable
@@ -177,11 +180,9 @@ end
177
180
 
178
181
  ### High performance requirements
179
182
 
180
- Solid Queue was designed for the highest throughput when used with MySQL 8+ or PostgreSQL 9.5+, as they support `FOR UPDATE SKIP LOCKED`. You can use it with older versions, but in that case, you might run into lock waits if you run multiple workers for the same queue. You can also use it with SQLite on smaller applications.
181
-
182
- ## Configuration
183
+ Solid Queue was designed for the highest throughput when used with MySQL 8+, MariaDB 10.6+, or PostgreSQL 9.5+, as they support `FOR UPDATE SKIP LOCKED`. You can use it with older versions, but in that case, you might run into lock waits if you run multiple workers for the same queue. You can also use it with SQLite on smaller applications.
183
184
 
184
- ### Workers, dispatchers, and scheduler
185
+ ## Workers, dispatchers, and scheduler
185
186
 
186
187
  We have several types of actors in Solid Queue:
187
188
 
@@ -190,7 +191,19 @@ We have several types of actors in Solid Queue:
190
191
  - The _scheduler_ manages [recurring tasks](#recurring-tasks), enqueuing jobs for them when they're due.
191
192
  - The _supervisor_ runs workers and dispatchers according to the configuration, controls their heartbeats, and stops and starts them when needed.
192
193
 
193
- Solid Queue's supervisor will fork a separate process for each supervised worker/dispatcher/scheduler.
194
+ ### Fork vs. async mode
195
+
196
+ By default, Solid Queue runs in `fork` mode. This means the supervisor will fork a separate process for each supervised worker/dispatcher/scheduler. This provides the best isolation and performance, but can have additional memory usage and might not work with some Ruby implementations. As an alternative, you can run all workers, dispatchers and schedulers in the same process as the supervisor, in different threads, with an `async` mode. You can choose this mode by running `bin/jobs` as:
197
+
198
+ ```
199
+ bin/jobs --mode async
200
+ ```
201
+
202
+ Or you can also set the environment variable `SOLID_QUEUE_SUPERVISOR_MODE` to `async`. If you use the `async` mode, the `processes` option in the configuration described below will be ignored.
203
+
204
+ **The recommended and default mode is `fork`. Only use `async` if you know what you're doing and have strong reasons to**
205
+
206
+ ## Configuration
194
207
 
195
208
  By default, Solid Queue will try to find your configuration under `config/queue.yml`, but you can set a different path using the environment variable `SOLID_QUEUE_CONFIG` or by using the `-c/--config_file` option with `bin/jobs`, like this:
196
209
 
@@ -198,7 +211,7 @@ By default, Solid Queue will try to find your configuration under `config/queue.
198
211
  bin/jobs -c config/calendar.yml
199
212
  ```
200
213
 
201
- You can also skip all recurring tasks by setting the environment variable `SOLID_QUEUE_SKIP_RECURRING=true`. This is useful for environments like staging, review apps, or development where you don't want any recurring jobs to run. This is equivalent to using the `--skip-recurring` option with `bin/jobs`.
214
+ You can also skip the scheduler process by setting the environment variable `SOLID_QUEUE_SKIP_RECURRING=true`. This is useful for environments like staging, review apps, or development where you don't want any recurring jobs to run. This is equivalent to using the `--skip-recurring` option with `bin/jobs`.
202
215
 
203
216
  This is what this configuration looks like:
204
217
 
@@ -216,6 +229,10 @@ production:
216
229
  threads: 5
217
230
  polling_interval: 0.1
218
231
  processes: 3
232
+ scheduler:
233
+ dynamic_tasks_enabled: true
234
+ polling_interval: 5
235
+
219
236
  ```
220
237
 
221
238
  Everything is optional. If no configuration at all is provided, Solid Queue will run with one dispatcher and one worker with default settings. If you want to run only dispatchers or workers, you just need to include that section alone in the configuration. For example, with the following configuration:
@@ -247,6 +264,8 @@ Here's an overview of the different options:
247
264
  ```
248
265
 
249
266
  This will create a worker fetching jobs from all queues starting with `staging`. The wildcard `*` is only allowed on its own or at the end of a queue name; you can't specify queue names such as `*_some_queue`. These will be ignored.
267
+
268
+ Also, if a wildcard (*) is included alongside explicit queue names, for example: `queues: [default, backend, *]`, then it would behave like `queues: *`
250
269
 
251
270
  Finally, you can combine prefixes with exact names, like `[ staging*, background ]`, and the behaviour with respect to order will be the same as with only exact names.
252
271
 
@@ -254,10 +273,23 @@ Here's an overview of the different options:
254
273
 
255
274
  - `threads`: this is the max size of the thread pool that each worker will have to run jobs. Each worker will fetch this number of jobs from their queue(s), at most and will post them to the thread pool to be run. By default, this is `3`. Only workers have this setting.
256
275
  It is recommended to set this value less than or equal to the queue database's connection pool size minus 2, as each worker thread uses one connection, and two additional connections are reserved for polling and heartbeat.
257
- - `processes`: this is the number of worker processes that will be forked by the supervisor with the settings given. By default, this is `1`, just a single process. This setting is useful if you want to dedicate more than one CPU core to a queue or queues with the same configuration. Only workers have this setting.
276
+ - `processes`: this is the number of worker processes that will be forked by the supervisor with the settings given. By default, this is `1`, just a single process. This setting is useful if you want to dedicate more than one CPU core to a queue or queues with the same configuration. Only workers have this setting. **Note**: this option will be ignored if [running in `async` mode](#fork-vs-async-mode).
258
277
  - `concurrency_maintenance`: whether the dispatcher will perform the concurrency maintenance work. This is `true` by default, and it's useful if you don't use any [concurrency controls](#concurrency-controls) and want to disable it or if you run multiple dispatchers and want some of them to just dispatch jobs without doing anything else.
259
278
 
260
279
 
280
+ ### Optional scheduler configuration
281
+
282
+ Optionally, you can configure the scheduler process under the `scheduler` section in your `config/queue.yml` if you'd like to [schedule recurring tasks dynamically](#scheduling-and-unscheduling-recurring-tasks-dynamically).
283
+
284
+ ```yaml
285
+ scheduler:
286
+ dynamic_tasks_enabled: true
287
+ polling_interval: 5
288
+ ```
289
+
290
+ - `dynamic_tasks_enabled`: whether the scheduler should poll for [dynamically scheduled recurring tasks](#scheduling-and-unscheduling-recurring-tasks-dynamically). This is `false` by default. When enabled, the scheduler will poll the database at the given `polling_interval` to pick up tasks scheduled via `SolidQueue.schedule_recurring_task`.
291
+ - `polling_interval`: how frequently (in seconds) the scheduler checks for dynamic task changes. Defaults to `5`.
292
+
261
293
  ### Queue order and priorities
262
294
 
263
295
  As mentioned above, if you specify a list of queues for a worker, these will be polled in the order given, such as for the list `real_time,background`, no jobs will be taken from `background` unless there aren't any more jobs waiting in `real_time`.
@@ -334,7 +366,7 @@ queues: back*
334
366
 
335
367
  Workers in Solid Queue use a thread pool to run work in multiple threads, configurable via the `threads` parameter above. Besides this, parallelism can be achieved via multiple processes on one machine (configurable via different workers or the `processes` parameter above) or by horizontal scaling.
336
368
 
337
- The supervisor is in charge of managing these processes, and it responds to the following signals:
369
+ The supervisor is in charge of managing these processes, and it responds to the following signals when running in its own process via `bin/jobs` or with [the Puma plugin](#puma-plugin) with the default `fork` mode:
338
370
  - `TERM`, `INT`: starts graceful termination. The supervisor will send a `TERM` signal to its supervised processes, and it'll wait up to `SolidQueue.shutdown_timeout` time until they're done. If any supervised processes are still around by then, it'll send a `QUIT` signal to them to indicate they must exit.
339
371
  - `QUIT`: starts immediate termination. The supervisor will send a `QUIT` signal to its supervised processes, causing them to exit immediately.
340
372
 
@@ -366,7 +398,7 @@ There are several settings that control how Solid Queue works that you can set a
366
398
 
367
399
  **This is not used for errors raised within a job execution**. Errors happening in jobs are handled by Active Job's `retry_on` or `discard_on`, and ultimately will result in [failed jobs](#failed-jobs-and-retries). This is for errors happening within Solid Queue itself.
368
400
 
369
- - `use_skip_locked`: whether to use `FOR UPDATE SKIP LOCKED` when performing locking reads. This will be automatically detected in the future, and for now, you only need to set this to `false` if your database doesn't support it. For MySQL, that'd be versions < 8, and for PostgreSQL, versions < 9.5. If you use SQLite, this has no effect, as writes are sequential.
401
+ - `use_skip_locked`: whether to use `FOR UPDATE SKIP LOCKED` when performing locking reads. This will be automatically detected in the future, and for now, you only need to set this to `false` if your database doesn't support it. For MySQL, that'd be versions < 8; for MariaDB, versions < 10.6; and for PostgreSQL, versions < 9.5. If you use SQLite, this has no effect, as writes are sequential.
370
402
  - `process_heartbeat_interval`: the heartbeat interval that all processes will follow—defaults to 60 seconds.
371
403
  - `process_alive_threshold`: how long to wait until a process is considered dead after its last heartbeat—defaults to 5 minutes.
372
404
  - `shutdown_timeout`: time the supervisor will wait since it sent the `TERM` signal to its supervised processes before sending a `QUIT` version to them requesting immediate termination—defaults to 5 seconds.
@@ -449,7 +481,7 @@ class MyJob < ApplicationJob
449
481
  - `group` is used to control the concurrency of different job classes together. It defaults to the job class name.
450
482
  - `on_conflict` controls behaviour when enqueuing a job that conflicts with the concurrency limits configured. It can be set to one of the following:
451
483
  - (default) `:block`: the job is blocked and is dispatched when another job completes and unblocks it, or when the duration expires.
452
- - `:discard`: the job is discarded. When you choose this option, bear in mind that if a job runs and fails to remove the concurrency lock (or _semaphore_, read below to know more about this), all jobs conflicting with it will be discarded up to the interval defined by `duration` has elapsed.
484
+ - `:discard`: the job is discarded. When you choose this option, bear in mind that if a job runs and fails to remove the concurrency lock (or _semaphore_, read below to know more about this), all jobs conflicting with it will be discarded until the interval defined by `duration` has elapsed.
453
485
 
454
486
  When a job includes these controls, we'll ensure that, at most, the number of jobs (indicated as `to`) that yield the same `key` will be performed concurrently, and this guarantee will last for `duration` for each job enqueued. Note that there's no guarantee about _the order of execution_, only about jobs being performed at the same time (overlapping).
455
487
 
@@ -459,7 +491,7 @@ Since something can happen that prevents the first job from releasing the semaph
459
491
 
460
492
  It's important to note that after one or more candidate jobs are unblocked (either because a job finishes or because `duration` expires and a semaphore is released), the `duration` timer for the still blocked jobs is reset. This happens indirectly via the expiration time of the semaphore, which is updated.
461
493
 
462
- When using `discard` as the behaviour to handle conflicts, you might have jobs discarded for up to the `duration` interval if something happens and a running job fails to release the semaphore.
494
+ When using `discard` as the behaviour to handle conflicts, you might have jobs discarded for until the `duration` interval if something happens and a running job fails to release the semaphore.
463
495
 
464
496
 
465
497
  For example:
@@ -519,11 +551,11 @@ DeliverAnnouncementToContactJob.set(wait: 30.minutes).perform_later(contact)
519
551
 
520
552
  The 3 jobs will go into the scheduled queue and will wait there until they're due. Then, 10 minutes after, the first two jobs will be enqueued and the second one most likely will be blocked because the first one will be running first. Then, assuming the jobs are fast and finish in a few seconds, when the third job is due, it'll be enqueued normally.
521
553
 
522
- Normally scheduled jobs are enqueued in batches, but with concurrency controls, jobs need to be enqueued one by one. This has an impact on performance, similarly to the impact of concurrency controls in bulk enqueuing. Read below for more details. I'd generally advise against mixing concurrency controls with waiting/scheduling in the future.
554
+ Normally scheduled jobs are enqueued in batches, but with concurrency controls, jobs need to be enqueued one by one. This has an impact on performance, similarly to the impact of concurrency controls in bulk enqueuing. Read below for more details. We generally advise against mixing concurrency controls with waiting/scheduling in the future.
523
555
 
524
556
  ### Performance considerations
525
557
 
526
- Concurrency controls introduce significant overhead (blocked executions need to be created and promoted to ready, semaphores need to be created and updated) so you should consider carefully whether you need them. For throttling purposes, where you plan to have `limit` significantly larger than 1, I'd encourage relying on a limited number of workers per queue instead. For example:
558
+ Concurrency controls introduce significant overhead (blocked executions need to be created and promoted to ready, semaphores need to be created and updated) so you should consider carefully whether you need them. For throttling purposes, where you plan to have `limit` significantly larger than 1, we encourage relying on a limited number of workers per queue instead. For example:
527
559
 
528
560
  ```ruby
529
561
  class ThrottledJob < ApplicationJob
@@ -603,6 +635,22 @@ that you set in production only. This is what Rails 8's default Puma config look
603
635
 
604
636
  **Note**: phased restarts are not supported currently because the plugin requires [app preloading](https://github.com/puma/puma?tab=readme-ov-file#cluster-mode) to work.
605
637
 
638
+ ### Running as a fork or asynchronously
639
+
640
+ By default, the Puma plugin will fork additional processes for each worker and dispatcher so that they run in different processes. This provides the best isolation and performance, but can have additional memory usage.
641
+
642
+ Alternatively, workers and dispatchers can be run within the same Puma process(s). To do so just configure the plugin as:
643
+
644
+ ```ruby
645
+ plugin :solid_queue
646
+ solid_queue_mode :async
647
+ ```
648
+
649
+ Note that in this case, the `processes` configuration option will be ignored. See also [Fork vs. async mode](#fork-vs-async-mode).
650
+
651
+ **The recommended and default mode is `fork`. Only use `async` if you know what you're doing and have strong reasons to**
652
+
653
+
606
654
  ## Jobs and transactional integrity
607
655
  :warning: Having your jobs in the same ACID-compliant database as your application data enables a powerful yet sharp tool: taking advantage of transactional integrity to ensure some action in your app is not committed unless your job is also committed and vice versa, and ensuring that your job won't be enqueued until the transaction within which you're enqueuing it is committed. This can be very powerful and useful, but it can also backfire if you base some of your logic on this behaviour, and in the future, you move to another active job backend, or if you simply move Solid Queue to its own database, and suddenly the behaviour changes under you. Because this can be quite tricky and many people shouldn't need to worry about it, by default Solid Queue is configured in a different database as the main app.
608
656
 
@@ -703,6 +751,38 @@ my_periodic_resque_job:
703
751
 
704
752
  and the job will be enqueued via `perform_later` so it'll run in Resque. However, in this case we won't track any `solid_queue_recurring_execution` record for it and there won't be any guarantees that the job is enqueued only once each time.
705
753
 
754
+ ### Scheduling and unscheduling recurring tasks dynamically
755
+
756
+ You can schedule and unschedule recurring tasks at runtime, without editing the configuration file. To enable this, you need to set `dynamic_tasks_enabled: true` in the `scheduler` section of your `config/queue.yml`, [as explained earlier](#optional-scheduler-configuration).
757
+
758
+ ```yaml
759
+ scheduler:
760
+ dynamic_tasks_enabled: true
761
+ ```
762
+
763
+ Then you can use the following methods to add recurring tasks dynamically:
764
+
765
+ ```ruby
766
+ SolidQueue.schedule_recurring_task(
767
+ "my_dynamic_task",
768
+ class: "MyJob",
769
+ args: [1, 2],
770
+ schedule: "every 10 minutes"
771
+ )
772
+ ```
773
+
774
+ This accepts the same options as the YAML configuration: `class`, `args`, `command`, `schedule`, `queue`, `priority`, and `description`.
775
+
776
+ To remove a dynamically scheduled task:
777
+
778
+ ```ruby
779
+ SolidQueue.unschedule_recurring_task("my_dynamic_task")
780
+ ```
781
+
782
+ Only dynamic tasks can be unscheduled at runtime. Attempting to unschedule a static task (defined in `config/recurring.yml`) will raise an `ActiveRecord::RecordNotFound` error.
783
+
784
+ Tasks scheduled like this persist between Solid Queue's restarts and won't stop running until you manually unschedule them.
785
+
706
786
  ## Inspiration
707
787
 
708
788
  Solid Queue has been inspired by [resque](https://github.com/resque/resque) and [GoodJob](https://github.com/bensheldon/good_job). We recommend checking out these projects as they're great examples from which we've learnt a lot.
@@ -26,7 +26,9 @@ module SolidQueue
26
26
 
27
27
  def release_one(concurrency_key)
28
28
  transaction do
29
- if execution = ordered.where(concurrency_key: concurrency_key).limit(1).non_blocking_lock.first
29
+ if execution = ordered.where(concurrency_key: concurrency_key).limit(1)
30
+ .use_index(:index_solid_queue_blocked_executions_for_release)
31
+ .non_blocking_lock.first
30
32
  execution.release
31
33
  end
32
34
  end
@@ -6,7 +6,7 @@ module SolidQueue
6
6
 
7
7
  serialize :error, coder: JSON
8
8
 
9
- before_create :expand_error_details_from_exception
9
+ before_save :expand_error_details_from_exception, if: :exception
10
10
 
11
11
  attr_accessor :exception
12
12
 
@@ -26,7 +26,7 @@ module SolidQueue
26
26
  end
27
27
 
28
28
  def concurrency_limited?
29
- concurrency_key.present?
29
+ concurrency_key.present? && job_class.present?
30
30
  end
31
31
 
32
32
  def blocked?
@@ -16,7 +16,16 @@ module SolidQueue
16
16
  end
17
17
 
18
18
  def failed_with(exception)
19
- FailedExecution.create_or_find_by!(job_id: id, exception: exception)
19
+ FailedExecution.transaction(requires_new: true) do
20
+ FailedExecution.create!(job_id: id, exception: exception)
21
+ end
22
+ rescue ActiveRecord::RecordNotUnique
23
+ if (failed_execution = FailedExecution.find_by(job_id: id))
24
+ failed_execution.exception = exception
25
+ failed_execution.save!
26
+ else
27
+ retry
28
+ end
20
29
  end
21
30
 
22
31
  def reset_execution_counters
@@ -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
@@ -6,11 +6,12 @@ module SolidQueue
6
6
  class RecurringTask < Record
7
7
  serialize :arguments, coder: Arguments, default: []
8
8
 
9
- validate :supported_schedule
9
+ validate :ensure_schedule_supported
10
10
  validate :ensure_command_or_class_present
11
- validate :existing_job_class
11
+ validate :ensure_existing_job_class
12
12
 
13
13
  scope :static, -> { where(static: true) }
14
+ scope :dynamic, -> { where(static: false) }
14
15
 
15
16
  has_many :recurring_executions, foreign_key: :task_key, primary_key: :key
16
17
 
@@ -32,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)
@@ -102,19 +111,28 @@ module SolidQueue
102
111
  end
103
112
 
104
113
  private
105
- def supported_schedule
114
+ def ensure_schedule_supported
106
115
  unless parsed_schedule.instance_of?(Fugit::Cron)
107
116
  errors.add :schedule, :unsupported, message: "is not a supported recurring schedule"
108
117
  end
118
+ rescue ArgumentError => error
119
+ message = if error.message.include?("multiple crons")
120
+ "generates multiple cron schedules. Please use separate recurring tasks for each schedule, " +
121
+ "or use explicit cron syntax (e.g., '40 0,15 * * *' for multiple times with the same minutes)"
122
+ else
123
+ error.message
124
+ end
125
+
126
+ errors.add :schedule, :unsupported, message: message
109
127
  end
110
128
 
111
129
  def ensure_command_or_class_present
112
130
  unless command.present? || class_name.present?
113
- errors.add :base, :command_and_class_blank, message: "either command or class_name must be present"
131
+ errors.add :base, :command_and_class_blank, message: "either command or class must be present"
114
132
  end
115
133
  end
116
134
 
117
- def existing_job_class
135
+ def ensure_existing_job_class
118
136
  if class_name.present? && job_class.nil?
119
137
  errors.add :class_name, :undefined, message: "doesn't correspond to an existing class"
120
138
  end
@@ -152,7 +170,7 @@ module SolidQueue
152
170
 
153
171
 
154
172
  def parsed_schedule
155
- @parsed_schedule ||= Fugit.parse(schedule)
173
+ @parsed_schedule ||= Fugit.parse(schedule, multi: :fail)
156
174
  end
157
175
 
158
176
  def job_class
@@ -40,7 +40,7 @@ module SolidQueue
40
40
  end
41
41
 
42
42
  def wait
43
- if semaphore = Semaphore.find_by(key: key)
43
+ if semaphore = Semaphore.lock.find_by(key: key)
44
44
  semaphore.value > 0 && attempt_decrement
45
45
  else
46
46
  attempt_creation
@@ -1,5 +1,13 @@
1
1
  require "puma/plugin"
2
2
 
3
+ module Puma
4
+ class DSL
5
+ def solid_queue_mode(mode = :fork)
6
+ @options[:solid_queue_mode] = mode.to_sym
7
+ end
8
+ end
9
+ end
10
+
3
11
  Puma::Plugin.create do
4
12
  attr_reader :puma_pid, :solid_queue_pid, :log_writer, :solid_queue_supervisor
5
13
 
@@ -7,38 +15,78 @@ Puma::Plugin.create do
7
15
  @log_writer = launcher.log_writer
8
16
  @puma_pid = $$
9
17
 
10
- in_background do
11
- monitor_solid_queue
18
+ if launcher.options[:solid_queue_mode] == :async
19
+ start_async(launcher)
20
+ else
21
+ start_forked(launcher)
12
22
  end
23
+ end
13
24
 
14
- if Gem::Version.new(Puma::Const::VERSION) < Gem::Version.new("7")
15
- launcher.events.on_booted do
16
- @solid_queue_pid = fork do
17
- Thread.new { monitor_puma }
18
- SolidQueue::Supervisor.start
25
+ private
26
+ def start_forked(launcher)
27
+ in_background do
28
+ monitor_solid_queue
29
+ end
30
+
31
+ if Gem::Version.new(Puma::Const::VERSION) < Gem::Version.new("7")
32
+ launcher.events.on_booted do
33
+ @solid_queue_pid = fork do
34
+ Thread.new { monitor_puma }
35
+ SolidQueue::Supervisor.start(mode: :fork)
36
+ end
37
+ end
38
+
39
+ launcher.events.on_stopped { stop_solid_queue_fork }
40
+ launcher.events.on_restart { stop_solid_queue_fork }
41
+ else
42
+ launcher.events.after_booted do
43
+ @solid_queue_pid = fork do
44
+ Thread.new { monitor_puma }
45
+ start_solid_queue(mode: :fork)
46
+ end
19
47
  end
48
+
49
+ launcher.events.after_stopped { stop_solid_queue_fork }
50
+ launcher.events.before_restart { stop_solid_queue_fork }
20
51
  end
52
+ end
21
53
 
22
- launcher.events.on_stopped { stop_solid_queue }
23
- launcher.events.on_restart { stop_solid_queue }
24
- else
25
- launcher.events.after_booted do
26
- @solid_queue_pid = fork do
27
- Thread.new { monitor_puma }
28
- SolidQueue::Supervisor.start
54
+ def start_async(launcher)
55
+ if Gem::Version.new(Puma::Const::VERSION) < Gem::Version.new("7")
56
+ launcher.events.on_booted do
57
+ start_solid_queue(mode: :async, standalone: false)
58
+ end
59
+
60
+ launcher.events.on_stopped { solid_queue_supervisor&.stop }
61
+
62
+ launcher.events.on_restart do
63
+ solid_queue_supervisor&.stop
64
+ start_solid_queue(mode: :async, standalone: false)
65
+ end
66
+ else
67
+ launcher.events.after_booted do
68
+ start_solid_queue(mode: :async, standalone: false)
69
+ end
70
+
71
+ launcher.events.after_stopped { solid_queue_supervisor&.stop }
72
+
73
+ launcher.events.before_restart do
74
+ solid_queue_supervisor&.stop
75
+ start_solid_queue(mode: :async, standalone: false)
29
76
  end
30
77
  end
78
+ end
31
79
 
32
- launcher.events.after_stopped { stop_solid_queue }
33
- launcher.events.before_restart { stop_solid_queue }
80
+ def start_solid_queue(**options)
81
+ @solid_queue_supervisor = SolidQueue::Supervisor.start(**options)
34
82
  end
35
- end
36
83
 
37
- private
38
- def stop_solid_queue
84
+ def stop_solid_queue_fork
85
+ return unless solid_queue_pid
86
+
39
87
  Process.waitpid(solid_queue_pid, Process::WNOHANG)
40
88
  log "Stopping Solid Queue..."
41
- Process.kill(:INT, solid_queue_pid) if solid_queue_pid
89
+ Process.kill(:INT, solid_queue_pid)
42
90
  Process.wait(solid_queue_pid)
43
91
  rescue Errno::ECHILD, Errno::ESRCH
44
92
  end
@@ -48,7 +96,7 @@ Puma::Plugin.create do
48
96
  end
49
97
 
50
98
  def monitor_solid_queue
51
- monitor(:solid_queue_dead?, "Detected Solid Queue has gone away, stopping Puma...")
99
+ monitor(:solid_queue_fork_dead?, "Detected Solid Queue has gone away, stopping Puma...")
52
100
  end
53
101
 
54
102
  def monitor(process_dead, message)
@@ -62,7 +110,7 @@ Puma::Plugin.create do
62
110
  end
63
111
  end
64
112
 
65
- def solid_queue_dead?
113
+ def solid_queue_fork_dead?
66
114
  if solid_queue_started?
67
115
  Process.waitpid(solid_queue_pid, Process::WNOHANG)
68
116
  end
@@ -17,5 +17,15 @@ module SolidQueue
17
17
  SolidQueue.on_thread_error.call(error)
18
18
  end
19
19
  end
20
+
21
+ def create_thread(&block)
22
+ Thread.new do
23
+ Thread.current.name = name
24
+ block.call
25
+ rescue Exception => exception
26
+ handle_thread_error(exception)
27
+ raise
28
+ end
29
+ end
20
30
  end
21
31
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class AsyncSupervisor < Supervisor
5
+ after_shutdown :terminate_gracefully, unless: :standalone?
6
+
7
+ def stop
8
+ super
9
+ @thread&.join
10
+ end
11
+
12
+ private
13
+ def supervise
14
+ if standalone? then super
15
+ else
16
+ @thread = create_thread { super }
17
+ end
18
+ end
19
+
20
+ def check_and_replace_terminated_processes
21
+ terminated_threads = process_instances.select { |thread_id, instance| !instance.alive? }
22
+ terminated_threads.each { |thread_id, _| replace_thread(thread_id) }
23
+ end
24
+
25
+ def replace_thread(thread_id)
26
+ SolidQueue.instrument(:replace_thread, supervisor_pid: ::Process.pid) do |payload|
27
+ if (instance = process_instances.delete(thread_id))
28
+ payload[:thread] = instance
29
+
30
+ error = Processes::ThreadTerminatedError.new(instance.name)
31
+ release_claimed_jobs_by(instance, with_error: error)
32
+
33
+ start_process(configured_processes.delete(thread_id))
34
+ end
35
+ end
36
+ end
37
+
38
+ def perform_graceful_termination
39
+ process_instances.values.each(&:stop)
40
+
41
+ Timer.wait_until(SolidQueue.shutdown_timeout, -> { all_processes_terminated? })
42
+ end
43
+
44
+ def perform_immediate_termination
45
+ exit!
46
+ end
47
+
48
+ def all_processes_terminated?
49
+ process_instances.values.none?(&:alive?)
50
+ end
51
+ end
52
+ end
@@ -8,6 +8,10 @@ module SolidQueue
8
8
  desc: "Path to config file (default: #{Configuration::DEFAULT_CONFIG_FILE_PATH}).",
9
9
  banner: "SOLID_QUEUE_CONFIG"
10
10
 
11
+ class_option :mode, type: :string, enum: %w[ fork async ],
12
+ desc: "Whether to fork processes for workers and dispatchers (fork) or to run these in the same process as the supervisor (async) (default: fork).",
13
+ banner: "SOLID_QUEUE_SUPERVISOR_MODE"
14
+
11
15
  class_option :recurring_schedule_file, type: :string,
12
16
  desc: "Path to recurring schedule definition (default: #{Configuration::DEFAULT_RECURRING_SCHEDULE_FILE_PATH}).",
13
17
  banner: "SOLID_QUEUE_RECURRING_SCHEDULE"