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,721 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module River
6
+ class ClientRuntime
7
+ class Interrupted < StandardError; end
8
+
9
+ # Keep wall time for queue latency and error timestamps, and monotonic time
10
+ # for execution and completion durations. Externally executed jobs start
11
+ # with no locally observed execution time. Snapshot queue latency before
12
+ # retries, snoozes, or application code change the job's schedule.
13
+ class JobTiming
14
+ attr_accessor :finished_monotonic
15
+ attr_reader :queue_wait_duration, :started_at, :started_monotonic
16
+
17
+ def initialize(started_at, started_monotonic, scheduled_at)
18
+ @queue_wait_duration = started_at - scheduled_at
19
+ @started_at = started_at
20
+ @finished_monotonic = @started_monotonic = started_monotonic
21
+ end
22
+ end
23
+
24
+ attr_reader :periodic_jobs
25
+
26
+ def initialize(client, driver, config)
27
+ @client = client
28
+ @condition = ConditionVariable.new
29
+ @config = config
30
+ @driver = driver
31
+ @mutex = Mutex.new
32
+ @producer_threads = {}
33
+ @queue_configs = config.queues.dup
34
+ @removed_queues = {}
35
+ @running = {}
36
+ @started = false
37
+ @stopped = true
38
+ @subscriptions = []
39
+ @threads = []
40
+ @periodic_jobs = PeriodicJobBundle.new(config.periodic_jobs,
41
+ enabled: !config.leader_election_disabled, wake: method(:periodic_jobs_changed))
42
+ end
43
+
44
+ def finish_claimed(row, error = nil)
45
+ timing = JobTiming.new(row.attempted_at || Time.now.utc, monotonic_now, row.scheduled_at)
46
+ job = Job.new(@client, row)
47
+ case error
48
+ when nil
49
+ now = Time.now.utc
50
+ completed = @driver.job_complete(id: row.id, finalized_at: now, now: now)
51
+ case completed
52
+ when :cancelled
53
+ finish_failed(row, job, JobCancelError.new, timing, cancelled: true)
54
+ else
55
+ publish(EVENT_JOB_COMPLETED, completed, timing) if completed
56
+ end
57
+ when JobCancelError
58
+ finish_failed(row, job, error, timing, cancelled: true)
59
+ when JobSnoozeError
60
+ finish_snoozed(row, job, error, timing)
61
+ when Interrupted
62
+ finish_interrupted(row, job, timing)
63
+ else
64
+ worker = begin
65
+ resolve_worker(row.kind) if !row.__decode_error && @config.workers.include?(row.kind)
66
+ rescue => worker_error
67
+ @config.logger.error("River worker initialization failed during finalization: #{worker_error.full_message}")
68
+ nil
69
+ end
70
+ finish_failed(row, job, error, timing, worker: worker)
71
+ end
72
+ end
73
+
74
+ def perform_job(id, allow_scheduled: false)
75
+ @mutex.synchronize do
76
+ raise ClientAlreadyStartedError, "synchronous execution requires an idle, stopped client" if @started || @performing
77
+
78
+ @performing = true
79
+ @stop_requested = false
80
+ end
81
+
82
+ begin
83
+ row = @driver.job_claim(id: id, allow_scheduled: allow_scheduled, attempted_by: @config.id)
84
+ raise ArgumentError, "job #{id} is missing, not runnable, or scheduled in the future" unless row
85
+
86
+ @mutex.synchronize { @running[row.id] = {queue: row.queue, thread: Thread.current, working: false} }
87
+ outcome, error = execute(row)
88
+ final_row = begin
89
+ @driver.job_get_by_id(id)
90
+ rescue JobRowDecodeError => decode_error
91
+ decode_error.job
92
+ end
93
+ [final_row, error, outcome]
94
+ ensure
95
+ @mutex.synchronize { @performing = false }
96
+ end
97
+ end
98
+
99
+ def publish_queue(kind, queue)
100
+ event = Event.new(kind, nil, queue, nil)
101
+ @mutex.synchronize { @subscriptions.dup }.each { |subscription| subscription.publish(event) }
102
+ end
103
+
104
+ def healthy?
105
+ @mutex.synchronize do
106
+ @stop_requested || (@producer_threads.all? { |name, thread| @removed_queues[name] || thread.alive? } && (!@maintenance_thread || @maintenance_thread.alive?))
107
+ end
108
+ end
109
+
110
+ def interrupt_workers
111
+ @mutex.synchronize do
112
+ @running.values.each { |entry| entry[:thread].raise(Interrupted) if entry[:working] }
113
+ end
114
+ end
115
+
116
+ def queue_add(name, queue_config)
117
+ name = name.to_s
118
+ queue_config = QueueConfig.new(max_workers: queue_config) unless queue_config.is_a?(QueueConfig)
119
+ raise ArgumentError, "invalid queue name: #{name.inspect}" unless name.match?(QUEUE_NAME_REGEX) && name.length < 128
120
+
121
+ queue_config.resolved_fetch_poll_interval(@config)
122
+ should_start = @mutex.synchronize do
123
+ raise ArgumentError, "queue is already configured: #{name}" if @queue_configs.key?(name)
124
+
125
+ @queue_configs[name] = queue_config
126
+ @removed_queues.delete(name)
127
+ @started && !@stop_requested
128
+ end
129
+
130
+ if should_start
131
+ begin
132
+ start_producer(name, queue_config)
133
+ rescue
134
+ @mutex.synchronize do
135
+ if @queue_configs[name].equal?(queue_config) && !@producer_threads.key?(name)
136
+ @queue_configs.delete(name)
137
+ end
138
+ end
139
+ raise
140
+ end
141
+ start_maintenance
142
+ end
143
+
144
+ wake
145
+ queue_config
146
+ end
147
+
148
+ def queue_remove(name)
149
+ name = name.to_s
150
+ producer = @mutex.synchronize do
151
+ raise NotFoundError, "queue is not configured: #{name}" unless @queue_configs.key?(name)
152
+ if @producer_threads[name] == Thread.current || @running.any? { |_id, entry| entry[:queue] == name && entry[:thread] == Thread.current }
153
+ raise ThreadError, "cannot remove a queue from its own worker or producer"
154
+ end
155
+
156
+ @removed_queues[name] = true
157
+ @condition.broadcast
158
+ @producer_threads[name]
159
+ end
160
+
161
+ producer&.join
162
+ running = @mutex.synchronize do
163
+ @running.values.filter_map { |entry| entry[:thread] if entry[:queue] == name }
164
+ end
165
+ running.each(&:join)
166
+
167
+ @mutex.synchronize do
168
+ @queue_configs.delete(name)
169
+ @producer_threads.delete(name)
170
+ end
171
+
172
+ true
173
+ end
174
+
175
+ def start
176
+ @driver.init_driver if @driver.respond_to?(:init_driver)
177
+ @mutex.synchronize do
178
+ raise ClientAlreadyStartedError, "client is already started" if @started || @performing
179
+
180
+ @started = true
181
+ @stop_requested = false
182
+ @stopped = false
183
+ end
184
+
185
+ begin
186
+ queues = @mutex.synchronize { @queue_configs.dup }
187
+ queues.each { |name, queue_config| start_producer(name, queue_config) }
188
+ start_maintenance unless @queue_configs.empty? && @periodic_jobs.empty? && @config.maintenance_services.empty?
189
+
190
+ self
191
+ rescue
192
+ stop(cancel: true)
193
+ raise
194
+ end
195
+ end
196
+
197
+ def started?
198
+ @mutex.synchronize { @started }
199
+ end
200
+
201
+ def stop(cancel: false, wait: true)
202
+ threads = @mutex.synchronize do
203
+ return self if @stopped
204
+ if wait && (@threads.include?(Thread.current) || @running.any? { |_id, entry| entry[:thread] == Thread.current })
205
+ raise ThreadError, "cannot wait for client stop from its own runtime thread; use wait: false"
206
+ end
207
+
208
+ @stop_requested = true
209
+ @condition.broadcast
210
+ @running.values.each { |entry| entry[:thread].raise(Interrupted) if cancel && entry[:working] }
211
+ @threads.dup
212
+ end
213
+
214
+ return self unless wait
215
+
216
+ threads.each(&:join)
217
+
218
+ running_threads = @mutex.synchronize { @running.values.map { |entry| entry[:thread] } }
219
+ running_threads.each(&:join)
220
+
221
+ @driver.leader_release(@config.id) unless @config.leader_election_disabled
222
+ @mutex.synchronize do
223
+ @started = false
224
+ @stopped = true
225
+ @threads.clear
226
+ @producer_threads.clear
227
+ @maintenance_thread = nil
228
+ end
229
+
230
+ self
231
+ end
232
+
233
+ def stopped?
234
+ @mutex.synchronize { @stopped }
235
+ end
236
+
237
+ def subscribe(kinds, buffer_size: 100)
238
+ subscription = Subscription.new(
239
+ kinds,
240
+ buffer_size: buffer_size,
241
+ on_close: method(:remove_subscription)
242
+ )
243
+ @mutex.synchronize { @subscriptions << subscription }
244
+ subscription
245
+ end
246
+
247
+ def wake
248
+ @mutex.synchronize { @condition.broadcast }
249
+ end
250
+
251
+ private def begin_work(id)
252
+ should_interrupt = @mutex.synchronize do
253
+ entry = @running.fetch(id)
254
+ if @stop_requested
255
+ true
256
+ else
257
+ entry[:working] = true
258
+ false
259
+ end
260
+ end
261
+
262
+ raise Interrupted if should_interrupt
263
+ end
264
+
265
+ private def check_remote_cancellations(queue)
266
+ entries = @mutex.synchronize { @running.select { |_id, entry| entry[:queue] == queue && entry[:working] } }
267
+ return if entries.empty?
268
+
269
+ @driver.job_get_cancelled_ids(entries.keys).each do |id|
270
+ entries.fetch(id)[:thread].raise(JobCancelError)
271
+ end
272
+ end
273
+
274
+ private def error_handler_cancel?(error, job)
275
+ return false unless @config.error_handler
276
+
277
+ result = if @config.error_handler.respond_to?(:handle_error)
278
+ @config.error_handler.handle_error(error, job)
279
+ else
280
+ @config.error_handler.call(error, job)
281
+ end
282
+ result == :cancel || result == true
283
+ rescue => handler_error
284
+ @config.logger.error("River error handler failed: #{handler_error.full_message}")
285
+ false
286
+ end
287
+
288
+ private def execute(row)
289
+ timing = JobTiming.new(Time.now.utc, monotonic_now, row.scheduled_at)
290
+ job = Job.new(@client, row)
291
+ worker = nil #: untyped
292
+ begin
293
+ begin
294
+ raise row.__decode_error if row.__decode_error
295
+
296
+ worker = resolve_worker(row.kind)
297
+ begin_work(row.id)
298
+ Thread.handle_interrupt(Interrupted => :immediate, JobCancelError => :immediate) do
299
+ invoke_worker(worker, job)
300
+ end
301
+ ensure
302
+ timing.finished_monotonic = monotonic_now
303
+ finish_work(row.id)
304
+ end
305
+
306
+ finalize_hooks = @config.plugins.any? { |plugin| plugin.respond_to?(:job_finalize) }
307
+ if finalize_hooks
308
+ raise JobCancelError if @driver.job_get_cancelled_ids([row.id]).include?(row.id)
309
+
310
+ if invoke_plugins(:job_finalize, job, JOB_STATE_COMPLETED).include?(:delete)
311
+ raise JobCancelError if @driver.job_delete_if_running(row.id) == :cancelled
312
+
313
+ return [:deleted, nil]
314
+ end
315
+ end
316
+
317
+ completed_at = Time.now.utc
318
+ completed = @driver.job_complete(id: row.id, finalized_at: completed_at, metadata: job.metadata_updates, now: completed_at)
319
+ case completed
320
+ when :cancelled then raise JobCancelError
321
+ else publish(EVENT_JOB_COMPLETED, completed, timing) if completed
322
+ end
323
+
324
+ [:completed, nil]
325
+ rescue JobSnoozeError => error
326
+ job.__capture_resumable_metadata!
327
+ [finish_snoozed(row, job, error, timing), error]
328
+ rescue JobCancelError => error
329
+ job.__capture_resumable_metadata!
330
+ finish_failed(row, job, error, timing, cancelled: true)
331
+ [:cancelled, error]
332
+ rescue Interrupted => error
333
+ job.__capture_resumable_metadata!
334
+ [finish_interrupted(row, job, timing), error]
335
+ rescue => error
336
+ job.__capture_resumable_metadata!
337
+ [finish_failed(row, job, error, timing, worker: worker), error]
338
+ ensure
339
+ @mutex.synchronize do
340
+ @running.delete(row.id)
341
+ @condition.broadcast
342
+ end
343
+ end
344
+ end
345
+
346
+ private def finish_failed(row, job, error, timing, cancelled: false, worker: nil)
347
+ cancelled ||= error_handler_cancel?(error, job)
348
+ now = Time.now.utc
349
+ attempt_error = AttemptError.new(
350
+ at: timing.started_at,
351
+ attempt: row.attempt,
352
+ error: error.message,
353
+ trace: Array(error.backtrace).join("\n")
354
+ )
355
+ final = cancelled || row.attempt >= row.max_attempts || !retry_allowed?(worker, job, error)
356
+ state = if cancelled
357
+ JOB_STATE_CANCELLED
358
+ elsif final
359
+ JOB_STATE_DISCARDED
360
+ else
361
+ JOB_STATE_RETRYABLE
362
+ end
363
+ scheduled_at = final ? nil : next_retry(row, error, now, worker: worker)
364
+ state = JOB_STATE_AVAILABLE if scheduled_at && scheduled_at <= now + 5
365
+
366
+ updated = @driver.job_set_state_if_running(
367
+ id: row.id,
368
+ error: attempt_error,
369
+ finalized_at: final ? now : nil,
370
+ metadata: job.metadata_updates,
371
+ now: now,
372
+ scheduled_at: scheduled_at,
373
+ state: state
374
+ )
375
+ event = cancelled ? EVENT_JOB_CANCELLED : EVENT_JOB_FAILED
376
+ publish(event, updated, timing) if updated
377
+
378
+ case updated&.state || state
379
+ when JOB_STATE_CANCELLED then :cancelled
380
+ when JOB_STATE_DISCARDED then :discarded
381
+ else :retried
382
+ end
383
+ end
384
+
385
+ private def finish_interrupted(row, job, timing)
386
+ interrupted = @driver.job_set_state_if_running(
387
+ id: row.id,
388
+ attempt: [row.attempt - 1, 0].max,
389
+ metadata: job.metadata_updates,
390
+ scheduled_at: Time.now.utc,
391
+ state: JOB_STATE_AVAILABLE
392
+ )
393
+ publish(EVENT_JOB_INTERRUPTED, interrupted, timing) if interrupted
394
+
395
+ (interrupted&.state == JOB_STATE_CANCELLED) ? :cancelled : :interrupted
396
+ end
397
+
398
+ private def finish_snoozed(row, job, error, timing)
399
+ scheduled_at = Time.now.utc + error.duration
400
+ state = (error.duration <= 5) ? JOB_STATE_AVAILABLE : JOB_STATE_SCHEDULED
401
+ metadata = job.metadata_updates.merge("snoozes" => next_snooze_count(row.metadata["snoozes"]))
402
+ updated = @driver.job_set_state_if_running(
403
+ id: row.id,
404
+ attempt: [row.attempt - 1, 0].max,
405
+ metadata: metadata,
406
+ scheduled_at: scheduled_at,
407
+ state: state
408
+ )
409
+ publish(EVENT_JOB_SNOOZED, updated, timing) if updated
410
+ (updated&.state == JOB_STATE_CANCELLED) ? :cancelled : :snoozed
411
+ end
412
+
413
+ private def finish_work(id)
414
+ @mutex.synchronize do
415
+ entry = @running[id]
416
+ entry[:working] = false if entry
417
+ end
418
+ end
419
+
420
+ private def invoke_plugins(name, ...)
421
+ @config.plugins.filter_map { |plugin| plugin.public_send(name, ...) if plugin.respond_to?(name) }
422
+ end
423
+
424
+ private def invoke_worker(worker, job)
425
+ return perform_work(worker, job) if @config.plugins.empty?
426
+
427
+ operation = -> do
428
+ error = nil
429
+ begin
430
+ invoke_plugins(:work_begin, job)
431
+ perform_work(worker, job)
432
+ rescue => error
433
+ raise
434
+ ensure
435
+ invoke_plugins(:work_end, job, error)
436
+ end
437
+ end
438
+ @config.plugins.reverse_each do |plugin|
439
+ next unless plugin.respond_to?(:work)
440
+
441
+ next_operation = operation
442
+ operation = -> { plugin.work(job, next_operation) }
443
+ end
444
+
445
+ operation.call
446
+ end
447
+
448
+ private def launch(row)
449
+ gate = ::Queue.new
450
+ thread = Thread.new do
451
+ gate.pop
452
+ begin
453
+ Thread.handle_interrupt(Interrupted => :never, JobCancelError => :never) { execute(row) }
454
+ rescue Interrupted, JobCancelError
455
+ # A late asynchronous interrupt may become pending after work has
456
+ # already finalized. The database transition won that race.
457
+ end
458
+ end
459
+
460
+ @mutex.synchronize { @running[row.id] = {queue: row.queue, thread: thread, working: false} }
461
+ gate.push(true)
462
+ end
463
+
464
+ private def maintenance_loop
465
+ leader = false
466
+ next_schedule = next_rescue = next_cleanup = Time.at(0)
467
+ until stopping?
468
+ now = Time.now.utc
469
+ leader = leader ? @driver.leader_renew(@config.id, now: now) : @driver.leader_acquire(@config.id, now: now)
470
+ if leader
471
+ if now >= next_schedule
472
+ @driver.job_schedule(now: now)
473
+ run_periodic(now)
474
+ @config.maintenance_services.each do |service|
475
+ service.run(@client, @driver, now)
476
+ rescue => error
477
+ @config.logger.error("River maintenance service failed (#{service.class}): #{error.full_message}")
478
+ end
479
+ next_schedule = now + 5
480
+ end
481
+
482
+ if now >= next_rescue
483
+ @driver.job_rescue_stuck(horizon: now - 3_600, logger: @config.logger, now: now,
484
+ rescue_if: method(:rescue_job?), retry_policy: @config.retry_policy)
485
+ next_rescue = now + 30
486
+ end
487
+
488
+ if now >= next_cleanup
489
+ @driver.job_delete_finalized(now: now, retention: {
490
+ JOB_STATE_CANCELLED => @config.cancelled_job_retention_period,
491
+ JOB_STATE_COMPLETED => @config.completed_job_retention_period,
492
+ JOB_STATE_DISCARDED => @config.discarded_job_retention_period
493
+ })
494
+ @driver.notification_delete_before(horizon: now - 300) if @driver.respond_to?(:notification_delete_before)
495
+ next_cleanup = now + 30
496
+ end
497
+ end
498
+
499
+ wait(5)
500
+ end
501
+ rescue => error
502
+ @config.logger.error("River maintenance stopped: #{error.full_message}")
503
+ wait(5)
504
+ retry unless stopping?
505
+ end
506
+
507
+ private def monotonic_now
508
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
509
+ end
510
+
511
+ private def next_retry(row, error, now, worker: nil)
512
+ worker ||= @config.workers[row.kind] unless row.__decode_error
513
+ custom = worker.next_retry(row, error) if worker.respond_to?(:next_retry)
514
+
515
+ retry_at = custom || @config.retry_policy.next_retry(row, error, now: now)
516
+ raise ArgumentError, "next_retry must return a Time" unless retry_at.is_a?(Time)
517
+
518
+ (retry_at < now) ? DefaultClientRetryPolicy.new.next_retry(row, error, now: now) : retry_at
519
+ rescue => retry_error
520
+ @config.logger.error("River retry scheduling failed; using default backoff: #{retry_error.full_message}")
521
+ DefaultClientRetryPolicy.new.next_retry(row, error, now: now)
522
+ end
523
+
524
+ # Preserve Ruby's lenient counter conversion without calling to_i on
525
+ # booleans/collections, or accepting only a prefix of a numeric string.
526
+ # Only canonical non-negative integers are part of the shared protocol.
527
+ private def next_snooze_count(value)
528
+ count = case value
529
+ when Integer then value
530
+ when Float then value.finite? ? value.to_i : 0
531
+ when String then /\A-?[0-9]+\z/.match?(value) ? value.to_i : 0
532
+ when true then 1
533
+ else 0
534
+ end
535
+ # Go stores and increments a signed 64-bit integer.
536
+ ((count + 1 + (1 << 63)) % (1 << 64)) - (1 << 63)
537
+ end
538
+
539
+ private def perform_work(worker, job)
540
+ timeout = worker_timeout(worker, job)
541
+ result = timeout ? Timeout.timeout(timeout) { worker.work(job) } : worker.work(job)
542
+ job.__finish_resumable_work!
543
+ result
544
+ end
545
+
546
+ private def periodic_jobs_changed
547
+ start_maintenance
548
+ wake
549
+ end
550
+
551
+ private def producer_loop(queue, queue_config)
552
+ cooldown = queue_config.resolved_fetch_cooldown(@config)
553
+ # Capture registrations (including aliases) at startup, not construction.
554
+ fetch_options = {} #: Hash[Symbol, Array[String]]
555
+ fetch_options[:kinds] = @config.workers.kinds if @config.fetch_only_known_kinds
556
+ last_fetch = 0.0
557
+ poll_interval = queue_config.resolved_fetch_poll_interval(@config)
558
+ loop do
559
+ draining = queue_stopping?(queue)
560
+ break if draining && running_count(queue).zero?
561
+
562
+ check_remote_cancellations(queue)
563
+ if draining
564
+ wait_for_running_jobs(queue, poll_interval)
565
+ next
566
+ end
567
+
568
+ capacity = queue_config.max_workers - running_count(queue)
569
+ if capacity.positive?
570
+ # Inserts, completions, and spurious condition wakes must not bypass
571
+ # the minimum interval between database fetches.
572
+ while (sleep_for = cooldown - (monotonic_now - last_fetch)).positive? && !queue_stopping?(queue)
573
+ wait(sleep_for)
574
+ end
575
+ next if queue_stopping?(queue)
576
+
577
+ # A pause may arrive during cooldown; only read the queue immediately
578
+ # before fetching, and avoid the read entirely when all slots are busy.
579
+ unless @driver.queue_get(queue)&.paused_at
580
+ jobs = @driver.job_get_available(attempted_by: @config.id, max: capacity, queue: queue, **fetch_options)
581
+ last_fetch = monotonic_now
582
+ jobs.each { |job| launch(job) }
583
+ next unless jobs.empty?
584
+ end
585
+ end
586
+
587
+ wait(poll_interval)
588
+ end
589
+ rescue => error
590
+ @config.logger.error("River producer for #{queue.inspect} stopped: #{error.full_message}")
591
+ if queue_stopping?(queue)
592
+ wait_for_running_jobs(queue, poll_interval || @config.fetch_poll_interval)
593
+ else
594
+ wait(poll_interval || @config.fetch_poll_interval)
595
+ end
596
+ retry unless queue_stopping?(queue) && running_count(queue).zero?
597
+ end
598
+
599
+ private def publish(kind, job, timing)
600
+ # Another actor may have changed the row after our update. Pending,
601
+ # running, and unknown states do not represent a settled attempt.
602
+ return unless %w[available cancelled completed discarded retryable scheduled].include?(job.state)
603
+
604
+ kind = EVENT_JOB_CANCELLED if job.state == JOB_STATE_CANCELLED
605
+ stats = JobStatistics.new(
606
+ monotonic_now - timing.finished_monotonic,
607
+ timing.queue_wait_duration,
608
+ timing.finished_monotonic - timing.started_monotonic
609
+ )
610
+ event = Event.new(kind, job, nil, stats)
611
+ @mutex.synchronize { @subscriptions.dup }.each { |subscription| subscription.publish(event) }
612
+ end
613
+
614
+ private def queue_stopping?(queue)
615
+ @mutex.synchronize { @stop_requested || @removed_queues[queue] }
616
+ end
617
+
618
+ private def remove_subscription(subscription)
619
+ @mutex.synchronize { @subscriptions.delete(subscription) }
620
+ end
621
+
622
+ private def rescue_job?(row, now)
623
+ return true if row.__decode_error
624
+
625
+ worker = @config.workers[row.kind]
626
+ worker = worker.new if worker.is_a?(Class)
627
+ timeout = worker_timeout(worker, Job.new(@client, row))
628
+ attempted_at = row.attempted_at #: Time
629
+ !timeout.nil? && now - attempted_at >= timeout
630
+ rescue => error
631
+ @config.logger.error("River rescue timeout check failed; rescuing attempt: #{error.full_message}")
632
+ true
633
+ end
634
+
635
+ private def resolve_worker(kind)
636
+ worker = @config.workers[kind]
637
+ raise UnknownJobKindError, kind unless worker
638
+ worker.is_a?(Class) ? worker.new : worker
639
+ end
640
+
641
+ private def retry_allowed?(worker, job, error)
642
+ !worker.respond_to?(:retry?) || worker.retry?(job, error)
643
+ rescue => retry_error
644
+ @config.logger.error("River retry? hook failed; allowing retry: #{retry_error.full_message}")
645
+ true
646
+ end
647
+
648
+ private def run_periodic(now)
649
+ due = @periodic_jobs.due(now) do |job, error|
650
+ @config.logger.error("River periodic job schedule failed (id=#{job.id.inspect}): #{error.full_message}")
651
+ end
652
+ due.each do |periodic_job|
653
+ value = periodic_job.constructor.call
654
+ next unless value
655
+
656
+ args, opts = value.is_a?(Array) ? value : [value, nil]
657
+ opts = (opts || InsertOpts.new).dup
658
+ opts.metadata = (opts.metadata || {}).merge("periodic" => true)
659
+ opts.metadata["river:periodic_job_id"] = periodic_job.id if periodic_job.id && !periodic_job.id.empty?
660
+ @client.insert(args, insert_opts: opts)
661
+ rescue => error
662
+ @config.logger.error("River periodic job failed to insert: #{error.full_message}")
663
+ end
664
+ end
665
+
666
+ private def running_count(queue)
667
+ @mutex.synchronize { @running.count { |_id, entry| entry[:queue] == queue } }
668
+ end
669
+
670
+ private def start_maintenance
671
+ return if @config.leader_election_disabled
672
+
673
+ @mutex.synchronize do
674
+ return unless @started && !@stop_requested
675
+ return if @maintenance_thread&.alive?
676
+
677
+ thread = Thread.new { maintenance_loop }
678
+ @maintenance_thread = thread
679
+ @threads << thread
680
+ end
681
+ end
682
+
683
+ private def start_producer(name, queue_config)
684
+ @driver.queue_upsert(name)
685
+ @mutex.synchronize do
686
+ return if @stop_requested || @removed_queues[name] || @producer_threads.key?(name)
687
+
688
+ # Shutdown must see every launched thread, including a producer whose
689
+ # database setup overlapped a stop or queue removal.
690
+ thread = Thread.new { producer_loop(name, queue_config) }
691
+ @producer_threads[name] = thread
692
+ @threads << thread
693
+ end
694
+ end
695
+
696
+ private def stopping?
697
+ @mutex.synchronize { @stop_requested }
698
+ end
699
+
700
+ private def wait(duration)
701
+ @mutex.synchronize { @condition.wait(@mutex, duration) unless @stop_requested }
702
+ end
703
+
704
+ private def wait_for_running_jobs(queue, duration)
705
+ @mutex.synchronize do
706
+ # Draining still needs a poll interval after stop is requested. Check
707
+ # under the same mutex as completion so the last job's wake isn't lost.
708
+ @condition.wait(@mutex, duration) if @running.any? { |_id, entry| entry[:queue] == queue }
709
+ end
710
+ end
711
+
712
+ private def worker_timeout(worker, job)
713
+ timeout = worker.respond_to?(:timeout) ? worker.timeout(job) : @config.job_timeout
714
+ timeout = Float(timeout) unless timeout.nil?
715
+ timeout = @config.job_timeout if timeout == 0
716
+ raise ArgumentError, "worker timeout must be finite and nonnegative, or nil" if timeout && (!timeout.finite? || timeout.negative?)
717
+
718
+ timeout
719
+ end
720
+ end
721
+ end