pgbus 0.21.1 → 0.21.3

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.
@@ -97,6 +97,7 @@ module Pgbus
97
97
  add_workflows: "pgbus:add_workflows",
98
98
  add_workflow_signals: "pgbus:add_workflow_signals",
99
99
  add_failed_events_index: "pgbus:add_failed_events_index",
100
+ add_failed_events_source_queue: "pgbus:add_failed_events_source_queue",
100
101
  add_processed_event_completion: "pgbus:add_processed_event_completion",
101
102
  add_batch_executions: "pgbus:add_batch_executions",
102
103
  add_batch_callback_jobs: "pgbus:add_batch_callback_jobs",
@@ -122,6 +123,7 @@ module Pgbus
122
123
  add_workflows: "workflow tables (durable multi-step workflows: runs, step attempts, audit trail)",
123
124
  add_workflow_signals: "waiting workflow steps (fan_out, wait_for: signals, wait timeouts)",
124
125
  add_failed_events_index: "unique index on pgbus_failed_events (queue_name, msg_id)",
126
+ add_failed_events_source_queue: "source_queue on pgbus_failed_events (one row per priority sub-queue's failure)",
125
127
  add_processed_event_completion: "completed_at on pgbus_processed_events (two-phase idempotency claim)",
126
128
  add_batch_executions: "batch execution-row tracking (self-healing completion, on_failure rename)",
127
129
  add_batch_callback_jobs: "batch callback-job columns (configured ActiveJob instances as batch callbacks)",
@@ -151,7 +153,7 @@ module Pgbus
151
153
  *recurring_migrations,
152
154
  *deploy_tasks_migrations,
153
155
  *workflows_migrations,
154
- *failed_events_index_migrations,
156
+ *failed_events_migrations,
155
157
  *processed_event_completion_migrations,
156
158
  *batch_executions_migrations,
157
159
  *batch_callback_jobs_migrations,
@@ -266,16 +268,21 @@ module Pgbus
266
268
  migrations
267
269
  end
268
270
 
269
- def failed_events_index_migrations
270
- # pgbus_failed_events is created by pgbus:install, but the
271
- # unique (queue_name, msg_id) index was added later via its own
272
- # generator. If the table exists without the unique index,
273
- # FailedEventRecorder's upsert silently swallows ON CONFLICT
274
- # errors — so this is a real bug waiting to bite.
271
+ # FailedEventRecorder's upsert needs the unique index that matches its
272
+ # ON CONFLICT target, or it fails and the failure goes unrecorded. The
273
+ # source_queue migration (issue #583) adds the column, creates the
274
+ # (queue_name, msg_id, source_queue) index and drops the old
275
+ # (queue_name, msg_id) one, so it is the only migration queued here:
276
+ # add_failed_events_index would put back the index that collapses two
277
+ # priority sub-queues' failures into one row. That index still present
278
+ # (or put back by running the old generator by name) queues it again.
279
+ def failed_events_migrations
275
280
  return [] unless table_exists?("pgbus_failed_events")
276
- return [] if index_exists?("pgbus_failed_events", "idx_pgbus_failed_events_queue_msg")
281
+ return [] if column_names("pgbus_failed_events").include?("source_queue") &&
282
+ index_exists?("pgbus_failed_events", "idx_pgbus_failed_events_queue_msg_source") &&
283
+ !index_exists?("pgbus_failed_events", "idx_pgbus_failed_events_queue_msg")
277
284
 
278
- [:add_failed_events_index]
285
+ [:add_failed_events_source_queue]
279
286
  end
280
287
 
281
288
  # completed_at backs the two-phase idempotency claim (issue #385).
@@ -35,8 +35,12 @@ module Pgbus
35
35
  # a multi-KB log line (issue #367 minor).
36
36
  NAME_LIMIT = 10
37
37
 
38
- def initialize(data_source)
38
+ # +grace+ overrides Pgbus.configuration.health_unclaimed_grace (issue #589):
39
+ # seconds a visible read_ct=0 message must have been claimable before it
40
+ # counts as "never claimed". nil reads the config when the verdict runs.
41
+ def initialize(data_source, grace: nil)
39
42
  @data_source = data_source
43
+ @grace = grace
40
44
  end
41
45
 
42
46
  # Returns a machine-readable verdict hash suitable for both interactive
@@ -81,7 +85,9 @@ module Pgbus
81
85
  status: status,
82
86
  reasons: stalled + degraded,
83
87
  checked_at: Time.now.utc.iso8601,
84
- summary: build_summary(queues, non_dlq, processes, health)
88
+ summary: build_summary(queues, non_dlq, processes, health).merge(
89
+ oldest_unread_claimable_age_sec: oldest_unread_age(drained)
90
+ )
85
91
  }
