riverqueue 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,21 @@ module River
6
6
  class ClientRuntime
7
7
  class Interrupted < StandardError; end
8
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
+
9
24
  attr_reader :periodic_jobs
10
25
 
11
26
  def initialize(client, driver, config)
@@ -13,27 +28,54 @@ module River
13
28
  @condition = ConditionVariable.new
14
29
  @config = config
15
30
  @driver = driver
31
+ @fetching_queues = {}
32
+ @leader = false
33
+ @maintenance_condition = ConditionVariable.new
34
+ @maintenance_generation = 0
16
35
  @mutex = Mutex.new
17
- @periodic_jobs = PeriodicJobBundle.new(config.periodic_jobs, wake: method(:wake))
36
+ @notifications_disabled = false
18
37
  @producer_threads = {}
19
38
  @queue_configs = config.queues.dup
39
+ @queue_generations = Hash.new(0)
40
+ @queue_paused = {}
20
41
  @removed_queues = {}
42
+ @resign_requested = false
21
43
  @running = {}
22
44
  @started = false
23
45
  @stopped = true
24
46
  @subscriptions = []
25
47
  @threads = []
48
+ @periodic_jobs = PeriodicJobBundle.new(config.periodic_jobs,
49
+ enabled: !config.leader_election_disabled, wake: method(:periodic_jobs_changed))
26
50
  end
27
51
 
28
52
  def finish_claimed(row, error = nil)
29
- started_at = Time.now.utc
53
+ timing = JobTiming.new(row.attempted_at || Time.now.utc, monotonic_now, row.scheduled_at)
30
54
  job = Job.new(@client, row)
31
- if error
32
- finish_failed(row, job, error, started_at)
33
- else
55
+ case error
56
+ when nil
34
57
  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
58
+ completed = @driver.job_complete(id: row.id, finalized_at: now, now: now)
59
+ case completed
60
+ when :cancelled
61
+ finish_failed(row, job, JobCancelError.new, timing, cancelled: true)
62
+ else
63
+ publish(EVENT_JOB_COMPLETED, completed, timing) if completed
64
+ end
65
+ when JobCancelError
66
+ finish_failed(row, job, error, timing, cancelled: true)
67
+ when JobSnoozeError
68
+ finish_snoozed(row, job, error, timing)
69
+ when Interrupted
70
+ finish_interrupted(row, job, timing)
71
+ else
72
+ worker = begin
73
+ resolve_worker(row.kind) if !row.__decode_error && @config.workers.include?(row.kind)
74
+ rescue => worker_error
75
+ @config.logger.error("River worker initialization failed during finalization: #{worker_error.full_message}")
76
+ nil
77
+ end
78
+ finish_failed(row, job, error, timing, worker: worker)
37
79
  end
38
80
  end
39
81
 
@@ -64,12 +106,21 @@ module River
64
106
 
65
107
  def publish_queue(kind, queue)
66
108
  event = Event.new(kind, nil, queue, nil)
67
- @mutex.synchronize { @subscriptions.dup }.each { |subscription| subscription.publish(event) }
109
+ subscriptions = @mutex.synchronize do
110
+ paused = kind == EVENT_QUEUE_PAUSED
111
+ return if @queue_paused[queue.name] == paused
112
+
113
+ @queue_paused[queue.name] = paused
114
+ @subscriptions.dup
115
+ end
116
+ subscriptions.each { |subscription| subscription.publish(event) }
68
117
  end
69
118
 
70
119
  def healthy?
71
120
  @mutex.synchronize do
72
- @stop_requested || (@producer_threads.all? { |name, thread| @removed_queues[name] || thread.alive? } && (!@maintenance_thread || @maintenance_thread.alive?))
121
+ @stop_requested || (@producer_threads.all? { |name, thread| @removed_queues[name] || thread.alive? } &&
122
+ (!@maintenance_thread || @maintenance_thread.alive?) &&
123
+ (!@notification_thread || @notifications_disabled || @notification_thread.alive?))
73
124
  end
74
125
  end
