pgbus 0.21.0 → 0.21.2

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.
@@ -9,6 +9,9 @@ module Pgbus
9
9
 
10
10
  FORK_WAIT = 1 # seconds between fork checks
11
11
  WATCHDOG_INTERVAL = 10 # seconds between stall checks
12
+ # Seconds between asking a child for a thread dump (TTIN) and the
13
+ # watchdog's SIGKILL, so the dump can reach the log (issue #587).
14
+ WATCHDOG_DUMP_GRACE = 2
12
15
 
13
16
  # A child that crashes within this many seconds of forking is treated
14
17
  # as crash-looping and restarted with exponential backoff (base
@@ -46,7 +49,8 @@ module Pgbus
46
49
  @notify_hub = notify_hub
47
50
  @intended_children = 0
48
51
  @readiness = Concurrent::AtomicReference.new(
49
- ReadinessSnapshot.new(booted: false, shutting_down: shutting_down, expected: 0, live: forks.size)
52
+ ReadinessSnapshot.new(booted: false, shutting_down: shutting_down, expected: 0,
53
+ live: checked_in_count, forked: forks.size)
50
54
  )
51
55
  end
52
56
 
@@ -151,16 +155,22 @@ module Pgbus
151
155
  end
152
156
 
153
157
  # Publish a fresh snapshot; the swapped-in Data is immutable, so the
154
- # health server's accept thread always reads a consistent state.
158
+ # health server's accept thread always reads a consistent state. A
159
+ # child counts as live only once it has checked in (issue #587): a fork
160
+ # wedged before its first liveness signal keeps the gate shut.
155
161
  def refresh_readiness
156
162
  @readiness.set(
157
163
  ReadinessSnapshot.new(
158
164
  booted: !!@booted, shutting_down: @shutting_down,
159
- expected: @expected_children || 0, live: @forks.size
165
+ expected: @expected_children || 0, live: checked_in_count, forked: @forks.size
160
166
  )
161
167
  )
162
168
  end
163
169
 
170
+ def checked_in_count
171
+ @forks.count { |_, info| info[:checked_in] }
172
+ end
173
+
164
174
  # Log a single boot diagnostics banner: the settings that actually
165
175
  # determine whether this deployment works. One consecutive block of
166
176
  # "[Pgbus] boot:"-prefixed info lines so it reads cleanly under both the
@@ -327,6 +337,7 @@ module Pgbus
327
337
  # :supervisor scope (hub present).
328
338
  wake_reader, wake_writer = IO.pipe if @notify_hub
329
339
 
340
+ spawned_wall_at = Time.current
330
341
  pid = fork do
331
342
  # Child owns the liveness writer + wake reader; close this fork's
332
343
  # own copies of the parent-side ends. Sibling pipe ends and the
@@ -367,8 +378,8 @@ module Pgbus
367
378
  register_fork_with_hub(pid, wake_writer, queues)
368
379
  @forks[pid] = {
369
380
  type: :worker, config: worker_config, slot: slot, process_index: process_index, spawned_at: monotonic_now,
370
- liveness_reader: liveness_reader, last_pipe_tick_at: monotonic_now, pipe_seen: false,
371
- wake_writer: wake_writer
381
+ spawned_wall_at: spawned_wall_at, liveness_reader: liveness_reader, last_pipe_tick_at: monotonic_now, pipe_seen: false,
382
+ wake_writer: wake_writer, checked_in: false
372
383
  }
373
384
  Pgbus.logger.info { "[Pgbus] Forked worker pid=#{pid} queues=#{queues.join(",")} mode=#{exec_mode}" }
374
385
  rescue Errno::EAGAIN, Errno::ENOMEM => e
@@ -406,6 +417,7 @@ module Pgbus
406
417
 
407
418
  def fork_dispatcher
408
419
  note_intended_child
420
+ spawned_wall_at = Time.current
409
421
  pid = fork do
410
422
  restore_signals
411
423
  setup_child_process
@@ -419,7 +431,7 @@ module Pgbus
419
431
  return
420
432
  end
421
433
 
422
- @forks[pid] = { type: :dispatcher, spawned_at: monotonic_now }
434
+ @forks[pid] = { type: :dispatcher, spawned_at: monotonic_now, spawned_wall_at: spawned_wall_at, checked_in: false }
423
435
  Pgbus.logger.info { "[Pgbus] Forked dispatcher pid=#{pid}" }
424
436
  rescue Errno::EAGAIN, Errno::ENOMEM => e
425
437
  ErrorReporter.report(e, { action: "fork_dispatcher" })
