riverqueue 0.11.0 → 0.12.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +132 -0
  3. data/LICENSE +374 -0
  4. data/docs/README.md +765 -0
  5. data/docs/migrations.md +121 -0
  6. data/docs/testing.md +173 -0
  7. data/docs/workers.md +126 -0
  8. data/exe/river +7 -0
  9. data/lib/cli.rb +55 -0
  10. data/lib/client.rb +314 -54
  11. data/lib/client_runtime.rb +612 -0
  12. data/lib/config.rb +154 -0
  13. data/lib/driver/job_row_decoder.rb +85 -0
  14. data/lib/driver/postgres_capabilities.rb +41 -0
  15. data/lib/driver/runtime.rb +661 -0
  16. data/lib/driver.rb +13 -6
  17. data/lib/errors.rb +83 -0
  18. data/lib/event.rb +78 -0
  19. data/lib/insert_opts.rb +40 -11
  20. data/lib/job.rb +28 -13
  21. data/lib/job_persisted_logging/plugin.rb +108 -0
  22. data/lib/migration_cli.rb +90 -0
  23. data/lib/migrator.rb +189 -0
  24. data/lib/params.rb +96 -0
  25. data/lib/periodic_cron.rb +24 -0
  26. data/lib/periodic_job.rb +135 -0
  27. data/lib/resumable.rb +148 -0
  28. data/lib/riverqueue/testing/minitest.rb +18 -0
  29. data/lib/riverqueue/testing/rspec.rb +109 -0
  30. data/lib/riverqueue/testing.rb +134 -0
  31. data/lib/riverqueue.rb +16 -0
  32. data/lib/unique_args.rb +97 -0
  33. data/lib/unique_bitmask.rb +14 -12
  34. data/lib/worker.rb +152 -0
  35. data/lib/worker_runner.rb +106 -0
  36. data/migration/LICENSE +374 -0
  37. data/migration/README.md +11 -0
  38. data/migration/manifest.json +38 -0
  39. data/migration/postgresql/main/001_create_river_migration.down.sql +1 -0
  40. data/migration/postgresql/main/001_create_river_migration.up.sql +8 -0
  41. data/migration/postgresql/main/002_initial_schema.down.sql +5 -0
  42. data/migration/postgresql/main/002_initial_schema.up.sql +96 -0
  43. data/migration/postgresql/main/003_river_job_tags_non_null.down.sql +3 -0
  44. data/migration/postgresql/main/003_river_job_tags_non_null.up.sql +3 -0
  45. data/migration/postgresql/main/004_pending_and_more.down.sql +42 -0
  46. data/migration/postgresql/main/004_pending_and_more.up.sql +45 -0
  47. data/migration/postgresql/main/005_migration_unique_client.down.sql +57 -0
  48. data/migration/postgresql/main/005_migration_unique_client.up.sql +79 -0
  49. data/migration/postgresql/main/006_bulk_unique.down.sql +16 -0
  50. data/migration/postgresql/main/006_bulk_unique.up.sql +40 -0
  51. data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +56 -0
  52. data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +44 -0
  53. data/migration/postgresql/main/008_job_id_autoincrement.down.sql +3 -0
  54. data/migration/postgresql/main/008_job_id_autoincrement.up.sql +3 -0
  55. data/migration/sqlite/main/001_create_river_migration.down.sql +1 -0
  56. data/migration/sqlite/main/001_create_river_migration.up.sql +8 -0
  57. data/migration/sqlite/main/002_initial_schema.down.sql +8 -0
  58. data/migration/sqlite/main/002_initial_schema.up.sql +19 -0
  59. data/migration/sqlite/main/003_river_job_tags_non_null.down.sql +6 -0
  60. data/migration/sqlite/main/003_river_job_tags_non_null.up.sql +6 -0
  61. data/migration/sqlite/main/004_pending_and_more.down.sql +26 -0
  62. data/migration/sqlite/main/004_pending_and_more.up.sql +33 -0
  63. data/migration/sqlite/main/005_migration_unique_client.down.sql +37 -0
  64. data/migration/sqlite/main/005_migration_unique_client.up.sql +64 -0
  65. data/migration/sqlite/main/006_bulk_unique.down.sql +7 -0
  66. data/migration/sqlite/main/006_bulk_unique.up.sql +63 -0
  67. data/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +255 -0
  68. data/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +261 -0
  69. data/migration/sqlite/main/008_job_id_autoincrement.down.sql +121 -0
  70. data/migration/sqlite/main/008_job_id_autoincrement.up.sql +123 -0
  71. data/sig/client.rbs +95 -0
  72. data/sig/driver.rbs +170 -0
  73. data/sig/insert_opts.rbs +25 -0
  74. data/sig/job.rbs +72 -0
  75. data/sig/job_persisted_logging/plugin.rbs +29 -0
  76. data/sig/migrator.rbs +44 -0
  77. data/sig/riverqueue.rbs +2 -0
  78. data/sig/runtime.rbs +382 -0
  79. data/sig/testing.rbs +95 -0
  80. data/sig/unique_args.rbs +8 -0
  81. data/sig/unique_bitmask.rbs +8 -0
  82. data/sig/worker_runner.rbs +22 -0
  83. metadata +165 -10