75
126
 
@@ -115,6 +166,9 @@ module River
115
166
  name = name.to_s
116
167
  producer = @mutex.synchronize do
117
168
  raise NotFoundError, "queue is not configured: #{name}" unless @queue_configs.key?(name)
169
+ if @producer_threads[name] == Thread.current || @running.any? { |_id, entry| entry[:queue] == name && entry[:thread] == Thread.current }
170
+ raise ThreadError, "cannot remove a queue from its own worker or producer"
171
+ end
118
172
 
119
173
  @removed_queues[name] = true
120
174
  @condition.broadcast
@@ -146,9 +200,10 @@ module River
146
200
  end
147
201
 
148
202
  begin
203
+ start_notifications
149
204
  queues = @mutex.synchronize { @queue_configs.dup }
150
205
  queues.each { |name, queue_config| start_producer(name, queue_config) }
151
- start_maintenance unless @queue_configs.empty?
206
+ start_maintenance unless @queue_configs.empty? && @periodic_jobs.empty? && @config.maintenance_services.empty?
152
207
 
153
208
  self
154
209
  rescue
@@ -164,9 +219,13 @@ module River
164
219
  def stop(cancel: false, wait: true)
165
220
  threads = @mutex.synchronize do
166
221
  return self if @stopped
222
+ if wait && (@threads.include?(Thread.current) || @running.any? { |_id, entry| entry[:thread] == Thread.current })
223
+ raise ThreadError, "cannot wait for client stop from its own runtime thread; use wait: false"
224
+ end
167
225
 
168
226
  @stop_requested = true
169
227
  @condition.broadcast
228
+ @maintenance_condition.broadcast
170
229
  @running.values.each { |entry| entry[:thread].raise(Interrupted) if cancel && entry[:working] }
171
230
  @threads.dup
172
231
  end
@@ -185,6 +244,9 @@ module River
185
244
  @threads.clear
186
245
  @producer_threads.clear
187
246
  @maintenance_thread = nil
247
+ @notification_thread = nil
248
+ @leader = false
249
+ @resign_requested = false
188
250
  end
189
251
 
190
252
  self
@@ -205,21 +267,23 @@ module River
205
267
  end
206
268
 
207
269
  def wake
208
- @mutex.synchronize { @condition.broadcast }
270
+ wake_queue("*")
209
271
  end
210
272
 
211
273
  private def begin_work(id)
212
- should_interrupt = @mutex.synchronize do
274
+ interruption = @mutex.synchronize do
213
275
  entry = @running.fetch(id)
214
276
  if @stop_requested
215
- true
277
+ Interrupted
278
+ elsif entry[:cancelled]
279
+ JobCancelError
216
280
  else
217
281
  entry[:working] = true
218
- false
282
+ nil
219
283
  end
220
284
  end
221
285
 
222
- raise Interrupted if should_interrupt
286
+ raise interruption if interruption
223
287
  end
224
288
 
225
289
  private def check_remote_cancellations(queue)
@@ -246,79 +310,74 @@ module River
246
310
  end
247
311
 
248
312
  private def execute(row)
249
- started_at = Time.now.utc
313
+ timing = JobTiming.new(Time.now.utc, monotonic_now, row.scheduled_at)
250
314
  job = Job.new(@client, row)
315
+ worker = nil #: untyped
251
316
  begin
252
- raise row.__decode_error if row.__decode_error
253
-
254
- worker = resolve_worker(row.kind)
255
- begin_work(row.id)
256
317
  begin
318
+ raise row.__decode_error if row.__decode_error
319
+
320
+ worker = resolve_worker(row.kind)
321
+ begin_work(row.id)
257
322
  Thread.handle_interrupt(Interrupted => :immediate, JobCancelError => :immediate) do
258
323
  invoke_worker(worker, job)
259
324
  end
260
325
  ensure
326
+ timing.finished_monotonic = monotonic_now
261
327
  finish_work(row.id)
262
328
  end
263
329
 
