Skip to content

Commit 7fca542

Browse files
authored
Merge pull request #155 from basecamp/cron-jobs-take-2
Add support for recurring tasks (cron style jobs)
2 parents 53f0e48 + 4ea0e2a commit 7fca542

35 files changed

+684
-167
lines changed

Gemfile

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,3 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
33

44
# Specify your gem's dependencies in solid_queue.gemspec.
55
gemspec
6-
7-
gem "mysql2"
8-
gem "pg"
9-
gem "sqlite3"

Gemfile.lock

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ PATH
44
solid_queue (0.2.2)
55
activejob (>= 7.1)
66
activerecord (>= 7.1)
7+
concurrent-ruby (~> 1.2.2)
8+
fugit (~> 1.9.0)
79
railties (>= 7.1)
810

911
GEM
@@ -55,6 +57,11 @@ GEM
5557
drb (2.1.1)
5658
ruby2_keywords
5759
erubi (1.12.0)
60+
et-orbi (1.2.7)
61+
tzinfo
62+
fugit (1.9.0)
63+
et-orbi (~> 1, >= 1.2.7)
64+
raabro (~> 1.4)
5865
globalid (1.2.1)
5966
activesupport (>= 6.1)
6067
i18n (1.14.1)
@@ -81,6 +88,7 @@ GEM
8188
pg (1.5.4)
8289
puma (6.4.2)
8390
nio4r (~> 2.0)
91+
raabro (1.4.0)
8492
racc (1.7.3)
8593
rack (3.0.8)
8694
rack-session (2.0.0)