@@ -437,6 +449,7 @@ module Pgbus
437
449
 
438
450
  def fork_scheduler
439
451
  note_intended_child
452
+ spawned_wall_at = Time.current
440
453
  pid = fork do
441
454
  restore_signals
442
455
  setup_child_process
@@ -453,7 +466,7 @@ module Pgbus
453
466
  return
454
467
  end
455
468
 
456
- @forks[pid] = { type: :scheduler, spawned_at: monotonic_now }
469
+ @forks[pid] = { type: :scheduler, spawned_at: monotonic_now, spawned_wall_at: spawned_wall_at, checked_in: false }
457
470
  Pgbus.logger.info { "[Pgbus] Forked scheduler pid=#{pid}" }
458
471
  rescue Errno::EAGAIN, Errno::ENOMEM => e
459
472
  ErrorReporter.report(e, { action: "fork_scheduler" })
@@ -542,6 +555,7 @@ module Pgbus
542
555
  # Wake channel from the NotifyHub (issue #381), as in fork_worker.
543
556
  wake_reader, wake_writer = IO.pipe if @notify_hub
544
557
 
558
+ spawned_wall_at = Time.current
545
559
  pid = fork do
546
560
  # Child owns the liveness writer + wake reader; close this fork's
547
561
  # own copies of the parent-side ends (see fork_worker).
@@ -572,8 +586,8 @@ module Pgbus
572
586
  register_consumer_with_hub(pid, wake_writer, topics)
573
587
  @forks[pid] = {
574
588
  type: :consumer, config: consumer_config, slot: slot, spawned_at: monotonic_now,
575
- liveness_reader: liveness_reader, last_pipe_tick_at: monotonic_now, pipe_seen: false,
576
- wake_writer: wake_writer
589
+ spawned_wall_at: spawned_wall_at, liveness_reader: liveness_reader, last_pipe_tick_at: monotonic_now, pipe_seen: false,
590
+ wake_writer: wake_writer, checked_in: false
577
591
  }
578
592
  Pgbus.logger.info { "[Pgbus] Forked consumer pid=#{pid} topics=#{topics.join(",")}" }
579
593
  rescue Errno::EAGAIN, Errno::ENOMEM => e
@@ -608,6 +622,7 @@ module Pgbus
608
622
 
609
623
  def fork_outbox_poller
610
624
  note_intended_child
625
+ spawned_wall_at = Time.current
611
626
  pid = fork do
612
627
  restore_signals
613
628
  setup_child_process
@@ -621,7 +636,7 @@ module Pgbus
621
636
  return
622
637
  end
623
638
 
624
- @forks[pid] = { type: :outbox_poller, spawned_at: monotonic_now }
639
+ @forks[pid] = { type: :outbox_poller, spawned_at: monotonic_now, spawned_wall_at: spawned_wall_at, checked_in: false }
625
640
  Pgbus.logger.info { "[Pgbus] Forked outbox poller pid=#{pid}" }
626
641
  rescue Errno::EAGAIN, Errno::ENOMEM => e
627
642
  ErrorReporter.report(e, { action: "fork_outbox_poller" })
@@ -710,7 +725,11 @@ module Pgbus
710
725
  key = [info[:type], info[:slot]]
711
726
  uptime = info[:spawned_at] ? monotonic_now - info[:spawned_at] : nil
712
727
 
713
- if status&.success? || uptime.nil? || uptime >= RESTART_STABLE_UPTIME
728
+ # A child killed for its boot deadline never booted, whatever its
729
+ # uptime: back it off like a crash so a permanently hung boot does not
730
+ # re-fork every boot_timeout (issue #587).
731
+ stable = uptime && uptime >= RESTART_STABLE_UPTIME && !info[:boot_timed_out]
732
+ if status&.success? || uptime.nil? || stable
714
733
  @crash_counts.delete(key)
715
734
  return restart_child(info)
716
735
  end
@@ -746,55 +765,124 @@ module Pgbus
746
765
  end
747
766
  end
748
767
 
768
+ # Runs every WATCHDOG_INTERVAL. One process-row read (this host only)
769
+ # serves three jobs: it checks in forks that registered a row (the only
770
+ # check-in channel for the pipeless dispatcher, scheduler and outbox
771
+ # poller, so it runs whatever the thresholds are — /readyz depends on
772
+ # it); it enforces boot_timeout on forks that have not checked in
773
+ # (issue #587); and it feeds the loop-tick ages of the stall check.
749
774
  def check_stalled_workers
750
775
  now = monotonic_now
751
776
  return if (now - @last_watchdog_at) < WATCHDOG_INTERVAL
