solid_queue-coalesce 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +64 -0
- data/README.md +800 -72
- data/UPGRADING.md +76 -0
- data/app/models/solid_queue/coalesce_key.rb +57 -67
- data/app/models/solid_queue/coalesced_call.rb +22 -0
- data/app/models/solid_queue/job/coalescible.rb +201 -89
- data/lib/active_job/coalescing.rb +226 -71
- data/lib/generators/solid_queue/coalesce/install/templates/db/create_solid_queue_coalesce_tables.rb +4 -9
- data/lib/solid_queue/coalesce/engine.rb +1 -1
- data/lib/solid_queue/coalesce/patches.rb +74 -24
- data/lib/solid_queue/coalesce/version.rb +1 -1
- metadata +9 -4
- data/app/models/solid_queue/coalesced_argument.rb +0 -15
data/UPGRADING.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Upgrading to version 0.2.x
|
|
2
|
+
This version replaces the gem's tables and the options of `coalesce`. There's no in-place migration for waiting calls, so drain them first. The generator skips an app that already has its migration, so this upgrade needs a migration of your own.
|
|
3
|
+
|
|
4
|
+
Stop enqueuing jobs that use `coalesce`, and on 0.1.0 wait until none of those jobs are left. This query includes scheduled, blocked, running and failed jobs, so retry or discard the failed ones:
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
SolidQueue::Job.where(finished_at: nil, class_name: %w[ ReindexDocumentsJob SendDigestJob ]).count
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Stop your Solid Queue processes, add this migration to your queue database's migrations path, usually `db/queue_migrate`, and run it. It deletes the rows in both tables, including the calls of any finished jobs you've preserved:
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
class ReplaceSolidQueueCoalesceTables < ActiveRecord::Migration[7.1]
|
|
14
|
+
def up
|
|
15
|
+
drop_table :solid_queue_coalesced_arguments
|
|
16
|
+
drop_table :solid_queue_coalesce_keys
|
|
17
|
+
|
|
18
|
+
create_table :solid_queue_coalesce_keys do |t|
|
|
19
|
+
t.string :key, null: false
|
|
20
|
+
t.bigint :job_id
|
|
21
|
+
t.integer :calls_count, null: false, default: 0
|
|
22
|
+
t.datetime :created_at, null: false
|
|
23
|
+
|
|
24
|
+
t.index :key, unique: true
|
|
25
|
+
t.index :job_id, unique: true
|
|
26
|
+
t.foreign_key :solid_queue_jobs, column: :job_id, on_delete: :cascade
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
create_table :solid_queue_coalesced_calls do |t|
|
|
30
|
+
t.bigint :job_id, null: false
|
|
31
|
+
t.text :arguments, null: false
|
|
32
|
+
t.datetime :created_at, null: false
|
|
33
|
+
|
|
34
|
+
t.index :job_id
|
|
35
|
+
t.foreign_key :solid_queue_jobs, column: :job_id, on_delete: :cascade
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def down
|
|
40
|
+
raise ActiveRecord::IrreversibleMigration
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
bin/rails db:migrate:queue
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
If you use a single database, put the migration in `db/migrate` and run `bin/rails db:migrate`.
|
|
50
|
+
|
|
51
|
+
Deploy these changes together with the migration:
|
|
52
|
+
|
|
53
|
+
| 0.1.0 | 0.2.0 |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| `collect: true` | `on_conflict: :collect` |
|
|
56
|
+
| `limit: 100` | `max_calls: 100` |
|
|
57
|
+
| `flush_when_full: true`, which was the default | `on_full: :dispatch` |
|
|
58
|
+
| `flush_when_full: false` | Remove it. A full job waits for its time by default |
|
|
59
|
+
| `perform(array)` on a collecting job | `perform_calls(calls)`, and read `call.arguments` |
|
|
60
|
+
| `coalesced?` | `coalesce_status`, which is `:discarded` or `:collected` for a call that joined a job |
|
|
61
|
+
| `:dropped` in `coalesce.solid_queue` | `:discarded` |
|
|
62
|
+
| `action` in `coalesce.solid_queue` | `status` |
|
|
63
|
+
| `drop_coalesced_argument.solid_queue` | `drop_coalesced_call.solid_queue` |
|
|
64
|
+
| `argument_id` in that event's payload | `call_id` |
|
|
65
|
+
| `SolidQueue::CoalescedArgument` | `SolidQueue::CoalescedCall` |
|
|
66
|
+
|
|
67
|
+
These behaviours change too:
|
|
68
|
+
|
|
69
|
+
- Calls that refer to deleted records are skipped for that attempt, and their rows stay until the job is destroyed. Retrying or re-running the job reports each still-missing call again; a restored record's call can run. Allow for these payloads in finished-job retention. To decide which records to process in your own code, enqueue IDs and look them up in `perform_calls` instead of passing records as GlobalIDs.
|
|
70
|
+
- `drop_coalesced_call.solid_queue` reports a skip on an attempt, with no deletion outcome. Count these events as skipped attempts, or deduplicate by `call_id` if you need unique calls.
|
|
71
|
+
- If you relied on reaching `limit` to dispatch a scheduled job early, jobs now wait for their time unless you set `on_full: :dispatch`.
|
|
72
|
+
- A retry runs all of the job's calls again, so make `perform_calls` idempotent. Cursors over `perform`'s array don't carry over.
|
|
73
|
+
- `SolidQueue::Job::CoalescedInBatchError` is gone. Remove any `rescue` of it, because Ruby would raise `NameError` when an exception reaches that `rescue`. Coalesced jobs that a `rescue` kept out of a batch now join it.
|
|
74
|
+
- The key column uses the database's default collation. If that's case-insensitive, keys that differ only by case now coalesce together. Use a case-sensitive collation in the migration above if they have to stay apart.
|
|
75
|
+
|
|
76
|
+
If you run pre-release code from `main`, remove `call_deleted` from notification subscribers, log parsers and metric labels. This field was added after 0.1.0 and removed before 0.2.0.
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SolidQueue
|
|
4
|
-
# A row means "this job is the open job for this key": it exists only while
|
|
5
|
-
#
|
|
6
|
-
# the end of the admission that inserted it, so it's never visible to others.
|
|
7
|
-
|
|
4
|
+
# A row means "this job is the open job for this key": it exists only while its
|
|
5
|
+
# job exists, hasn't started and has room. job_id is null only between the insert
|
|
6
|
+
# and the end of the admission that inserted it, so it's never visible to others.
|
|
7
|
+
# calls_count is how many calls the job owns: at least one if it's collecting, and
|
|
8
|
+
# zero if it's discarding. A sealed or started job has no row, and so no count.
|
|
9
|
+
class CoalesceKey < Record # :nodoc:
|
|
10
|
+
# Enqueuing a job that uses +coalesce+ before the gem's migration has run.
|
|
8
11
|
class PendingMigrations < StandardError
|
|
9
12
|
def initialize(message = "The coalescing schema hasn't been installed yet. Run `bin/rails generate solid_queue:coalesce:install` to copy the migration to your application, and then `bin/rails db:migrate` to run it")
|
|
10
13
|
super
|
|
@@ -12,28 +15,33 @@ module SolidQueue
|
|
|
12
15
|
end
|
|
13
16
|
|
|
14
17
|
belongs_to :job, optional: true
|
|
15
|
-
has_many :coalesced_arguments
|
|
16
|
-
|
|
17
|
-
scope :stranded, -> { left_joins(job: :failed_execution).merge(Job.finished.or(Job.where.not(failed_execution: { id: nil }))) }
|
|
18
18
|
|
|
19
19
|
class << self
|
|
20
20
|
# Until the app has run the gem's migration everything else works as before.
|
|
21
21
|
def migrated?
|
|
22
|
-
@migrated ||= table_exists? &&
|
|
22
|
+
@migrated ||= table_exists? && CoalescedCall.table_exists?
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
# Returns the row for this key, inserted if nobody holds it, and locked so
|
|
26
|
-
# a job start can't
|
|
26
|
+
# a job start can't delete it until this transaction commits. A row
|
|
27
27
|
# with a null job_id is ours: the caller has to create the job for it.
|
|
28
|
-
def admit(key)
|
|
28
|
+
def admit(key, lock_clause = share_lock)
|
|
29
29
|
loop do
|
|
30
30
|
insert_unless_held(key)
|
|
31
31
|
|
|
32
|
-
admitted = lock(
|
|
32
|
+
admitted = lock(lock_clause).find_by(key: key)
|
|
33
33
|
return admitted if admitted
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Admission for a collecting job, which goes on to change the row. Nearly every
|
|
38
|
+
# call finds a row, so it tries the lock first. On MySQL, an insert that hits a
|
|
39
|
+
# duplicate locks the row and the gap next to it, and the calls queued on a row
|
|
40
|
+
# that gets sealed deadlock on those locks.
|
|
41
|
+
def admit_exclusively(key)
|
|
42
|
+
lock_if_present(key) || admit(key, true)
|
|
43
|
+
end
|
|
44
|
+
|
|
37
45
|
# Locks through the key's index, like admission. Going through the job_id
|
|
38
46
|
# index, even by deleting by it, takes InnoDB's locks on this row's indexes in
|
|
39
47
|
# the opposite order to admission, and that deadlocks.
|
|
@@ -43,16 +51,30 @@ module SolidQueue
|
|
|
43
51
|
end
|
|
44
52
|
end
|
|
45
53
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
# Lets go of a job from when the key's class collected. Checks again under the
|
|
55
|
+
# lock, because another call may have got here first. Doesn't check for the row
|
|
56
|
+
# first like lock_if_present: inside an older MySQL snapshot that plain read can
|
|
57
|
+
# miss a row that admission's locking read keeps finding, and we'd never seal it.
|
|
58
|
+
def seal_collecting(key)
|
|
59
|
+
coalesce_key = lock.find_by(key: key)
|
|
60
|
+
coalesce_key.destroy! if coalesce_key&.collecting?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# For jobs deleted in bulk, which skips their destroy callback. In the order of
|
|
64
|
+
# the keys, so that two of these can't deadlock with each other.
|
|
65
|
+
def release_all(job_ids)
|
|
66
|
+
where(job_id: job_ids).order(:key).pluck(:key, :job_id).each do |key, job_id|
|
|
67
|
+
lock.find_by(key: key, job_id: job_id)&.destroy!
|
|
52
68
|
end
|
|
53
69
|
end
|
|
54
70
|
|
|
55
71
|
private
|
|
72
|
+
# MySQL's check is because locking a row that isn't there takes a gap lock,
|
|
73
|
+
# and that blocks the insert of every other new key.
|
|
74
|
+
def lock_if_present(key)
|
|
75
|
+
lock.find_by(key: key) if supports_insert_conflict_target? || exists?(key: key)
|
|
76
|
+
end
|
|
77
|
+
|
|
56
78
|
# MariaDB and MySQL before 8 don't have FOR SHARE, and this means the same on MySQL 8
|
|
57
79
|
def share_lock
|
|
58
80
|
connection_pool.with_connection { |connection| connection.adapter_name == "PostgreSQL" } ? "FOR SHARE" : "LOCK IN SHARE MODE"
|
|
@@ -62,71 +84,39 @@ module SolidQueue
|
|
|
62
84
|
# a duplicate would abort an enclosing transaction on PostgreSQL. On MySQL it
|
|
63
85
|
# also avoids a deadlock: a plain INSERT that hits a duplicate takes a shared
|
|
64
86
|
# lock, and two of those on a row being deleted can't both go on to insert.
|
|
87
|
+
# created_at starts the max_wait clock, so it's set here and not by the database:
|
|
88
|
+
# MySQL would write it in the server's time zone, whatever the app's is.
|
|
65
89
|
def insert_unless_held(key)
|
|
66
90
|
if supports_insert_conflict_target?
|
|
67
|
-
insert({ key: key }, unique_by: :key)
|
|
91
|
+
insert({ key: key, created_at: Time.current }, unique_by: :key)
|
|
68
92
|
else
|
|
69
|
-
insert({ key: key })
|
|
93
|
+
insert({ key: key, created_at: Time.current })
|
|
70
94
|
end
|
|
71
95
|
end
|
|
72
96
|
end
|
|
73
97
|
|
|
74
|
-
|
|
75
|
-
|
|
98
|
+
# How many more calls the open job can take. A job created while its class was
|
|
99
|
+
# discarding runs from its own arguments, so a call that joined it would replace them.
|
|
100
|
+
def capacity(max_calls)
|
|
101
|
+
discarding? ? 0 : max_calls - calls_count
|
|
76
102
|
end
|
|
77
103
|
|
|
78
|
-
def
|
|
79
|
-
|
|
104
|
+
def collecting?
|
|
105
|
+
calls_count.positive?
|
|
80
106
|
end
|
|
81
107
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
# it's been dispatched before we got here there's nothing to lock.
|
|
85
|
-
def flush_if_full(limit)
|
|
86
|
-
if at_least?(limit)
|
|
87
|
-
scheduled = ScheduledExecution.where(job_id: job_id).non_blocking_lock.pluck(:job_id)
|
|
88
|
-
ScheduledExecution.dispatch_jobs(scheduled) if scheduled.any?
|
|
89
|
-
end
|
|
108
|
+
def discarding?
|
|
109
|
+
job_id.present? && calls_count.zero?
|
|
90
110
|
end
|
|
91
111
|
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
# row with rows we didn't see would cascade to them.
|
|
97
|
-
#
|
|
98
|
-
# A finished job won't run again, so it claims nothing when there's a next job
|
|
99
|
-
# that can process the rows. Without a limit there can't be one: the class that
|
|
100
|
-
# would process them is gone, or stopped collecting.
|
|
101
|
-
def release(claiming: true)
|
|
102
|
-
limit = job.coalesce_limit
|
|
103
|
-
claim(claiming || limit.nil? ? limit : 0)
|
|
104
|
-
|
|
105
|
-
if oldest = coalesced_arguments.order(:id).lock.first
|
|
106
|
-
next_job = job.enqueue_next_coalesced(oldest, due_now: next_job_due_now?(limit))
|
|
107
|
-
update_columns(job_id: next_job.id)
|
|
108
|
-
else
|
|
112
|
+
# Records calls added to the job. A full job is sealed by deleting this row, so
|
|
113
|
+
# the next call opens a new one, and nothing about the sealed job changes.
|
|
114
|
+
def fill(job_id, count, max_calls)
|
|
115
|
+
if count >= capacity(max_calls)
|
|
109
116
|
destroy!
|
|
117
|
+
else
|
|
118
|
+
update_columns(job_id: job_id, calls_count: calls_count + count)
|
|
110
119
|
end
|
|
111
120
|
end
|
|
112
|
-
|
|
113
|
-
private
|
|
114
|
-
def next_job_due_now?(limit)
|
|
115
|
-
limit.present? && job.coalesce_flush_when_full? && at_least?(limit)
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def argument_attributes_from(active_job)
|
|
119
|
-
{ coalesce_key_id: id, arguments: active_job.serialize["arguments"], scheduled_at: active_job.scheduled_at }
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def at_least?(count)
|
|
123
|
-
coalesced_arguments.order(:id).offset(count - 1).limit(1).pick(:id).present?
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
# A nil limit means we can't know it, so nothing is left behind
|
|
127
|
-
def claim(limit)
|
|
128
|
-
claimed = limit ? coalesced_arguments.where(id: coalesced_arguments.order(:id).limit(limit).lock.pluck(:id)) : coalesced_arguments
|
|
129
|
-
claimed.update_all(job_id: job_id, coalesce_key_id: nil)
|
|
130
|
-
end
|
|
131
121
|
end
|
|
132
122
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidQueue
|
|
4
|
+
# The Active Job payload of one collected perform_later call. It belongs to one
|
|
5
|
+
# job from the moment it's admitted, across that job's retries, until the job is
|
|
6
|
+
# destroyed.
|
|
7
|
+
class CoalescedCall < Record
|
|
8
|
+
belongs_to :job
|
|
9
|
+
|
|
10
|
+
serialize :arguments, coder: JSON
|
|
11
|
+
|
|
12
|
+
##
|
|
13
|
+
# :singleton-method: waiting
|
|
14
|
+
#
|
|
15
|
+
# Calls whose job hasn't finished, isn't running and hasn't failed, for measuring
|
|
16
|
+
# the backlog.
|
|
17
|
+
#
|
|
18
|
+
# SolidQueue::CoalescedCall.waiting.count
|
|
19
|
+
# SolidQueue::CoalescedCall.waiting.minimum(:created_at)
|
|
20
|
+
scope :waiting, -> { where(job_id: Job.where(finished_at: nil).where.missing(:claimed_execution, :failed_execution).select(:id)) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -2,41 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidQueue
|
|
4
4
|
class Job
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
module Coalescible
|
|
5
|
+
module Coalescible # :nodoc: all
|
|
8
6
|
extend ActiveSupport::Concern
|
|
9
7
|
|
|
10
8
|
included do
|
|
11
|
-
has_many :
|
|
9
|
+
has_many :coalesced_calls
|
|
10
|
+
|
|
11
|
+
# Before anything else, so the key is locked before the job, as in admission.
|
|
12
|
+
# Left to the cascade, they'd lock in the opposite order and deadlock.
|
|
13
|
+
before_destroy :release_coalesce_key, prepend: true
|
|
12
14
|
end
|
|
13
15
|
|
|
14
16
|
class_methods do
|
|
15
|
-
# Every call in active_jobs has the same key. Returns the job if
|
|
16
|
-
#
|
|
17
|
-
def enqueue_coalesced(key, active_jobs)
|
|
17
|
+
# Every call in active_jobs has the same key. Returns the job if a call
|
|
18
|
+
# created one, nil if the calls joined the open job.
|
|
19
|
+
def enqueue_coalesced(key, active_jobs, payloads = nil)
|
|
20
|
+
return collect_coalesced(key, active_jobs, payloads || active_jobs.map(&:serialize)) if active_jobs.first.class.coalesce_collect?
|
|
21
|
+
|
|
18
22
|
enqueued_job, open_job_id = raising_enqueue_errors do
|
|
19
|
-
retrying_deadlocks {
|
|
23
|
+
retrying_deadlocks { admit_discarding(key, active_jobs) }
|
|
20
24
|
end
|
|
21
25
|
|
|
22
|
-
|
|
23
|
-
record_coalesced(key, joined, open_job_id)
|
|
26
|
+
record_coalesced(key, active_jobs, enqueued_job&.persisted?, open_job_id)
|
|
24
27
|
|
|
25
28
|
enqueued_job
|
|
26
29
|
end
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
# So a key or a payload that can't be built raises before anything is written.
|
|
32
|
+
# A repeated :discard call is never stored, so its arguments don't have to
|
|
33
|
+
# serialize, as with perform_later. The first one's only get checked here,
|
|
34
|
+
# because its job is built later, if there's no open job to find.
|
|
35
|
+
def prepare_all_coalesced(active_jobs)
|
|
36
|
+
active_jobs.group_by(&:coalesce_key).sort_by(&:first).map do |key, calls|
|
|
37
|
+
if calls.first.class.coalesce_collect?
|
|
38
|
+
[ key, calls, calls.map(&:serialize) ]
|
|
39
|
+
else
|
|
40
|
+
calls.first.serialize
|
|
41
|
+
[ key, calls ]
|
|
42
|
+
end
|
|
31
43
|
end
|
|
32
44
|
end
|
|
33
45
|
|
|
34
|
-
def
|
|
35
|
-
|
|
46
|
+
def enqueue_all_coalesced(prepared_calls)
|
|
47
|
+
prepared_calls.each { |key, calls, payloads| enqueue_coalesced(key, calls, payloads) }
|
|
48
|
+
end
|
|
36
49
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
end
|
|
50
|
+
def ensure_coalescible
|
|
51
|
+
raise CoalesceKey::PendingMigrations unless CoalesceKey.migrated?
|
|
40
52
|
end
|
|
41
53
|
|
|
42
54
|
MAX_COALESCE_DEADLOCK_RETRIES = 5
|
|
@@ -65,12 +77,12 @@ module SolidQueue
|
|
|
65
77
|
# block creates the new job, and sets provider_job_id to it.
|
|
66
78
|
def continuing_coalesced_run(active_job)
|
|
67
79
|
previous_job_id = active_job.provider_job_id
|
|
68
|
-
moving_calls = previous_job_id && active_job.class.coalesce_collect && CoalesceKey.migrated?
|
|
80
|
+
moving_calls = previous_job_id && active_job.class.coalesce_collect? && CoalesceKey.migrated?
|
|
69
81
|
|
|
70
82
|
enqueued_job = raising_enqueue_errors do
|
|
71
83
|
transaction do
|
|
72
84
|
yield.tap do |job|
|
|
73
|
-
|
|
85
|
+
CoalescedCall.where(job_id: previous_job_id).update_all(job_id: job.id) if moving_calls && job.persisted?
|
|
74
86
|
end
|
|
75
87
|
end
|
|
76
88
|
end
|
|
@@ -93,112 +105,212 @@ module SolidQueue
|
|
|
93
105
|
raise enqueue_error
|
|
94
106
|
end
|
|
95
107
|
|
|
96
|
-
|
|
97
|
-
|
|
108
|
+
# A key with calls belongs to a job from when the class collected. A call
|
|
109
|
+
# can't count on that job, which fails without running it, so it seals the
|
|
110
|
+
# job and tries again. Sealing is outside the transaction that found it: every
|
|
111
|
+
# call waiting on the key holds the same share lock, and upgrading it deadlocks
|
|
112
|
+
# with all of them. Inside the caller's own transaction it can't be outside,
|
|
113
|
+
# so that deadlock can reach the caller, as in retrying_deadlocks.
|
|
114
|
+
def admit_discarding(key, active_jobs)
|
|
115
|
+
loop do
|
|
116
|
+
admitted = transaction { admit_unless_collecting(key, active_jobs) }
|
|
117
|
+
return admitted if admitted
|
|
118
|
+
|
|
119
|
+
transaction { CoalesceKey.seal_collecting(key) }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def admit_unless_collecting(key, active_jobs)
|
|
98
124
|
coalesce_key = CoalesceKey.admit(key)
|
|
99
|
-
enqueued_job = create_for_coalesce_key(coalesce_key, active_jobs.first) if coalesce_key.job_id.nil?
|
|
100
125
|
|
|
101
|
-
|
|
102
|
-
coalesce_key.
|
|
103
|
-
coalesce_key.
|
|
104
|
-
end
|
|
126
|
+
unless coalesce_key.collecting?
|
|
127
|
+
enqueued_job = create_for_coalesce_key(coalesce_key, active_jobs.first) if coalesce_key.job_id.nil?
|
|
128
|
+
debounce_coalesced(coalesce_key, coalesce_key.job_id, enqueued_job ? active_jobs.drop(1) : active_jobs)
|
|
105
129
|
|
|
106
|
-
|
|
130
|
+
[ enqueued_job, coalesce_key.job_id ]
|
|
131
|
+
end
|
|
107
132
|
end
|
|
108
133
|
|
|
109
134
|
def create_for_coalesce_key(coalesce_key, active_job)
|
|
110
|
-
|
|
135
|
+
attributes = attributes_from_active_job(active_job)
|
|
136
|
+
|
|
137
|
+
create!(**attributes, arguments: attributes[:arguments].merge("coalesce_on_conflict" => "discard")).tap do |job|
|
|
111
138
|
# limits_concurrency with on_conflict: :discard destroys the job right away
|
|
112
139
|
if job.persisted?
|
|
113
140
|
coalesce_key.update_columns(job_id: job.id)
|
|
114
141
|
else
|
|
115
142
|
coalesce_key.destroy!
|
|
116
143
|
end
|
|
117
|
-
|
|
118
|
-
active_job.provider_job_id = job.id if job.persisted?
|
|
119
|
-
active_job.successfully_enqueued = job.persisted?
|
|
120
144
|
end
|
|
121
145
|
end
|
|
122
146
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
147
|
+
# Takes one job's worth of calls at a time, each in its own transaction.
|
|
148
|
+
# A call's outcome is set once its transaction is over, so a deadlock retry
|
|
149
|
+
# can't leave one behind. Serialized once and before any job exists, so the
|
|
150
|
+
# first call's payload is like every other call's, and a retry doesn't take
|
|
151
|
+
# a new snapshot.
|
|
152
|
+
def collect_coalesced(key, active_jobs, payloads)
|
|
153
|
+
max_calls = active_jobs.first.class.coalesce_max_calls
|
|
154
|
+
pending = active_jobs.zip(payloads)
|
|
155
|
+
enqueued_job = nil
|
|
156
|
+
|
|
157
|
+
until pending.empty?
|
|
158
|
+
admitted, created_job, job_id = raising_enqueue_errors do
|
|
159
|
+
retrying_deadlocks { transaction { admit_collected(key, pending, max_calls) } }
|
|
160
|
+
end
|
|
126
161
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
162
|
+
record_coalesced(key, pending.shift(admitted).map(&:first), created_job.present?, job_id)
|
|
163
|
+
enqueued_job ||= created_job
|
|
164
|
+
end
|
|
130
165
|
|
|
131
|
-
|
|
132
|
-
|
|
166
|
+
enqueued_job
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Returns how many of the pending calls went in, and where. It can be none:
|
|
170
|
+
# a smaller max_calls can leave the open job over its capacity, and a job
|
|
171
|
+
# from when the class discarded has none, and that only seals it.
|
|
172
|
+
def admit_collected(key, pending, max_calls)
|
|
173
|
+
coalesce_key = CoalesceKey.admit_exclusively(key)
|
|
174
|
+
calls = pending.first([ coalesce_key.capacity(max_calls), 0 ].max)
|
|
175
|
+
|
|
176
|
+
if calls.empty?
|
|
177
|
+
coalesce_key.destroy!
|
|
178
|
+
[ 0 ]
|
|
179
|
+
else
|
|
180
|
+
created_job = create_collecting(*calls.first) if coalesce_key.job_id.nil?
|
|
181
|
+
job_id = coalesce_key.job_id || created_job.id
|
|
182
|
+
|
|
183
|
+
CoalescedCall.insert_all(calls.map { |_, payload| { job_id: job_id, arguments: payload } })
|
|
184
|
+
coalesce_key.fill(job_id, calls.size, max_calls)
|
|
185
|
+
|
|
186
|
+
if coalesce_key.destroyed? && calls.first.first.class.coalesce_on_full == :dispatch
|
|
187
|
+
dispatch_full(job_id)
|
|
188
|
+
else
|
|
189
|
+
debounce_coalesced(coalesce_key, job_id, calls.drop(created_job ? 1 : 0).map(&:first))
|
|
133
190
|
end
|
|
191
|
+
|
|
192
|
+
[ calls.size, created_job, job_id ]
|
|
134
193
|
end
|
|
135
194
|
end
|
|
136
|
-
end
|
|
137
195
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
196
|
+
# The time moves too, or retrying the job after it fails would wait for the old one.
|
|
197
|
+
def dispatch_full(job_id)
|
|
198
|
+
scheduled = lock_scheduled(ScheduledExecution.where(job_id: job_id))
|
|
141
199
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
200
|
+
if scheduled.any?
|
|
201
|
+
where(id: scheduled).update_all(scheduled_at: Time.current)
|
|
202
|
+
ScheduledExecution.dispatch_jobs(scheduled)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
145
205
|
|
|
146
|
-
|
|
147
|
-
|
|
206
|
+
# The calls that joined the job move it once, to the latest of their times,
|
|
207
|
+
# capped at max_wait from when its key opened. Each open job has its own key,
|
|
208
|
+
# so each has its own clock. A sealed job has no key, so nothing moves it again.
|
|
209
|
+
def debounce_coalesced(coalesce_key, job_id, joined_jobs)
|
|
210
|
+
job_class = joined_jobs.first&.class
|
|
211
|
+
|
|
212
|
+
if job_id && job_class&.coalesce_debounce
|
|
213
|
+
scheduled_at = joined_jobs.map(&:scheduled_at).max
|
|
214
|
+
scheduled_at = [ scheduled_at, coalesce_key.created_at + job_class.coalesce_max_wait ].min if job_class.coalesce_max_wait
|
|
215
|
+
|
|
216
|
+
move_later(job_id, scheduled_at)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Only an execution more than a second before the new time matches. That keeps
|
|
221
|
+
# moves later-only, and saves a hot key from writing on every call. The time
|
|
222
|
+
# moves on the job too, because a failed job's retry goes by the job's. If this
|
|
223
|
+
# skips a row that's held, the job runs early, and the key still covers the call.
|
|
224
|
+
def move_later(job_id, scheduled_at)
|
|
225
|
+
scheduled = lock_scheduled(ScheduledExecution.where(job_id: job_id, scheduled_at: ...(scheduled_at - 1.second)))
|
|
226
|
+
|
|
227
|
+
if scheduled.any?
|
|
228
|
+
where(id: scheduled).update_all(scheduled_at: scheduled_at)
|
|
229
|
+
ScheduledExecution.where(job_id: scheduled).update_all(scheduled_at: scheduled_at)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# A job that's ready, blocked or claimed has no scheduled execution, and has
|
|
234
|
+
# nothing to do. SKIP LOCKED because we hold the key, and a discard holds the
|
|
235
|
+
# execution while it waits for the key. Whoever holds the row is dispatching
|
|
236
|
+
# or discarding the job already. On MySQL, locking a row that isn't there takes
|
|
237
|
+
# a gap lock that blocks other jobs being scheduled, so it checks first.
|
|
238
|
+
def lock_scheduled(executions)
|
|
239
|
+
executions.exists? ? executions.non_blocking_lock.pluck(:job_id) : []
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Not attributes_from_active_job, which serializes the call again, and that
|
|
243
|
+
# could raise after earlier jobs committed.
|
|
244
|
+
def create_collecting(active_job, payload)
|
|
245
|
+
attributes = {
|
|
246
|
+
queue_name: active_job.queue_name || singleton_class::DEFAULT_QUEUE_NAME,
|
|
247
|
+
active_job_id: active_job.job_id,
|
|
248
|
+
priority: active_job.priority || singleton_class::DEFAULT_PRIORITY,
|
|
249
|
+
scheduled_at: active_job.scheduled_at,
|
|
250
|
+
class_name: active_job.class.name,
|
|
251
|
+
arguments: payload.merge("coalesce_on_conflict" => "collect"),
|
|
252
|
+
concurrency_key: active_job.concurrency_key
|
|
253
|
+
}
|
|
254
|
+
attributes[:batch_id] = active_job.batch_id if Batch.migrated?
|
|
255
|
+
|
|
256
|
+
create!(**attributes)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# A call has an outcome only if it was admitted: when on_conflict: :discard
|
|
260
|
+
# destroys the job that was just created, nothing holds the key.
|
|
261
|
+
def record_coalesced(key, active_jobs, created, job_id)
|
|
262
|
+
active_jobs.each_with_index do |active_job, index|
|
|
263
|
+
collect = active_job.class.coalesce_collect?
|
|
264
|
+
created_by_this_call = created && index.zero?
|
|
265
|
+
|
|
266
|
+
active_job.provider_job_id = job_id
|
|
267
|
+
active_job.successfully_enqueued = created_by_this_call || collect
|
|
268
|
+
active_job.send(:coalesce_status=, job_id && (created_by_this_call ? :created : (collect ? :collected : :discarded)))
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# After every outcome, because a subscriber can raise. These calls are stored,
|
|
272
|
+
# so one without an outcome would look like it needs enqueuing again.
|
|
273
|
+
if job_id
|
|
274
|
+
active_jobs.each do |active_job|
|
|
275
|
+
SolidQueue.instrument(:coalesce, status: active_job.coalesce_status, key: key, job_id: job_id, active_job_id: active_job.job_id, class_name: active_job.class.name)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
148
279
|
end
|
|
149
280
|
|
|
150
281
|
# The start step. Returns what the Active Job needs to know about this run,
|
|
151
282
|
# which stays in memory instead of going into its serialized data.
|
|
283
|
+
#
|
|
284
|
+
# Closing commits before the calls are read, in a statement of their own. The
|
|
285
|
+
# read that finds the key in lock_for_release sets the snapshot of a MySQL
|
|
286
|
+
# REPEATABLE READ transaction, so a read in the same transaction could miss
|
|
287
|
+
# calls committed while it waited for the lock.
|
|
152
288
|
def start_coalesced_run
|
|
153
289
|
return unless coalesced? && CoalesceKey.migrated?
|
|
154
290
|
|
|
155
|
-
self.class.retrying_deadlocks
|
|
156
|
-
transaction do
|
|
157
|
-
release_coalesce_key
|
|
158
|
-
{ "calls" => coalesced_arguments.order(:id).pluck(:id, :arguments), "concurrency_key" => concurrency_key }
|
|
159
|
-
end
|
|
160
|
-
end
|
|
161
|
-
end
|
|
291
|
+
self.class.retrying_deadlocks { transaction { release_coalesce_key } }
|
|
162
292
|
|
|
163
|
-
|
|
164
|
-
# starting left waiting. Its arguments are the raw ones in the oldest row:
|
|
165
|
-
# deserializing them here would let a deleted record break the handover.
|
|
166
|
-
def enqueue_next_coalesced(oldest, due_now:)
|
|
167
|
-
due_at = (oldest.scheduled_at unless due_now) || Time.current
|
|
168
|
-
active_job_id = SecureRandom.uuid
|
|
169
|
-
|
|
170
|
-
self.class.create!(
|
|
171
|
-
queue_name: queue_name,
|
|
172
|
-
class_name: class_name,
|
|
173
|
-
priority: priority,
|
|
174
|
-
active_job_id: active_job_id,
|
|
175
|
-
scheduled_at: due_at,
|
|
176
|
-
concurrency_key: concurrency_key,
|
|
177
|
-
arguments: next_coalesced_arguments(active_job_id, oldest, due_at)
|
|
178
|
-
)
|
|
293
|
+
{ "calls" => coalesced_calls.order(:id).pluck(:id, :arguments), "concurrency_key" => concurrency_key }
|
|
179
294
|
end
|
|
180
295
|
|
|
181
296
|
private
|
|
182
|
-
|
|
183
|
-
|
|
297
|
+
# Whether this job's class coalesces
|
|
298
|
+
def coalesced?
|
|
299
|
+
job_class.try(:coalesce_key).present?
|
|
300
|
+
end
|
|
184
301
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
302
|
+
# Admission marks a job before giving it a key, and the class covers jobs from
|
|
303
|
+
# before the marker. Anything that can't answer gets the read, so that failing
|
|
304
|
+
# or discarding a job still works when its payload or its class can't be loaded.
|
|
305
|
+
def may_hold_coalesce_key?
|
|
306
|
+
!arguments.is_a?(Hash) || arguments.key?("coalesce_on_conflict") || coalesced?
|
|
307
|
+
rescue StandardError, ScriptError
|
|
308
|
+
true
|
|
190
309
|
end
|
|
191
310
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
"provider_job_id" => nil,
|
|
196
|
-
"arguments" => oldest.arguments,
|
|
197
|
-
"executions" => 0,
|
|
198
|
-
"exception_executions" => {},
|
|
199
|
-
"enqueued_at" => Time.now.utc.iso8601(9),
|
|
200
|
-
"scheduled_at" => due_at.utc.iso8601(9)
|
|
201
|
-
)
|
|
311
|
+
# A sealed job has no row, and lock_for_release won't touch a newer job's
|
|
312
|
+
def release_coalesce_key
|
|
313
|
+
CoalesceKey.lock_for_release(id)&.destroy! if may_hold_coalesce_key? && CoalesceKey.migrated?
|
|
202
314
|
end
|
|
203
315
|
end
|
|
204
316
|
end
|