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.
@@ -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; PostgreSQL defers delivery until commit.
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
- # Drivers may combine the cancellation probe and completion atomically.
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
- return :cancelled if job_get_cancelled_ids([id]).include?(id)
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
- job_set_state_if_running(id: id, finalized_at: finalized_at, metadata: metadata, now: now, state: "completed")
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
- runtime_returning_ids("DELETE FROM river_job WHERE id = #{Integer(id)} AND state = 'running' RETURNING id").any?
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 = job_list(params).reject { |job| job.state == River::JOB_STATE_RUNNING }
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
- clauses = [] #: Array[String]
146
- clauses << runtime_cursor_clause(params) if params.after
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
- params.metadata&.each { |key, value| clauses << runtime_metadata_equals(key, value) }
164
- Array(params.tags_all).each { |tag| clauses << runtime_tag_contains(tag) }
165
- if params.tags_any&.any?
166
- clauses << "(" + params.tags_any.map { |tag| runtime_tag_contains(tag) }.join(" OR ") + ")"
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
- where = clauses.empty? ? "" : "WHERE #{clauses.join(" AND ")}"
170
- runtime_job_rows(<<~SQL)
171
- #{where}
172
- ORDER BY #{params.sort_by} #{params.sort_order.to_s.upcase}#{null_order}, id #{params.sort_order.to_s.upcase}
173
- LIMIT #{params.limit}
174
- SQL
175
- end
176
-
177
- def job_metadata_merge(id, metadata)
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
- # Select only stuck jobs before applying the limit, and hold their locks
190
- # until rescue finishes so a newer attempt cannot be rescued by mistake.
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
- ids = runtime_returning_ids(<<~SQL)
193
- SELECT id FROM river_job
194
- WHERE state = 'running' AND attempted_at < #{runtime_time(horizon)}
195
- ORDER BY id LIMIT #{Integer(max)} #{lock}
196
- SQL
197
- jobs = ids.map { |id| runtime_read_job(id) }
198
- jobs.each do |job|
199
- cancelled = job.metadata.key?("cancel_attempted_at")
200
- final = cancelled || job.attempt >= job.max_attempts
201
- state = if cancelled
202
- River::JOB_STATE_CANCELLED
203
- elsif final
204
- River::JOB_STATE_DISCARDED
205
- else
206
- River::JOB_STATE_RETRYABLE
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
- jobs.length
247
+ rescued
221
248
  end
222
249
  end
223
250
 
224
251
  def job_retry(id, now: Time.now.utc)
225
- updated_id = runtime_returning_ids(<<~SQL).first
226
- UPDATE river_job
227
- SET state = 'available',
228
- max_attempts = CASE WHEN attempt = max_attempts THEN max_attempts + 1 ELSE max_attempts END,
229
- finalized_at = NULL,
230
- scheduled_at = #{runtime_time(now)}
231
- WHERE id = #{Integer(id)}
232
- AND state != 'running'
233
- AND (state != 'available' OR scheduled_at > #{runtime_time(now)})
234
- RETURNING id
235
- SQL
236
- job_get_by_id(updated_id || id)
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
- # Hold each selected row until its transition (including uniqueness
242
- # conflict handling) finishes. A concurrent retry may change its due time.
243
- lock = runtime_postgres? ? "FOR UPDATE SKIP LOCKED" : ""
244
- ids = runtime_query_rows(<<~SQL).map { |row| runtime_value(row, :id).to_i }
245
- SELECT id FROM river_job
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
- retrying = [River::JOB_STATE_AVAILABLE, River::JOB_STATE_RETRYABLE, River::JOB_STATE_SCHEDULED].include?(state)
271
- cancel_path = retrying ? runtime_cancel_attempted : "false"
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
- runtime_execute("DELETE FROM river_leader WHERE leader_id = #{runtime_quote(id)}")
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
- filter = (name == "*") ? "true" : "name = #{runtime_quote(name)}"
360
- runtime_execute(<<~SQL)
361
- UPDATE river_queue
362
- SET paused_at = CASE WHEN paused_at IS NULL THEN #{runtime_time(now)} ELSE paused_at END,
363
- updated_at = CASE WHEN paused_at IS NULL THEN #{runtime_time(now)} ELSE updated_at END
364
- WHERE #{filter}
365
- SQL
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
- filter = (name == "*") ? "true" : "name = #{runtime_quote(name)}"
370
- runtime_execute(<<~SQL)
371
- UPDATE river_queue
372
- SET updated_at = CASE WHEN paused_at IS NOT NULL THEN #{runtime_time(now)} ELSE updated_at END,
373
- paused_at = NULL
374
- WHERE #{filter}
375
- SQL
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
- id = runtime_query_rows(<<~SQL).first
380
- UPDATE river_queue SET metadata = #{runtime_json(metadata)}, updated_at = #{runtime_time(now)}
381
- WHERE name = #{runtime_quote(name)} RETURNING name
382
- SQL
383
- id ? queue_get(name) : nil
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(coalesce(attempted_by, jsonb('[]'))), '$[#]', #{runtime_quote(attempted_by)})) END
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
- # PostgreSQL rejects batches that upsert the same indexed row twice.
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
- # PostgreSQL's || replaces top-level values, including JSON null.
544
- # JSON Merge Patch would recursively merge objects and delete nulls.
545
- merged = metadata.reduce("metadata") do |expression, (key, value)|
546
- path = runtime_quote("$.#{JSON.generate(key.to_s)}")
547
- "jsonb_set(#{expression}, #{path}, jsonb(#{runtime_quote(JSON.generate(value))}))"
548
- end
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.getutc.iso8601(6)
894
+ normalize_time(value).iso8601(6)
636
895
  else
637
- value.getutc.round(3).strftime("%Y-%m-%d %H:%M:%S.%3N")
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, :max_attempts
645
- Integer(value).to_s
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
- runtime_postgres? ? "ARRAY[#{Array(value).map { |item| runtime_quote(item) }.join(",")}]::text[]" : runtime_json(Array(value))
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)