752
777
 
753
778
  @last_watchdog_at = now
754
- threshold = config.stall_threshold
755
- return unless threshold&.positive?
779
+ stall_threshold = positive_limit(config.stall_threshold)
780
+ boot_timeout = positive_limit(config.boot_timeout)
781
+
782
+ pids = watchdog_pids(stall_threshold)
783
+ return if pids.empty?
784
+
785
+ rows = watchdog_rows(pids)
786
+ mark_checked_in(rows.keys) if rows
787
+
788
+ victims = []
789
+ victims.concat(boot_timeout_victims(now, boot_timeout)) if boot_timeout && rows
790
+ victims.concat(stalled_victims(now, stall_threshold, rows || {})) if stall_threshold
791
+ kill_unresponsive_children(victims.uniq(&:first))
792
+ rescue StandardError => e
793
+ Pgbus.logger.warn { "[Pgbus] Supervisor watchdog check failed: #{e.message}" }
794
+ end
795
+
796
+ def positive_limit(value)
797
+ value if value.is_a?(Numeric) && value.positive?
798
+ end
799
+
800
+ # Forks still waiting to check in, plus every worker/consumer when the
801
+ # stall check is on. A checked-in dispatcher/scheduler/outbox poller has
802
+ # no loop beacon, so once it has a row it is never read again.
803
+ def watchdog_pids(stall_threshold)
804
+ @forks.select do |_, info|
805
+ !info[:checked_in] || (stall_threshold && %i[worker consumer].include?(info[:type]))
806
+ end.keys
807
+ end
756
808
 
757
- # Workers AND consumers both stamp a loop beacon and carry a liveness
758
- # pipe (issue #274), so both are watched for a wedged claim/consume loop.
759
- watched_pids = @forks.select { |_, info| %i[worker consumer].include?(info[:type]) }.keys
760
- return if watched_pids.empty?
809
+ # {pid => ProcessEntry} for the forks' rows on THIS host — pids repeat
810
+ # across containers sharing one database, so a sibling's row must never
811
+ # vouch for a local child. A row last beaten before the fork belongs to
812
+ # a dead predecessor with the same pid (a SIGKILLed child never deletes
813
+ # its row, and a container restarted in place reuses hostname and
814
+ # pids), so it is dropped too. nil when the read fails: the stall check
815
+ # then falls back to the liveness pipe, and the boot deadline waits for
816
+ # a pass that can see the rows (a DB blip at boot must not kill every
817
+ # pipeless child).
818
+ def watchdog_rows(pids)
819
+ rows = ProcessEntry.where(hostname: Socket.gethostname, pid: pids).to_a
820
+ rows.select { |entry| row_of_current_fork?(entry) }.to_h { |entry| [entry.pid, entry] }
821
+ rescue StandardError => e
822
+ Pgbus.logger.warn { "[Pgbus] Supervisor watchdog DB read failed, using pipe fallback: #{e.message}" }
823
+ nil
824
+ end
825
+
826
+ def row_of_current_fork?(entry)
827
+ spawned_wall_at = @forks.dig(entry.pid, :spawned_wall_at)
828
+ return true unless spawned_wall_at
761
829
 
762
- db_ages = db_loop_tick_ages(watched_pids)
830
+ # floor: a table created without sub-second precision stores a row
831
+ # registered in the fork's own second below the fork's timestamp.
832
+ entry.last_heartbeat_at.present? && entry.last_heartbeat_at >= spawned_wall_at.floor
833
+ end
763
834
 
764
- watched_pids.each do |pid|
835
+ def mark_checked_in(pids)
836
+ pids.each do |pid|
765
837
  info = @forks[pid]
766
- next unless info
838
+ info[:checked_in] = true if info
839
+ end
840
+ end
767
841
 
768
- kill_stalled_worker(pid, threshold) if worker_stalled?(info, db_ages[pid], now, threshold)
842
+ # A fork with neither a row nor a liveness byte boot_timeout seconds
843
+ # after fork is wedged in boot (issue #587). Without this it is never
844
+ # reaped (it does not exit) and never stalled (no channel ever spoke).
845
+ def boot_timeout_victims(now, boot_timeout)
846
+ @forks.filter_map do |pid, info|
847
+ next if info[:checked_in] || info[:spawned_at].nil?
848
+
849
+ waited = now - info[:spawned_at]
850
+ next unless waited > boot_timeout
851
+
852
+ info[:boot_timed_out] = true
853
+ [pid, "#{info[:type]} pid=#{pid} boot timeout (no check-in #{waited.round}s after fork, " \
854
+ "boot_timeout=#{boot_timeout}s)"]
855
+ end
856
+ end
857
+
858
+ def stalled_victims(now, threshold, rows)
859
+ db_ages = loop_tick_ages(rows)
860
+ @forks.filter_map do |pid, info|
861
+ next unless %i[worker consumer].include?(info[:type])
862
+ next unless worker_stalled?(info, db_ages[pid], now, threshold)
863
+
864
+ [pid, "#{info[:type]} pid=#{pid} claim loop stalled (no liveness within threshold=#{threshold}s)"]
769
865
  end