330
+ return [:completed, nil] if finish_transactional_completion(job, timing)
331
+
264
332
  finalize_hooks = @config.plugins.any? { |plugin| plugin.respond_to?(:job_finalize) }
265
333
  if finalize_hooks
266
334
  raise JobCancelError if @driver.job_get_cancelled_ids([row.id]).include?(row.id)
267
335
 
268
336
  if invoke_plugins(:job_finalize, job, JOB_STATE_COMPLETED).include?(:delete)
269
- @driver.job_delete_if_running(row.id)
337
+ raise JobCancelError if @driver.job_delete_if_running(row.id) == :cancelled
338
+
270
339
  return [:deleted, nil]
271
340
  end
272
341
  end
273
342
 
274
343
  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
344
+ completed = @driver.job_complete(id: row.id, finalized_at: completed_at, metadata: job.metadata_updates, now: completed_at)
345
+ case completed
346
+ when :cancelled then raise JobCancelError
347
+ else publish(EVENT_JOB_COMPLETED, completed, timing) if completed
283
348
  end
284
- publish(EVENT_JOB_COMPLETED, completed, started_at) if completed
285
349
 
286
350
  [: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
351
  rescue => error
352
+ return [:completed, nil] if finish_transactional_completion(job, timing)
353
+
307
354
  job.__capture_resumable_metadata!
308
- [finish_failed(row, job, error, started_at, worker: worker), error]
355
+ outcome = case error
356
+ when JobSnoozeError
357
+ finish_snoozed(row, job, error, timing)
358
+ when JobCancelError
359
+ finish_failed(row, job, error, timing, cancelled: true)
360
+ :cancelled
361
+ when Interrupted
362
+ finish_interrupted(row, job, timing)
363
+ else
364
+ finish_failed(row, job, error, timing, worker: worker)
365
+ end
366
+ [outcome, error]
309
367
  ensure
310
368
  @mutex.synchronize do
311
369
  @running.delete(row.id)
370
+ @queue_generations[row.queue] += 1
312
371
  @condition.broadcast
313
372
  end
314
373
  end
315
374
  end
316
375
 
317
- private def finish_failed(row, job, error, started_at, cancelled: false, worker: nil)
376
+ private def finish_failed(row, job, error, timing, cancelled: false, worker: nil)
318
377
  cancelled ||= error_handler_cancel?(error, job)
319
378
  now = Time.now.utc
320
379
  attempt_error = AttemptError.new(
321
- at: started_at,
380
+ at: timing.started_at,
322
381
  attempt: row.attempt,
323
382
  error: error.message,
324
383
  trace: Array(error.backtrace).join("\n")
@@ -344,7 +403,7 @@ module River
344
403
  state: state
345
404
  )
346
405
  event = cancelled ? EVENT_JOB_CANCELLED : EVENT_JOB_FAILED
347
- publish(event, updated, started_at) if updated
406
+ publish(event, updated, timing) if updated
348
407
 
349
408
  case updated&.state || state
350
409
  when JOB_STATE_CANCELLED then :cancelled
@@ -353,10 +412,23 @@ module River
353
412
  end
354
413
  end
355
414
 
356
- private def finish_snoozed(row, job, error, started_at)
415
+ private def finish_interrupted(row, job, timing)
416
+ interrupted = @driver.job_set_state_if_running(
417
+ id: row.id,
418
+ attempt: [row.attempt - 1, 0].max,
419
+ metadata: job.metadata_updates,
420
+ scheduled_at: Time.now.utc,
421
+ state: JOB_STATE_AVAILABLE
422
+ )
423
+ publish(EVENT_JOB_INTERRUPTED, interrupted, timing) if interrupted
424
+
425
+ (interrupted&.state == JOB_STATE_CANCELLED) ? :cancelled : :interrupted
426
+ end
427
+
428
+ private def finish_snoozed(row, job, error, timing)
357
429
  scheduled_at = Time.now.utc + error.duration
358
430
  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)
