riverqueue 0.13.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -11
- data/README.md +83 -14
- data/docs/README.md +160 -30
- data/docs/conformance.md +11 -11
- data/docs/migrations.md +16 -16
- data/docs/testing.md +1 -1
- data/lib/client.rb +61 -6
- data/lib/client_runtime.rb +273 -35
- data/lib/config.rb +61 -9
- data/lib/driver/job_row_decoder.rb +2 -2
- data/lib/driver/notification_listener.rb +61 -0
- data/lib/driver/postgres_capabilities.rb +1 -1
- data/lib/driver/runtime.rb +214 -52
- data/lib/driver.rb +1 -0
- data/lib/insert_opts.rb +1 -1
- data/lib/job.rb +4 -4
- data/lib/job_persisted_logging/plugin.rb +3 -2
- data/lib/migrator.rb +1 -1
- data/lib/periodic_cron.rb +5 -4
- data/lib/periodic_job.rb +14 -3
- data/lib/resumable.rb +24 -1
- data/lib/worker.rb +32 -2
- data/migration/manifest.json +5 -5
- data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +1 -1
- data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +1 -1
- data/migration/postgresql/main/008_job_id_autoincrement.down.sql +1 -1
- data/migration/postgresql/main/008_job_id_autoincrement.up.sql +1 -1
- data/migration/sqlite/main/008_job_id_autoincrement.up.sql +1 -1
- data/sig/client.rbs +4 -1
- data/sig/driver.rbs +40 -5
- data/sig/runtime.rbs +52 -4
- metadata +4 -3
data/lib/client_runtime.rb
CHANGED
|
@@ -28,10 +28,18 @@ module River
|
|
|
28
28
|
@condition = ConditionVariable.new
|
|
29
29
|
@config = config
|
|
30
30
|
@driver = driver
|
|
31
|
+
@fetching_queues = {}
|
|
32
|
+
@leader = false
|
|
33
|
+
@maintenance_condition = ConditionVariable.new
|
|
34
|
+
@maintenance_generation = 0
|
|
31
35
|
@mutex = Mutex.new
|
|
36
|
+
@notifications_disabled = false
|
|
32
37
|
@producer_threads = {}
|
|
33
38
|
@queue_configs = config.queues.dup
|
|
39
|
+
@queue_generations = Hash.new(0)
|
|
40
|
+
@queue_paused = {}
|
|
34
41
|
@removed_queues = {}
|
|
42
|
+
@resign_requested = false
|
|
35
43
|
@running = {}
|
|
36
44
|
@started = false
|
|
37
45
|
@stopped = true
|
|
@@ -98,12 +106,21 @@ module River
|
|
|
98
106
|
|
|
99
107
|
def publish_queue(kind, queue)
|
|
100
108
|
event = Event.new(kind, nil, queue, nil)
|
|
101
|
-
@mutex.synchronize
|
|
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) }
|
|
102
117
|
end
|
|
103
118
|
|
|
104
119
|
def healthy?
|
|
105
120
|
@mutex.synchronize do
|
|
106
|
-
@stop_requested || (@producer_threads.all? { |name, thread| @removed_queues[name] || 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?))
|
|
107
124
|
end
|
|
108
125
|
end
|
|
109
126
|
|
|
@@ -183,6 +200,7 @@ module River
|
|
|
183
200
|
end
|
|
184
201
|
|
|
185
202
|
begin
|
|
203
|
+
start_notifications
|
|
186
204
|
queues = @mutex.synchronize { @queue_configs.dup }
|
|
187
205
|
queues.each { |name, queue_config| start_producer(name, queue_config) }
|
|
188
206
|
start_maintenance unless @queue_configs.empty? && @periodic_jobs.empty? && @config.maintenance_services.empty?
|
|
@@ -207,6 +225,7 @@ module River
|
|
|
207
225
|
|
|
208
226
|
@stop_requested = true
|
|
209
227
|
@condition.broadcast
|
|
228
|
+
@maintenance_condition.broadcast
|
|
210
229
|
@running.values.each { |entry| entry[:thread].raise(Interrupted) if cancel && entry[:working] }
|
|
211
230
|
@threads.dup
|
|
212
231
|
end
|
|
@@ -225,6 +244,9 @@ module River
|
|
|
225
244
|
@threads.clear
|
|
226
245
|
@producer_threads.clear
|
|
227
246
|
@maintenance_thread = nil
|
|
247
|
+
@notification_thread = nil
|
|
248
|
+
@leader = false
|
|
249
|
+
@resign_requested = false
|
|
228
250
|
end
|
|
229
251
|
|
|
230
252
|
self
|
|
@@ -245,21 +267,23 @@ module River
|
|
|
245
267
|
end
|
|
246
268
|
|
|
247
269
|
def wake
|
|
248
|
-
|
|
270
|
+
wake_queue("*")
|
|
249
271
|
end
|
|
250
272
|
|
|
251
273
|
private def begin_work(id)
|
|
252
|
-
|
|
274
|
+
interruption = @mutex.synchronize do
|
|
253
275
|
entry = @running.fetch(id)
|
|
254
276
|
if @stop_requested
|
|
255
|
-
|
|
277
|
+
Interrupted
|
|
278
|
+
elsif entry[:cancelled]
|
|
279
|
+
JobCancelError
|
|
256
280
|
else
|
|
257
281
|
entry[:working] = true
|
|
258
|
-
|
|
282
|
+
nil
|
|
259
283
|
end
|
|
260
284
|
end
|
|
261
285
|
|
|
262
|
-
raise
|
|
286
|
+
raise interruption if interruption
|
|
263
287
|
end
|
|
264
288
|
|
|
265
289
|
private def check_remote_cancellations(queue)
|
|
@@ -303,6 +327,8 @@ module River
|
|
|
303
327
|
finish_work(row.id)
|
|
304
328
|
end
|
|
305
329
|
|
|
330
|
+
return [:completed, nil] if finish_transactional_completion(job, timing)
|
|
331
|
+
|
|
306
332
|
finalize_hooks = @config.plugins.any? { |plugin| plugin.respond_to?(:job_finalize) }
|
|
307
333
|
if finalize_hooks
|
|
308
334
|
raise JobCancelError if @driver.job_get_cancelled_ids([row.id]).include?(row.id)
|
|
@@ -322,22 +348,26 @@ module River
|
|
|
322
348
|
end
|
|
323
349
|
|
|
324
350
|
[: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
351
|
rescue => error
|
|
352
|
+
return [:completed, nil] if finish_transactional_completion(job, timing)
|
|
353
|
+
|
|
336
354
|
job.__capture_resumable_metadata!
|
|
337
|
-
|
|
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]
|
|
338
367
|
ensure
|
|
339
368
|
@mutex.synchronize do
|
|
340
369
|
@running.delete(row.id)
|
|
370
|
+
@queue_generations[row.queue] += 1
|
|
341
371
|
@condition.broadcast
|
|
342
372
|
end
|
|
343
373
|
end
|
|
@@ -410,6 +440,35 @@ module River
|
|
|
410
440
|
(updated&.state == JOB_STATE_CANCELLED) ? :cancelled : :snoozed
|
|
411
441
|
end
|
|
412
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
|
+
|
|
413
472
|
private def finish_work(id)
|
|
414
473
|
@mutex.synchronize do
|
|
415
474
|
entry = @running[id]
|
|
@@ -425,15 +484,22 @@ module River
|
|
|
425
484
|
return perform_work(worker, job) if @config.plugins.empty?
|
|
426
485
|
|
|
427
486
|
operation = -> do
|
|
487
|
+
invoke_plugins(:work_begin, job)
|
|
488
|
+
|
|
428
489
|
error = nil
|
|
429
|
-
begin
|
|
430
|
-
invoke_plugins(:work_begin, job)
|
|
490
|
+
result = begin
|
|
431
491
|
perform_work(worker, job)
|
|
432
|
-
rescue =>
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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)
|
|
436
499
|
end
|
|
500
|
+
raise error if error
|
|
501
|
+
|
|
502
|
+
result
|
|
437
503
|
end
|
|
438
504
|
@config.plugins.reverse_each do |plugin|
|
|
439
505
|
next unless plugin.respond_to?(:work)
|
|
@@ -457,16 +523,39 @@ module River
|
|
|
457
523
|
end
|
|
458
524
|
end
|
|
459
525
|
|
|
460
|
-
@mutex.synchronize
|
|
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
|
|
461
529
|
gate.push(true)
|
|
462
530
|
end
|
|
463
531
|
|
|
464
532
|
private def maintenance_loop
|
|
465
533
|
leader = false
|
|
534
|
+
next_election = 0.0
|
|
466
535
|
next_schedule = next_rescue = next_cleanup = Time.at(0)
|
|
467
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
|
+
|
|
468
553
|
now = Time.now.utc
|
|
469
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
|
|
470
559
|
if leader
|
|
471
560
|
if now >= next_schedule
|
|
472
561
|
@driver.job_schedule(now: now)
|
|
@@ -496,7 +585,7 @@ module River
|
|
|
496
585
|
end
|
|
497
586
|
end
|
|
498
587
|
|
|
499
|
-
|
|
588
|
+
wait_for_maintenance(5, generation)
|
|
500
589
|
end
|
|
501
590
|
rescue => error
|
|
502
591
|
@config.logger.error("River maintenance stopped: #{error.full_message}")
|
|
@@ -536,6 +625,55 @@ module River
|
|
|
536
625
|
((count + 1 + (1 << 63)) % (1 << 64)) - (1 << 63)
|
|
537
626
|
end
|
|
538
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
|
|
676
|
+
|
|
539
677
|
private def perform_work(worker, job)
|
|
540
678
|
timeout = worker_timeout(worker, job)
|
|
541
679
|
result = timeout ? Timeout.timeout(timeout) { worker.work(job) } : worker.work(job)
|
|
@@ -545,7 +683,7 @@ module River
|
|
|
545
683
|
|
|
546
684
|
private def periodic_jobs_changed
|
|
547
685
|
start_maintenance
|
|
548
|
-
|
|
686
|
+
wake_maintenance
|
|
549
687
|
end
|
|
550
688
|
|
|
551
689
|
private def producer_loop(queue, queue_config)
|
|
@@ -556,6 +694,7 @@ module River
|
|
|
556
694
|
last_fetch = 0.0
|
|
557
695
|
poll_interval = queue_config.resolved_fetch_poll_interval(@config)
|
|
558
696
|
loop do
|
|
697
|
+
generation = @mutex.synchronize { @queue_generations[queue] }
|
|
559
698
|
draining = queue_stopping?(queue)
|
|
560
699
|
break if draining && running_count(queue).zero?
|
|
561
700
|
|
|
@@ -573,18 +712,25 @@ module River
|
|
|
573
712
|
wait(sleep_for)
|
|
574
713
|
end
|
|
575
714
|
next if queue_stopping?(queue)
|
|
715
|
+
end
|
|
576
716
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
|
580
724
|
jobs = @driver.job_get_available(attempted_by: @config.id, max: capacity, queue: queue, **fetch_options)
|
|
581
725
|
last_fetch = monotonic_now
|
|
582
726
|
jobs.each { |job| launch(job) }
|
|
583
727
|
next unless jobs.empty?
|
|
728
|
+
ensure
|
|
729
|
+
@mutex.synchronize { @fetching_queues.delete(queue) }
|
|
584
730
|
end
|
|
585
731
|
end
|
|
586
732
|
|
|
587
|
-
|
|
733
|
+
wait_for_queue(queue, poll_interval, generation)
|
|
588
734
|
end
|
|
589
735
|
rescue => error
|
|
590
736
|
@config.logger.error("River producer for #{queue.inspect} stopped: #{error.full_message}")
|
|
@@ -615,6 +761,52 @@ module River
|
|
|
615
761
|
@mutex.synchronize { @stop_requested || @removed_queues[queue] }
|
|
616
762
|
end
|
|
617
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
|
+
|
|
618
810
|
private def remove_subscription(subscription)
|
|
619
811
|
@mutex.synchronize { @subscriptions.delete(subscription) }
|
|
620
812
|
end
|
|
@@ -646,10 +838,10 @@ module River
|
|
|
646
838
|
end
|
|
647
839
|
|
|
648
840
|
private def run_periodic(now)
|
|
649
|
-
due = @periodic_jobs.
|
|
841
|
+
due = @periodic_jobs.__due_with_times(now) do |job, error|
|
|
650
842
|
@config.logger.error("River periodic job schedule failed (id=#{job.id.inspect}): #{error.full_message}")
|
|
651
843
|
end
|
|
652
|
-
due.each do |periodic_job|
|
|
844
|
+
due.each do |periodic_job, scheduled_at|
|
|
653
845
|
value = periodic_job.constructor.call
|
|
654
846
|
next unless value
|
|
655
847
|
|
|
@@ -657,7 +849,7 @@ module River
|
|
|
657
849
|
opts = (opts || InsertOpts.new).dup
|
|
658
850
|
opts.metadata = (opts.metadata || {}).merge("periodic" => true)
|
|
659
851
|
opts.metadata["river:periodic_job_id"] = periodic_job.id if periodic_job.id && !periodic_job.id.empty?
|
|
660
|
-
@client.
|
|
852
|
+
@client.__insert_with_default_scheduled_at(args, opts, scheduled_at)
|
|
661
853
|
rescue => error
|
|
662
854
|
@config.logger.error("River periodic job failed to insert: #{error.full_message}")
|
|
663
855
|
end
|
|
@@ -680,11 +872,28 @@ module River
|
|
|
680
872
|
end
|
|
681
873
|
end
|
|
682
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
|
+
|
|
683
890
|
private def start_producer(name, queue_config)
|
|
684
|
-
@driver.queue_upsert(name)
|
|
891
|
+
queue = @driver.queue_upsert(name)
|
|
685
892
|
@mutex.synchronize do
|
|
686
893
|
return if @stop_requested || @removed_queues[name] || @producer_threads.key?(name)
|
|
687
894
|
|
|
895
|
+
@queue_paused[name] = !queue.paused_at.nil? if queue
|
|
896
|
+
|
|
688
897
|
# Shutdown must see every launched thread, including a producer whose
|
|
689
898
|
# database setup overlapped a stop or queue removal.
|
|
690
899
|
thread = Thread.new { producer_loop(name, queue_config) }
|
|
@@ -701,6 +910,21 @@ module River
|
|
|
701
910
|
@mutex.synchronize { @condition.wait(@mutex, duration) unless @stop_requested }
|
|
702
911
|
end
|
|
703
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
|
+
|
|
704
928
|
private def wait_for_running_jobs(queue, duration)
|
|
705
929
|
@mutex.synchronize do
|
|
706
930
|
# Draining still needs a poll interval after stop is requested. Check
|
|
@@ -709,6 +933,20 @@ module River
|
|
|
709
933
|
end
|
|
710
934
|
end
|
|
711
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
|
+
|
|
712
950
|
private def worker_timeout(worker, job)
|
|
713
951
|
timeout = worker.respond_to?(:timeout) ? worker.timeout(job) : @config.job_timeout
|
|
714
952
|
timeout = Float(timeout) unless timeout.nil?
|
data/lib/config.rb
CHANGED
|
@@ -40,22 +40,52 @@ module River
|
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
# Lazily generates a client ID once per process. The process ID check
|
|
44
|
+
# regenerates it in forked children without needing a fork hook.
|
|
45
|
+
class DefaultClientID # :nodoc:
|
|
46
|
+
HOSTNAME_MAX_LENGTH = 80
|
|
47
|
+
|
|
48
|
+
def initialize
|
|
49
|
+
@mutex = Mutex.new
|
|
50
|
+
@state = nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def value
|
|
54
|
+
pid = Process.pid
|
|
55
|
+
@state&.[](pid) || @mutex.synchronize { @state&.[](pid) || generate(pid) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The PID and ID are swapped in as one frozen hash so a lock-free reader
|
|
59
|
+
# never pairs one process's PID with another's ID.
|
|
60
|
+
private def generate(pid)
|
|
61
|
+
id = "#{Socket.gethostname[0, HOSTNAME_MAX_LENGTH]}-#{pid}-#{SecureRandom.hex(6)}".freeze
|
|
62
|
+
@state = {pid => id}.freeze
|
|
63
|
+
id
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
private_constant :DefaultClientID
|
|
67
|
+
|
|
43
68
|
# Complete configuration for a River client and its worker runtime.
|
|
44
69
|
class Config
|
|
45
70
|
# Configured values used by Client and its runtime.
|
|
46
71
|
attr_reader :cancelled_job_retention_period, :completed_job_retention_period,
|
|
47
72
|
:discarded_job_retention_period, :error_handler, :fetch_cooldown,
|
|
48
|
-
:fetch_only_known_kinds, :fetch_poll_interval, :
|
|
73
|
+
:fetch_only_known_kinds, :fetch_poll_interval, :job_timeout,
|
|
49
74
|
:leader_election_disabled, :logger, :maintenance_services,
|
|
50
|
-
:periodic_jobs, :plugins, :queues, :retry_policy, :workers
|
|
75
|
+
:periodic_jobs, :plugins, :poll_only, :queues, :retry_policy, :workers
|
|
51
76
|
|
|
52
77
|
# Creates a client configuration.
|
|
53
78
|
#
|
|
54
79
|
# Configure queues and workers here to enable job processing; a client with
|
|
55
80
|
# neither may still be used for insertion and administrative operations.
|
|
56
|
-
# +
|
|
57
|
-
#
|
|
58
|
-
#
|
|
81
|
+
# An explicit +id+ is used exactly as given and must be unique among all
|
|
82
|
+
# running clients. Without one, each process using this config generates
|
|
83
|
+
# its own ID; see #id. +fetch_only_known_kinds+ leaves unregistered kinds
|
|
84
|
+
# untouched in shared queues. +leader_election_disabled+ prevents this
|
|
85
|
+
# client's maintenance; another eligible client must run scheduling,
|
|
86
|
+
# rescue, and cleanup.
|
|
87
|
+
# +poll_only+ disables notification receiving, including leadership
|
|
88
|
+
# resignation requests. Job and queue state are still polled.
|
|
59
89
|
def initialize(
|
|
60
90
|
queues: {},
|
|
61
91
|
workers: Workers.new,
|
|
@@ -70,6 +100,7 @@ module River
|
|
|
70
100
|
plugins: [],
|
|
71
101
|
maintenance_services: [],
|
|
72
102
|
periodic_jobs: [],
|
|
103
|
+
poll_only: false,
|
|
73
104
|
cancelled_job_retention_period: 86_400,
|
|
74
105
|
completed_job_retention_period: 86_400,
|
|
75
106
|
discarded_job_retention_period: 604_800,
|
|
@@ -82,13 +113,15 @@ module River
|
|
|
82
113
|
@fetch_cooldown = Float(fetch_cooldown)
|
|
83
114
|
@fetch_only_known_kinds = fetch_only_known_kinds
|
|
84
115
|
@fetch_poll_interval = Float(fetch_poll_interval)
|
|
85
|
-
@
|
|
116
|
+
@default_id = DefaultClientID.new
|
|
117
|
+
@id = id
|
|
86
118
|
@job_timeout = job_timeout.nil? ? nil : Float(job_timeout)
|
|
87
119
|
@leader_election_disabled = leader_election_disabled
|
|
88
120
|
@logger = logger || Logger.new($stdout, level: Logger::WARN)
|
|
89
121
|
@maintenance_services = maintenance_services.dup.freeze
|
|
90
122
|
@periodic_jobs = periodic_jobs.dup.freeze
|
|
91
123
|
@plugins = plugins.dup.freeze
|
|
124
|
+
@poll_only = poll_only
|
|
92
125
|
@queues = normalize_queues(queues).freeze
|
|
93
126
|
@retry_policy = retry_policy
|
|
94
127
|
@workers = workers
|
|
@@ -96,10 +129,22 @@ module River
|
|
|
96
129
|
validate
|
|
97
130
|
end
|
|
98
131
|
|
|
132
|
+
# Returns the client ID recorded in +attempted_by+ and used for leadership.
|
|
133
|
+
#
|
|
134
|
+
# An explicitly configured ID is returned unchanged. Otherwise, the ID is
|
|
135
|
+
# generated on first use in each process and stays stable for the life of
|
|
136
|
+
# that process, so clients built from a config before a fork (for example,
|
|
137
|
+
# by a preloading Puma, Unicorn, or Pitchfork server) get distinct IDs in
|
|
138
|
+
# each forked child instead of sharing the parent's.
|
|
139
|
+
def id
|
|
140
|
+
@id || @default_id.value
|
|
141
|
+
end
|
|
142
|
+
|
|
99
143
|
# Returns a new Config with the supplied values replacing this config's
|
|
100
|
-
# corresponding settings.
|
|
144
|
+
# corresponding settings. Without an +id+ override, the copy shares this
|
|
145
|
+
# config's ID, including its per-process generated default.
|
|
101
146
|
def with(**overrides)
|
|
102
|
-
Config.new(id: id,
|
|
147
|
+
copy = Config.new(id: @id,
|
|
103
148
|
cancelled_job_retention_period: cancelled_job_retention_period,
|
|
104
149
|
completed_job_retention_period: completed_job_retention_period,
|
|
105
150
|
discarded_job_retention_period: discarded_job_retention_period,
|
|
@@ -113,9 +158,16 @@ module River
|
|
|
113
158
|
maintenance_services: maintenance_services,
|
|
114
159
|
periodic_jobs: periodic_jobs,
|
|
115
160
|
plugins: plugins,
|
|
161
|
+
poll_only: poll_only,
|
|
116
162
|
queues: queues,
|
|
117
163
|
retry_policy: retry_policy,
|
|
118
164
|
workers: workers, **overrides)
|
|
165
|
+
copy.adopt_default_id(@default_id) unless overrides.key?(:id)
|
|
166
|
+
copy
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
protected def adopt_default_id(default_id)
|
|
170
|
+
@default_id = default_id
|
|
119
171
|
end
|
|
120
172
|
|
|
121
173
|
private def normalize_queues(queues)
|
|
@@ -136,7 +188,7 @@ module River
|
|
|
136
188
|
|
|
137
189
|
private def validate
|
|
138
190
|
raise ArgumentError, "fetch intervals and job_timeout must be finite" unless [fetch_cooldown, fetch_poll_interval, job_timeout].compact.all?(&:finite?)
|
|
139
|
-
raise ArgumentError, "id must be between 1 and 127 characters"
|
|
191
|
+
raise ArgumentError, "id must be between 1 and 127 characters" if (explicit_id = @id) && !(1...128).cover?(explicit_id.length)
|
|
140
192
|
raise ArgumentError, "fetch_cooldown must be at least 0.001 seconds" if fetch_cooldown < 0.001
|
|
141
193
|
raise ArgumentError, "fetch_poll_interval cannot be less than fetch_cooldown" if fetch_poll_interval < fetch_cooldown
|
|
142
194
|
raise ArgumentError, "job_timeout must be greater than zero or nil" if job_timeout && job_timeout <= 0
|
|
@@ -23,7 +23,7 @@ module River::Driver
|
|
|
23
23
|
def self.error_time(value)
|
|
24
24
|
return ZERO_TIME unless value.is_a?(String)
|
|
25
25
|
|
|
26
|
-
# Keep Ruby's existing acceptance of
|
|
26
|
+
# Keep Ruby's existing acceptance of Postgres-style timestamps too.
|
|
27
27
|
Time.parse(value).utc
|
|
28
28
|
rescue ArgumentError
|
|
29
29
|
ZERO_TIME
|
|
@@ -63,7 +63,7 @@ module River::Driver
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def decoded(field, value, type: nil, strings: false, default: nil)
|
|
66
|
-
# Sequel's
|
|
66
|
+
# Sequel's Postgres JSON and array wrappers delegate to Ruby values.
|
|
67
67
|
value = value.__getobj__ if value.respond_to?(:__getobj__)
|
|
68
68
|
return default if value.nil?
|
|
69
69
|
|