770
- rescue StandardError => e
771
- Pgbus.logger.warn { "[Pgbus] Supervisor watchdog check failed: #{e.message}" }
772
866
  end
773
867
 
774
- # Read each watched fork's loop_tick_at from the process table and return
775
- # a {pid => wall-clock age in seconds} map. Isolated in its own rescue so a
776
- # database outage degrades to the OS-pipe fallback instead of skipping
777
- # the whole watchdog — the exact failure this pipe channel exists to fix.
778
- def db_loop_tick_ages(worker_pids)
779
- ages = {}
780
- ProcessEntry.where(kind: %w[worker consumer], pid: worker_pids).to_a.each do |entry|
868
+ # {pid => wall-clock age in seconds} of each worker/consumer row's
869
+ # loop_tick_at.
870
+ def loop_tick_ages(rows)
871
+ rows.each_with_object({}) do |(pid, entry), ages|
872
+ next unless %w[worker consumer].include?(entry.kind)
873
+
781
874
  meta = entry.metadata
782
- next unless meta.is_a?(Hash)
875
+ next unless meta.is_a?(Hash) && meta["loop_tick_at"]
783
876
 
784
- loop_tick = meta["loop_tick_at"]
785
- ages[entry.pid] = Time.current.to_f - loop_tick.to_f if loop_tick
877
+ ages[pid] = Time.current.to_f - meta["loop_tick_at"].to_f
786
878
  end
787
- ages
788
- rescue StandardError => e
789
- Pgbus.logger.warn { "[Pgbus] Supervisor watchdog DB read failed, using pipe fallback: #{e.message}" }
790
- ages
791
879
  end
792
880
 
793
881
  # A worker is stalled only when EVERY liveness channel that has spoken
794
882
  # agrees it is stale (min-age / fresh-wins): a fresh DB row OR a fresh
795
883
  # pipe tick proves the loop is advancing. If no channel has any signal
796
884
  # (a slow-booting worker with no DB row and an unarmed pipe), we do not
797
- # kill — mirroring the pre-existing "no loop_tick_at → skip" tolerance.
885
+ # kill — the boot deadline above owns that case.
798
886
  def worker_stalled?(info, db_age, now, threshold)
799
887
  pipe_age = (now - info[:last_pipe_tick_at] if info[:pipe_seen] && info[:last_pipe_tick_at])
800
888
  ages = [db_age, pipe_age].compact
@@ -803,14 +891,33 @@ module Pgbus
803
891
  ages.min > threshold
804
892
  end
805
893
 
806
- def kill_stalled_worker(pid, threshold)
807
- Pgbus.logger.error do
808
- "[Pgbus] Supervisor watchdog: worker pid=#{pid} claim loop stalled " \
809
- "(no liveness within threshold=#{threshold}s), sending SIGKILL"
894
+ # Log each victim at ERROR, ask it for a thread dump (TTIN, trapped in
895
+ # setup_child_process), give every dump one shared grace, then SIGKILL.
896
+ # The dump is best effort: a child stuck in a native call holding the
897
+ # GVL never answers. No waitpid here — reap_children must see the exit
898
+ # so schedule_restart re-forks the child.
899
+ def kill_unresponsive_children(victims)
900
+ return if victims.empty?
901
+
902
+ signalled = victims.filter_map do |pid, reason|
903
+ Pgbus.logger.error { "[Pgbus] Supervisor watchdog: #{reason} — dumping threads (TTIN), then SIGKILL" }
904
+ signal_child("TTIN", pid) && pid
810
905
  end
811
- ::Process.kill("KILL", pid)
906
+ return if signalled.empty?
907
+
908
+ dump_grace_sleep
909
+ signalled.each { |pid| signal_child("KILL", pid) }
910
+ end
911
+
912
+ def signal_child(sig, pid)
913
+ ::Process.kill(sig, pid)
914
+ true
812
915
  rescue Errno::ESRCH