431
+ metadata = job.metadata_updates.merge("snoozes" => next_snooze_count(row.metadata["snoozes"]))
360
432
  updated = @driver.job_set_state_if_running(
361
433
  id: row.id,
362
434
  attempt: [row.attempt - 1, 0].max,
@@ -364,10 +436,39 @@ module River
364
436
  scheduled_at: scheduled_at,
365
437
  state: state
366
438
  )
367
- publish(EVENT_JOB_SNOOZED, updated, started_at) if updated
439
+ publish(EVENT_JOB_SNOOZED, updated, timing) if updated
368
440
  (updated&.state == JOB_STATE_CANCELLED) ? :cancelled : :snoozed
369
441
  end
370
442
 
443
+ private def finish_transactional_completion(job, timing)
444
+ return false unless job.__completion_attempted
445
+
446
+ completed = @driver.job_get_by_id(job.row.id)
447
+ unless completed
448
+ snapshot = job.__completion_snapshot
449
+ return false unless snapshot
450
+
451
+ publish(EVENT_JOB_COMPLETED, snapshot, timing)
452
+ return true
453
+ end
454
+
455
+ # A retry can replace a committed attempt before its worker or middleware
456
+ # returns. Only a still-running original attempt may have rolled back and
457
+ # need normal finalization; never finalize or publish a replacement.
458
+ return true unless completed.attempt == job.row.attempt && completed.attempted_at == job.row.attempted_at
459
+ return false if completed.state == JOB_STATE_RUNNING
460
+ return true unless completed.state == JOB_STATE_COMPLETED
461
+
462
+ # Work middleware (including persisted logging) can add metadata after the
463
+ # worker commits. Save it without changing the committed state or timestamp.
464
+ # Guard the write too: a retry can happen after the read above.
465
+ metadata = job.metadata_updates
466
+ completed = @driver.job_metadata_merge(job.row.id, metadata, completed) || completed unless metadata.empty?
467
+
468
+ publish(EVENT_JOB_COMPLETED, completed, timing)
469
+ true
470
+ end
471
+
371
472
  private def finish_work(id)
372
473
  @mutex.synchronize do
373
474
  entry = @running[id]
@@ -383,15 +484,22 @@ module River
383
484
  return perform_work(worker, job) if @config.plugins.empty?
384
485
 
385
486
  operation = -> do
487
+ invoke_plugins(:work_begin, job)
488
+
386
489
  error = nil
387
- begin
388
- invoke_plugins(:work_begin, job)
490
+ result = begin
389
491
  perform_work(worker, job)
390
- rescue => error
391
- raise
392
- ensure
393
- invoke_plugins(:work_end, job, error)
492
+ rescue => work_error
493
+ error = work_error
494
+ nil
495
+ end
496
+
497
+ @config.plugins.each do |plugin|
498
+ error = plugin.work_end(job, error) if plugin.respond_to?(:work_end)
394
499
  end
500
+ raise error if error
501
+
502
+ result
395
503
  end
396
504
  @config.plugins.reverse_each do |plugin|
397
505
  next unless plugin.respond_to?(:work)
@@ -415,26 +523,54 @@ module River
415
523
  end
416
524
  end
417
525
 
418
- @mutex.synchronize { @running[row.id] = {queue: row.queue, thread: thread, working: false} }
526
+ @mutex.synchronize do
527
+ @running[row.id] = {cancelled: @fetching_queues.fetch(row.queue).include?(row.id), queue: row.queue, thread: thread, working: false}
528
+ end
419
529
  gate.push(true)
420
530
  end
421
531
 
422
532
  private def maintenance_loop
423
533
  leader = false
534
+ next_election = 0.0
424
535
  next_schedule = next_rescue = next_cleanup = Time.at(0)
425
536
  until stopping?
537
+ generation, resign = @mutex.synchronize { [@maintenance_generation, @resign_requested] }
538
+ if resign && leader
539
+ @driver.leader_release(@config.id)
540
+ leader = false
541
+ @mutex.synchronize do
542
+ @leader = false
543
+ @resign_requested = false
544
+ end
545
+ # Give followers a chance to acquire the term we just released.
546
+ next_election = monotonic_now + 5
547
+ end
548
+ if !leader && (delay = next_election - monotonic_now).positive?
549
+ wait_for_maintenance(delay, generation)
550
+ next
551
+ end
552
+
426
553
  now = Time.now.utc
