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.
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
- # its job exists and hasn't started. job_id is null only between the insert and
6
- # the end of the admission that inserted it, so it's never visible to others.
7
- class CoalesceKey < Record
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? && CoalescedArgument.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 claim or delete it until this transaction commits. A row
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(share_lock).find_by(key: key)
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
- def release_stranded(batch_size)
47
- stranded.limit(batch_size).pluck(:key).each do |key|
48
- transaction do
49
- coalesce_key = lock.find_by(key: key)
50
- coalesce_key.release(claiming: !coalesce_key.job.finished?) if coalesce_key&.stranded?
51
- end
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
- def stranded?
75
- job.finished? || job.failed?
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 collect(active_jobs)
79
- CoalescedArgument.insert_all(active_jobs.map { |active_job| argument_attributes_from(active_job) })
104
+ def collecting?
105
+ calls_count.positive?
80
106
  end
81
107
 
82
- # Early flush. Bounded by the limit, however big the backlog is. A concurrent
83
- # dispatcher holding the scheduled execution is dispatching it already, and if
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
- # Hands the waiting rows over as the job leaves the queue. Requires this row
93
- # to be locked: that's what waits for in-flight appends, and it serializes this
94
- # with other starts. Reads of the rows lock too, because on MySQL a plain read
95
- # could use a snapshot older than the appends we waited for, and deleting this
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
- class CoalescedInBatchError < EnqueueError; end
6
-
7
- module Coalescible
5
+ module Coalescible # :nodoc: all
8
6
  extend ActiveSupport::Concern
9
7
 
10
8
  included do
11
- has_many :coalesced_arguments
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 the first
16
- # call created one, nil if the calls joined the open job.
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 { transaction { admit_coalesced(key, active_jobs) } }
23
+ retrying_deadlocks { admit_discarding(key, active_jobs) }
20
24
  end
21
25
 
22
- joined = enqueued_job ? active_jobs.drop(1) : active_jobs
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
- def enqueue_all_coalesced(active_jobs)
29
- active_jobs.group_by(&:coalesce_key).sort_by(&:first).each do |key, calls|
30
- enqueue_coalesced(key, calls)
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 ensure_coalescible(active_jobs)
35
- raise CoalesceKey::PendingMigrations unless CoalesceKey.migrated?
46
+ def enqueue_all_coalesced(prepared_calls)
47
+ prepared_calls.each { |key, calls, payloads| enqueue_coalesced(key, calls, payloads) }
48
+ end
36
49
 
37
- if Batch.current_batch_id || active_jobs.any?(&:batch_id)
38
- raise CoalescedInBatchError, "#{active_jobs.first.class.name} coalesces its calls, so it can't be enqueued as part of a batch: a call that joins an open job adds no job to the batch"
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
- CoalescedArgument.where(job_id: previous_job_id).update_all(job_id: job.id) if moving_calls && job.persisted?
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
- def admit_coalesced(key, active_jobs)
97
- job_class = active_jobs.first.class
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
- if job_class.coalesce_collect && coalesce_key.job_id
102
- coalesce_key.collect(active_jobs)
103
- coalesce_key.flush_if_full(job_class.coalesce_limit) if job_class.coalesce_flush_when_full
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
- [ enqueued_job, coalesce_key.job_id ]
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
- create!(**attributes_from_active_job(active_job)).tap do |job|
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
- def record_coalesced(key, active_jobs, open_job_id)
124
- active_jobs.each do |active_job|
125
- collected = active_job.class.coalesce_collect
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
- active_job.provider_job_id = open_job_id
128
- active_job.successfully_enqueued = collected
129
- active_job.coalesced = open_job_id.present?
162
+ record_coalesced(key, pending.shift(admitted).map(&:first), created_job.present?, job_id)
163
+ enqueued_job ||= created_job
164
+ end
130
165
 
131
- if open_job_id
132
- SolidQueue.instrument(:coalesce, key: key, job_id: open_job_id, active_job_id: active_job.job_id, action: collected ? :collected : :dropped)
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
- def coalesced?
139
- job_class.try(:coalesce_key).present?
140
- end
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
- def coalesce_limit
143
- job_class.try(:coalesce_limit)
144
- end
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
- def coalesce_flush_when_full?
147
- job_class.try(:coalesce_flush_when_full)
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 do
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
- # The next job for the same key, picking up from the rows the job that's
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
- def release_coalesce_key
183
- return unless CoalesceKey.migrated?
297
+ # Whether this job's class coalesces
298
+ def coalesced?
299
+ job_class.try(:coalesce_key).present?
300
+ end
184
301
 
185
- if job_class&.coalesce_key && !job_class.coalesce_collect
186
- CoalesceKey.lock_for_release(id)&.destroy!
187
- elsif job_class.nil? || coalesced?
188
- CoalesceKey.lock_for_release(id)&.release
189
- end
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
- def next_coalesced_arguments(active_job_id, oldest, due_at)
193
- arguments.except("continuation", "resumptions").merge(
194
- "job_id" => active_job_id,
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