@@ -0,0 +1,612 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module River
6
+ class ClientRuntime
7
+ class Interrupted < StandardError; end
8
+
9
+ attr_reader :periodic_jobs
10
+
11
+ def initialize(client, driver, config)
12
+ @client = client
13
+ @condition = ConditionVariable.new
14
+ @config = config
15
+ @driver = driver
16
+ @mutex = Mutex.new
17
+ @periodic_jobs = PeriodicJobBundle.new(config.periodic_jobs, wake: method(:wake))
18
+ @producer_threads = {}
19
+ @queue_configs = config.queues.dup
20
+ @removed_queues = {}
21
+ @running = {}
22
+ @started = false
23
+ @stopped = true
24
+ @subscriptions = []
25
+ @threads = []
26
+ end
27
+
28
+ def finish_claimed(row, error = nil)
29
+ started_at = Time.now.utc
30
+ job = Job.new(@client, row)
31
+ if error
32
+ finish_failed(row, job, error, started_at)
33
+ else
34
+ now = Time.now.utc
35
+ completed = @driver.job_set_state_if_running(id: row.id, finalized_at: now, now: now, state: JOB_STATE_COMPLETED)
36
+ publish(EVENT_JOB_COMPLETED, completed, started_at) if completed
37
+ end
38
+ end
39
+
40
+ def perform_job(id, allow_scheduled: false)
41
+ @mutex.synchronize do
42
+ raise ClientAlreadyStartedError, "synchronous execution requires an idle, stopped client" if @started || @performing
43
+
44
+ @performing = true
45
+ @stop_requested = false
46
+ end
47
+
48
+ begin
49
+ row = @driver.job_claim(id: id, allow_scheduled: allow_scheduled, attempted_by: @config.id)
50
+ raise ArgumentError, "job #{id} is missing, not runnable, or scheduled in the future" unless row
51
+
52
+ @mutex.synchronize { @running[row.id] = {queue: row.queue, thread: Thread.current, working: false} }
53
+ outcome, error = execute(row)
54
+ final_row = begin
55
+ @driver.job_get_by_id(id)
56
+ rescue JobRowDecodeError => decode_error
57
+ decode_error.job
58
+ end
59
+ [final_row, error, outcome]
60
+ ensure
61
+ @mutex.synchronize { @performing = false }
62
+ end
63
+ end
64
+
65
+ def publish_queue(kind, queue)
66
+ event = Event.new(kind, nil, queue, nil)
67
+ @mutex.synchronize { @subscriptions.dup }.each { |subscription| subscription.publish(event) }
68
+ end
69
+
70
+ def healthy?
71
+ @mutex.synchronize do
72
+ @stop_requested || (@producer_threads.all? { |name, thread| @removed_queues[name] || thread.alive? } && (!@maintenance_thread || @maintenance_thread.alive?))
73
+ end
74
+ end
75
+
76
+ def interrupt_workers
77
+ @mutex.synchronize do
78
+ @running.values.each { |entry| entry[:thread].raise(Interrupted) if entry[:working] }
79
+ end
80
+ end
81
+
82
+ def queue_add(name, queue_config)
83
+ name = name.to_s
84
+ queue_config = QueueConfig.new(max_workers: queue_config) unless queue_config.is_a?(QueueConfig)
85
+ raise ArgumentError, "invalid queue name: #{name.inspect}" unless name.match?(QUEUE_NAME_REGEX) && name.length < 128
86
+
87
+ queue_config.resolved_fetch_poll_interval(@config)
88
+ should_start = @mutex.synchronize do
89
+ raise ArgumentError, "queue is already configured: #{name}" if @queue_configs.key?(name)
90
+
91
+ @queue_configs[name] = queue_config
92
+ @removed_queues.delete(name)
93
+ @started && !@stop_requested
94
+ end
95
+
96
+ if should_start
97
+ begin
98
+ start_producer(name, queue_config)
99
+ rescue
100
+ @mutex.synchronize do
101
+ if @queue_configs[name].equal?(queue_config) && !@producer_threads.key?(name)
102
+ @queue_configs.delete(name)
103
+ end
104
+ end
105
+ raise
106
+ end
107
+ start_maintenance
108
+ end
109
+
110
+ wake
111
+ queue_config
112
+ end
113
+
114
+ def queue_remove(name)
115
+ name = name.to_s
116
+ producer = @mutex.synchronize do
117
+ raise NotFoundError, "queue is not configured: #{name}" unless @queue_configs.key?(name)
118
+
119
+ @removed_queues[name] = true
120
+ @condition.broadcast
121
+ @producer_threads[name]
122
+ end
123
+
124
+ producer&.join
125
+ running = @mutex.synchronize do
126
+ @running.values.filter_map { |entry| entry[:thread] if entry[:queue] == name }
127
+ end
128
+ running.each(&:join)
129
+
130
+ @mutex.synchronize do
131
+ @queue_configs.delete(name)
132
+ @producer_threads.delete(name)
133
+ end
134
+
135
+ true
136
+ end
137
+
138
+ def start
139
+ @driver.init_driver if @driver.respond_to?(:init_driver)
140
+ @mutex.synchronize do
141
+ raise ClientAlreadyStartedError, "client is already started" if @started || @performing
142
+
143
+ @started = true
144
+ @stop_requested = false
145
+ @stopped = false
146
+ end
147
+
148
+ begin
149
+ queues = @mutex.synchronize { @queue_configs.dup }
150
+ queues.each { |name, queue_config| start_producer(name, queue_config) }
151
+ start_maintenance unless @queue_configs.empty?
152
+
153
+ self
154
+ rescue
155
+ stop(cancel: true)
156
+ raise
157
+ end
158
+ end
159
+
160
+ def started?
161
+ @mutex.synchronize { @started }
162
+ end
163
+
164
+ def stop(cancel: false, wait: true)
165
+ threads = @mutex.synchronize do
166
+ return self if @stopped
167
+
168
+ @stop_requested = true
169
+ @condition.broadcast
170
+ @running.values.each { |entry| entry[:thread].raise(Interrupted) if cancel && entry[:working] }
171
+ @threads.dup
172
+ end
173
+
174
+ return self unless wait
175
+
176
+ threads.each(&:join)
177
+
178
+ running_threads = @mutex.synchronize { @running.values.map { |entry| entry[:thread] } }
179
+ running_threads.each(&:join)
180
+
181
+ @driver.leader_release(@config.id) unless @config.leader_election_disabled
182
+ @mutex.synchronize do
183
+ @started = false
184
+ @stopped = true
185
+ @threads.clear
186
+ @producer_threads.clear
187
+ @maintenance_thread = nil
188
+ end
189
+
190
+ self
191
+ end
192
+
193
+ def stopped?
194
+ @mutex.synchronize { @stopped }
195
+ end
196
+
197
+ def subscribe(kinds, buffer_size: 100)
198
+ subscription = Subscription.new(
199
+ kinds,
200
+ buffer_size: buffer_size,
201
+ on_close: method(:remove_subscription)
202
+ )
203
+ @mutex.synchronize { @subscriptions << subscription }
204
+ subscription
205
+ end
206
+
207
+ def wake
208
+ @mutex.synchronize { @condition.broadcast }
209
+ end
210
+
211
+ private def begin_work(id)
212
+ should_interrupt = @mutex.synchronize do
213
+ entry = @running.fetch(id)
214
+ if @stop_requested
215
+ true
216
+ else
217
+ entry[:working] = true
218
+ false
219
+ end
220
+ end
221
+
222
+ raise Interrupted if should_interrupt
223
+ end
224
+
225
+ private def check_remote_cancellations(queue)
226
+ entries = @mutex.synchronize { @running.select { |_id, entry| entry[:queue] == queue && entry[:working] } }
227
+ return if entries.empty?
228
+
229
+ @driver.job_get_cancelled_ids(entries.keys).each do |id|
230
+ entries.fetch(id)[:thread].raise(JobCancelError)
231
+ end
232
+ end
233
+
234
+ private def error_handler_cancel?(error, job)
235
+ return false unless @config.error_handler
236
+
237
+ result = if @config.error_handler.respond_to?(:handle_error)
238
+ @config.error_handler.handle_error(error, job)
239
+ else
240
+ @config.error_handler.call(error, job)
241
+ end
242
+ result == :cancel || result == true
243
+ rescue => handler_error
244
+ @config.logger.error("River error handler failed: #{handler_error.full_message}")
245
+ false
246
+ end
247
+
248
+ private def execute(row)
249
+ started_at = Time.now.utc
250
+ job = Job.new(@client, row)
251
+ begin
252
+ raise row.__decode_error if row.__decode_error
253
+
254
+ worker = resolve_worker(row.kind)
255
+ begin_work(row.id)
256
+ begin
257
+ Thread.handle_interrupt(Interrupted => :immediate, JobCancelError => :immediate) do
258
+ invoke_worker(worker, job)
259
+ end
260
+ ensure
261
+ finish_work(row.id)
262
+ end
263
+
264
+ finalize_hooks = @config.plugins.any? { |plugin| plugin.respond_to?(:job_finalize) }
265
+ if finalize_hooks
266
+ raise JobCancelError if @driver.job_get_cancelled_ids([row.id]).include?(row.id)
267
+
268
+ if invoke_plugins(:job_finalize, job, JOB_STATE_COMPLETED).include?(:delete)
269
+ @driver.job_delete_if_running(row.id)
270
+ return [:deleted, nil]
271
+ end
272
+ end
273
+
274
+ completed_at = Time.now.utc
275
+ completed = if finalize_hooks
276
+ @driver.job_set_state_if_running(id: row.id, finalized_at: completed_at, metadata: job.metadata_updates, now: completed_at, state: JOB_STATE_COMPLETED)
277
+ else
278
+ result = @driver.job_complete(id: row.id, finalized_at: completed_at, metadata: job.metadata_updates, now: completed_at)
279
+ case result
280
+ when :cancelled then raise JobCancelError
281
+ else result
282
+ end
283
+ end
284
+ publish(EVENT_JOB_COMPLETED, completed, started_at) if completed
285
+
286
+ [:completed, nil]
287
+ rescue JobSnoozeError => error
288
+ job.__capture_resumable_metadata!
289
+ [finish_snoozed(row, job, error, started_at), error]
290
+ rescue JobCancelError => error
291
+ job.__capture_resumable_metadata!
292
+ finish_failed(row, job, error, started_at, cancelled: true)
293
+ [:cancelled, error]
294
+ rescue Interrupted => error
295
+ job.__capture_resumable_metadata!
296
+ interrupted = @driver.job_set_state_if_running(
297
+ id: row.id,
298
+ attempt: [row.attempt - 1, 0].max,
299
+ metadata: job.metadata_updates,
300
+ scheduled_at: Time.now.utc,
301
+ state: JOB_STATE_AVAILABLE
302
+ )
303
+ publish(EVENT_JOB_INTERRUPTED, interrupted, started_at) if interrupted
304
+
305
+ [(interrupted&.state == JOB_STATE_CANCELLED) ? :cancelled : :interrupted, error]
306
+ rescue => error
307
+ job.__capture_resumable_metadata!
308
+ [finish_failed(row, job, error, started_at, worker: worker), error]
309
+ ensure
310
+ @mutex.synchronize do
311
+ @running.delete(row.id)
312
+ @condition.broadcast
313
+ end
314
+ end
315
+ end
316
+
317
+ private def finish_failed(row, job, error, started_at, cancelled: false, worker: nil)
318
+ cancelled ||= error_handler_cancel?(error, job)
319
+ now = Time.now.utc
320
+ attempt_error = AttemptError.new(
321
+ at: started_at,
322
+ attempt: row.attempt,
323
+ error: error.message,
324
+ trace: Array(error.backtrace).join("\n")
325
+ )
326
+ final = cancelled || row.attempt >= row.max_attempts || !retry_allowed?(worker, job, error)
327
+ state = if cancelled
328
+ JOB_STATE_CANCELLED
329
+ elsif final
330
+ JOB_STATE_DISCARDED
331
+ else
332
+ JOB_STATE_RETRYABLE
333
+ end
334
+ scheduled_at = final ? nil : next_retry(row, error, now, worker: worker)
335
+ state = JOB_STATE_AVAILABLE if scheduled_at && scheduled_at <= now + 5
336
+
337
+ updated = @driver.job_set_state_if_running(
338
+ id: row.id,
339
+ error: attempt_error,
340
+ finalized_at: final ? now : nil,
341
+ metadata: job.metadata_updates,
342
+ now: now,
343
+ scheduled_at: scheduled_at,
344
+ state: state
345
+ )
346
+ event = cancelled ? EVENT_JOB_CANCELLED : EVENT_JOB_FAILED
347
+ publish(event, updated, started_at) if updated
348
+
349
+ case updated&.state || state
350
+ when JOB_STATE_CANCELLED then :cancelled
351
+ when JOB_STATE_DISCARDED then :discarded
352
+ else :retried
353
+ end
354
+ end
355
+
356
+ private def finish_snoozed(row, job, error, started_at)
357
+ scheduled_at = Time.now.utc + error.duration
358
+ state = (error.duration <= 5) ? JOB_STATE_AVAILABLE : JOB_STATE_SCHEDULED
359
+ metadata = job.metadata_updates.merge("snoozes" => row.metadata.fetch("snoozes", 0).to_i + 1)
360
+ updated = @driver.job_set_state_if_running(
361
+ id: row.id,
362
+ attempt: [row.attempt - 1, 0].max,
363
+ metadata: metadata,
364
+ scheduled_at: scheduled_at,
365
+ state: state
366
+ )
367
+ publish(EVENT_JOB_SNOOZED, updated, started_at) if updated
368
+ (updated&.state == JOB_STATE_CANCELLED) ? :cancelled : :snoozed
369
+ end
370
+
371
+ private def finish_work(id)
372
+ @mutex.synchronize do
373
+ entry = @running[id]
374
+ entry[:working] = false if entry
375
+ end
376
+ end
377
+
378
+ private def invoke_plugins(name, ...)
379
+ @config.plugins.filter_map { |plugin| plugin.public_send(name, ...) if plugin.respond_to?(name) }
380
+ end
381
+
382
+ private def invoke_worker(worker, job)
383
+ return perform_work(worker, job) if @config.plugins.empty?
384
+
385
+ operation = -> do
386
+ error = nil
387
+ begin
388
+ invoke_plugins(:work_begin, job)
389
+ perform_work(worker, job)
390
+ rescue => error
391
+ raise
392
+ ensure
393
+ invoke_plugins(:work_end, job, error)
394
+ end
395
+ end
396
+ @config.plugins.reverse_each do |plugin|
397
+ next unless plugin.respond_to?(:work)
398
+
399
+ next_operation = operation
400
+ operation = -> { plugin.work(job, next_operation) }
401
+ end
402
+
403
+ operation.call
404
+ end
405
+
406
+ private def launch(row)
407
+ gate = ::Queue.new
408
+ thread = Thread.new do
409
+ gate.pop
410
+ begin
411
+ Thread.handle_interrupt(Interrupted => :never, JobCancelError => :never) { execute(row) }
412
+ rescue Interrupted, JobCancelError
413
+ # A late asynchronous interrupt may become pending after work has
414
+ # already finalized. The database transition won that race.
415
+ end
416
+ end
417
+
418
+ @mutex.synchronize { @running[row.id] = {queue: row.queue, thread: thread, working: false} }
419
+ gate.push(true)
420
+ end
421
+
422
+ private def maintenance_loop
423
+ leader = false
424
+ next_schedule = next_rescue = next_cleanup = Time.at(0)
425
+ until stopping?
426
+ now = Time.now.utc
427
+ leader = leader ? @driver.leader_renew(@config.id, now: now) : @driver.leader_acquire(@config.id, now: now)
428
+ if leader
429
+ if now >= next_schedule
430
+ @driver.job_schedule(now: now)
431
+ run_periodic(now)
432
+ @config.maintenance_services.each { |service| service.run(@client, @driver, now) }
433
+ next_schedule = now + 5
434
+ end
435
+
436
+ if now >= next_rescue
437
+ @driver.job_rescue_stuck(horizon: now - 3_600, now: now, retry_policy: @config.retry_policy)
438
+ next_rescue = now + 30
439
+ end
440
+
441
+ if now >= next_cleanup
442
+ @driver.job_delete_finalized(now: now, retention: {
443
+ JOB_STATE_CANCELLED => @config.cancelled_job_retention_period,
444
+ JOB_STATE_COMPLETED => @config.completed_job_retention_period,
445
+ JOB_STATE_DISCARDED => @config.discarded_job_retention_period
446
+ })
447
+ @driver.notification_delete_before(horizon: now - 300) if @driver.respond_to?(:notification_delete_before)
448
+ next_cleanup = now + 30
449
+ end
450
+ end
451
+
452
+ wait(5)
453
+ end
454
+ rescue => error
455
+ @config.logger.error("River maintenance stopped: #{error.full_message}")
456
+ wait(5)
457
+ retry unless stopping?
458
+ end
459
+
460
+ private def monotonic_now
461
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
462
+ end
463
+
464
+ private def next_retry(row, error, now, worker: nil)
465
+ worker ||= @config.workers[row.kind] unless row.__decode_error
466
+ custom = worker.next_retry(row, error) if worker.respond_to?(:next_retry)
467
+
468
+ retry_at = custom || @config.retry_policy.next_retry(row, error, now: now)
469
+ raise ArgumentError, "next_retry must return a Time" unless retry_at.is_a?(Time)
470
+
471
+ (retry_at < now) ? DefaultClientRetryPolicy.new.next_retry(row, error, now: now) : retry_at
472
+ rescue => retry_error
473
+ @config.logger.error("River retry scheduling failed; using default backoff: #{retry_error.full_message}")
474
+ DefaultClientRetryPolicy.new.next_retry(row, error, now: now)
475
+ end
476
+
477
+ private def perform_work(worker, job)
478
+ timeout = worker.respond_to?(:timeout) ? worker.timeout(job) : @config.job_timeout
479
+ timeout = Float(timeout) unless timeout.nil?
480
+ timeout = @config.job_timeout if timeout == 0
481
+ raise ArgumentError, "worker timeout must be finite and nonnegative, or nil" if timeout && (!timeout.finite? || timeout.negative?)
482
+
483
+ result = timeout ? Timeout.timeout(timeout) { worker.work(job) } : worker.work(job)
484
+ job.__finish_resumable_work!
485
+ result
486
+ end
487
+
488
+ private def producer_loop(queue, queue_config)
489
+ cooldown = queue_config.resolved_fetch_cooldown(@config)
490
+ # Capture registrations (including aliases) at startup, not construction.
491
+ fetch_options = {} #: Hash[Symbol, Array[String]]
492
+ fetch_options[:kinds] = @config.workers.kinds if @config.fetch_only_known_kinds
493
+ last_fetch = 0.0
494
+ poll_interval = queue_config.resolved_fetch_poll_interval(@config)
495
+ loop do
496
+ break if queue_stopping?(queue)
497
+
498
+ check_remote_cancellations(queue)
499
+ capacity = queue_config.max_workers - running_count(queue)
500
+ if capacity.positive?
501
+ # Inserts, completions, and spurious condition wakes must not bypass
502
+ # the minimum interval between database fetches.
503
+ while (sleep_for = cooldown - (monotonic_now - last_fetch)).positive? && !queue_stopping?(queue)
504
+ wait(sleep_for)
505
+ end
506
+ break if queue_stopping?(queue)
507
+
508
+ # A pause may arrive during cooldown; only read the queue immediately
509
+ # before fetching, and avoid the read entirely when all slots are busy.
510
+ unless @driver.queue_get(queue)&.paused_at
511
+ jobs = @driver.job_get_available(attempted_by: @config.id, max: capacity, queue: queue, **fetch_options)
512
+ last_fetch = monotonic_now
513
+ jobs.each { |job| launch(job) }
514
+ next unless jobs.empty?
515
+ end
516
+ end
517
+
518
+ wait(poll_interval)
519
+ end
520
+ rescue => error
521
+ @config.logger.error("River producer for #{queue.inspect} stopped: #{error.full_message}")
522
+ wait(poll_interval || @config.fetch_poll_interval)
523
+ retry unless queue_stopping?(queue)
524
+ end
525
+
526
+ private def publish(kind, job, started_at)
527
+ # Another actor may have changed the row after our update. Pending,
528
+ # running, and unknown states do not represent a settled attempt.
529
+ return unless %w[available cancelled completed discarded retryable scheduled].include?(job.state)
530
+
531
+ kind = EVENT_JOB_CANCELLED if job.state == JOB_STATE_CANCELLED
532
+ completed_at = Time.now.utc
533
+ stats = JobStatistics.new(0, started_at - job.scheduled_at, completed_at - started_at)
534
+ event = Event.new(kind, job, nil, stats)
535
+ @mutex.synchronize { @subscriptions.dup }.each { |subscription| subscription.publish(event) }
536
+ end
537
+
538
+ private def queue_stopping?(queue)
539
+ @mutex.synchronize { @stop_requested || @removed_queues[queue] }
540
+ end
541
+
542
+ private def remove_subscription(subscription)
543
+ @mutex.synchronize { @subscriptions.delete(subscription) }
544
+ end
545
+
546
+ private def resolve_worker(kind)
547
+ worker = @config.workers[kind]
548
+ raise UnknownJobKindError, kind unless worker
549
+ worker.is_a?(Class) ? worker.new : worker
550
+ end
551
+
552
+ private def retry_allowed?(worker, job, error)
553
+ !worker.respond_to?(:retry?) || worker.retry?(job, error)
554
+ rescue => retry_error
555
+ @config.logger.error("River retry? hook failed; allowing retry: #{retry_error.full_message}")
556
+ true
557
+ end
558
+
559
+ private def run_periodic(now)
560
+ due = @periodic_jobs.due(now) do |job, error|
561
+ @config.logger.error("River periodic job schedule failed (id=#{job.id.inspect}): #{error.full_message}")
562
+ end
563
+ due.each do |periodic_job|
564
+ value = periodic_job.constructor.call
565
+ next unless value
566
+
567
+ args, opts = value.is_a?(Array) ? value : [value, nil]
568
+ @client.insert(args, insert_opts: opts || InsertOpts.new)
569
+ rescue => error
570
+ @config.logger.error("River periodic job failed to insert: #{error.full_message}")
571
+ end
572
+ end
573
+
574
+ private def running_count(queue)
575
+ @mutex.synchronize { @running.count { |_id, entry| entry[:queue] == queue } }
576
+ end
577
+
578
+ private def start_maintenance
579
+ return if @config.leader_election_disabled
580
+
581
+ @mutex.synchronize do
582
+ return if @stop_requested
583
+ return if @maintenance_thread&.alive?
584
+
585
+ thread = Thread.new { maintenance_loop }
586
+ @maintenance_thread = thread
587
+ @threads << thread
588
+ end
589
+ end
590
+
591
+ private def start_producer(name, queue_config)
592
+ @driver.queue_upsert(name)
593
+ @mutex.synchronize do
594
+ return if @stop_requested || @removed_queues[name] || @producer_threads.key?(name)
595
+
596
+ # Shutdown must see every launched thread, including a producer whose
597
+ # database setup overlapped a stop or queue removal.
598
+ thread = Thread.new { producer_loop(name, queue_config) }
599
+ @producer_threads[name] = thread
600
+ @threads << thread
601
+ end
602
+ end
603
+
604
+ private def stopping?
605
+ @mutex.synchronize { @stop_requested }
606
+ end
607
+
608
+ private def wait(duration)
609
+ @mutex.synchronize { @condition.wait(@mutex, duration) unless @stop_requested }
610
+ end
611
+ end
612
+ end