427
554
  leader = leader ? @driver.leader_renew(@config.id, now: now) : @driver.leader_acquire(@config.id, now: now)
555
+ @mutex.synchronize do
556
+ @leader = leader
557
+ @resign_requested = false unless leader
558
+ end
428
559
  if leader
429
560
  if now >= next_schedule
430
561
  @driver.job_schedule(now: now)
431
562
  run_periodic(now)
432
- @config.maintenance_services.each { |service| service.run(@client, @driver, now) }
563
+ @config.maintenance_services.each do |service|
564
+ service.run(@client, @driver, now)
565
+ rescue => error
566
+ @config.logger.error("River maintenance service failed (#{service.class}): #{error.full_message}")
567
+ end
433
568
  next_schedule = now + 5
434
569
  end
435
570
 
436
571
  if now >= next_rescue
437
- @driver.job_rescue_stuck(horizon: now - 3_600, now: now, retry_policy: @config.retry_policy)
572
+ @driver.job_rescue_stuck(horizon: now - 3_600, logger: @config.logger, now: now,
573
+ rescue_if: method(:rescue_job?), retry_policy: @config.retry_policy)
438
574
  next_rescue = now + 30
439
575
  end
440
576
 
@@ -449,7 +585,7 @@ module River
449
585
  end
450
586
  end
451
587
 
452
- wait(5)
588
+ wait_for_maintenance(5, generation)
453
589
  end
454
590
  rescue => error
455
591
  @config.logger.error("River maintenance stopped: #{error.full_message}")
@@ -474,17 +610,82 @@ module River
474
610
  DefaultClientRetryPolicy.new.next_retry(row, error, now: now)
475
611
  end
476
612
 
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?)
613
+ # Preserve Ruby's lenient counter conversion without calling to_i on
614
+ # booleans/collections, or accepting only a prefix of a numeric string.
615
+ # Only canonical non-negative integers are part of the shared protocol.
616
+ private def next_snooze_count(value)
617
+ count = case value
618
+ when Integer then value
619
+ when Float then value.finite? ? value.to_i : 0
620
+ when String then /\A-?[0-9]+\z/.match?(value) ? value.to_i : 0
621
+ when true then 1
622
+ else 0
623
+ end
624
+ # Go stores and increments a signed 64-bit integer.
625
+ ((count + 1 + (1 << 63)) % (1 << 64)) - (1 << 63)
626
+ end
627
+
628
+ private def notification_loop(ready)
629
+ listener = nil #: untyped
630
+ retry_delay = 0.1
631
+ begin
632
+ until notifications_stopping?
633
+ begin
634
+ unless listener
635
+ listener = @driver.notification_listener
636
+ @mutex.synchronize { @notifications_disabled = listener.nil? }
637
+ ready << true if ready
638
+ ready = nil
639
+ return unless listener
640
+
641
+ # Poll durable state after reconnecting: Postgres messages sent
642
+ # while disconnected cannot be replayed.
643
+ wake
644
+ wake_maintenance
645
+ end
646
+ listener.poll(0.1).each { |topic, payload| receive_notification(topic, payload) }
647
+ retry_delay = 0.1
648
+ rescue => error
649
+ if ready
650
+ ready << error
651
+ ready = nil
652
+ return
653
+ end
654
+ @config.logger.error("River notification receiver failed: #{error.full_message}")
655
+ # SQLite's cursor survives temporary read errors. Reopening it would
656
+ # skip requests committed while the database was unavailable.
657
+ unless listener.is_a?(Driver::NotificationListener::SQLite)
658
+ listener&.close
659
+ listener = nil
660
+ end
661
+ sleep(retry_delay) unless notifications_stopping?
662
+ retry_delay = [retry_delay * 2, 1.0].min
663
+ end
664
+ end
665
+ ensure
666
+ ready << true if ready
667
+ listener&.close
668
+ end
669
+ end
670
+
671
+ private def notifications_stopping?
672
+ @mutex.synchronize do
673
+ @stop_requested && @running.empty? && @producer_threads.values.none?(&:alive?)
674
+ end
675
+ end
482
676
 