README.md

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Solid Queue is a DB-based queuing backend for [Active Job](https://edgeguides.rubyonrails.org/active_job_basics.html), designed with simplicity and performance in mind.
44

5-
Besides regular job enqueuing and processing, Solid Queue supports delayed jobs, concurrency controls, pausing queues, numeric priorities per job, priorities by queue order, and bulk enqueuing (`enqueue_all` for Active Job's `perform_all_later`). _Improvements to logging and instrumentation, a better CLI tool, a way to run within an existing process in "async" mode, unique jobs and recurring, cron-like tasks are coming very soon._
5+
Besides regular job enqueuing and processing, Solid Queue supports delayed jobs, concurrency controls, pausing queues, numeric priorities per job, priorities by queue order, and bulk enqueuing (`enqueue_all` for Active Job's `perform_all_later`). _Improvements to logging and instrumentation, a better CLI tool, a way to run within an existing process in "async" mode, and some way of specifying unique jobs are coming very soon._
66

77
Solid Queue can be used with SQL databases such as MySQL, PostgreSQL or SQLite, and it leverages the `FOR UPDATE SKIP LOCKED` clause, if available, to avoid blocking and waiting on locks when polling jobs. It relies on Active Job for retries, discarding, error handling, serialization, or delays, and it's compatible with Ruby on Rails multi-threading.
88

@@ -77,7 +77,7 @@ Besides Rails 7.1, Solid Queue works best with MySQL 8+ or PostgreSQL 9.5+, as t
7777

7878
We have three types of processes in Solid Queue:
7979
- _Workers_ are in charge of picking jobs ready to run from queues and processing them. They work off the `solid_queue_ready_executions` table.
80-
- _Dispatchers_ are in charge of selecting jobs scheduled to run in the future that are due and _dispatching_ them, which is simply moving them from the `solid_queue_scheduled_executions` table over to the `solid_queue_ready_executions` table so that workers can pick them up. They also do some maintenance work related to concurrency controls.
80+
- _Dispatchers_ are in charge of selecting jobs scheduled to run in the future that are due and _dispatching_ them, which is simply moving them from the `solid_queue_scheduled_executions` table over to the `solid_queue_ready_executions` table so that workers can pick them up. They're also in charge of managing [recurring tasks](#recurring-tasks), dispatching jobs to process them according to their schedule. On top of that, they do some maintenance work related to [concurrency controls](#concurrency-controls).
8181
- The _supervisor_ forks workers and dispatchers according to the configuration, controls their heartbeats, and sends them signals to stop and start them when needed.
8282

8383
By default, Solid Queue will try to find your configuration under `config/solid_queue.yml`, but you can set a different path using the environment variable `SOLID_QUEUE_CONFIG`. This is what this configuration looks like:
@@ -119,6 +119,8 @@ Everything is optional. If no configuration is provided, Solid Queue will run wi
119119
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.
120120
- `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.
121121
- `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.
122+
- `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.
123+
- `recurring_tasks`: a list of recurring tasks the dispatcher will manage. Read more details about this one in the [Recurring tasks](#recurring-tasks) section.
122124

123125

124126
### Queue order and priorities
@@ -265,3 +267,48 @@ Solid Queue has been inspired by [resque](https://github.com/resque/resque) and
265267

266268
## License
267269
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
270+
271+
## Recurring tasks
272+
Solid Queue supports defining recurring tasks that run at specific times in the future, on a regular basis like cron jobs. These are managed by dispatcher processes and as such, they can be defined in the dispatcher's configuration like this:
273+
```yml
274+
dispatchers:
275+
- polling_interval: 1
276+
batch_size: 500
277+
recurring_tasks:
278+
my_periodic_job:
279+
class: MyJob
280+
args: [ 42, { status: "custom_status" } ]
281+
schedule: every second
282+
```
283+
`recurring_tasks` is a hash/dictionary, and the key will be the task key internally. Each task needs to have a class, which will be the job class to enqueue, and a schedule. The schedule 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 also provide 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.
284+
285+
The job in the example configuration above will be enqueued every second as:
286+
```ruby
287+
MyJob.perform_later(42, status: "custom_status")
288+
```
289+
290+
Tasks are enqueued at their corresponding times by the dispatcher that owns them, and each task schedules the next one. This is pretty much [inspired by what GoodJob does](https://github.com/bensheldon/good_job/blob/994ecff5323bf0337e10464841128fda100750e6/lib/good_job/cron_manager.rb).
291+
292+
It's possible to run multiple dispatchers with the same `recurring_tasks` configuration. To avoid enqueuing duplicate tasks at the same time, an entry in a new `solid_queue_recurring_executions` table is created in the same transaction as the job is enqueued. This table has a unique index on `task_key` and `run_at`, ensuring only one entry per task per time will be created. This only works if you have `preserve_finished_jobs` set to `true` (the default), and the guarantee applies as long as you keep the jobs around.
293+
294+
Finally, it's possible to configure jobs that aren't handled by Solid Queue. That's it, you can a have a job like this in your app:
295+
```ruby
296+
class MyResqueJob < ApplicationJob
297+
self.queue_adapter = :resque
298+
299+
def perform(arg)
300+
# ..
301+
end
302+
end
303+
```
304+
305+
You can still configure this in Solid Queue:
306+
```yml
307+
dispatchers:
308+
- recurring_tasks:
309+
my_periodic_resque_job:
310+
class: MyResqueJob
311+
args: 22
312+
schedule: "*/5 * * * *"
313+
```
314+
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.

app/models/solid_queue/blocked_execution.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def release
4343
promote_to_ready
4444
destroy!
4545

46-
SolidQueue.logger.info("[SolidQueue] Unblocked job #{job.id} under #{concurrency_key}")
46+
SolidQueue.logger.debug("[SolidQueue] Unblocked job #{job.id} under #{concurrency_key}")
4747
end
4848
end
4949
end

app/models/solid_queue/claimed_execution.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ def claiming(job_ids, process_id, &block)
1616
insert_all!(job_data)
1717
where(job_id: job_ids, process_id: process_id).load.tap do |claimed|
1818
block.call(claimed)
19-
SolidQueue.logger.info("[SolidQueue] Claimed #{claimed.size} jobs")
2019
end
2120
end
2221

app/models/solid_queue/job.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
module SolidQueue
44
class Job < Record
5-
include Executable
5+
include Executable, Clearable, Recurrable
66

77
serialize :arguments, coder: JSON
88

app/models/solid_queue/job/executable.rb

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ module Executable
66
extend ActiveSupport::Concern
77

88
included do
9-
include Clearable, ConcurrencyControls, Schedulable
9+
include ConcurrencyControls, Schedulable
1010

1111
has_one :ready_execution
1212
has_one :claimed_execution
@@ -78,7 +78,7 @@ def dispatch_bypassing_concurrency_limits
7878
end
7979

8080
def finished!
81-
if preserve_finished_jobs?
81+
if SolidQueue.preserve_finished_jobs?
8282
touch(:finished_at)
8383
else
8484
destroy!
@@ -117,10 +117,6 @@ def ready
117117
def execution
118118
%w[ ready claimed failed ].reduce(nil) { |acc, status| acc || public_send("#{status}_execution") }
119119
end
120-
121-
def preserve_finished_jobs?
122-
SolidQueue.preserve_finished_jobs
123-
end
124120
end
125121
end
126122
end
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# frozen_string_literal: true
2+
3+
module SolidQueue
4+
class Job
5+
module Recurrable
6+
extend ActiveSupport::Concern
7+
8+
included do
9+
has_one :recurring_execution, dependent: :destroy
10+
end
11+
end
12+
end
13+
end
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
module SolidQueue
4+
class RecurringExecution < Execution
5+
scope :clearable, -> { where.missing(:job) }
6+
7+
class << self
8+
def record(task_key, run_at, &block)
9+
transaction do
10+
if job_id = block.call
11+
create!(job_id: job_id, task_key: task_key, run_at: run_at)
12+
end
13+
end
14+
rescue ActiveRecord::RecordNotUnique
15+
SolidQueue.logger.info("[SolidQueue] Skipped recurring task #{task_key} at #{run_at} — already dispatched")
16+
end
17+
18+
def clear_in_batches(batch_size: 500)
19+
loop do
20+
records_deleted = clearable.limit(batch_size).delete_all
21+
break if records_deleted == 0
22+
end
23+
end
24+
end
25+
end
26+
end
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
class CreateRecurringExecutions < ActiveRecord::Migration[7.1]
2+
def change
3+
create_table :solid_queue_recurring_executions do |t|
4+
t.references :job, index: { unique: true }, null: false
5+
t.string :task_key, null: false
6+
t.datetime :run_at, null: false
7+
t.datetime :created_at, null: false
8+
9+
t.index [ :task_key, :run_at ], unique: true
10+
end
11+
12+
add_foreign_key :solid_queue_recurring_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade
13+
end
14+
end

0 commit comments

Comments
 (0)