86
92
  end
87
93
 
@@ -139,7 +145,8 @@ module Pgbus
139
145
  "while #{active_backlog.size} queue(s) have visible backlog: #{backlog_names(active_backlog)}"
140
146
  elsif live_drainers.any? && drained_backlog.any? && any_unread?(drained_backlog)
141
147
  unread = drained_backlog.select { |q| unread?(q) }
142
- reasons << "#{unread.size} queue(s) have visible messages with read_ct=0 (never claimed) " \
148
+ reasons << "#{unread.size} queue(s) have visible messages with read_ct=0 " \
149
+ "(never claimed#{unread_age_clause(unread)}) " \
143
150
  "while #{live_drainers.size} draining process(es) are alive: #{backlog_names(unread)}"
144
151
  end
145
152
  reasons
@@ -211,10 +218,39 @@ module Pgbus
211
218
  #
212
219
  # When metrics don't expose the count (an old hash / a stubbed test) we
213
220
  # conservatively treat the queue as unread so we never miss the wedge.
221
+ #
222
+ # Issue #589: the oldest such row must also have been claimable for at
223
+ # least the grace. A fresh enqueue, or a delayed job whose wait just
224
+ # expired, is visible with read_ct=0 until a worker's next poll, so
225
+ # without the grace a healthy fleet read STALLED (and /readyz 503) on a
226
+ # large share of checks. An unknown age (key missing or nil) stays
227
+ # conservative and counts.
214
228
  def unread?(queue)
215
229
  return true unless queue.key?(:visible_unread_length)
230
+ return false unless queue[:visible_unread_length].to_i.positive?
231
+
232
+ age = unread_age(queue)
233
+ age.nil? || age >= grace
234
+ end
235
+
236
+ def grace
237
+ @grace || Pgbus.configuration.health_unclaimed_grace
238
+ end
239
+
240
+ def unread_age(queue)
241
+ queue[:oldest_unread_claimable_age_sec]
242
+ end
243
+
244
+ # Oldest known unread claimable age over +queues+, nil when none is known.
245
+ def oldest_unread_age(queues)
246
+ queues.filter_map { |q| unread_age(q) }.max
247
+ end
216
248
 
217
- queue[:visible_unread_length].to_i.positive?
249
+ # ", oldest claimable for 74s" so an operator can tell a wedge from a
250
+ # spike; empty when no flagged queue reports an age.
251
+ def unread_age_clause(queues)
252
+ age = oldest_unread_age(queues)
253
+ age ? ", oldest claimable for #{age}s" : ""
218
254
  end
219
255
 
220
256
  # Join backlog queue names for a reason string, truncated so a flagged
@@ -13,8 +13,10 @@ module Pgbus
13
13
  description <<~DESC
14
14
  Top-level pgbus health verdict. Returns OK, DEGRADED, or STALLED with
15
15
  the specific reasons. STALLED means the silent-wedge condition:
16
- queues have visible backlog while workers are heart-beating but not
17
- claiming (or a worker's claim loop has stalled). DEGRADED covers
16
+ queues hold a visible, never-claimed backlog older than
17
+ health_unclaimed_grace (default 60s) while workers are heart-beating
18
+ but not claiming (or a worker's claim loop has stalled); the summary's
19
+ oldest_unread_claimable_age_sec tells a wedge from a spike. DEGRADED covers
18
20
  stale processes, paused queues holding a backlog, dead-letter
19
21
  buildup, or a long-running transaction pinning the MVCC horizon. Use
20
22
  this first when triaging — it summarizes the whole system in one call
@@ -267,7 +267,10 @@ module Pgbus
267
267
  Pgbus.client.archive_message(queue_name, message.msg_id.to_i)
268
268
  # A redelivery that succeeds leaves no stale failure behind. First
269
269
  # deliveries never had one: no query on the hot path (#484's gate).
270
- FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i) if message.read_ct.to_i > 1
270
+ if message.read_ct.to_i > 1
271
+ FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i,
272
+ source_queue: config.queue_name(queue_name))
273
+ end
271
274
  @circuit_breaker.record_success(queue_name)
272
275
  record_stat(message, queue_name, "success", execution_start)
273
276
  rescue StandardError => e
@@ -293,7 +296,8 @@ module Pgbus
293
296
  # failed-event row, then announce it. A move that raises keeps the row,
294
297
  # so the next attempt still carries the reason, and announces nothing.
295
298
  def dead_letter(message, queue_name)