677
+ private def perform_work(worker, job)
678
+ timeout = worker_timeout(worker, job)
483
679
  result = timeout ? Timeout.timeout(timeout) { worker.work(job) } : worker.work(job)
484
680
  job.__finish_resumable_work!
485
681
  result
486
682
  end
487
683
 
684
+ private def periodic_jobs_changed
685
+ start_maintenance
686
+ wake_maintenance
687
+ end
688
+
488
689
  private def producer_loop(queue, queue_config)
489
690
  cooldown = queue_config.resolved_fetch_cooldown(@config)
490
691
  # Capture registrations (including aliases) at startup, not construction.
@@ -493,9 +694,16 @@ module River
493
694
  last_fetch = 0.0
494
695
  poll_interval = queue_config.resolved_fetch_poll_interval(@config)
495
696
  loop do
496
- break if queue_stopping?(queue)
697
+ generation = @mutex.synchronize { @queue_generations[queue] }
698
+ draining = queue_stopping?(queue)
699
+ break if draining && running_count(queue).zero?
497
700
 
498
701
  check_remote_cancellations(queue)
702
+ if draining
703
+ wait_for_running_jobs(queue, poll_interval)
704
+ next
705
+ end
706
+
499
707
  capacity = queue_config.max_workers - running_count(queue)
500
708
  if capacity.positive?
501
709
  # Inserts, completions, and spurious condition wakes must not bypass
@@ -503,34 +711,48 @@ module River
503
711
  while (sleep_for = cooldown - (monotonic_now - last_fetch)).positive? && !queue_stopping?(queue)
504
712
  wait(sleep_for)
505
713
  end
506
- break if queue_stopping?(queue)
714
+ next if queue_stopping?(queue)
715
+ end
507
716
 
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
717
+ # Refresh controls even when every worker slot is occupied. A pause
718
+ # can also arrive during cooldown, so read immediately before fetching.
719
+ queue_state = @driver.queue_get(queue)
720
+ publish_queue(queue_state.paused_at ? EVENT_QUEUE_PAUSED : EVENT_QUEUE_RESUMED, queue_state) if queue_state
721
+ if capacity.positive? && !queue_state&.paused_at
722
+ @mutex.synchronize { @fetching_queues[queue] = [] }
723
+ begin
511
724
  jobs = @driver.job_get_available(attempted_by: @config.id, max: capacity, queue: queue, **fetch_options)
512
725
  last_fetch = monotonic_now
513
726
  jobs.each { |job| launch(job) }
514
727
  next unless jobs.empty?
728
+ ensure
729
+ @mutex.synchronize { @fetching_queues.delete(queue) }
515
730
  end
516
731
  end
517
732
 
518
- wait(poll_interval)
733
+ wait_for_queue(queue, poll_interval, generation)
519
734
  end
520
735
  rescue => error
521
736
  @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)
737
+ if queue_stopping?(queue)
738
+ wait_for_running_jobs(queue, poll_interval || @config.fetch_poll_interval)
739
+ else
740
+ wait(poll_interval || @config.fetch_poll_interval)
741
+ end
742
+ retry unless queue_stopping?(queue) && running_count(queue).zero?
524
743
  end
525
744
 
526
- private def publish(kind, job, started_at)
745
+ private def publish(kind, job, timing)
527
746
  # Another actor may have changed the row after our update. Pending,
528
747
  # running, and unknown states do not represent a settled attempt.
529
748
  return unless %w[available cancelled completed discarded retryable scheduled].include?(job.state)
530
749
 
531
750
  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)
