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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +3 -2
- 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/configuration.rb +38 -0
- data/lib/pgbus/failed_event_recorder.rb +135 -42
- data/lib/pgbus/generators/migration_detector.rb +16 -9
- data/lib/pgbus/mcp/health_analyzer.rb +40 -4
- data/lib/pgbus/mcp/tools/health_tool.rb +4 -2
- 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 +44 -25
- data/lib/pgbus/web/health_app.rb +1 -1
- data/sig/pgbus/configuration.rbs +4 -0
- metadata +3 -1
|
@@ -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
|
-
*
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
|
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
|
-
[:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
17
|
-
|
|
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
|
-
|
|
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,
|
|
367
|
-
headers: message.headers, error: error,
|
|
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
|
|
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; `
|
|
11
|
-
# current fork-table size
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
|
|
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,
|
|
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
|