Skip to content

Commit eebdd77

Browse files
authored
Merge pull request #272 from rails/persist-recurring-tasks
Store canonical recurring tasks in the DB
2 parents addd870 + 953349c commit eebdd77

22 files changed

+300
-129
lines changed

UPGRADING.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
# Upgrading to version 0.5.x
2+
This version includes a new migration to improve recurring tasks. To install it, just run:
3+
4+
```bash
5+
$ bin/rails solid_queue:install:migrations
6+
```
7+
8+
Or, if you're using a different database for Solid Queue:
9+
10+
```bash
11+
$ bin/rails solid_queue:install:migrations DATABASE=<the_name_of_your_solid_queue_db>
12+
```
13+
14+
And then run the migrations.
15+
16+
117
# Upgrading to version 0.4.x
218
This version introduced an _async_ mode to run the supervisor and have all workers and dispatchers run as part of the same process as the supervisor, instead of separate, forked, processes. Together with this, we introduced some changes in how the supervisor is started. Prior this change, you could choose whether you wanted to run workers, dispatchers or both, by starting Solid Queue as `solid_queue:work` or `solid_queue:dispatch`. From version 0.4.0, the only option available is:
319

@@ -26,7 +42,6 @@ the supervisor will run 1 dispatcher and no workers.
2642
2743
2844
# Upgrading to version 0.3.x
29-
3045
This version introduced support for [recurring (cron-style) jobs](https://github.com/rails/solid_queue/blob/main/README.md#recurring-tasks), and it needs a new DB migration for it. To install it, just run:
3146
3247
```bash

app/models/solid_queue/recurring_execution.rb

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,29 @@ class AlreadyRecorded < StandardError; end
77
scope :clearable, -> { where.missing(:job) }
88

99
class << self
10+
def create_or_insert!(**attributes)
11+
if connection.supports_insert_conflict_target?
12+
# PostgreSQL fails and aborts the current transaction when it hits a duplicate key conflict
13+
# during two concurrent INSERTs for the same value of an unique index. We need to explicitly
14+
# indicate unique_by to ignore duplicate rows by this value when inserting
15+
unless insert(attributes, unique_by: [ :task_key, :run_at ]).any?
16+
raise AlreadyRecorded
17+
end
18+
else
19+
create!(**attributes)
20+
end
21+
rescue ActiveRecord::RecordNotUnique
22+
raise AlreadyRecorded
23+
end
24+
1025
def record(task_key, run_at, &block)
1126
transaction do
1227
block.call.tap do |active_job|
1328
if active_job
14-
create!(job_id: active_job.provider_job_id, task_key: task_key, run_at: run_at)
29+
create_or_insert!(job_id: active_job.provider_job_id, task_key: task_key, run_at: run_at)
1530
end
1631
end
1732
end
18-
rescue ActiveRecord::RecordNotUnique => e
19-
raise AlreadyRecorded
2033
end
2134

2235
def clear_in_batches(batch_size: 500)

lib/solid_queue/dispatcher/recurring_task.rb renamed to app/models/solid_queue/recurring_task.rb

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,35 @@
1+
# frozen_string_literal: true
2+
13
require "fugit"
24

35
module SolidQueue
4-
class Dispatcher::RecurringTask
6+
class RecurringTask < Record
7+
serialize :arguments, coder: Arguments, default: []
8+
9+
validate :supported_schedule
10+
validate :existing_job_class
11+
12+
scope :static, -> { where(static: true) }
13+
514
class << self
615
def wrap(args)
716
args.is_a?(self) ? args : from_configuration(args.first, **args.second)
817
end
918

1019
def from_configuration(key, **options)
11-
new(key, class_name: options[:class], schedule: options[:schedule], arguments: options[:args])
20+
new(key: key, class_name: options[:class], schedule: options[:schedule], arguments: options[:args])
1221
end
13-
end
1422

15-
attr_reader :key, :schedule, :class_name, :arguments
16-
17-
def initialize(key, class_name:, schedule:, arguments: nil)
18-
@key = key
19-
@class_name = class_name
20-
@schedule = schedule
21-
@arguments = Array(arguments)
23+
def create_or_update_all(tasks)
24+
if connection.supports_insert_conflict_target?
25+
# PostgreSQL fails and aborts the current transaction when it hits a duplicate key conflict
26+
# during two concurrent INSERTs for the same value of an unique index. We need to explicitly
27+
# indicate unique_by to ignore duplicate rows by this value when inserting
28+
upsert_all tasks.map(&:attributes_for_upsert), unique_by: :key
29+
else
30+
upsert_all tasks.map(&:attributes_for_upsert)
31+
end
32+
end
2233
end
2334

2435
def delay_from_now
@@ -51,23 +62,27 @@ def enqueue(at:)
5162
end
5263
end
5364

54-
def valid?
55-
parsed_schedule.instance_of?(Fugit::Cron)
56-
end
57-
5865
def to_s
5966
"#{class_name}.perform_later(#{arguments.map(&:inspect).join(",")}) [ #{parsed_schedule.original} ]"
6067
end
6168

62-
def to_h
63-
{
64-
schedule: schedule,
65-
class_name: class_name,
66-
arguments: arguments
67-
}
69+
def attributes_for_upsert
70+
attributes.without("id", "created_at", "updated_at")
6871
end
6972

7073
private
74+
def supported_schedule
75+
unless parsed_schedule.instance_of?(Fugit::Cron)
76+
errors.add :schedule, :unsupported, message: "is not a supported recurring schedule"
77+
end
78+
end
79+
80+
def existing_job_class
81+
unless job_class.present?
82+
errors.add :class_name, :undefined, message: "doesn't correspond to an existing class"
83+
end
84+
end
85+
7186
def using_solid_queue_adapter?
7287
job_class.queue_adapter_name.inquiry.solid_queue?
7388
end
@@ -88,12 +103,13 @@ def arguments_with_kwargs
88103
end
89104
end
90105

106+
91107
def parsed_schedule
92108
@parsed_schedule ||= Fugit.parse(schedule)
93109
end
94110

95111
def job_class
96-
@job_class ||= class_name.safe_constantize
112+
@job_class ||= class_name&.safe_constantize
97113
end
98114
end
99115
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
require "active_job/arguments"
4+
5+
module SolidQueue
6+
class RecurringTask::Arguments
7+
class << self
8+
def load(data)
9+
data.nil? ? [] : ActiveJob::Arguments.deserialize(ActiveSupport::JSON.load(data))
10+
end
11+
12+
def dump(data)
13+
ActiveSupport::JSON.dump(ActiveJob::Arguments.serialize(Array(data)))
14+
end
15+
end
16+
end
17+
end

app/models/solid_queue/semaphore.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ def signal
5252
end
5353

5454
private
55-
5655
attr_accessor :job
5756

5857
def attempt_creation
@@ -63,7 +62,9 @@ def attempt_creation
6362
end
6463
end
6564

66-
def check_limit_or_decrement = limit == 1 ? false : attempt_decrement
65+
def check_limit_or_decrement
66+
limit == 1 ? false : attempt_decrement
67+
end
6768

6869
def attempt_decrement
6970
Semaphore.available.where(key: key).update_all([ "value = value - 1, expires_at = ?", expires_at ]) > 0
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
class CreateRecurringTasks < ActiveRecord::Migration[7.1]
2+
def change
3+
create_table :solid_queue_recurring_tasks do |t|
4+
t.string :key, null: false, index: { unique: true }
5+
t.string :schedule, null: false
6+
t.string :command, limit: 2048
7+
t.string :class_name
8+
t.text :arguments
9+
10+
t.string :queue_name
11+
t.integer :priority, default: 0
12+
13+
t.boolean :static, default: true, index: true
14+
15+
t.text :description
16+
17+
t.timestamps
18+
end
19+
end
20+
end

lib/solid_queue/configuration.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def options_from_raw_config(key, defaults)
7575

7676
def parse_recurring_tasks(tasks)
7777
Array(tasks).map do |id, options|
78-
Dispatcher::RecurringTask.from_configuration(id, **options)
78+
RecurringTask.from_configuration(id, **options)
7979
end.select(&:valid?)
8080
end
8181

lib/solid_queue/dispatcher.rb

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ module SolidQueue
44
class Dispatcher < Processes::Poller
55
attr_accessor :batch_size, :concurrency_maintenance, :recurring_schedule
66

7-
after_boot :start_concurrency_maintenance, :load_recurring_schedule
8-
before_shutdown :stop_concurrency_maintenance, :unload_recurring_schedule
7+
after_boot :start_concurrency_maintenance, :schedule_recurring_tasks
8+
before_shutdown :stop_concurrency_maintenance, :unschedule_recurring_tasks
99

1010
def initialize(**options)
1111
options = options.dup.with_defaults(SolidQueue::Configuration::DISPATCHER_DEFAULTS)
@@ -19,7 +19,7 @@ def initialize(**options)
1919
end
2020

2121
def metadata
22-
super.merge(batch_size: batch_size, concurrency_maintenance_interval: concurrency_maintenance&.interval, recurring_schedule: recurring_schedule.tasks.presence)
22+
super.merge(batch_size: batch_size, concurrency_maintenance_interval: concurrency_maintenance&.interval, recurring_schedule: recurring_schedule.task_keys.presence)
2323
end
2424

2525
private
@@ -38,16 +38,16 @@ def start_concurrency_maintenance
3838
concurrency_maintenance&.start
3939
end
4040

41-
def load_recurring_schedule
42-
recurring_schedule.load_tasks
41+
def schedule_recurring_tasks
42+
recurring_schedule.schedule_tasks
4343
end
4444

4545
def stop_concurrency_maintenance
4646
concurrency_maintenance&.stop
4747
end
4848

49-
def unload_recurring_schedule
50-
recurring_schedule.unload_tasks
49+
def unschedule_recurring_tasks
50+
recurring_schedule.unschedule_tasks
5151
end
5252

5353
def all_work_completed?

lib/solid_queue/dispatcher/recurring_schedule.rb

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,41 +7,50 @@ class Dispatcher::RecurringSchedule
77
attr_reader :configured_tasks, :scheduled_tasks
88

99
def initialize(tasks)
10-
@configured_tasks = Array(tasks).map { |task| Dispatcher::RecurringTask.wrap(task) }
10+
@configured_tasks = Array(tasks).map { |task| SolidQueue::RecurringTask.wrap(task) }.select(&:valid?)
1111
@scheduled_tasks = Concurrent::Hash.new
1212
end
1313

1414
def empty?
1515
configured_tasks.empty?
1616
end
1717

18-
def load_tasks
18+
def schedule_tasks
19+
wrap_in_app_executor do
20+
persist_tasks
21+
reload_tasks
22+
end
23+
1924
configured_tasks.each do |task|
20-
load_task(task)
25+
schedule_task(task)
2126
end
2227
end
2328

24-
def load_task(task)
29+
def schedule_task(task)
2530
scheduled_tasks[task.key] = schedule(task)
2631
end
2732

28-
def unload_tasks
33+
def unschedule_tasks
2934
scheduled_tasks.values.each(&:cancel)
3035
scheduled_tasks.clear
3136
end
3237

33-
def tasks
34-
configured_tasks.each_with_object({}) { |task, hsh| hsh[task.key] = task.to_h }
35-
end
36-
37-
def inspect
38-
configured_tasks.map(&:to_s).join(" | ")
38+
def task_keys
39+
configured_tasks.map(&:key)
3940
end
4041

4142
private
43+
def persist_tasks
44+
SolidQueue::RecurringTask.create_or_update_all configured_tasks
45+
end
46+
47+
def reload_tasks
48+
@configured_tasks = SolidQueue::RecurringTask.where(key: task_keys)
49+
end
50+
4251
def schedule(task)
4352
scheduled_task = Concurrent::ScheduledTask.new(task.delay_from_now, args: [ self, task, task.next_time ]) do |thread_schedule, thread_task, thread_task_run_at|
44-
thread_schedule.load_task(thread_task)
53+
thread_schedule.schedule_task(thread_task)
4554

4655
wrap_in_app_executor do
4756
thread_task.enqueue(at: thread_task_run_at)

test/dummy/db/schema.rb

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
#
1111
# It's strongly recommended that you check this file into your version control system.
1212

13-
ActiveRecord::Schema[7.1].define(version: 2024_02_18_110712) do
13+
ActiveRecord::Schema[7.1].define(version: 2024_07_19_134516) do
1414
create_table "job_results", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
1515
t.string "queue_name"
1616
t.string "status"
@@ -101,6 +101,22 @@
101101
t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
102102
end
103103

104+
create_table "solid_queue_recurring_tasks", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
105+
t.string "key", null: false
106+
t.string "schedule", null: false
107+
t.string "command", limit: 2048
108+
t.string "class_name"
109+
t.text "arguments"
110+
t.string "queue_name"
111+
t.integer "priority", default: 0
112+
t.boolean "static", default: true
113+
t.text "description"
114+
t.datetime "created_at", null: false
115+
t.datetime "updated_at", null: false
116+
t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true
117+
t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static"
118+
end
119+
104120
create_table "solid_queue_scheduled_executions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
105121
t.bigint "job_id", null: false
106122
t.string "queue_name", null: false

0 commit comments

Comments
 (0)