813
- # already gone
916
+ false # already gone; reap_children handles it
917
+ end
918
+
919
+ def dump_grace_sleep
920
+ sleep(WATCHDOG_DUMP_GRACE)
814
921
  end
815
922
 
816
923
  # Drain each worker's liveness pipe. Any readable byte means the worker's
@@ -839,10 +946,11 @@ module Pgbus
839
946
  next
840
947
  end
841
948
 
842
- if read_any
843
- info[:last_pipe_tick_at] = now
844
- info[:pipe_seen] = true
845
- end
949
+ next unless read_any
950
+
951
+ info[:last_pipe_tick_at] = now
952
+ info[:pipe_seen] = true
953
+ info[:checked_in] = true
846
954
  end
847
955
  end
848
956
 
@@ -855,6 +963,8 @@ module Pgbus
855
963
  end
856
964
 
857
965
  def setup_child_process
966
+ # First, so a hang anywhere in the rest of the boot can be dumped.
967
+ install_thread_dump_trap
858
968
  # Every child type (worker, consumer, dispatcher, scheduler, outbox
859
969
  # poller) releases its copies of the parent's per-fork resources —
860
970
  # a dispatcher child holding a sibling worker's wake WRITER would
@@ -871,6 +981,26 @@ module Pgbus
871
981
  end
872
982
  end
873
983
 
984
+ # `kill -TTIN <child pid>` logs every thread's backtrace at ERROR and
985
+ # leaves the child running; the watchdog sends it before a SIGKILL
986
+ # (issue #587). Logger takes a lock, which Ruby forbids in trap context,
987
+ # so the trap snapshots the backtraces and one short-lived thread logs.
988
+ def install_thread_dump_trap
989
+ trap("TTIN") do
990
+ dump = Thread.list.map { |thread| [thread.inspect, thread.backtrace] }
991
+ Thread.new { log_thread_dump(dump) } # rubocop:disable ThreadSafety/NewThread
992
+ end
993
+ end
994
+
995
+ def log_thread_dump(dump)
996
+ Pgbus.logger.error { "[Pgbus] Thread dump pid=#{::Process.pid} (#{dump.size} threads, TTIN)" }
997
+ dump.each do |label, backtrace|
998
+ Pgbus.logger.error do
999
+ "[Pgbus] Thread #{label}\n #{Array(backtrace).join("\n ")}"
1000
+ end
1001
+ end
1002
+ end
1003
+
874
1004
  # Lenient bootstrap for the parent (supervisor.rb#run). A transiently
875
1005
  # unavailable DB at boot must not kill the supervisor — children
876
1006
  # crash-and-backoff until it recovers — so every StandardError (including
@@ -296,7 +296,7 @@ module Pgbus
296
296
  @rate_counter.increment(:dequeued, tagged_messages.size)
297
297
  client = Pgbus.client
298
298
  tagged_messages.each do |queue_name, message, source_queue|
299
- detect_zombie(queue_name, message)
299
+ detect_zombie(queue_name, message, source_queue)
300
300
  @in_flight.increment
301
301
  @claim_buffer.push(queue_name, message, source_queue, client: client)
302
302
  end
@@ -603,11 +603,12 @@ module Pgbus
603
603
  [RESTORE_COOLDOWN_BASE * (2**@restore_streak), RESTORE_COOLDOWN_MAX].min
604
604
  end
605
605
 
606
- def detect_zombie(queue_name, message)
606
+ def detect_zombie(queue_name, message, source_queue = nil)
607
607
  return unless config.zombie_detection
608
608
  return unless message.read_ct.to_i > 1
609
609
 
610
- return if FailedEventRecorder.exists?(queue_name: queue_name, msg_id: message.msg_id.to_i)
610
+ return if FailedEventRecorder.exists?(queue_name: queue_name, msg_id: message.msg_id.to_i,
611
+ source_queue: source_queue || config.queue_name(queue_name))
611
612
 
612
613
  Pgbus.logger.warn do
613
614
  "[Pgbus] Zombie message redelivered: queue=#{queue_name} msg_id=#{message.msg_id} " \
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.21.0"
4
+ VERSION = "0.21.2"
5
5
  end
@@ -10,7 +10,8 @@ module Pgbus
10
10
  # Sources, one UNION ALL fragment each:
11
11
  # - every non-DLQ queue table, LEFT JOINed to its pgbus_failed_events row
12
12
  # (failed_events.queue_name is the LOGICAL name; the physical one is
13
- # matched too for rows older code wrote)
13
+ # matched too for rows older code wrote; source_queue, when set, names
14
+ # the one table the row's message was read from — issue #583)
14
15
  # - failed rows whose message is no longer in its queue ("orphans")