751
+ stats = JobStatistics.new(
752
+ monotonic_now - timing.finished_monotonic,
753
+ timing.queue_wait_duration,
754
+ timing.finished_monotonic - timing.started_monotonic
755
+ )
534
756
  event = Event.new(kind, job, nil, stats)
535
757
  @mutex.synchronize { @subscriptions.dup }.each { |subscription| subscription.publish(event) }
536
758
  end
@@ -539,10 +761,69 @@ module River
539
761
  @mutex.synchronize { @stop_requested || @removed_queues[queue] }
540
762
  end
541
763
 
764
+ private def receive_notification(topic, payload)
765
+ message = JSON.parse(payload)
766
+ return unless message.is_a?(Hash)
767
+
768
+ case topic
769
+ when "river_insert"
770
+ wake_queue(message["queue"]) if message["queue"].is_a?(String)
771
+ when "river_control"
772
+ queue = message["queue"]
773
+ return unless queue.is_a?(String)
774
+
775
+ case message["action"]
776
+ when "cancel"
777
+ return unless message["job_id"].is_a?(Integer)
778
+
779
+ @mutex.synchronize do
780
+ entry = @running[message["job_id"]]
781
+ if entry && entry[:queue] == queue
782
+ entry[:cancelled] = true
783
+ entry[:thread].raise(JobCancelError) if entry[:working]
784
+ elsif (pending = @fetching_queues[queue])
785
+ # A claim may have committed before its workers are registered.
786
+ pending << message["job_id"]
787
+ end
788
+ end
789
+ when "metadata_changed", "pause", "resume"
790
+ wake_queue(queue)
791
+ end
792
+ when "river_leadership"
793
+ case message["action"]
794
+ when "request_resign"
795
+ @mutex.synchronize do
796
+ if @leader
797
+ @resign_requested = true
798
+ @maintenance_generation += 1
799
+ @maintenance_condition.broadcast
800
+ end
801
+ end
802
+ when "resigned"
803
+ wake_maintenance if message["leader_id"].is_a?(String) && message["leader_id"] != @config.id
804
+ end
805
+ end
806
+ rescue JSON::ParserError, TypeError => error
807
+ @config.logger.warn("River ignored invalid notification: #{error.message}")
808
+ end
809
+
542
810
  private def remove_subscription(subscription)
543
811
  @mutex.synchronize { @subscriptions.delete(subscription) }
544
812
  end
545
813
 
814
+ private def rescue_job?(row, now)
815
+ return true if row.__decode_error
816
+
817
+ worker = @config.workers[row.kind]
818
+ worker = worker.new if worker.is_a?(Class)
819
+ timeout = worker_timeout(worker, Job.new(@client, row))
820
+ attempted_at = row.attempted_at #: Time
821
+ !timeout.nil? && now - attempted_at >= timeout
822
+ rescue => error
823
+ @config.logger.error("River rescue timeout check failed; rescuing attempt: #{error.full_message}")
824
+ true
825
+ end
826
+
546
827
  private def resolve_worker(kind)
547
828
  worker = @config.workers[kind]
548
829
  raise UnknownJobKindError, kind unless worker
@@ -557,15 +838,18 @@ module River
557
838
  end
558
839
 
559
840
  private def run_periodic(now)
560
- due = @periodic_jobs.due(now) do |job, error|
841
+ due = @periodic_jobs.__due_with_times(now) do |job, error|
561
842
  @config.logger.error("River periodic job schedule failed (id=#{job.id.inspect}): #{error.full_message}")
562
843
  end
563
- due.each do |periodic_job|
844
+ due.each do |periodic_job, scheduled_at|
564
845
  value = periodic_job.constructor.call
565
846
  next unless value
566
847
 
567
848
  args, opts = value.is_a?(Array) ? value : [value, nil]
568
- @client.insert(args, insert_opts: opts || InsertOpts.new)
849
+ opts = (opts || InsertOpts.new).dup
850
+ opts.metadata = (opts.metadata || {}).merge("periodic" => true)
851
+ opts.metadata["river:periodic_job_id"] = periodic_job.id if periodic_job.id && !periodic_job.id.empty?
852
+ @client.__insert_with_default_scheduled_at(args, opts, scheduled_at)
569
853
  rescue => error
