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.
@@ -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 { @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) }
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? } && (!@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?))
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
- @mutex.synchronize { @condition.broadcast }
270
+ wake_queue("*")
249
271
  end
250
272
 
251
273
  private def begin_work(id)
252
- should_interrupt = @mutex.synchronize do
274
+ interruption = @mutex.synchronize do
253
275
  entry = @running.fetch(id)
254
276
  if @stop_requested
255
- true
277
+ Interrupted
278
+ elsif entry[:cancelled]
279
+ JobCancelError
256
280
  else
257
281
  entry[:working] = true
258
- false
282
+ nil
259
283
  end
260
284
  end
261
285
 
262
- raise Interrupted if should_interrupt
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
- [finish_failed(row, job, error, timing, 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]
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 => error
433
- raise
434
- ensure
435
- 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)
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 { @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
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
- wait(5)
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
- wake
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
- # 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
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
- wait(poll_interval)
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.due(now) do |job, error|
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.insert(args, insert_opts: opts)
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, :id, :job_timeout,
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
- # +fetch_only_known_kinds+ leaves unregistered kinds untouched in shared
57
- # queues. +leader_election_disabled+ prevents this client's maintenance;
58
- # another eligible client must run scheduling, rescue, and cleanup.
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
- @id = id || "#{Socket.gethostname}-#{Process.pid}-#{SecureRandom.hex(6)}"
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" unless (1...128).cover?(id.length)
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 PostgreSQL-style timestamps too.
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 PostgreSQL JSON and array wrappers delegate to Ruby values.
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