riverqueue 0.11.0 → 0.13.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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +182 -0
  3. data/LICENSE +374 -0
  4. data/README.md +22 -0
  5. data/docs/README.md +812 -0
  6. data/docs/conformance.md +72 -0
  7. data/docs/migrations.md +118 -0
  8. data/docs/testing.md +173 -0
  9. data/docs/workers.md +126 -0
  10. data/exe/river +7 -0
  11. data/lib/cli.rb +55 -0
  12. data/lib/client.rb +347 -54
  13. data/lib/client_runtime.rb +721 -0
  14. data/lib/config.rb +154 -0
  15. data/lib/driver/job_row_decoder.rb +85 -0
  16. data/lib/driver/postgres_capabilities.rb +41 -0
  17. data/lib/driver/runtime.rb +772 -0
  18. data/lib/driver.rb +13 -6
  19. data/lib/errors.rb +83 -0
  20. data/lib/event.rb +85 -0
  21. data/lib/insert_opts.rb +46 -12
  22. data/lib/job.rb +28 -13
  23. data/lib/job_persisted_logging/plugin.rb +108 -0
  24. data/lib/migration_cli.rb +90 -0
  25. data/lib/migrator.rb +189 -0
  26. data/lib/params.rb +96 -0
  27. data/lib/periodic_cron.rb +59 -0
  28. data/lib/periodic_job.rb +143 -0
  29. data/lib/resumable.rb +148 -0
  30. data/lib/riverqueue/testing/minitest.rb +18 -0
  31. data/lib/riverqueue/testing/rspec.rb +109 -0
  32. data/lib/riverqueue/testing.rb +134 -0
  33. data/lib/riverqueue.rb +16 -0
  34. data/lib/unique_args.rb +97 -0
  35. data/lib/unique_bitmask.rb +14 -12
  36. data/lib/worker.rb +152 -0
  37. data/lib/worker_runner.rb +106 -0
  38. data/migration/LICENSE +374 -0
  39. data/migration/README.md +13 -0
  40. data/migration/manifest.json +37 -0
  41. data/migration/postgresql/main/001_create_river_migration.down.sql +1 -0
  42. data/migration/postgresql/main/001_create_river_migration.up.sql +8 -0
  43. data/migration/postgresql/main/002_initial_schema.down.sql +5 -0
  44. data/migration/postgresql/main/002_initial_schema.up.sql +96 -0
  45. data/migration/postgresql/main/003_river_job_tags_non_null.down.sql +3 -0
  46. data/migration/postgresql/main/003_river_job_tags_non_null.up.sql +3 -0
  47. data/migration/postgresql/main/004_pending_and_more.down.sql +42 -0
  48. data/migration/postgresql/main/004_pending_and_more.up.sql +45 -0
  49. data/migration/postgresql/main/005_migration_unique_client.down.sql +57 -0
  50. data/migration/postgresql/main/005_migration_unique_client.up.sql +79 -0
  51. data/migration/postgresql/main/006_bulk_unique.down.sql +16 -0
  52. data/migration/postgresql/main/006_bulk_unique.up.sql +40 -0
  53. data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +56 -0
  54. data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +44 -0
  55. data/migration/postgresql/main/008_job_id_autoincrement.down.sql +3 -0
  56. data/migration/postgresql/main/008_job_id_autoincrement.up.sql +3 -0
  57. data/migration/sqlite/main/001_create_river_migration.down.sql +1 -0
  58. data/migration/sqlite/main/001_create_river_migration.up.sql +8 -0
  59. data/migration/sqlite/main/002_initial_schema.down.sql +8 -0
  60. data/migration/sqlite/main/002_initial_schema.up.sql +19 -0
  61. data/migration/sqlite/main/003_river_job_tags_non_null.down.sql +6 -0
  62. data/migration/sqlite/main/003_river_job_tags_non_null.up.sql +6 -0
  63. data/migration/sqlite/main/004_pending_and_more.down.sql +26 -0
  64. data/migration/sqlite/main/004_pending_and_more.up.sql +33 -0
  65. data/migration/sqlite/main/005_migration_unique_client.down.sql +37 -0
  66. data/migration/sqlite/main/005_migration_unique_client.up.sql +64 -0
  67. data/migration/sqlite/main/006_bulk_unique.down.sql +7 -0
  68. data/migration/sqlite/main/006_bulk_unique.up.sql +63 -0
  69. data/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +255 -0
  70. data/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +261 -0
  71. data/migration/sqlite/main/008_job_id_autoincrement.down.sql +121 -0
  72. data/migration/sqlite/main/008_job_id_autoincrement.up.sql +123 -0
  73. data/sig/client.rbs +98 -0
  74. data/sig/driver.rbs +172 -0
  75. data/sig/insert_opts.rbs +25 -0
  76. data/sig/job.rbs +74 -0
  77. data/sig/job_persisted_logging/plugin.rbs +29 -0
  78. data/sig/migrator.rbs +44 -0
  79. data/sig/riverqueue.rbs +2 -0
  80. data/sig/runtime.rbs +402 -0
  81. data/sig/testing.rbs +95 -0
  82. data/sig/unique_args.rbs +8 -0
  83. data/sig/unique_bitmask.rbs +8 -0
  84. data/sig/worker_runner.rbs +22 -0
  85. metadata +170 -13