15
16
  # - pgbus_blocked_executions (jobs parked by limits_concurrency)
16
17
  #
@@ -187,7 +188,7 @@ module Pgbus
187
188
  SELECT f.id, f.error_class::text AS error_class, f.error_message,
188
189
  f.failed_at::timestamptz AS failed_at
189
190
  FROM pgbus_failed_events f
190
- WHERE f.queue_name IN ('#{logical}', '#{qtable}') AND f.msg_id = m.msg_id
191
+ WHERE f.queue_name IN ('#{logical}', '#{qtable}') AND f.msg_id = m.msg_id#{failed_in(qtable)}
191
192
  ORDER BY f.failed_at DESC LIMIT 1
192
193
  ) f ON true
193
194
  SQL
@@ -197,7 +198,9 @@ module Pgbus
197
198
  # queue, plus (unfiltered) one for rows whose queue no longer exists.
198
199
  def orphan_fragments(scope, fetch)
199
200
  fragments = scope[:by_logical].map do |logical, tables|
200
- missing = tables.map { |(qtable, _)| "NOT EXISTS (SELECT 1 FROM pgmq.q_#{qtable} m WHERE m.msg_id = f.msg_id)" }
201
+ missing = tables.map do |(qtable, _)|
202
+ "NOT EXISTS (SELECT 1 FROM pgmq.q_#{qtable} m WHERE m.msg_id = f.msg_id#{failed_in(qtable)})"
203
+ end
201
204
  names = quoted_list([logical, *tables.map(&:first)])
202
205
  orphan_fragment("'#{logical}'::text", "f.queue_name IN (#{names}) AND #{missing.join(" AND ")}", fetch)
203
206
  end
@@ -209,6 +212,16 @@ module Pgbus
209
212
  fragments << orphan_fragment("f.queue_name::text", where, fetch)
210
213
  end
211
214
 
215
+ # Under priority routing each _pN sub-queue numbers its messages from
216
+ # its own sequence: a row that names its table belongs to that table's
217
+ # message only. A row written before source_queue (NULL), or any row
218
+ # before the column is migrated, matches every table of its queue.
219
+ def failed_in(qtable)
220
+ return "" unless FailedEventRecorder.source_queue_column?
221
+
222
+ " AND (f.source_queue IS NULL OR f.source_queue = '#{qtable}')"
223
+ end
224
+
212
225
  def orphan_fragment(logical_sql, where, fetch)
213
226
  <<~SQL.strip
