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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/lib/generators/pgbus/add_failed_events_source_queue_generator.rb +44 -0
- data/lib/generators/pgbus/templates/add_failed_events_source_queue.rb.erb +27 -0
- data/lib/generators/pgbus/templates/add_failed_events_unique_index.rb.erb +5 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +6 -3
- data/lib/pgbus/active_job/executor.rb +29 -9
- data/lib/pgbus/batch/sweep.rb +10 -5
- data/lib/pgbus/client.rb +109 -42
- data/lib/pgbus/configuration.rb +11 -0
- data/lib/pgbus/failed_event_recorder.rb +135 -42
- data/lib/pgbus/generators/migration_detector.rb +16 -9
- data/lib/pgbus/process/consumer.rb +12 -5
- data/lib/pgbus/process/heartbeat.rb +12 -1
- data/lib/pgbus/process/readiness_snapshot.rb +14 -6
- data/lib/pgbus/process/supervisor.rb +178 -48
- data/lib/pgbus/process/worker.rb +4 -3
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source/job_list.rb +16 -3
- data/lib/pgbus/web/data_source.rb +67 -36
- data/lib/pgbus/web/health_app.rb +1 -1
- data/sig/pgbus/client.rbs +7 -5
- data/sig/pgbus/configuration.rbs +2 -0
- metadata +3 -1
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
755
|
-
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
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
|
-
|
|
835
|
+
def mark_checked_in(pids)
|
|
836
|
+
pids.each do |pid|
|
|
765
837
|
info = @forks[pid]
|
|
766
|
-
|
|
838
|
+
info[:checked_in] = true if info
|
|
839
|
+
end
|
|
840
|
+
end
|
|
767
841
|
|
|
768
|
-
|
|
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
|
-
#
|
|
775
|
-
#
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
-
|
|
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 —
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
data/lib/pgbus/process/worker.rb
CHANGED
|
@@ -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
|
@@ -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
|
|
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
|
-
|
|
303
|
-
if
|
|
304
|
-
@client.set_visibility_timeout(event["
|
|
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
|
|
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
|
|
1666
|
-
#
|
|
1667
|
-
#
|
|
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)
|
|
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
|
-
|
|
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
|
|
1738
|
-
#
|
|
1739
|
-
# Falls back to per-row archive inside a rescue if
|
|
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
|
|
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
|
|
1748
|
-
grouped.each do |queue_name, events|
|
|
1749
|
-
msg_ids
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
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}" }
|
data/lib/pgbus/web/health_app.rb
CHANGED
|
@@ -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}" }
|