@@ -0,0 +1,772 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module River::Driver
6
+ # Database operations used by River's worker runtime. Driver gems implement a
7
+ # small set of raw SQL primitives and include this module so the state machine
8
+ # stays identical across ActiveRecord and Sequel.
9
+ module Runtime
10
+ def init_driver
11
+ postgres_capabilities if runtime_postgres?
12
+ end
13
+
14
+ def postgres_capabilities
15
+ @postgres_capabilities_cache.fetch(runtime_connection_pool) do
16
+ row = runtime_query_rows(<<~SQL).first
17
+ SELECT version()::text AS product,
18
+ current_setting('server_version_num')::int AS version_num,
19
+ coalesce(current_setting('yb_enable_listen_notify', true), 'off')::boolean AS yb_listen_notify_enabled
20
+ SQL
21
+ PostgresCapabilities.new(
22
+ product: runtime_value(row, :product),
23
+ version_num: runtime_value(row, :version_num),
24
+ yb_listen_notify_enabled: runtime_value(row, :yb_listen_notify_enabled) == true
25
+ )
26
+ end
27
+ end
28
+
29
+ # Notifications and rows must commit together. The insertion drivers call
30
+ # this inside their transaction; PostgreSQL defers delivery until commit.
31
+ private def postgres_notify_insert(params)
32
+ queues = params.select { |param| param.state == River::JOB_STATE_AVAILABLE }.map(&:queue).uniq
33
+ return if queues.empty?
34
+ return unless postgres_capabilities.supports_listen_notify
35
+
36
+ payloads = queues.map { |queue| "(#{runtime_quote(JSON.generate(queue: queue))})" }.join(",")
37
+ runtime_execute("SELECT pg_notify(current_schema() || '.river_insert', payload) FROM (VALUES #{payloads}) AS notifications(payload)")
38
+ end
39
+
40
+ def job_cancel(id, now: Time.now.utc)
41
+ original_id = Integer(id)
42
+ transaction do
43
+ updated_id = runtime_returning_ids(<<~SQL).first
44
+ UPDATE river_job
45
+ SET state = CASE WHEN state = 'running' THEN state ELSE 'cancelled' END,
46
+ finalized_at = CASE WHEN state = 'running' THEN finalized_at ELSE #{runtime_time(now)} END,
47
+ metadata = #{runtime_merge_metadata("cancel_attempted_at" => now.iso8601(6))}
48
+ WHERE id = #{original_id}
49
+ AND state NOT IN ('cancelled', 'completed', 'discarded')
50
+ AND finalized_at IS NULL
51
+ RETURNING id
52
+ SQL
53
+ row = job_get_by_id(updated_id || original_id)
54
+ # Other engines interrupt running workers through this notification;
55
+ # the durable marker remains Ruby's polling fallback.
56
+ runtime_notify("river_control", action: "cancel", job_id: row.id, queue: row.queue) if updated_id
57
+ row
58
+ end
59
+ end
60
+
61
+ def job_claim(id:, attempted_by:, allow_scheduled: false, now: Time.now.utc)
62
+ predicate = "id = #{Integer(id)} AND state IN ('available', 'retryable', 'scheduled')"
63
+ predicate += " AND scheduled_at <= #{runtime_time(now)}" unless allow_scheduled
64
+
65
+ runtime_claim_jobs(predicate, 1, attempted_by, now).first
66
+ end
67
+
68
+ # The cancellation predicate must be part of the completion update so it is
69
+ # checked against the row we lock, including concurrent cancellations.
70
+ # :cancelled asks the runtime to apply its normal cancellation/error hooks.
71
+ def job_complete(id:, finalized_at:, metadata: nil, now: Time.now.utc)
72
+ id = Integer(id)
73
+ assignments = ["state = 'completed'", "finalized_at = #{runtime_time(finalized_at)}"]
74
+ assignments << "metadata = #{runtime_merge_metadata(metadata)}" unless metadata.nil? || metadata.empty?
75
+
76
+ updated_id = runtime_returning_ids(<<~SQL).first
77
+ UPDATE river_job SET #{assignments.join(", ")}
78
+ WHERE id = #{id} AND state = 'running' AND NOT (#{runtime_cancel_attempted})
79
+ RETURNING id
80
+ SQL
81
+ return runtime_read_job(updated_id) if updated_id
82
+
83
+ :cancelled if job_get_cancelled_ids([id]).include?(id)
84
+ end
85
+
86
+ def job_delete(id)
87
+ existing = job_get_by_id(id)
88
+ return nil unless existing
89
+ return existing if existing.state == River::JOB_STATE_RUNNING
90
+
91
+ deleted_id = runtime_returning_ids("DELETE FROM river_job WHERE id = #{Integer(id)} AND state != 'running' RETURNING id").first
92
+ deleted_id ? existing : job_get_by_id(id)
93
+ end
94
+
95
+ def job_delete_finalized(retention:, now: Time.now.utc, max: 1_000)
96
+ clauses = retention.filter_map do |state, seconds|
97
+ next unless seconds
98
+
99
+ "(state = #{runtime_quote(state.to_s)} AND finalized_at < #{runtime_time(now - seconds)})"
100
+ end
101
+
102
+ return 0 if clauses.empty?
103
+
104
+ ids = runtime_query_rows(<<~SQL).map { |row| runtime_value(row, :id).to_i }
105
+ SELECT id FROM river_job WHERE #{clauses.join(" OR ")} ORDER BY id LIMIT #{Integer(max)}
106
+ SQL
107
+ return 0 if ids.empty?
108
+
109
+ runtime_returning_ids(<<~SQL).length
110
+ DELETE FROM river_job
111
+ WHERE id IN (#{ids.join(",")}) AND (#{clauses.join(" OR ")})
112
+ RETURNING id
113
+ SQL
114
+ end
115
+
116
+ # A finalization hook's deletion must honor the same cancellation marker as
117
+ # completion. Return :cancelled so the runtime can record the cancellation.
118
+ def job_delete_if_running(id)
119
+ id = Integer(id)
120
+ deleted = runtime_returning_ids(<<~SQL).any?
121
+ DELETE FROM river_job
122
+ WHERE id = #{id} AND state = 'running' AND NOT (#{runtime_cancel_attempted})
123
+ RETURNING id
124
+ SQL
125
+ return true if deleted
126
+ return :cancelled if job_get_cancelled_ids([id]).include?(id)
127
+
128
+ false
129
+ end
130
+
131
+ def job_delete_many(params)
132
+ transaction do
133
+ jobs = runtime_job_list(params, for_delete: true)
134
+ next [] if jobs.empty?
135
+
136
+ ids = jobs.map(&:id)
137
+ deleted = runtime_returning_ids(<<~SQL)
138
+ DELETE FROM river_job
139
+ WHERE id IN (#{ids.join(",")}) AND state != 'running'
140
+ RETURNING id
141
+ SQL
142
+ jobs.select { |job| deleted.include?(job.id) }
143
+ end
144
+ end
145
+
146
+ def job_get_available(queue:, max:, attempted_by:, kinds: nil, now: Time.now.utc)
147
+ return [] if kinds&.empty?
148
+
149
+ predicate = "state = 'available' AND queue = #{runtime_quote(queue)} AND scheduled_at <= #{runtime_time(now)}"
150
+ predicate += " AND #{runtime_in_clause("kind", kinds)}" if kinds
151
+ runtime_claim_jobs(predicate, max, attempted_by, now)
152
+ end
153
+
154
+ def job_get_cancelled_ids(ids)
155
+ return [] if ids.empty?
156
+
157
+ runtime_query_rows("SELECT id FROM river_job WHERE id IN (#{ids.map { |id| Integer(id) }.join(",")}) AND (#{runtime_cancel_attempted}) ORDER BY id")
158
+ .map { |row| runtime_value(row, :id).to_i }
159
+ end
160
+
161
+ def job_list(params = nil)
162
+ params ||= River::JobListParams.new
163
+ return runtime_job_list_without_params if params == :all
164
+
165
+ runtime_job_list(params)
166
+ end
167
+
168
+ def job_metadata_merge(id, metadata)
169
+ updated_id = runtime_returning_ids(<<~SQL).first
170
+ UPDATE river_job
171
+ SET metadata = #{runtime_merge_metadata(metadata)}
172
+ WHERE id = #{Integer(id)}
173
+ RETURNING id
174
+ SQL
175
+ updated_id ? job_get_by_id(updated_id) : nil
176
+ end
177
+
178
+ def job_rescue_stuck(horizon:, retry_policy:, now: Time.now.utc, max: 1_000, logger: nil, rescue_if: nil)
179
+ max = Integer(max)
180
+ transaction do
181
+ # Lock each candidate until its transition finishes. Continue past jobs
182
+ # that are still within their timeouts without consuming the rescue limit.
183
+ after_id = rescued = 0
184
+ lock = runtime_postgres? ? "FOR UPDATE SKIP LOCKED" : ""
185
+ while rescued < max
186
+ ids = runtime_returning_ids(<<~SQL)
187
+ SELECT id FROM river_job
188
+ WHERE state = 'running' AND attempted_at < #{runtime_time(horizon)} AND id > #{after_id}
189
+ ORDER BY id LIMIT #{max - rescued} #{lock}
190
+ SQL
191
+ break if ids.empty?
192
+
193
+ after_id = ids.last
194
+ ids.each do |id|
195
+ job = runtime_read_job(id)
196
+ cancelled = job.metadata.key?("cancel_attempted_at")
197
+ next if !cancelled && rescue_if && !rescue_if.call(job, now)
198
+ final = cancelled || job.attempt >= job.max_attempts
199
+ state = if cancelled
200
+ River::JOB_STATE_CANCELLED
201
+ elsif final
202
+ River::JOB_STATE_DISCARDED
203
+ else
204
+ River::JOB_STATE_RETRYABLE
205
+ end
206
+ error = River::AttemptError.new(at: now, attempt: job.attempt, error: "Stuck job rescued by River", trace: "")
207
+ rescue_count = job.metadata["river:rescue_count"]
208
+ rescue_count = case rescue_count
209
+ when Integer then rescue_count
210
+ when Float then rescue_count.finite? ? rescue_count.to_i : 0
211
+ else 0
212
+ end
213
+ job_set_state_if_running(
214
+ id: job.id,
215
+ error: error,
216
+ finalized_at: final ? now : nil,
217
+ metadata: {"river:rescue_count" => rescue_count + 1},
218
+ now: now,
219
+ scheduled_at: final ? nil : runtime_rescue_retry(job, error, retry_policy, now, logger),
220
+ state: state
221
+ )
222
+ rescued += 1
223
+ end
224
+ end
225
+
226
+ rescued
227
+ end
228
+ end
229
+
230
+ def job_retry(id, now: Time.now.utc)
231
+ updated_id, job = transaction do
232
+ # Check the counter in the write itself so a concurrent claim cannot
233
+ # advance it between validation and the update.
234
+ updated_id = runtime_returning_ids(<<~SQL).first
235
+ UPDATE river_job
236
+ SET state = 'available',
237
+ max_attempts = CASE WHEN attempt = max_attempts THEN max_attempts + 1 ELSE max_attempts END,
238
+ metadata = #{runtime_postgres? ? "metadata - 'cancel_attempted_at'" : "jsonb_remove(metadata, '$.cancel_attempted_at')"},
239
+ finalized_at = NULL,
240
+ scheduled_at = #{runtime_time(now)}
241
+ WHERE id = #{Integer(id)}
242
+ AND state != 'running'
243
+ AND (state != 'available' OR scheduled_at > #{runtime_time(now)})
244
+ AND attempt < #{River::MAX_ATTEMPTS_LIMIT}
245
+ RETURNING id
246
+ SQL
247
+ [updated_id, job_get_by_id(updated_id || id)]
248
+ end
249
+ if !updated_id && job && job.state != "running" && (job.state != "available" || job.scheduled_at > now) && job.attempt >= River::MAX_ATTEMPTS_LIMIT
250
+ raise ArgumentError, "cannot retry a job with #{River::MAX_ATTEMPTS_LIMIT} or more attempts"
251
+ end
252
+ job
253
+ end
254
+
255
+ def job_schedule(now: Time.now.utc, max: 1_000)
256
+ transaction do
257
+ # Hold each selected row until its transition (including uniqueness
258
+ # conflict handling) finishes. A concurrent retry may change its due time.
259
+ lock = runtime_postgres? ? "FOR UPDATE SKIP LOCKED" : ""
260
+ ids = runtime_query_rows(<<~SQL).map { |row| runtime_value(row, :id).to_i }
261
+ SELECT id FROM river_job
262
+ WHERE state IN ('retryable', 'scheduled') AND scheduled_at <= #{runtime_time(now)}
263
+ ORDER BY priority, scheduled_at, id
264
+ LIMIT #{Integer(max)} #{lock}
265
+ SQL
266
+ ids.each do |id|
267
+ transaction do
268
+ runtime_execute("UPDATE river_job SET state = 'available' WHERE id = #{id} AND state IN ('retryable', 'scheduled')")
269
+ end
270
+ rescue runtime_unique_violation_class
271
+ runtime_execute(<<~SQL)
272
+ UPDATE river_job
273
+ SET state = 'discarded', finalized_at = #{runtime_time(now)},
274
+ metadata = #{runtime_merge_metadata("unique_key_conflict" => "scheduler_discarded")}
275
+ WHERE id = #{id}
276
+ SQL
277
+ end
278
+
279
+ ids.length
280
+ end
281
+ end
282
+
283
+ def job_set_state_if_running(id:, state:, now: Time.now.utc, attempt: nil,
284
+ error: nil, finalized_at: nil, metadata: nil, scheduled_at: nil)
285
+ state = state.to_s
286
+ # Cancellation wins over every attempt outcome, including exhausted
287
+ # retries, direct completion, and transitions requested by extensions.
288
+ cancel_path = runtime_cancel_attempted
289
+
290
+ assignments = [] #: Array[String]
291
+ assignments << "attempt = CASE WHEN NOT (#{cancel_path}) THEN #{Integer(attempt)} ELSE attempt END" unless attempt.nil?
292
+ assignments << "errors = #{runtime_append_error(error)}" if error
293
+
294
+ assignments << "finalized_at = CASE WHEN #{cancel_path} THEN #{runtime_time(now)} ELSE #{runtime_nullable_time(finalized_at)} END"
295
+ assignments << "metadata = #{runtime_merge_metadata(metadata)}" unless metadata.nil? || metadata.empty?
296
+ assignments << "scheduled_at = CASE WHEN NOT (#{cancel_path}) THEN #{runtime_time(scheduled_at)} ELSE scheduled_at END" if scheduled_at
297
+
298
+ assignments << "state = CASE WHEN #{cancel_path} THEN 'cancelled' ELSE #{runtime_state(state)} END"
299
+
300
+ id = runtime_returning_ids(<<~SQL).first
301
+ UPDATE river_job
302
+ SET #{assignments.join(",\n ")}
303
+ WHERE id = #{Integer(id)} AND state = 'running'
304
+ RETURNING id
305
+ SQL
306
+ id ? runtime_read_job(id) : nil
307
+ end
308
+
309
+ def job_update(id, params)
310
+ assignments = params.each.map do |field, value|
311
+ raise ArgumentError, "unknown update field: #{field}" unless River::JobUpdateParams.method_defined?(field)
312
+
313
+ "#{field} = #{runtime_update_value(field, value)}"
314
+ end
315
+
316
+ return job_get_by_id(id) if assignments.empty?
317
+
318
+ updated_id = runtime_returning_ids(<<~SQL).first
319
+ UPDATE river_job SET #{assignments.join(", ")}
320
+ WHERE id = #{Integer(id)}
321
+ RETURNING id
322
+ SQL
323
+ updated_id ? job_get_by_id(updated_id) : nil
324
+ end
325
+
326
+ def leader_acquire(id, ttl: 30, now: Time.now.utc)
327
+ transaction do
328
+ runtime_execute("DELETE FROM river_leader WHERE expires_at < #{runtime_time(now)}")
329
+ runtime_execute(<<~SQL)
330
+ INSERT INTO river_leader (leader_id, elected_at, expires_at)
331
+ VALUES (#{runtime_quote(id)}, #{runtime_time(now)}, #{runtime_time(now + ttl)})
332
+ ON CONFLICT (name) DO NOTHING
333
+ SQL
334
+ runtime_query_rows("SELECT leader_id FROM river_leader WHERE leader_id = #{runtime_quote(id)}").any?
335
+ end
336
+ end
337
+
338
+ def leader_release(id)
339
+ transaction do
340
+ deleted = runtime_query_rows("DELETE FROM river_leader WHERE leader_id = #{runtime_quote(id)} RETURNING leader_id")
341
+ runtime_notify("river_leadership", action: "resigned", leader_id: id) unless deleted.empty?
342
+ end
343
+ end
344
+
345
+ def leader_renew(id, ttl: 30, now: Time.now.utc)
346
+ runtime_query_rows(<<~SQL).any?
347
+ UPDATE river_leader SET expires_at = #{runtime_time(now + ttl)}
348
+ WHERE leader_id = #{runtime_quote(id)} AND expires_at >= #{runtime_time(now)}
349
+ RETURNING leader_id
350
+ SQL
351
+ end
352
+
353
+ # Bounded batches release SQLite's writer lock between maintenance passes.
354
+ def notification_delete_before(horizon:, max: 10_000)
355
+ return 0 if runtime_postgres?
356
+
357
+ runtime_returning_ids(<<~SQL).length
358
+ DELETE FROM river_notification
359
+ WHERE id IN (
360
+ SELECT id FROM river_notification
361
+ WHERE created_at < #{runtime_time(horizon)}
362
+ ORDER BY created_at, id LIMIT #{Integer(max)}
363
+ )
364
+ RETURNING id
365
+ SQL
366
+ end
367
+
368
+ def queue_get(name)
369
+ row = runtime_query_rows("SELECT #{runtime_queue_columns} FROM river_queue WHERE name = #{runtime_quote(name)}").first
370
+ runtime_queue_from_row(row)
371
+ end
372
+
373
+ def queue_list(max: 100)
374
+ runtime_query_rows("SELECT #{runtime_queue_columns} FROM river_queue ORDER BY name LIMIT #{Integer(max)}")
375
+ .map { |row| runtime_queue_from_row(row) }
376
+ end
377
+
378
+ def queue_pause(name, now: Time.now.utc)
379
+ transaction do
380
+ filter = (name == "*") ? "true" : "name = #{runtime_quote(name)}"
381
+ changed = runtime_query_rows(<<~SQL)
382
+ UPDATE river_queue
383
+ SET paused_at = CASE WHEN paused_at IS NULL THEN #{runtime_time(now)} ELSE paused_at END,
384
+ updated_at = CASE WHEN paused_at IS NULL THEN #{runtime_time(now)} ELSE updated_at END
385
+ WHERE #{filter}
386
+ RETURNING #{runtime_queue_columns}
387
+ SQL
388
+ runtime_notify("river_control", action: "pause", queue: name) unless changed.empty?
389
+ changed.map { |row| runtime_queue_from_row(row) }
390
+ end
391
+ end
392
+
393
+ def queue_resume(name, now: Time.now.utc)
394
+ transaction do
395
+ filter = (name == "*") ? "true" : "name = #{runtime_quote(name)}"
396
+ changed = runtime_query_rows(<<~SQL)
397
+ UPDATE river_queue
398
+ SET updated_at = CASE WHEN paused_at IS NOT NULL THEN #{runtime_time(now)} ELSE updated_at END,
399
+ paused_at = NULL
400
+ WHERE #{filter}
401
+ RETURNING #{runtime_queue_columns}
402
+ SQL
403
+ runtime_notify("river_control", action: "resume", queue: name) unless changed.empty?
404
+ changed.map { |row| runtime_queue_from_row(row) }
405
+ end
406
+ end
407
+
408
+ def queue_update(name, metadata:, now: Time.now.utc)
409
+ raise ArgumentError, "metadata must be a Hash" unless metadata.is_a?(Hash)
410
+
411
+ transaction do
412
+ row = runtime_query_rows(<<~SQL).first
413
+ UPDATE river_queue SET metadata = #{runtime_json(metadata)}, updated_at = #{runtime_time(now)}
414
+ WHERE name = #{runtime_quote(name)} RETURNING name
415
+ SQL
416
+ if row
417
+ runtime_notify("river_control", action: "metadata_changed", queue: name, metadata: metadata)
418
+ queue_get(name)
419
+ end
420
+ end
421
+ end
422
+
423
+ def queue_upsert(name, metadata: {}, now: Time.now.utc)
424
+ raise ArgumentError, "metadata must be a Hash" unless metadata.is_a?(Hash)
425
+
426
+ runtime_execute(<<~SQL)
427
+ INSERT INTO river_queue (name, created_at, metadata, updated_at)
428
+ VALUES (#{runtime_quote(name)}, #{runtime_time(now)}, #{runtime_json(metadata)}, #{runtime_time(now)})
429
+ ON CONFLICT (name) DO UPDATE SET updated_at = excluded.updated_at
430
+ SQL
431
+ queue_get(name)
432
+ end
433
+
434
+ private def runtime_append_error(error)
435
+ value = error.respond_to?(:to_h) ? error.to_h : error
436
+ if runtime_postgres?
437
+ "array_append(errors, #{runtime_json(value)})"
438
+ else
439
+ encoded = "json(#{runtime_quote(JSON.generate(value))})"
440
+ # Damaged JSONB may contain invalid UTF-8. Preserve its bytes as hex
441
+ # rather than creating an unreadable JSON string by casting it to text.
442
+ <<~SQL
443
+ CASE WHEN NOT json_valid(errors, 10) AND errors IS NOT NULL THEN
444
+ jsonb(json_array(CASE WHEN typeof(errors) = 'blob' THEN 'invalid JSONB: ' || hex(errors) ELSE errors END, #{encoded}))
445
+ WHEN coalesce(json_type(errors), 'array') <> 'array' THEN jsonb(json_array(json(errors), #{encoded}))
446
+ ELSE jsonb(json_insert(json(coalesce(errors, jsonb('[]'))), '$[#]', #{encoded})) END
447
+ SQL
448
+ end
449
+ end
450
+
451
+ private def runtime_cancel_attempted
452
+ runtime_postgres? ? "metadata ? 'cancel_attempted_at'" : "(CASE WHEN json_valid(metadata, 10) THEN metadata -> 'cancel_attempted_at' END) IS NOT NULL"
453
+ end
454
+
455
+ private def runtime_claim_jobs(predicate, max, attempted_by, now)
456
+ transaction do
457
+ attempted_by_sql = if runtime_postgres?
458
+ "array_append(CASE WHEN cardinality(attempted_by) >= 100 THEN attempted_by[(cardinality(attempted_by) - 98):] ELSE attempted_by END, #{runtime_quote(attempted_by)})"
459
+ else
460
+ # Preserve corrupt history so decoding can fail this attempt instead
461
+ # of silently repairing it or aborting the entire claim batch. Trim
462
+ # to the newest 99 entries before appending, preserving JSON types.
463
+ <<~SQL
464
+ CASE WHEN NOT json_valid(attempted_by, 10) AND attempted_by IS NOT NULL THEN attempted_by
465
+ ELSE jsonb(json_insert(json(
466
+ CASE WHEN json_array_length(attempted_by) >= 100 THEN (
467
+ SELECT json_group_array(json(value)) FROM (
468
+ SELECT attempted_by -> ('$[' || key || ']') AS value FROM json_each(attempted_by)
469
+ WHERE key >= json_array_length(attempted_by) - 99 ORDER BY key
470
+ )
471
+ ) ELSE coalesce(attempted_by, jsonb('[]')) END
472
+ ), '$[#]', #{runtime_quote(attempted_by)})) END
473
+ SQL
474
+ end
475
+
476
+ lock_clause = runtime_postgres? ? "FOR UPDATE SKIP LOCKED" : ""
477
+ # An administratively requeued job may already be at the counter limit.
478
+ # Saturate instead of letting that row abort the entire claim batch.
479
+ ids = runtime_returning_ids(<<~SQL)
480
+ UPDATE river_job
481
+ SET attempt = CASE WHEN attempt < #{River::MAX_ATTEMPTS_LIMIT} THEN attempt + 1 ELSE attempt END,
482
+ attempted_at = #{runtime_time(now)},
483
+ attempted_by = #{attempted_by_sql},
484
+ state = 'running'
485
+ WHERE id IN (
486
+ SELECT id FROM river_job
487
+ WHERE #{predicate}
488
+ ORDER BY priority ASC, scheduled_at ASC, id ASC
489
+ LIMIT #{Integer(max)}
490
+ #{lock_clause}
491
+ )
492
+ RETURNING id
493
+ SQL
494
+ ids.map { |id| runtime_read_job(id) }
495
+ end
496
+ end
497
+
498
+ private def runtime_read_job(id)
499
+ job_get_by_id(id)
500
+ rescue River::JobRowDecodeError => error
501
+ error.job
502
+ end
503
+
504
+ private def runtime_decode_postgres_job(row)
505
+ row = row.transform_keys(&:to_sym)
506
+ decoder = JobRowDecoder.new
507
+ decoder.finish(River::JobRow.new(
508
+ id: row[:id], args: decoder.decoded(:args, row[:args], default: {}),
509
+ attempt: row[:attempt], attempted_at: row[:attempted_at]&.getutc,
510
+ attempted_by: decoder.decoded(:attempted_by, row[:attempted_by], type: Array, strings: true),
511
+ created_at: row[:created_at].getutc,
512
+ errors: row[:errors]&.map { |error| JobRowDecoder.attempt_error(error) },
513
+ finalized_at: row[:finalized_at]&.getutc, kind: row[:kind], max_attempts: row[:max_attempts],
514
+ metadata: decoder.decoded(:metadata, row[:metadata], type: Hash, default: {}),
515
+ priority: row[:priority], queue: row[:queue], scheduled_at: row[:scheduled_at].getutc,
516
+ state: row[:state], tags: decoder.decoded(:tags, row[:tags], type: Array, strings: true),
517
+ unique_key: row[:unique_key]&.to_s,
518
+ unique_states: row[:unique_states] ? River::UniqueBitmask.to_states(row[:unique_states].to_i(2)) : nil
519
+ ))
520
+ end
521
+
522
+ private def runtime_decode_sqlite_job(row)
523
+ row = row.transform_keys(&:to_sym)
524
+ decoder = JobRowDecoder.new
525
+ errors = decoder.json(:errors, row[:errors], type: Array, default: [])
526
+ decoder.finish(River::JobRow.new(
527
+ id: row[:id], args: decoder.json(:args, row[:args], default: {}),
528
+ attempt: row[:attempt], attempted_at: runtime_parse_time(row[:attempted_at]),
529
+ attempted_by: decoder.json(:attempted_by, row[:attempted_by], type: Array, strings: true),
530
+ created_at: runtime_parse_time(row[:created_at]),
531
+ errors: errors.map { |error| JobRowDecoder.attempt_error(error) },
532
+ finalized_at: runtime_parse_time(row[:finalized_at]), kind: row[:kind], max_attempts: row[:max_attempts],
533
+ metadata: decoder.json(:metadata, row[:metadata], type: Hash, default: {}),
534
+ priority: row[:priority], queue: row[:queue], scheduled_at: runtime_parse_time(row[:scheduled_at]),
535
+ state: row[:state], tags: decoder.json(:tags, row[:tags], type: Array, strings: true),
536
+ unique_key: row[:unique_key]&.to_s,
537
+ unique_states: row[:unique_states] ? River::UniqueBitmask.to_states(row[:unique_states]) : nil
538
+ ))
539
+ end
540
+
541
+ private def runtime_in_clause(column, values)
542
+ "#{column} IN (#{values.map { |value| runtime_quote(value) }.join(",")})"
543
+ end
544
+
545
+ private def runtime_notify(topic, payload)
546
+ encoded = runtime_quote(JSON.generate(payload))
547
+ if runtime_postgres?
548
+ return unless postgres_capabilities.supports_listen_notify
549
+
550
+ runtime_execute("SELECT pg_notify(current_schema() || '.' || #{runtime_quote(topic)}, #{encoded})")
551
+ else
552
+ runtime_execute("INSERT INTO river_notification (topic, payload) VALUES (#{runtime_quote(topic)}, #{encoded})")
553
+ end
554
+ end
555
+
556
+ private def sqlite_insert_nonces(params)
557
+ keys = {} #: Hash[String, bool]
558
+ params.map do |param|
559
+ key = param.unique_key
560
+ if key && !key.empty? && River::UniqueBitmask.to_states((param.unique_states || "0").to_i(2)).include?(param.state)
561
+ # PostgreSQL rejects batches that upsert the same indexed row twice.
562
+ raise ArgumentError, "unique key appears more than once in batch" if keys[key]
563
+
564
+ keys[key] = true
565
+ end
566
+ SecureRandom.hex(8)
567
+ end
568
+ end
569
+
570
+ private def runtime_cursor_clause(params)
571
+ cursor = params.after
572
+ comparison = (params.sort_order == :asc) ? ">" : "<"
573
+ id_clause = "id #{comparison} #{Integer(cursor.id)}"
574
+ return id_clause if params.sort_by == :id
575
+
576
+ column = params.sort_by
577
+ return "(#{column} IS NULL AND #{id_clause})" if cursor.value.nil?
578
+
579
+ value = runtime_time(cursor.value)
580
+ "(#{column} IS NULL OR #{column} #{comparison} #{value} OR (#{column} = #{value} AND #{id_clause}))"
581
+ end
582
+
583
+ private def runtime_job_list(params, for_delete: false)
584
+ clauses = [] #: Array[String]
585
+ clauses << "state != 'running'" if for_delete
586
+ clauses << runtime_cursor_clause(params) if params.after
587
+ clauses << "id #{(params.sort_order == :asc) ? ">" : "<"} #{Integer(params.after_id)}" if params.after_id
588
+ clauses << runtime_in_clause("id", params.ids.map { |value| Integer(value) }) if params.ids&.any?
589
+ clauses << runtime_in_clause("kind", params.kinds) if params.kinds&.any?
590
+ clauses << runtime_in_clause("priority", params.priorities.map { |value| Integer(value) }) if params.priorities&.any?
591
+ clauses << runtime_in_clause("queue", params.queues) if params.queues&.any?
592
+ clauses << runtime_in_clause("state", params.states) if params.states&.any?
593
+
594
+ finalized = params.sort_by == :finalized_at && params.states&.length == 1 &&
595
+ %w[cancelled completed discarded].include?(params.states.first)
596
+ # Schemas require finalized timestamps for terminal states. Spell this out
597
+ # so PostgreSQL can use the partial (state, finalized_at) index.
598
+ clauses << "finalized_at IS NOT NULL" if finalized
599
+ # Explicit NULLS LAST prevents a backward index scan, even when there are
600
+ # no nulls. Only request it for timestamps that can actually be null.
601
+ null_order = (params.sort_by == :finalized_at && !finalized) ? " NULLS LAST" : ""
602
+
603
+ params.metadata&.each { |key, value| clauses << runtime_metadata_equals(key, value) }
604
+ Array(params.tags_all).each { |tag| clauses << runtime_tag_contains(tag) }
605
+ if params.tags_any&.any?
606
+ clauses << "(" + params.tags_any.map { |tag| runtime_tag_contains(tag) }.join(" OR ") + ")"
607
+ end
608
+
609
+ where = clauses.empty? ? "" : "WHERE #{clauses.join(" AND ")}"
610
+ runtime_job_rows(<<~SQL)
611
+ #{where}
612
+ ORDER BY #{params.sort_by} #{params.sort_order.to_s.upcase}#{null_order}, id #{params.sort_order.to_s.upcase}
613
+ LIMIT #{params.limit}
614
+ #{"FOR UPDATE SKIP LOCKED" if for_delete && runtime_postgres?}
615
+ SQL
616
+ end
617
+
618
+ private def runtime_json(value)
619
+ encoded = value.is_a?(String) ? value : JSON.generate(value)
620
+ runtime_postgres? ? "#{runtime_quote(encoded)}::jsonb" : "jsonb(#{runtime_quote(encoded)})"
621
+ end
622
+
623
+ private def runtime_merge_metadata(metadata)
624
+ raise ArgumentError, "metadata must be a Hash" unless metadata.is_a?(Hash)
625
+
626
+ if runtime_postgres?
627
+ "metadata || #{runtime_json(metadata)}"
628
+ else
629
+ # PostgreSQL's || replaces top-level values, including JSON null.
630
+ # JSON Merge Patch would recursively merge objects and delete nulls.
631
+ merged = metadata.reduce("metadata") do |expression, (key, value)|
632
+ path = runtime_quote("$.#{JSON.generate(key.to_s)}")
633
+ "jsonb_set(#{expression}, #{path}, jsonb(#{runtime_quote(JSON.generate(value))}))"
634
+ end
635
+ "CASE WHEN json_valid(metadata, 10) THEN #{merged} ELSE metadata END"
636
+ end
637
+ end
638
+
639
+ private def runtime_metadata_equals(key, value)
640
+ encoded = runtime_quote(JSON.generate(value))
641
+ if runtime_postgres?
642
+ "metadata -> #{runtime_quote(key.to_s)} = #{encoded}::jsonb"
643
+ else
644
+ # Compare JSON trees so object key order is immaterial, while strings,
645
+ # booleans, null, and missing keys remain distinct. json_each also treats
646
+ # the requested metadata key literally instead of as a JSON path.
647
+ columns = "fullkey, CASE WHEN type IN ('integer', 'real') THEN 'number' ELSE type END, atom"
648
+ actual = <<~SQL
649
+ SELECT #{columns} FROM json_tree(CASE entry.type
650
+ WHEN 'text' THEN json_quote(entry.value)
651
+ WHEN 'null' THEN 'null'
652
+ WHEN 'true' THEN 'true'
653
+ WHEN 'false' THEN 'false'
654
+ ELSE entry.value END)
655
+ SQL
656
+ expected = "SELECT #{columns} FROM json_tree(#{encoded})"
657
+ <<~SQL
658
+ EXISTS (SELECT 1 FROM json_each(metadata) AS entry
659
+ WHERE entry.key = #{runtime_quote(key.to_s)}
660
+ AND NOT EXISTS (#{actual} EXCEPT #{expected})
661
+ AND NOT EXISTS (#{expected} EXCEPT #{actual}))
662
+ SQL
663
+ end
664
+ end
665
+
666
+ private def runtime_nullable_time(value)
667
+ value ? runtime_time(value) : "NULL"
668
+ end
669
+
670
+ private def runtime_parse_json(value)
671
+ value.is_a?(String) ? JSON.parse(value) : value.to_h
672
+ end
673
+
674
+ private def runtime_parse_time(value)
675
+ return nil unless value
676
+
677
+ if value.respond_to?(:getutc)
678
+ value.getutc
679
+ else
680
+ Time.parse(value.to_s + (value.to_s.match?(/(?:Z|[+-]\d{2}:?\d{2})\z/) ? "" : " UTC")).utc
681
+ end
682
+ end
683
+
684
+ private def runtime_queue_columns
685
+ runtime_postgres? ? "name, created_at, metadata, paused_at, updated_at" : "name, CAST(created_at AS text) AS created_at, json(metadata) AS metadata, CAST(paused_at AS text) AS paused_at, CAST(updated_at AS text) AS updated_at"
686
+ end
687
+
688
+ private def runtime_queue_from_row(row)
689
+ return nil unless row
690
+
691
+ River::Queue.new(
692
+ runtime_value(row, :name),
693
+ runtime_parse_time(runtime_value(row, :created_at)),
694
+ runtime_parse_json(runtime_value(row, :metadata)),
695
+ runtime_parse_time(runtime_value(row, :paused_at)),
696
+ runtime_parse_time(runtime_value(row, :updated_at))
697
+ )
698
+ end
699
+
700
+ private def runtime_rescue_retry(job, error, retry_policy, now, logger)
701
+ retry_at = retry_policy.next_retry(job, error, now: now)
702
+ raise ArgumentError, "next_retry must return a Time" unless retry_at.is_a?(Time)
703
+ raise ArgumentError, "next_retry must not return a past Time" if retry_at < now
704
+
705
+ retry_at
706
+ rescue => retry_error
707
+ logger&.error("River rescue retry scheduling failed; using default backoff: #{retry_error.full_message}")
708
+ River::DefaultClientRetryPolicy.new.next_retry(job, error, now: now)
709
+ end
710
+
711
+ private def runtime_returning_ids(sql)
712
+ runtime_query_rows(sql).map { |row| runtime_value(row, :id).to_i }
713
+ end
714
+
715
+ private def runtime_state(value)
716
+ runtime_postgres? ? "#{runtime_quote(value)}::river_job_state" : runtime_quote(value)
717
+ end
718
+
719
+ private def runtime_tag_contains(tag)
720
+ if runtime_postgres?
721
+ "tags @> ARRAY[#{runtime_quote(tag)}]::varchar[]"
722
+ else
723
+ "EXISTS (SELECT 1 FROM json_each(json(tags)) WHERE value = #{runtime_quote(tag)})"
724
+ end
725
+ end
726
+
727
+ private def runtime_time(value)
728
+ raise ArgumentError, "time cannot be nil" unless value
729
+
730
+ cast = runtime_postgres? ? "::timestamptz" : ""
731
+ encoded = if runtime_postgres?
732
+ value.getutc.iso8601(6)
733
+ else
734
+ value.getutc.round(3).strftime("%Y-%m-%d %H:%M:%S.%3N")
735
+ end
736
+ "#{runtime_quote(encoded)}#{cast}"
737
+ end
738
+
739
+ private def runtime_update_value(field, value)
740
+ case field
741
+ when :attempt
742
+ attempt = Integer(value)
743
+ raise ArgumentError, "attempt must be between 0 and #{River::MAX_ATTEMPTS_LIMIT}" unless (0..River::MAX_ATTEMPTS_LIMIT).cover?(attempt)
744
+
745
+ attempt.to_s
746
+ when :attempted_at, :finalized_at
747
+ value ? runtime_time(value) : "NULL"
748
+ when :attempted_by
749
+ values = Array(value) #: Array[untyped]
750
+ raise ArgumentError, "attempted_by must contain only Strings" unless values.all? { |item| item.is_a?(String) }
751
+
752
+ runtime_postgres? ? "ARRAY[#{values.map { |item| runtime_quote(item) }.join(",")}]::text[]" : runtime_json(values)
753
+ when :errors
754
+ input_values = Array(value) #: Array[untyped]
755
+ values = input_values.map { |error| error.respond_to?(:to_h) ? error.to_h : error }
756
+ runtime_postgres? ? "ARRAY[#{values.map { |item| runtime_json(item) }.join(",")}]::jsonb[]" : runtime_json(values)
757
+ when :max_attempts
758
+ max_attempts = Integer(value)
759
+ raise ArgumentError, "max_attempts must be greater than zero" unless max_attempts > 0
760
+ raise ArgumentError, "max_attempts must not exceed #{River::MAX_ATTEMPTS_LIMIT}" if max_attempts > River::MAX_ATTEMPTS_LIMIT
761
+
762
+ max_attempts.to_s
763
+ when :metadata
764
+ raise ArgumentError, "metadata must be a Hash" unless value.is_a?(Hash)
765
+
766
+ runtime_json(value)
767
+ when :state
768
+ runtime_state(value)
769
+ end
770
+ end
771
+ end
772
+ end