214
227
  (SELECT 'failed'::text AS source, f.id, f.queue_name::text AS queue_name, #{logical_sql} AS logical_queue,
@@ -299,9 +299,9 @@ module Pgbus
299
299
  # duplicate (the original is still in the queue waiting for retry).
300
300
  # Falls back to enqueueing a fresh copy only if the original is gone
301
301
  # (e.g., already moved to DLQ).
302
- msg_id = event["msg_id"]
303
- if msg_id && @client.message_exists?(event["queue_name"], msg_id: msg_id.to_i)
304
- @client.set_visibility_timeout(event["queue_name"], msg_id.to_i, vt: 0)
302
+ physical = failed_message_queue(event)
303
+ if physical
304
+ @client.set_visibility_timeout(physical, event["msg_id"].to_i, vt: 0, prefixed: false)
305
305
  else
306
306
  payload = JSON.parse(event["payload"])
307
307
  headers = event["headers"]
@@ -1627,20 +1627,22 @@ module Pgbus
1627
1627
  # lock handed to a retry before the failure would count as the job's.
1628
1628
  # A message that is gone has no send time, so only a bound lock goes.
1629
1629
  def release_locks_held_by_failed_events(condition, binds)
1630
+ source = FailedEventRecorder.source_queue_column? ? ", source_queue" : ""
1630
1631
  rows = connection.select_all(
1631
- "SELECT queue_name, msg_id, payload FROM pgbus_failed_events WHERE #{condition}",
1632
+ "SELECT queue_name, msg_id, payload#{source} FROM pgbus_failed_events WHERE #{condition}",
1632
1633
  "Pgbus Collect Failed Keys", binds
1633
1634
  )
1634
1635
 
1635
1636
  # queue_name is the logical name the executor saw: the message is looked
1636
- # up under it, and the lock is released under its table spelling.
1637
+ # up under it (or in the table source_queue names), and the lock is
1638
+ # released under its table spelling.
1637
1639
  # A key proc may return a number; the queue lookup reads it as text.
1638
1640
  holders = rows.to_a.filter_map do |row|
1639
1641
  key = extract_uniqueness_key_from_payload_str(row["payload"])&.to_s
1640
- [key, row["queue_name"], row["msg_id"].to_i] if key && row["msg_id"]
1642
+ [key, row["queue_name"], row["msg_id"].to_i, row["source_queue"]] if key && row["msg_id"]
1641
1643
  end
1642
- holders = holders.group_by { |_key, queue, _msg_id| queue }.flat_map do |queue, entries|
1643
- failed_queue_lock_holders(queue, entries)
1644
+ holders = holders.group_by { |_key, queue, _msg_id, table| [queue, table] }.flat_map do |(queue, table), entries|
1645
+ failed_queue_lock_holders(queue, table, entries)
1644
1646
  end
1645
1647
  UniquenessKey.release_all_held_by!(holders) if holders.any?
1646
1648
  rescue StandardError => e
@@ -1650,8 +1652,8 @@ module Pgbus
1650
1652
  # The release tuples for one queue's failed jobs. A queue that cannot
1651
1653
  # be resolved (a stored name no longer valid as a table name) is skipped
1652
1654
  # with a warning, so it does not stop the other queues' release.
1653
- def failed_queue_lock_holders(queue, entries)
1654
- sent = queued_send_times(queue, entries)
1655
+ def failed_queue_lock_holders(queue, table, entries)
1656
+ sent = queued_send_times(queue, table, entries)
1655
1657
  lock_queue = Uniqueness.lock_queue_name(queue)
1656
1658
  entries.map { |key, _queue, msg_id| [key, lock_queue, msg_id, sent[[msg_id, key]]] }
1657
1659
  rescue ActiveRecord::StatementInvalid
@@ -1662,13 +1664,15 @@ module Pgbus
1662
1664
  end
1663
1665
 
1664
1666
  # { [msg_id, uniqueness key] => enqueued_at } for the messages of
1665
- # +entries+ ([key, queue, msg_id]) still in the logical queue. Every
1666
- # priority sub-queue is searched: a failed event records the logical
1667
- # queue only, and msg_ids repeat across sub-queues, so the key must
1667
+ # +entries+ ([key, queue, msg_id, table]) still queued. A row that names
1668
+ # its table (issue #583) is that table's message. A row written before
1669
+ # source_queue records the logical queue only, so every priority
1670
+ # sub-queue is searched: msg_ids repeat across them, so the key must
1668
1671
  # match too, and the earliest send wins.
1669
- def queued_send_times(queue, entries)
1672
+ def queued_send_times(queue, table, entries)
1670
1673
  msg_ids = "{#{entries.map { |_key, _queue, msg_id| msg_id }.uniq.join(",")}}"
1671
- @client.physical_queue_names(queue).each_with_object({}) do |physical, found|
1674
+ tables = table ? [table] : @client.physical_queue_names(queue)
1675
+ tables.each_with_object({}) do |physical, found|
1672
1676
  queued_rows(physical, msg_ids).each do |row|
1673
1677
  pair = [row["msg_id"].to_i, row["lock_key"]]
1674
1678
  found[pair] = [found[pair], row["enqueued_at"]].compact.min
@@ -1721,12 +1725,36 @@ module Pgbus
1721
1725
  nil
1722
1726
  end
1723
1727
 
1728
+ # The physical queue holding a failed job's message, or nil when it is
1729
+ # gone. The job's job_id must match too: under priority routing a msg_id
1730
+ # repeats across the _pN sub-queues (issue #577). A row that names the
1731
+ # table its message was read from is looked up there alone (issue
1732
+ # #583); a row written before source_queue, under every table of its
1733
+ # logical queue.
1734
+ def failed_message_queue(event)
1735
+ return unless event["queue_name"] && event["msg_id"]
1736
+
1737
+ name, prefixed = failed_message_scope(event)
1738
+ @client.locate_message(name, msg_id: event["msg_id"].to_i, job_id: failed_job_id(event), prefixed: prefixed)
1739
+ end
1740
+
1741
+ # [queue, prefixed] to look a failed row's message up under: the physical
1742
+ # table the row names, else its logical queue.
1743
+ def failed_message_scope(event)
1744
+ event["source_queue"] ? [event["source_queue"], false] : [event["queue_name"], true]
1745
+ end
1746
+
1747
+ def failed_job_id(event)
1748
+ payload = event["payload"]
1749
+ payload = JSON.parse(payload) if payload.is_a?(String)
1750
+ payload["job_id"] if payload.is_a?(Hash)
1751
+ end
1752
+
1724
1753
  # Archive the queue message a failed_event row points to. Idempotent —
1725
1754
  # silently no-ops if the message no longer exists in the queue.
1726
1755
  def archive_failed_message(event)
1727
- return unless event["queue_name"] && event["msg_id"]
1728
-
1729
- @client.archive_message(event["queue_name"], event["msg_id"].to_i)
1756
+ physical = failed_message_queue(event)
1757
+ @client.archive_message(physical, event["msg_id"].to_i, prefixed: false) if physical
1730
1758
  rescue StandardError => e
1731
1759
  Pgbus.logger.debug do
1732
1760
  "[Pgbus::Web] Error archiving message for failed event #{event["id"]}: #{e.message}"
@@ -1734,30 +1762,33 @@ module Pgbus
1734
1762
  end
1735
1763
 
1736
1764
  # Archive every queue message referenced by a failed_event row.
1737
- # Groups by queue and uses archive_batch so an N-event discard is
1738
- # one SQL statement per queue instead of N per-row roundtrips.
1739
- # Falls back to per-row archive inside a rescue if a batch fails,
1740
- # so one bad queue can't block progress on the others.
1765
+ # Groups by queue, then locates and archive_batches per physical table,
1766
+ # so an N-event discard is two SQL statements per table instead of N
1767
+ # per-row roundtrips. Falls back to per-row archive inside a rescue if
1768
+ # a batch fails, so one bad queue can't block progress on the others.
1741
1769
  def archive_all_failed_messages
1770
+ source = FailedEventRecorder.source_queue_column? ? ", source_queue" : ""
1742
1771
  rows = connection.select_all(
1743
- "SELECT id, queue_name, msg_id FROM pgbus_failed_events WHERE msg_id IS NOT NULL AND #{NOT_HANDLER_QUEUE}",
1772
+ "SELECT id, queue_name, msg_id, payload#{source} FROM pgbus_failed_events " \
1773
+ "WHERE msg_id IS NOT NULL AND #{NOT_HANDLER_QUEUE}",
1744
1774
  "Pgbus Collect Failed Messages", [handler_queue_names_literal]
1745
1775
  )
1746
1776
 
1747
- grouped = rows.to_a.group_by { |row| row["queue_name"] }
1748
- grouped.each do |queue_name, events|
1749
- msg_ids = events.filter_map { |row| row["msg_id"]&.to_i }
1750
- next if msg_ids.empty?
1751
-
1752
- begin
1753
- @client.archive_batch(queue_name, msg_ids)
1754
- rescue StandardError => e
1755
- Pgbus.logger.debug do
1756
- "[Pgbus::Web] archive_batch failed for #{queue_name} (#{e.message}); " \
1757
- "falling back to per-row archive"
1758
- end
1759
- events.each { |row| archive_failed_message(row) }
1777
+ grouped = rows.to_a.group_by { |row| failed_message_scope(row) }
1778
+ grouped.each do |(queue_name, prefixed), events|
1779
+ # A priority queue's msg_ids repeat across its _pN sub-queues, so
1780
+ # each message is located first and archived from its own table.
1781
+ located = @client.locate_messages(queue_name, events.map { |row| [row["msg_id"].to_i, failed_job_id(row)] },
1782
+ prefixed: prefixed)
1783
+ located.group_by { |_msg_id, physical| physical }.each do |physical, pairs|
1784
+ @client.archive_batch(physical, pairs.map(&:first), prefixed: false)
1785
+ end
1786
+ rescue StandardError => e
1787
+ Pgbus.logger.debug do
1788
+ "[Pgbus::Web] archive_batch failed for #{queue_name} (#{e.message}); " \
1789
+ "falling back to per-row archive"
1760
1790
  end
1791
+ events.each { |row| archive_failed_message(row) }
1761
1792
  end
1762
1793
  rescue StandardError => e
1763
1794
  Pgbus.logger.debug { "[Pgbus::Web] Error archiving failed messages: #{e.message}" }
@@ -98,7 +98,7 @@ module Pgbus
98
98
  def local_readyz
99
99
  snapshot = @local_readiness.call
100
100
  status = snapshot.ready? ? 200 : 503
101
- body = { status: snapshot.status, expected: snapshot.expected, live: snapshot.live }
101
+ body = { status: snapshot.status, expected: snapshot.expected, live: snapshot.live, forked: snapshot.forked }
102
102
  [status, JSON_HEADERS.dup, [body.to_json]]
103
103
  rescue StandardError => e
104
104
  Pgbus.logger.error { "[Pgbus::Web::HealthApp] local readiness check failed: #{e.class}: #{e.message}" }