296
- last_error = FailedEventRecorder.last_error(queue_name: queue_name, msg_id: message.msg_id.to_i)
299
+ last_error = FailedEventRecorder.last_error(queue_name: queue_name, msg_id: message.msg_id.to_i,
300
+ source_queue: config.queue_name(queue_name))
297
301
  headers = DeadLetterHeader.build(
298
302
  existing: message.headers, reason: DeadLetterHeader::REASON_MAX_RETRIES, source: "consumer",
299
303
  source_queue: config.queue_name(queue_name), source_msg_id: message.msg_id.to_i,
@@ -301,7 +305,8 @@ module Pgbus
301
305
  max_retries: config.max_retries, error: last_error
302
306
  )
303
307
  Pgbus.client.move_to_dead_letter(queue_name, message, headers: headers)
304
- FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i)
308
+ FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i,
309
+ source_queue: config.queue_name(queue_name))
305
310
  instrument_dead_lettered(message, queue_name, last_error)
306
311
  end
307
312
 
@@ -361,10 +366,12 @@ module Pgbus
361
366
  # failure, under the LOGICAL queue name, so the dashboard reads the event
362
367
  # as retrying with its error. No ErrorReporter call: Handler#process!
363
368
  # already instruments pgbus.event_failed, which feeds the reporters.
369
+ # A handler queue has no priority sub-queues: its one table is the source.
364
370
  def record_failed_event(message, queue_name, error)
365
371
  FailedEventRecorder.record!(
366
- queue_name: queue_name, msg_id: message.msg_id.to_i, payload: message.message,
367
- headers: message.headers, error: error, retry_count: [message.read_ct.to_i - 1, 0].max
372
+ queue_name: queue_name, msg_id: message.msg_id.to_i, source_queue: config.queue_name(queue_name),
373
+ payload: message.message, headers: message.headers, error: error,
374
+ retry_count: [message.read_ct.to_i - 1, 0].max
368
375
  )
369
376
  end
370
377
 
@@ -32,6 +32,7 @@ module Pgbus
32
32
  end
33
33
 
34
34
  def start
35
+ @started = true
35
36
  register_process
36
37
  @timer = Concurrent::TimerTask.new(execution_interval: INTERVAL) { beat }
37
38
  @timer.execute
@@ -44,7 +45,8 @@ module Pgbus
44
45
  end
45
46
 
46
47
  def beat
47
- return unless @process_id && !@stopped
48
+ return if @stopped
49
+ return retry_registration unless @process_id
48
50
 
49
51
  @on_beat&.call
50
52
  updates = { last_heartbeat_at: Time.current }
@@ -57,6 +59,15 @@ module Pgbus
57
59
 
58
60
  private
59
61
 
62
+ # The registration at start failed (a DB blip at boot). Retry it here so
63
+ # the process does not stay invisible: a pipeless child checks in with
64
+ # the supervisor's boot deadline only through this row (issue #587).
65
+ def retry_registration
66
+ return unless @started
67
+
68
+ @mutex.synchronize { register_process unless @stopped }
69
+ end
70
+
60
71
  # Build the metadata hash to persist on this beat. Returns nil when no
61
72
  # supplier is configured so heartbeats without dynamic metadata
62
73
  # (supervisor/dispatcher/scheduler) leave the column untouched, exactly
@@ -7,12 +7,20 @@ module Pgbus
7
7
  # server's accept thread — the immutability is what makes the cross-thread
8
8
  # handoff safe without a lock (issue #386).
9
9
  #
10
- # `expected` is the child count forked by boot_processes; `live` is the
11
- # current fork-table size. A child sitting in crash-restart backoff keeps
12
- # `live < expected`, which is exactly the signal a rolling deploy's health
13
- # gate needs to fail on: the replacement container never goes ready, and
14
- # the orchestrator keeps the old container running.
15
- ReadinessSnapshot = Data.define(:booted, :shutting_down, :expected, :live) do
10
+ # `expected` is the child count forked by boot_processes; `forked` is the
11
+ # current fork-table size; `live` counts the forks that have checked in
12
+ # (a process row on this host, or a liveness byte). A child sitting in
13
+ # crash-restart backoff, or one that forked and hangs before checking in
14
+ # (issue #587), keeps `live < expected`, which is exactly the signal a
15
+ # rolling deploy's health gate needs to fail on: the replacement
16
+ # container never goes ready, and the orchestrator keeps the old
17
+ # container running. `forked` defaults to `live` for callers that do not
18
+ # track check-ins.
19
+ ReadinessSnapshot = Data.define(:booted, :shutting_down, :expected, :live, :forked) do
20
+ def initialize(booted:, shutting_down:, expected:, live:, forked: live)
21
+ super
22
+ end
23
+
16
24
  def ready?
17
25
  booted && !shutting_down && live >= expected
18
26
  end
@@ -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