riverqueue 0.12.0 → 0.14.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 +80 -4
- data/README.md +91 -0
- data/docs/README.md +213 -36
- data/docs/conformance.md +72 -0
- data/docs/migrations.md +20 -23
- data/docs/testing.md +1 -1
- data/lib/client.rb +103 -15
- data/lib/client_runtime.rb +434 -87
- data/lib/config.rb +61 -9
- data/lib/driver/job_row_decoder.rb +2 -2
- data/lib/driver/notification_listener.rb +61 -0
- data/lib/driver/postgres_capabilities.rb +1 -1
- data/lib/driver/runtime.rb +419 -146
- data/lib/driver.rb +1 -0
- data/lib/event.rb +8 -1
- data/lib/insert_opts.rb +8 -3
- data/lib/job.rb +5 -5
- data/lib/job_persisted_logging/plugin.rb +3 -2
- data/lib/migrator.rb +1 -1
- data/lib/periodic_cron.rb +42 -6
- data/lib/periodic_job.rb +23 -4
- data/lib/resumable.rb +24 -1
- data/lib/worker.rb +32 -2
- data/migration/README.md +4 -2
- data/migration/manifest.json +6 -7
- data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +1 -1
- data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +1 -1
- data/migration/postgresql/main/008_job_id_autoincrement.down.sql +1 -1
- data/migration/postgresql/main/008_job_id_autoincrement.up.sql +1 -1
- data/migration/sqlite/main/008_job_id_autoincrement.up.sql +1 -1
- data/sig/client.rbs +9 -3
- data/sig/driver.rbs +46 -9
- data/sig/insert_opts.rbs +2 -2
- data/sig/job.rbs +2 -0
- data/sig/runtime.rbs +76 -8
- metadata +9 -6
data/lib/driver/runtime.rb
CHANGED
|
@@ -5,12 +5,19 @@ require "time"
|
|
|
5
5
|
module River::Driver
|
|
6
6
|
# Database operations used by River's worker runtime. Driver gems implement a
|
|
7
7
|
# small set of raw SQL primitives and include this module so the state machine
|
|
8
|
-
# stays identical across ActiveRecord and Sequel.
|
|
8
|
+
# stays identical across ActiveRecord and Sequel. Those private primitives
|
|
9
|
+
# provide execution, quoting, row access, and connection management inside
|
|
10
|
+
# driver modules; callers use the driver's public operations and Ruby values.
|
|
9
11
|
module Runtime
|
|
10
12
|
def init_driver
|
|
11
13
|
postgres_capabilities if runtime_postgres?
|
|
12
14
|
end
|
|
13
15
|
|
|
16
|
+
# Returns the UTC timestamp that writing and reading +time+ preserves.
|
|
17
|
+
def normalize_time(time)
|
|
18
|
+
runtime_postgres? ? time.getutc.floor(6) : time.getutc.round(3)
|
|
19
|
+
end
|
|
20
|
+
|
|
14
21
|
def postgres_capabilities
|
|
15
22
|
@postgres_capabilities_cache.fetch(runtime_connection_pool) do
|
|
16
23
|
row = runtime_query_rows(<<~SQL).first
|
|
@@ -27,7 +34,7 @@ module River::Driver
|
|
|
27
34
|
end
|
|
28
35
|
|
|
29
36
|
# Notifications and rows must commit together. The insertion drivers call
|
|
30
|
-
# this inside their transaction;
|
|
37
|
+
# this inside their transaction; Postgres defers delivery until commit.
|
|
31
38
|
private def postgres_notify_insert(params)
|
|
32
39
|
queues = params.select { |param| param.state == River::JOB_STATE_AVAILABLE }.map(&:queue).uniq
|
|
33
40
|
return if queues.empty?
|
|
@@ -65,13 +72,26 @@ module River::Driver
|
|
|
65
72
|
runtime_claim_jobs(predicate, 1, attempted_by, now).first
|
|
66
73
|
end
|
|
67
74
|
|
|
68
|
-
#
|
|
75
|
+
# The cancellation predicate must be part of the completion update so it is
|
|
76
|
+
# checked against the row we lock, including concurrent cancellations.
|
|
69
77
|
# :cancelled asks the runtime to apply its normal cancellation/error hooks.
|
|
70
|
-
def job_complete(id:, finalized_at:, metadata: nil, now: Time.now.utc)
|
|
78
|
+
def job_complete(id:, finalized_at:, expected_job: nil, metadata: nil, now: Time.now.utc)
|
|
71
79
|
id = Integer(id)
|
|
72
|
-
|
|
80
|
+
predicate = "id = #{id}"
|
|
81
|
+
predicate += " AND #{runtime_job_attempt_predicate(expected_job)}" if expected_job
|
|
82
|
+
assignments = ["state = 'completed'", "finalized_at = #{runtime_time(finalized_at)}"]
|
|
83
|
+
assignments << "metadata = #{runtime_merge_metadata(metadata)}" unless metadata.nil? || metadata.empty?
|
|
73
84
|
|
|
74
|
-
|
|
85
|
+
transaction do
|
|
86
|
+
updated_id = runtime_returning_ids(<<~SQL).first
|
|
87
|
+
UPDATE river_job SET #{assignments.join(", ")}
|
|
88
|
+
WHERE #{predicate} AND state = 'running' AND NOT (#{runtime_cancel_attempted})
|
|
89
|
+
RETURNING id
|
|
90
|
+
SQL
|
|
91
|
+
next runtime_read_job(updated_id) if updated_id
|
|
92
|
+
|
|
93
|
+
:cancelled if runtime_returning_ids("SELECT id FROM river_job WHERE #{predicate} AND (#{runtime_cancel_attempted})").any?
|
|
94
|
+
end
|
|
75
95
|
end
|
|
76
96
|
|
|
77
97
|
def job_delete(id)
|
|
@@ -104,13 +124,24 @@ module River::Driver
|
|
|
104
124
|
SQL
|
|
105
125
|
end
|
|
106
126
|
|
|
127
|
+
# A finalization hook's deletion must honor the same cancellation marker as
|
|
128
|
+
# completion. Return :cancelled so the runtime can record the cancellation.
|
|
107
129
|
def job_delete_if_running(id)
|
|
108
|
-
|
|
130
|
+
id = Integer(id)
|
|
131
|
+
deleted = runtime_returning_ids(<<~SQL).any?
|
|
132
|
+
DELETE FROM river_job
|
|
133
|
+
WHERE id = #{id} AND state = 'running' AND NOT (#{runtime_cancel_attempted})
|
|
134
|
+
RETURNING id
|
|
135
|
+
SQL
|
|
136
|
+
return true if deleted
|
|
137
|
+
return :cancelled if job_get_cancelled_ids([id]).include?(id)
|
|
138
|
+
|
|
139
|
+
false
|
|
109
140
|
end
|
|
110
141
|
|
|
111
142
|
def job_delete_many(params)
|
|
112
143
|
transaction do
|
|
113
|
-
jobs =
|
|
144
|
+
jobs = runtime_job_list(params, for_delete: true)
|
|
114
145
|
next [] if jobs.empty?
|
|
115
146
|
|
|
116
147
|
ids = jobs.map(&:id)
|
|
@@ -142,133 +173,129 @@ module River::Driver
|
|
|
142
173
|
params ||= River::JobListParams.new
|
|
143
174
|
return runtime_job_list_without_params if params == :all
|
|
144
175
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
clauses << "id #{(params.sort_order == :asc) ? ">" : "<"} #{Integer(params.after_id)}" if params.after_id
|
|
148
|
-
clauses << runtime_in_clause("id", params.ids.map { |value| Integer(value) }) if params.ids&.any?
|
|
149
|
-
clauses << runtime_in_clause("kind", params.kinds) if params.kinds&.any?
|
|
150
|
-
clauses << runtime_in_clause("priority", params.priorities.map { |value| Integer(value) }) if params.priorities&.any?
|
|
151
|
-
clauses << runtime_in_clause("queue", params.queues) if params.queues&.any?
|
|
152
|
-
clauses << runtime_in_clause("state", params.states) if params.states&.any?
|
|
153
|
-
|
|
154
|
-
finalized = params.sort_by == :finalized_at && params.states&.length == 1 &&
|
|
155
|
-
%w[cancelled completed discarded].include?(params.states.first)
|
|
156
|
-
# Schemas require finalized timestamps for terminal states. Spell this out
|
|
157
|
-
# so PostgreSQL can use the partial (state, finalized_at) index.
|
|
158
|
-
clauses << "finalized_at IS NOT NULL" if finalized
|
|
159
|
-
# Explicit NULLS LAST prevents a backward index scan, even when there are
|
|
160
|
-
# no nulls. Only request it for timestamps that can actually be null.
|
|
161
|
-
null_order = (params.sort_by == :finalized_at && !finalized) ? " NULLS LAST" : ""
|
|
176
|
+
runtime_job_list(params)
|
|
177
|
+
end
|
|
162
178
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
179
|
+
# When supplied, expected_job prevents metadata from crossing into another
|
|
180
|
+
# attempt or state. Keep the update and returned snapshot in one transaction.
|
|
181
|
+
def job_metadata_merge(id, metadata, expected_job = nil)
|
|
182
|
+
predicate = "id = #{Integer(id)}"
|
|
183
|
+
if expected_job
|
|
184
|
+
predicate += " AND #{runtime_job_attempt_predicate(expected_job)}"
|
|
185
|
+
predicate += " AND state = #{runtime_quote(expected_job.state)}"
|
|
167
186
|
end
|
|
168
187
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
updated_id = runtime_returning_ids(<<~SQL).first
|
|
179
|
-
UPDATE river_job
|
|
180
|
-
SET metadata = #{runtime_merge_metadata(metadata)}
|
|
181
|
-
WHERE id = #{Integer(id)}
|
|
182
|
-
RETURNING id
|
|
183
|
-
SQL
|
|
184
|
-
updated_id ? job_get_by_id(updated_id) : nil
|
|
188
|
+
transaction do
|
|
189
|
+
updated_id = runtime_returning_ids(<<~SQL).first
|
|
190
|
+
UPDATE river_job
|
|
191
|
+
SET metadata = #{runtime_merge_metadata(metadata)}
|
|
192
|
+
WHERE #{predicate}
|
|
193
|
+
RETURNING id
|
|
194
|
+
SQL
|
|
195
|
+
updated_id ? job_get_by_id(updated_id) : nil
|
|
196
|
+
end
|
|
185
197
|
end
|
|
186
198
|
|
|
187
|
-
def job_rescue_stuck(horizon:, retry_policy:, now: Time.now.utc, max: 1_000)
|
|
199
|
+
def job_rescue_stuck(horizon:, retry_policy:, now: Time.now.utc, max: 1_000, logger: nil, rescue_if: nil)
|
|
200
|
+
max = Integer(max)
|
|
188
201
|
transaction do
|
|
189
|
-
#
|
|
190
|
-
#
|
|
202
|
+
# Lock each candidate until its transition finishes. Continue past jobs
|
|
203
|
+
# that are still within their timeouts without consuming the rescue limit.
|
|
204
|
+
after_id = rescued = 0
|
|
191
205
|
lock = runtime_postgres? ? "FOR UPDATE SKIP LOCKED" : ""
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
206
|
+
while rescued < max
|
|
207
|
+
ids = runtime_returning_ids(<<~SQL)
|
|
208
|
+
SELECT id FROM river_job
|
|
209
|
+
WHERE state = 'running' AND attempted_at < #{runtime_time(horizon)} AND id > #{after_id}
|
|
210
|
+
ORDER BY id LIMIT #{max - rescued} #{lock}
|
|
211
|
+
SQL
|
|
212
|
+
break if ids.empty?
|
|
213
|
+
|
|
214
|
+
after_id = ids.last
|
|
215
|
+
ids.each do |id|
|
|
216
|
+
job = runtime_read_job(id)
|
|
217
|
+
cancelled = job.metadata.key?("cancel_attempted_at")
|
|
218
|
+
next if !cancelled && rescue_if && !rescue_if.call(job, now)
|
|
219
|
+
final = cancelled || job.attempt >= job.max_attempts
|
|
220
|
+
state = if cancelled
|
|
221
|
+
River::JOB_STATE_CANCELLED
|
|
222
|
+
elsif final
|
|
223
|
+
River::JOB_STATE_DISCARDED
|
|
224
|
+
else
|
|
225
|
+
River::JOB_STATE_RETRYABLE
|
|
226
|
+
end
|
|
227
|
+
error = River::AttemptError.new(at: now, attempt: job.attempt, error: "Stuck job rescued by River", trace: "")
|
|
228
|
+
rescue_count = job.metadata["river:rescue_count"]
|
|
229
|
+
rescue_count = case rescue_count
|
|
230
|
+
when Integer then rescue_count
|
|
231
|
+
when Float then rescue_count.finite? ? rescue_count.to_i : 0
|
|
232
|
+
else 0
|
|
233
|
+
end
|
|
234
|
+
job_set_state_if_running(
|
|
235
|
+
id: job.id,
|
|
236
|
+
error: error,
|
|
237
|
+
finalized_at: final ? now : nil,
|
|
238
|
+
metadata: {"river:rescue_count" => rescue_count + 1},
|
|
239
|
+
now: now,
|
|
240
|
+
scheduled_at: final ? nil : runtime_rescue_retry(job, error, retry_policy, now, logger),
|
|
241
|
+
state: state
|
|
242
|
+
)
|
|
243
|
+
rescued += 1
|
|
207
244
|
end
|
|
208
|
-
error = River::AttemptError.new(at: now, attempt: job.attempt, error: "Stuck job rescued by River", trace: "")
|
|
209
|
-
job_set_state_if_running(
|
|
210
|
-
id: job.id,
|
|
211
|
-
error: error,
|
|
212
|
-
finalized_at: final ? now : nil,
|
|
213
|
-
metadata: {"river:rescue_count" => job.metadata.fetch("river:rescue_count", 0).to_i + 1},
|
|
214
|
-
now: now,
|
|
215
|
-
scheduled_at: final ? nil : retry_policy.next_retry(job, error, now: now),
|
|
216
|
-
state: state
|
|
217
|
-
)
|
|
218
245
|
end
|
|
219
246
|
|
|
220
|
-
|
|
247
|
+
rescued
|
|
221
248
|
end
|
|
222
249
|
end
|
|
223
250
|
|
|
224
251
|
def job_retry(id, now: Time.now.utc)
|
|
225
|
-
updated_id =
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
252
|
+
updated_id, job = transaction do
|
|
253
|
+
# Check the counter in the write itself so a concurrent claim cannot
|
|
254
|
+
# advance it between validation and the update.
|
|
255
|
+
updated_id = runtime_returning_ids(<<~SQL).first
|
|
256
|
+
UPDATE river_job
|
|
257
|
+
SET state = 'available',
|
|
258
|
+
max_attempts = CASE WHEN attempt = max_attempts THEN max_attempts + 1 ELSE max_attempts END,
|
|
259
|
+
metadata = #{runtime_postgres? ? "metadata - 'cancel_attempted_at'" : "jsonb_remove(metadata, '$.cancel_attempted_at')"},
|
|
260
|
+
finalized_at = NULL,
|
|
261
|
+
scheduled_at = #{runtime_time(now)}
|
|
262
|
+
WHERE id = #{Integer(id)}
|
|
263
|
+
AND state != 'running'
|
|
264
|
+
AND (state != 'available' OR scheduled_at > #{runtime_time(now)})
|
|
265
|
+
AND attempt < #{River::MAX_ATTEMPTS_LIMIT}
|
|
266
|
+
RETURNING id
|
|
267
|
+
SQL
|
|
268
|
+
job = job_get_by_id(updated_id || id)
|
|
269
|
+
runtime_notify("river_insert", queue: job.queue) if updated_id && job
|
|
270
|
+
[updated_id, job]
|
|
271
|
+
end
|
|
272
|
+
if !updated_id && job && job.state != "running" && (job.state != "available" || job.scheduled_at > now) && job.attempt >= River::MAX_ATTEMPTS_LIMIT
|
|
273
|
+
raise ArgumentError, "cannot retry a job with #{River::MAX_ATTEMPTS_LIMIT} or more attempts"
|
|
274
|
+
end
|
|
275
|
+
job
|
|
237
276
|
end
|
|
238
277
|
|
|
278
|
+
# Makes due scheduled and retryable jobs available. A job is discarded
|
|
279
|
+
# instead if another row already holds its unique key, or if an earlier
|
|
280
|
+
# job in the same pass shares that key. The queries find these conflicts
|
|
281
|
+
# before they write, so the pass never needs a savepoint to recover from a
|
|
282
|
+
# failed write.
|
|
239
283
|
def job_schedule(now: Time.now.utc, max: 1_000)
|
|
240
284
|
transaction do
|
|
241
|
-
|
|
242
|
-
#
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
WHERE state IN ('retryable', 'scheduled') AND scheduled_at <= #{runtime_time(now)}
|
|
247
|
-
ORDER BY priority, scheduled_at, id
|
|
248
|
-
LIMIT #{Integer(max)} #{lock}
|
|
249
|
-
SQL
|
|
250
|
-
ids.each do |id|
|
|
251
|
-
transaction do
|
|
252
|
-
runtime_execute("UPDATE river_job SET state = 'available' WHERE id = #{id} AND state IN ('retryable', 'scheduled')")
|
|
253
|
-
end
|
|
254
|
-
rescue runtime_unique_violation_class
|
|
255
|
-
runtime_execute(<<~SQL)
|
|
256
|
-
UPDATE river_job
|
|
257
|
-
SET state = 'discarded', finalized_at = #{runtime_time(now)},
|
|
258
|
-
metadata = #{runtime_merge_metadata("unique_key_conflict" => "scheduler_discarded")}
|
|
259
|
-
WHERE id = #{id}
|
|
260
|
-
SQL
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
ids.length
|
|
285
|
+
queues = runtime_postgres? ? runtime_schedule_postgres(now, Integer(max)) : runtime_schedule_sqlite(now, Integer(max))
|
|
286
|
+
# Notify each changed queue, including queues with only discarded
|
|
287
|
+
# jobs, as Go does.
|
|
288
|
+
queues.uniq.each { |queue| runtime_notify("river_insert", queue: queue) }
|
|
289
|
+
queues.length
|
|
264
290
|
end
|
|
265
291
|
end
|
|
266
292
|
|
|
267
293
|
def job_set_state_if_running(id:, state:, now: Time.now.utc, attempt: nil,
|
|
268
294
|
error: nil, finalized_at: nil, metadata: nil, scheduled_at: nil)
|
|
269
295
|
state = state.to_s
|
|
270
|
-
|
|
271
|
-
|
|
296
|
+
# Cancellation wins over every attempt outcome, including exhausted
|
|
297
|
+
# retries, direct completion, and transitions requested by extensions.
|
|
298
|
+
cancel_path = runtime_cancel_attempted
|
|
272
299
|
|
|
273
300
|
assignments = [] #: Array[String]
|
|
274
301
|
assignments << "attempt = CASE WHEN NOT (#{cancel_path}) THEN #{Integer(attempt)} ELSE attempt END" unless attempt.nil?
|
|
@@ -319,7 +346,10 @@ module River::Driver
|
|
|
319
346
|
end
|
|
320
347
|
|
|
321
348
|
def leader_release(id)
|
|
322
|
-
|
|
349
|
+
transaction do
|
|
350
|
+
deleted = runtime_query_rows("DELETE FROM river_leader WHERE leader_id = #{runtime_quote(id)} RETURNING leader_id")
|
|
351
|
+
runtime_notify("river_leadership", action: "resigned", leader_id: id) unless deleted.empty?
|
|
352
|
+
end
|
|
323
353
|
end
|
|
324
354
|
|
|
325
355
|
def leader_renew(id, ttl: 30, now: Time.now.utc)
|
|
@@ -345,6 +375,16 @@ module River::Driver
|
|
|
345
375
|
SQL
|
|
346
376
|
end
|
|
347
377
|
|
|
378
|
+
# Opens a receiver independent of application transactions. Postgres uses
|
|
379
|
+
# one dedicated connection; SQLite keeps a cursor in its shared outbox.
|
|
380
|
+
def notification_listener
|
|
381
|
+
return NotificationListener::SQLite.new(method(:notification_query)) unless runtime_postgres?
|
|
382
|
+
return unless postgres_capabilities.supports_listen_notify
|
|
383
|
+
|
|
384
|
+
connection, schema = runtime_notification_connection
|
|
385
|
+
NotificationListener::Postgres.new(connection, schema)
|
|
386
|
+
end
|
|
387
|
+
|
|
348
388
|
def queue_get(name)
|
|
349
389
|
row = runtime_query_rows("SELECT #{runtime_queue_columns} FROM river_queue WHERE name = #{runtime_quote(name)}").first
|
|
350
390
|
runtime_queue_from_row(row)
|
|
@@ -356,34 +396,53 @@ module River::Driver
|
|
|
356
396
|
end
|
|
357
397
|
|
|
358
398
|
def queue_pause(name, now: Time.now.utc)
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
399
|
+
transaction do
|
|
400
|
+
filter = (name == "*") ? "true" : "name = #{runtime_quote(name)}"
|
|
401
|
+
changed = runtime_query_rows(<<~SQL)
|
|
402
|
+
UPDATE river_queue
|
|
403
|
+
SET paused_at = CASE WHEN paused_at IS NULL THEN #{runtime_time(now)} ELSE paused_at END,
|
|
404
|
+
updated_at = CASE WHEN paused_at IS NULL THEN #{runtime_time(now)} ELSE updated_at END
|
|
405
|
+
WHERE #{filter}
|
|
406
|
+
RETURNING #{runtime_queue_columns}
|
|
407
|
+
SQL
|
|
408
|
+
runtime_notify("river_control", action: "pause", queue: name) unless changed.empty?
|
|
409
|
+
changed.map { |row| runtime_queue_from_row(row) }
|
|
410
|
+
end
|
|
366
411
|
end
|
|
367
412
|
|
|
368
413
|
def queue_resume(name, now: Time.now.utc)
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
414
|
+
transaction do
|
|
415
|
+
filter = (name == "*") ? "true" : "name = #{runtime_quote(name)}"
|
|
416
|
+
changed = runtime_query_rows(<<~SQL)
|
|
417
|
+
UPDATE river_queue
|
|
418
|
+
SET updated_at = CASE WHEN paused_at IS NOT NULL THEN #{runtime_time(now)} ELSE updated_at END,
|
|
419
|
+
paused_at = NULL
|
|
420
|
+
WHERE #{filter}
|
|
421
|
+
RETURNING #{runtime_queue_columns}
|
|
422
|
+
SQL
|
|
423
|
+
runtime_notify("river_control", action: "resume", queue: name) unless changed.empty?
|
|
424
|
+
changed.map { |row| runtime_queue_from_row(row) }
|
|
425
|
+
end
|
|
376
426
|
end
|
|
377
427
|
|
|
378
428
|
def queue_update(name, metadata:, now: Time.now.utc)
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
429
|
+
raise ArgumentError, "metadata must be a Hash" unless metadata.is_a?(Hash)
|
|
430
|
+
|
|
431
|
+
transaction do
|
|
432
|
+
row = runtime_query_rows(<<~SQL).first
|
|
433
|
+
UPDATE river_queue SET metadata = #{runtime_json(metadata)}, updated_at = #{runtime_time(now)}
|
|
434
|
+
WHERE name = #{runtime_quote(name)} RETURNING name
|
|
435
|
+
SQL
|
|
436
|
+
if row
|
|
437
|
+
runtime_notify("river_control", action: "metadata_changed", queue: name, metadata: metadata)
|
|
438
|
+
queue_get(name)
|
|
439
|
+
end
|
|
440
|
+
end
|
|
384
441
|
end
|
|
385
442
|
|
|
386
443
|
def queue_upsert(name, metadata: {}, now: Time.now.utc)
|
|
444
|
+
raise ArgumentError, "metadata must be a Hash" unless metadata.is_a?(Hash)
|
|
445
|
+
|
|
387
446
|
runtime_execute(<<~SQL)
|
|
388
447
|
INSERT INTO river_queue (name, created_at, metadata, updated_at)
|
|
389
448
|
VALUES (#{runtime_quote(name)}, #{runtime_time(now)}, #{runtime_json(metadata)}, #{runtime_time(now)})
|
|
@@ -392,6 +451,21 @@ module River::Driver
|
|
|
392
451
|
queue_get(name)
|
|
393
452
|
end
|
|
394
453
|
|
|
454
|
+
# Delivery follows the caller's transaction, just like job insertion.
|
|
455
|
+
def request_resign
|
|
456
|
+
transaction { runtime_notify("river_leadership", action: "request_resign", leader_id: "") }
|
|
457
|
+
nil
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
private def notification_query(sql)
|
|
461
|
+
runtime_query_rows(sql)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Smallest timestamp increment preserved by this driver, in seconds.
|
|
465
|
+
def time_precision
|
|
466
|
+
runtime_postgres? ? Rational(1, 1_000_000) : Rational(1, 1_000)
|
|
467
|
+
end
|
|
468
|
+
|
|
395
469
|
private def runtime_append_error(error)
|
|
396
470
|
value = error.respond_to?(:to_h) ? error.to_h : error
|
|
397
471
|
if runtime_postgres?
|
|
@@ -419,17 +493,27 @@ module River::Driver
|
|
|
419
493
|
"array_append(CASE WHEN cardinality(attempted_by) >= 100 THEN attempted_by[(cardinality(attempted_by) - 98):] ELSE attempted_by END, #{runtime_quote(attempted_by)})"
|
|
420
494
|
else
|
|
421
495
|
# Preserve corrupt history so decoding can fail this attempt instead
|
|
422
|
-
# of silently repairing it or aborting the entire claim batch.
|
|
496
|
+
# of silently repairing it or aborting the entire claim batch. Trim
|
|
497
|
+
# to the newest 99 entries before appending, preserving JSON types.
|
|
423
498
|
<<~SQL
|
|
424
499
|
CASE WHEN NOT json_valid(attempted_by, 10) AND attempted_by IS NOT NULL THEN attempted_by
|
|
425
|
-
ELSE jsonb(json_insert(json(
|
|
500
|
+
ELSE jsonb(json_insert(json(
|
|
501
|
+
CASE WHEN json_array_length(attempted_by) >= 100 THEN (
|
|
502
|
+
SELECT json_group_array(json(value)) FROM (
|
|
503
|
+
SELECT attempted_by -> ('$[' || key || ']') AS value FROM json_each(attempted_by)
|
|
504
|
+
WHERE key >= json_array_length(attempted_by) - 99 ORDER BY key
|
|
505
|
+
)
|
|
506
|
+
) ELSE coalesce(attempted_by, jsonb('[]')) END
|
|
507
|
+
), '$[#]', #{runtime_quote(attempted_by)})) END
|
|
426
508
|
SQL
|
|
427
509
|
end
|
|
428
510
|
|
|
429
511
|
lock_clause = runtime_postgres? ? "FOR UPDATE SKIP LOCKED" : ""
|
|
512
|
+
# An administratively requeued job may already be at the counter limit.
|
|
513
|
+
# Saturate instead of letting that row abort the entire claim batch.
|
|
430
514
|
ids = runtime_returning_ids(<<~SQL)
|
|
431
515
|
UPDATE river_job
|
|
432
|
-
SET attempt = attempt + 1,
|
|
516
|
+
SET attempt = CASE WHEN attempt < #{River::MAX_ATTEMPTS_LIMIT} THEN attempt + 1 ELSE attempt END,
|
|
433
517
|
attempted_at = #{runtime_time(now)},
|
|
434
518
|
attempted_by = #{attempted_by_sql},
|
|
435
519
|
state = 'running'
|
|
@@ -509,7 +593,7 @@ module River::Driver
|
|
|
509
593
|
params.map do |param|
|
|
510
594
|
key = param.unique_key
|
|
511
595
|
if key && !key.empty? && River::UniqueBitmask.to_states((param.unique_states || "0").to_i(2)).include?(param.state)
|
|
512
|
-
#
|
|
596
|
+
# Postgres rejects batches that upsert the same indexed row twice.
|
|
513
597
|
raise ArgumentError, "unique key appears more than once in batch" if keys[key]
|
|
514
598
|
|
|
515
599
|
keys[key] = true
|
|
@@ -531,21 +615,77 @@ module River::Driver
|
|
|
531
615
|
"(#{column} IS NULL OR #{column} #{comparison} #{value} OR (#{column} = #{value} AND #{id_clause}))"
|
|
532
616
|
end
|
|
533
617
|
|
|
618
|
+
private def runtime_job_attempt_predicate(job)
|
|
619
|
+
"attempt = #{Integer(job.attempt)} AND attempted_at IS NOT DISTINCT FROM #{runtime_nullable_time(job.attempted_at)}"
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
private def runtime_job_list(params, for_delete: false)
|
|
623
|
+
clauses = [] #: Array[String]
|
|
624
|
+
clauses << "state != 'running'" if for_delete
|
|
625
|
+
clauses << runtime_cursor_clause(params) if params.after
|
|
626
|
+
clauses << "id #{(params.sort_order == :asc) ? ">" : "<"} #{Integer(params.after_id)}" if params.after_id
|
|
627
|
+
clauses << runtime_in_clause("id", params.ids.map { |value| Integer(value) }) if params.ids&.any?
|
|
628
|
+
clauses << runtime_in_clause("kind", params.kinds) if params.kinds&.any?
|
|
629
|
+
clauses << runtime_in_clause("priority", params.priorities.map { |value| Integer(value) }) if params.priorities&.any?
|
|
630
|
+
clauses << runtime_in_clause("queue", params.queues) if params.queues&.any?
|
|
631
|
+
clauses << runtime_in_clause("state", params.states) if params.states&.any?
|
|
632
|
+
|
|
633
|
+
finalized = params.sort_by == :finalized_at && params.states&.length == 1 &&
|
|
634
|
+
%w[cancelled completed discarded].include?(params.states.first)
|
|
635
|
+
# Schemas require finalized timestamps for terminal states. Spell this out
|
|
636
|
+
# so Postgres can use the partial (state, finalized_at) index.
|
|
637
|
+
clauses << "finalized_at IS NOT NULL" if finalized
|
|
638
|
+
# Explicit NULLS LAST prevents a backward index scan, even when there are
|
|
639
|
+
# no nulls. Only request it for timestamps that can actually be null.
|
|
640
|
+
null_order = (params.sort_by == :finalized_at && !finalized) ? " NULLS LAST" : ""
|
|
641
|
+
|
|
642
|
+
params.metadata&.each { |key, value| clauses << runtime_metadata_equals(key, value) }
|
|
643
|
+
Array(params.tags_all).each { |tag| clauses << runtime_tag_contains(tag) }
|
|
644
|
+
if params.tags_any&.any?
|
|
645
|
+
clauses << "(" + params.tags_any.map { |tag| runtime_tag_contains(tag) }.join(" OR ") + ")"
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
where = clauses.empty? ? "" : "WHERE #{clauses.join(" AND ")}"
|
|
649
|
+
runtime_job_rows(<<~SQL)
|
|
650
|
+
#{where}
|
|
651
|
+
ORDER BY #{params.sort_by} #{params.sort_order.to_s.upcase}#{null_order}, id #{params.sort_order.to_s.upcase}
|
|
652
|
+
LIMIT #{params.limit}
|
|
653
|
+
#{"FOR UPDATE SKIP LOCKED" if for_delete && runtime_postgres?}
|
|
654
|
+
SQL
|
|
655
|
+
end
|
|
656
|
+
|
|
534
657
|
private def runtime_json(value)
|
|
535
658
|
encoded = value.is_a?(String) ? value : JSON.generate(value)
|
|
536
659
|
runtime_postgres? ? "#{runtime_quote(encoded)}::jsonb" : "jsonb(#{runtime_quote(encoded)})"
|
|
537
660
|
end
|
|
538
661
|
|
|
662
|
+
# Temporary fallback: when true, transaction helpers open a savepoint
|
|
663
|
+
# inside an open transaction instead of joining it.
|
|
664
|
+
private def runtime_legacy_subtransactions?
|
|
665
|
+
%w[1 true].include?(ENV.fetch("RIVER_USE_LEGACY_SUBTRANSACTIONS", ""))
|
|
666
|
+
end
|
|
667
|
+
|
|
539
668
|
private def runtime_merge_metadata(metadata)
|
|
669
|
+
raise ArgumentError, "metadata must be a Hash" unless metadata.is_a?(Hash)
|
|
670
|
+
|
|
540
671
|
if runtime_postgres?
|
|
541
672
|
"metadata || #{runtime_json(metadata)}"
|
|
542
673
|
else
|
|
543
|
-
#
|
|
544
|
-
#
|
|
545
|
-
merged =
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
674
|
+
# Keep the SQL shape fixed regardless of the number of updated keys.
|
|
675
|
+
# -> preserves JSON booleans and large numeric tokens during aggregation.
|
|
676
|
+
merged = <<~SQL.strip
|
|
677
|
+
(WITH updates(doc) AS (SELECT #{runtime_json(metadata)})
|
|
678
|
+
SELECT CASE WHEN json_type(metadata) = 'object' THEN (
|
|
679
|
+
SELECT jsonb_group_object(key, jsonb(doc -> fullkey))
|
|
680
|
+
FROM (
|
|
681
|
+
SELECT key, fullkey, metadata AS doc FROM json_each(metadata)
|
|
682
|
+
WHERE key NOT IN (SELECT key FROM updates, json_each(doc))
|
|
683
|
+
UNION ALL
|
|
684
|
+
SELECT key, fullkey, doc FROM updates, json_each(doc)
|
|
685
|
+
)
|
|
686
|
+
) WHEN json_type(metadata) = 'array' THEN jsonb_insert(metadata, '$[#]', doc)
|
|
687
|
+
ELSE jsonb_array(json(metadata), doc) END FROM updates)
|
|
688
|
+
SQL
|
|
549
689
|
"CASE WHEN json_valid(metadata, 10) THEN #{merged} ELSE metadata END"
|
|
550
690
|
end
|
|
551
691
|
end
|
|
@@ -611,10 +751,129 @@ module River::Driver
|
|
|
611
751
|
)
|
|
612
752
|
end
|
|
613
753
|
|
|
754
|
+
private def runtime_rescue_retry(job, error, retry_policy, now, logger)
|
|
755
|
+
retry_at = retry_policy.next_retry(job, error, now: now)
|
|
756
|
+
raise ArgumentError, "next_retry must return a Time" unless retry_at.is_a?(Time)
|
|
757
|
+
raise ArgumentError, "next_retry must not return a past Time" if retry_at < now
|
|
758
|
+
|
|
759
|
+
retry_at
|
|
760
|
+
rescue => retry_error
|
|
761
|
+
logger&.error("River rescue retry scheduling failed; using default backoff: #{retry_error.full_message}")
|
|
762
|
+
River::DefaultClientRetryPolicy.new.next_retry(job, error, now: now)
|
|
763
|
+
end
|
|
764
|
+
|
|
614
765
|
private def runtime_returning_ids(sql)
|
|
615
766
|
runtime_query_rows(sql).map { |row| runtime_value(row, :id).to_i }
|
|
616
767
|
end
|
|
617
768
|
|
|
769
|
+
# Finds conflicts and changes the whole pass in one statement.
|
|
770
|
+
private def runtime_schedule_postgres(now, max)
|
|
771
|
+
now = runtime_time(now)
|
|
772
|
+
runtime_query_rows(<<~SQL).map { |row| runtime_value(row, :queue) }
|
|
773
|
+
WITH jobs_to_schedule AS (
|
|
774
|
+
SELECT id, unique_key, unique_states, priority, scheduled_at
|
|
775
|
+
FROM river_job
|
|
776
|
+
WHERE state IN ('retryable', 'scheduled')
|
|
777
|
+
AND priority >= 0
|
|
778
|
+
AND queue IS NOT NULL
|
|
779
|
+
AND scheduled_at <= #{now}
|
|
780
|
+
ORDER BY priority, scheduled_at, id
|
|
781
|
+
LIMIT #{max}
|
|
782
|
+
FOR UPDATE
|
|
783
|
+
),
|
|
784
|
+
jobs_with_rownum AS (
|
|
785
|
+
SELECT
|
|
786
|
+
*,
|
|
787
|
+
CASE
|
|
788
|
+
WHEN unique_key IS NOT NULL AND unique_states IS NOT NULL THEN
|
|
789
|
+
ROW_NUMBER() OVER (PARTITION BY unique_key ORDER BY priority, scheduled_at, id)
|
|
790
|
+
END AS row_num
|
|
791
|
+
FROM jobs_to_schedule
|
|
792
|
+
),
|
|
793
|
+
unique_conflicts AS (
|
|
794
|
+
SELECT river_job.unique_key
|
|
795
|
+
FROM river_job
|
|
796
|
+
JOIN jobs_with_rownum
|
|
797
|
+
ON river_job.unique_key = jobs_with_rownum.unique_key
|
|
798
|
+
AND river_job.id != jobs_with_rownum.id
|
|
799
|
+
WHERE river_job.unique_key IS NOT NULL
|
|
800
|
+
AND river_job.unique_states IS NOT NULL
|
|
801
|
+
AND river_job_state_in_bitmask(river_job.unique_states, river_job.state)
|
|
802
|
+
),
|
|
803
|
+
job_updates AS (
|
|
804
|
+
SELECT
|
|
805
|
+
job.id,
|
|
806
|
+
job.row_num IS NOT NULL AND (uc.unique_key IS NOT NULL OR job.row_num > 1) AS discard
|
|
807
|
+
FROM jobs_with_rownum job
|
|
808
|
+
LEFT JOIN unique_conflicts uc ON job.unique_key = uc.unique_key
|
|
809
|
+
)
|
|
810
|
+
UPDATE river_job
|
|
811
|
+
SET state = CASE WHEN job_updates.discard THEN 'discarded'::river_job_state ELSE 'available'::river_job_state END,
|
|
812
|
+
finalized_at = CASE WHEN job_updates.discard THEN #{now} ELSE river_job.finalized_at END,
|
|
813
|
+
metadata = CASE WHEN job_updates.discard THEN river_job.metadata || '{"unique_key_conflict": "scheduler_discarded"}'::jsonb
|
|
814
|
+
ELSE river_job.metadata END
|
|
815
|
+
FROM job_updates
|
|
816
|
+
WHERE river_job.id = job_updates.id
|
|
817
|
+
RETURNING river_job.queue
|
|
818
|
+
SQL
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
# Follows Go's SQLite driver: check each unique job in order, and make it
|
|
822
|
+
# available before checking the next one, so a later job with the same
|
|
823
|
+
# key sees it as a conflict. SQLite allows one writer at a time. Once this
|
|
824
|
+
# pass writes, other connections cannot write until it ends. If another
|
|
825
|
+
# connection writes first, this pass's next write fails instead of using
|
|
826
|
+
# stale rows.
|
|
827
|
+
private def runtime_schedule_sqlite(now, max)
|
|
828
|
+
rows = runtime_query_rows(<<~SQL)
|
|
829
|
+
SELECT id, queue, unique_key IS NOT NULL AS has_unique_key
|
|
830
|
+
FROM river_job
|
|
831
|
+
WHERE state IN ('retryable', 'scheduled') AND scheduled_at <= #{runtime_time(now)}
|
|
832
|
+
ORDER BY priority, scheduled_at, id
|
|
833
|
+
LIMIT #{max}
|
|
834
|
+
SQL
|
|
835
|
+
available = [] #: Array[Integer]
|
|
836
|
+
discarded = [] #: Array[Integer]
|
|
837
|
+
rows.each do |row|
|
|
838
|
+
id = runtime_value(row, :id).to_i
|
|
839
|
+
next available << id if runtime_value(row, :has_unique_key).to_i.zero?
|
|
840
|
+
|
|
841
|
+
conflict = runtime_query_rows(<<~SQL).any?
|
|
842
|
+
SELECT 1 FROM river_job
|
|
843
|
+
WHERE id <> #{id}
|
|
844
|
+
AND unique_key = (SELECT job.unique_key FROM river_job AS job WHERE job.id = #{id})
|
|
845
|
+
AND unique_states IS NOT NULL
|
|
846
|
+
AND CASE state
|
|
847
|
+
WHEN 'available' THEN unique_states & (1 << 0)
|
|
848
|
+
WHEN 'cancelled' THEN unique_states & (1 << 1)
|
|
849
|
+
WHEN 'completed' THEN unique_states & (1 << 2)
|
|
850
|
+
WHEN 'discarded' THEN unique_states & (1 << 3)
|
|
851
|
+
WHEN 'pending' THEN unique_states & (1 << 4)
|
|
852
|
+
WHEN 'retryable' THEN unique_states & (1 << 5)
|
|
853
|
+
WHEN 'running' THEN unique_states & (1 << 6)
|
|
854
|
+
WHEN 'scheduled' THEN unique_states & (1 << 7)
|
|
855
|
+
ELSE 0
|
|
856
|
+
END >= 1
|
|
857
|
+
LIMIT 1
|
|
858
|
+
SQL
|
|
859
|
+
next discarded << id if conflict
|
|
860
|
+
|
|
861
|
+
runtime_execute("UPDATE river_job SET state = 'available' WHERE id = #{id}")
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
unless discarded.empty?
|
|
865
|
+
runtime_execute(<<~SQL)
|
|
866
|
+
UPDATE river_job
|
|
867
|
+
SET state = 'discarded', finalized_at = #{runtime_time(now)},
|
|
868
|
+
metadata = #{runtime_merge_metadata("unique_key_conflict" => "scheduler_discarded")}
|
|
869
|
+
WHERE id IN (#{discarded.join(",")})
|
|
870
|
+
SQL
|
|
871
|
+
end
|
|
872
|
+
runtime_execute("UPDATE river_job SET state = 'available' WHERE id IN (#{available.join(",")})") unless available.empty?
|
|
873
|
+
|
|
874
|
+
rows.map { |row| runtime_value(row, :queue) }
|
|
875
|
+
end
|
|
876
|
+
|
|
618
877
|
private def runtime_state(value)
|
|
619
878
|
runtime_postgres? ? "#{runtime_quote(value)}::river_job_state" : runtime_quote(value)
|
|
620
879
|
end
|
|
@@ -632,26 +891,40 @@ module River::Driver
|
|
|
632
891
|
|
|
633
892
|
cast = runtime_postgres? ? "::timestamptz" : ""
|
|
634
893
|
encoded = if runtime_postgres?
|
|
635
|
-
value.
|
|
894
|
+
normalize_time(value).iso8601(6)
|
|
636
895
|
else
|
|
637
|
-
value
|
|
896
|
+
normalize_time(value).strftime("%Y-%m-%d %H:%M:%S.%3N")
|
|
638
897
|
end
|
|
639
898
|
"#{runtime_quote(encoded)}#{cast}"
|
|
640
899
|
end
|
|
641
900
|
|
|
642
901
|
private def runtime_update_value(field, value)
|
|
643
902
|
case field
|
|
644
|
-
when :attempt
|
|
645
|
-
Integer(value)
|
|
903
|
+
when :attempt
|
|
904
|
+
attempt = Integer(value)
|
|
905
|
+
raise ArgumentError, "attempt must be between 0 and #{River::MAX_ATTEMPTS_LIMIT}" unless (0..River::MAX_ATTEMPTS_LIMIT).cover?(attempt)
|
|
906
|
+
|
|
907
|
+
attempt.to_s
|
|
646
908
|
when :attempted_at, :finalized_at
|
|
647
909
|
value ? runtime_time(value) : "NULL"
|
|
648
910
|
when :attempted_by
|
|
649
|
-
|
|
911
|
+
values = Array(value) #: Array[untyped]
|
|
912
|
+
raise ArgumentError, "attempted_by must contain only Strings" unless values.all? { |item| item.is_a?(String) }
|
|
913
|
+
|
|
914
|
+
runtime_postgres? ? "ARRAY[#{values.map { |item| runtime_quote(item) }.join(",")}]::text[]" : runtime_json(values)
|
|
650
915
|
when :errors
|
|
651
916
|
input_values = Array(value) #: Array[untyped]
|
|
652
917
|
values = input_values.map { |error| error.respond_to?(:to_h) ? error.to_h : error }
|
|
653
918
|
runtime_postgres? ? "ARRAY[#{values.map { |item| runtime_json(item) }.join(",")}]::jsonb[]" : runtime_json(values)
|
|
919
|
+
when :max_attempts
|
|
920
|
+
max_attempts = Integer(value)
|
|
921
|
+
raise ArgumentError, "max_attempts must be greater than zero" unless max_attempts > 0
|
|
922
|
+
raise ArgumentError, "max_attempts must not exceed #{River::MAX_ATTEMPTS_LIMIT}" if max_attempts > River::MAX_ATTEMPTS_LIMIT
|
|
923
|
+
|
|
924
|
+
max_attempts.to_s
|
|
654
925
|
when :metadata
|
|
926
|
+
raise ArgumentError, "metadata must be a Hash" unless value.is_a?(Hash)
|
|
927
|
+
|
|
655
928
|
runtime_json(value)
|
|
656
929
|
when :state
|
|
657
930
|
runtime_state(value)
|