570
854
  @config.logger.error("River periodic job failed to insert: #{error.full_message}")
571
855
  end
@@ -579,7 +863,7 @@ module River
579
863
  return if @config.leader_election_disabled
580
864
 
581
865
  @mutex.synchronize do
582
- return if @stop_requested
866
+ return unless @started && !@stop_requested
583
867
  return if @maintenance_thread&.alive?
584
868
 
585
869
  thread = Thread.new { maintenance_loop }
@@ -588,11 +872,28 @@ module River
588
872
  end
589
873
  end
590
874
 
875
+ private def start_notifications
876
+ return if @config.poll_only || !@driver.respond_to?(:notification_listener)
877
+
878
+ ready = ::Queue.new
879
+ @mutex.synchronize do
880
+ return if @stop_requested
881
+
882
+ thread = Thread.new { notification_loop(ready) }
883
+ @notification_thread = thread
884
+ @threads << thread
885
+ end
886
+ result = ready.pop
887
+ raise result if result.is_a?(Exception)
888
+ end
889
+
591
890
  private def start_producer(name, queue_config)
592
- @driver.queue_upsert(name)
891
+ queue = @driver.queue_upsert(name)
593
892
  @mutex.synchronize do
594
893
  return if @stop_requested || @removed_queues[name] || @producer_threads.key?(name)
595
894
 
895
+ @queue_paused[name] = !queue.paused_at.nil? if queue
896
+
596
897
  # Shutdown must see every launched thread, including a producer whose
597
898
  # database setup overlapped a stop or queue removal.
598
899
  thread = Thread.new { producer_loop(name, queue_config) }
@@ -608,5 +909,51 @@ module River
608
909
  private def wait(duration)
609
910
  @mutex.synchronize { @condition.wait(@mutex, duration) unless @stop_requested }
610
911
  end
912
+
913
+ private def wait_for_maintenance(duration, generation)
914
+ @mutex.synchronize do
915
+ @maintenance_condition.wait(@mutex, duration) if !@stop_requested && @maintenance_generation == generation
916
+ end
917
+ end
918
+
919
+ private def wait_for_queue(queue, duration, generation)
920
+ deadline = monotonic_now + duration
921
+ @mutex.synchronize do
922
+ while !@stop_requested && !@removed_queues[queue] && @queue_generations[queue] == generation && (remaining = deadline - monotonic_now).positive?
923
+ @condition.wait(@mutex, remaining)
924
+ end
925
+ end
926
+ end
927
+
928
+ private def wait_for_running_jobs(queue, duration)
929
+ @mutex.synchronize do
930
+ # Draining still needs a poll interval after stop is requested. Check
931
+ # under the same mutex as completion so the last job's wake isn't lost.
932
+ @condition.wait(@mutex, duration) if @running.any? { |_id, entry| entry[:queue] == queue }
933
+ end
934
+ end
935
+
936
+ private def wake_maintenance
937
+ @mutex.synchronize do
938
+ @maintenance_generation += 1
939
+ @maintenance_condition.broadcast
940
+ end
941
+ end
942
+
943
+ private def wake_queue(queue)
944
+ @mutex.synchronize do
945
+ @queue_configs.each_key { |name| @queue_generations[name] += 1 if queue == "*" || queue == name }
946
+ @condition.broadcast
947
+ end
948
+ end
949
+
950
+ private def worker_timeout(worker, job)
951
+ timeout = worker.respond_to?(:timeout) ? worker.timeout(job) : @config.job_timeout
952
+ timeout = Float(timeout) unless timeout.nil?
953
+ timeout = @config.job_timeout if timeout == 0
954
+ raise ArgumentError, "worker timeout must be finite and nonnegative, or nil" if timeout && (!timeout.finite? || timeout.negative?)
955
+
956
+ timeout
957
+ end
611
958
  end
612
959
  end