pgbus 0.9.7 → 0.9.8

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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +119 -1
  4. data/Rakefile +10 -1
  5. data/app/helpers/pgbus/application_helper.rb +37 -0
  6. data/app/views/pgbus/processes/_processes_table.html.erb +4 -1
  7. data/config/locales/da.yml +4 -0
  8. data/config/locales/de.yml +4 -0
  9. data/config/locales/en.yml +4 -0
  10. data/config/locales/es.yml +4 -0
  11. data/config/locales/fi.yml +4 -0
  12. data/config/locales/fr.yml +4 -0
  13. data/config/locales/it.yml +4 -0
  14. data/config/locales/ja.yml +4 -0
  15. data/config/locales/nb.yml +4 -0
  16. data/config/locales/nl.yml +4 -0
  17. data/config/locales/pt.yml +4 -0
  18. data/config/locales/sv.yml +4 -0
  19. data/lib/pgbus/active_job/executor.rb +25 -4
  20. data/lib/pgbus/cli/dlq.rb +164 -0
  21. data/lib/pgbus/cli.rb +18 -1
  22. data/lib/pgbus/client/connection_health.rb +194 -0
  23. data/lib/pgbus/client.rb +592 -73
  24. data/lib/pgbus/config_loader.rb +23 -4
  25. data/lib/pgbus/configuration.rb +98 -12
  26. data/lib/pgbus/dedup_cache.rb +8 -0
  27. data/lib/pgbus/doctor.rb +250 -0
  28. data/lib/pgbus/engine.rb +15 -0
  29. data/lib/pgbus/execution_pools/async_pool.rb +7 -0
  30. data/lib/pgbus/execution_pools/thread_pool.rb +7 -0
  31. data/lib/pgbus/instrumentation.rb +1 -0
  32. data/lib/pgbus/integrations/appsignal/probe.rb +23 -1
  33. data/lib/pgbus/metrics/backend.rb +38 -0
  34. data/lib/pgbus/metrics/backends/prometheus.rb +123 -0
  35. data/lib/pgbus/metrics/backends/statsd.rb +64 -0
  36. data/lib/pgbus/metrics/prometheus_exporter.rb +34 -0
  37. data/lib/pgbus/metrics/subscriber.rb +190 -0
  38. data/lib/pgbus/metrics.rb +42 -0
  39. data/lib/pgbus/process/consumer.rb +215 -8
  40. data/lib/pgbus/process/consumer_priority.rb +34 -0
  41. data/lib/pgbus/process/dispatcher.rb +265 -41
  42. data/lib/pgbus/process/heartbeat.rb +18 -5
  43. data/lib/pgbus/process/memory_usage.rb +48 -0
  44. data/lib/pgbus/process/notify_listener.rb +26 -7
  45. data/lib/pgbus/process/notify_probe.rb +96 -0
  46. data/lib/pgbus/process/primary_validator.rb +53 -0
  47. data/lib/pgbus/process/signal_handler.rb +6 -0
  48. data/lib/pgbus/process/supervisor.rb +396 -46
  49. data/lib/pgbus/process/worker.rb +298 -35
  50. data/lib/pgbus/recurring/scheduler.rb +15 -1
  51. data/lib/pgbus/streams/turbo_broadcastable.rb +7 -5
  52. data/lib/pgbus/table_maintenance.rb +13 -2
  53. data/lib/pgbus/version.rb +1 -1
  54. data/lib/pgbus/web/data_source.rb +20 -4
  55. data/lib/pgbus/web/health_app.rb +102 -0
  56. data/lib/pgbus/web/health_server.rb +144 -0
  57. data/lib/pgbus/web/streamer/instance.rb +55 -1
  58. data/lib/pgbus/web/streamer/listener.rb +72 -9
  59. data/lib/pgbus.rb +37 -0
  60. data/lib/rubocop/cop/pgbus/no_ruby_timeout.rb +42 -0
  61. data/lib/rubocop/pgbus.rb +5 -0
  62. data/lib/tasks/pgbus_doctor.rake +12 -0
  63. metadata +18 -1
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "uri"
4
+
3
5
  module Pgbus
4
6
  module Process
5
7
  class Supervisor
@@ -8,26 +10,69 @@ module Pgbus
8
10
  FORK_WAIT = 1 # seconds between fork checks
9
11
  WATCHDOG_INTERVAL = 10 # seconds between stall checks
10
12
 
11
- attr_reader :config
13
+ # A child that crashes within this many seconds of forking is treated
14
+ # as crash-looping and restarted with exponential backoff (base
15
+ # RESTART_BACKOFF_BASE, doubling per consecutive crash, capped at
16
+ # RESTART_BACKOFF_MAX). A child that ran at least this long — or that
17
+ # exited cleanly, like a recycling worker — restarts immediately and
18
+ # resets its crash streak.
19
+ RESTART_STABLE_UPTIME = 30
20
+ RESTART_BACKOFF_BASE = 1
21
+ RESTART_BACKOFF_MAX = 60
12
22
 
13
- def initialize(config: Pgbus.configuration)
23
+ attr_reader :config
24
+ # forks is readable everywhere; the writer exists only so tests can seed a
25
+ # known set of children before exercising the reap/watchdog paths (in
26
+ # production forks is populated by fork_* as children spawn).
27
+ attr_accessor :forks
28
+
29
+ # The child-fork bookkeeping (`forks`, `pending_restarts`) and the
30
+ # `shutting_down` flag accept injected seeds so tests can drive the
31
+ # monitor/reap loops from a known state without poking private ivars. All
32
+ # default to the empty/false values production always starts from.
33
+ def initialize(config: Pgbus.configuration, forks: {}, shutting_down: false,
34
+ pending_restarts: [], last_watchdog_at: nil)
14
35
  @config = config
15
- @forks = {}
16
- @shutting_down = false
17
- @last_watchdog_at = monotonic_now
36
+ @forks = forks
37
+ @shutting_down = shutting_down
38
+ @last_watchdog_at = last_watchdog_at || monotonic_now
39
+ @pending_restarts = pending_restarts
40
+ @crash_counts = Hash.new(0)
18
41
  end
19
42
 
43
+ def shutting_down?
44
+ @shutting_down
45
+ end
46
+
47
+ # Test seam: reset the watchdog clock so check_stalled_workers runs on the
48
+ # next call instead of waiting out WATCHDOG_INTERVAL. Production advances
49
+ # this internally after each watchdog pass.
50
+ attr_writer :last_watchdog_at
51
+
20
52
  def run
21
53
  setup_signals
22
54
  start_heartbeat
55
+ start_health_server
23
56
 
24
57
  Pgbus.logger.info { "[Pgbus] Supervisor starting pid=#{::Process.pid}" }
25
58
 
59
+ # Emit a one-block boot diagnostics banner so operators can read the
60
+ # resolved deployment config (connection target, pool, notify flags,
61
+ # roles, capsules) straight from the log instead of attaching a console.
62
+ log_boot_banner
63
+
64
+ # Fail fast on a bad database_url / connection_params. PGMQ's pool is
65
+ # lazy, so an unreachable DB would otherwise only surface once forked
66
+ # children crash-loop against it. verify_connection! raises
67
+ # Pgbus::ConfigurationError with an actionable message; we let it
68
+ # propagate so the supervisor exits instead of forking anything.
69
+ Pgbus.client.verify_connection!
70
+
26
71
  # Bootstrap queues once in the parent process before forking children.
27
72
  # This avoids the deadlock that occurs when multiple forked children
28
73
  # race to call enable_notify_insert (DROP TRIGGER + CREATE TRIGGER)
29
74
  # concurrently on the same queue tables. Children still call
30
- # bootstrap_queues post-fork but the idempotent check in
75
+ # bootstrap_queues! post-fork but the idempotent check in
31
76
  # notify_trigger_current? makes those calls cheap no-ops.
32
77
  bootstrap_queues
33
78
 
@@ -51,13 +96,133 @@ module Pgbus
51
96
 
52
97
  private
53
98
 
99
+ # Log a single boot diagnostics banner: the settings that actually
100
+ # determine whether this deployment works. One consecutive block of
101
+ # "[Pgbus] boot:"-prefixed info lines so it reads cleanly under both the
102
+ # text and JSON log formatters. Every DB-dependent field is wrapped so a
103
+ # transient failure degrades that field to "unknown" — the banner must
104
+ # never abort boot.
105
+ ROLE_FLAGS = %i[workers dispatcher scheduler consumers outbox].freeze
106
+ private_constant :ROLE_FLAGS
107
+
108
+ def log_boot_banner
109
+ Pgbus.logger.info { "[Pgbus] boot: pgbus #{Pgbus::VERSION} pid=#{::Process.pid}" }
110
+ Pgbus.logger.info do
111
+ "[Pgbus] boot: connection=#{redacted_connection_target} pool=#{banner_field { config.resolved_pool_size }}"
112
+ end
113
+ Pgbus.logger.info do
114
+ "[Pgbus] boot: pgmq_schema_mode=#{config.pgmq_schema_mode} pgmq_version=#{installed_pgmq_version}"
115
+ end
116
+ Pgbus.logger.info do
117
+ "[Pgbus] boot: listen_notify=#{config.listen_notify} worker_notify_wakeup=#{config.worker_notify_wakeup?}"
118
+ end
119
+ Pgbus.logger.info { "[Pgbus] boot: roles=#{enabled_roles.join(",")}" }
120
+ log_capsule_banner
121
+ log_consumer_banner
122
+ end
123
+
124
+ def log_capsule_banner
125
+ return unless config.role_enabled?(:workers)
126
+
127
+ Array(config.workers).each do |worker_config|
128
+ name = worker_config[:name] || worker_config["name"] || "anonymous"
129
+ queues = worker_config[:queues] || worker_config["queues"] || [config.default_queue]
130
+ threads = worker_config[:threads] || worker_config["threads"] || 5
131
+ mode = banner_field { config.execution_mode_for(worker_config) }
132
+ Pgbus.logger.info do
133
+ "[Pgbus] boot: capsule=#{name} queues=#{Array(queues).join(",")} threads=#{threads} mode=#{mode}"
134
+ end
135
+ end
136
+ end
137
+
138
+ def log_consumer_banner
139
+ return unless config.role_enabled?(:consumers)
140
+
141
+ Array(config.event_consumers).each do |consumer_config|
142
+ topics = consumer_config[:topics] || consumer_config["topics"] || []
143
+ threads = consumer_config[:threads] || consumer_config["threads"] || 3
144
+ Pgbus.logger.info do
145
+ "[Pgbus] boot: consumer topics=#{Array(topics).join(",")} threads=#{threads}"
146
+ end
147
+ end
148
+ end
149
+
150
+ # The set of roles that will actually boot, honoring config.roles.
151
+ def enabled_roles
152
+ ROLE_FLAGS.select { |role| config.role_enabled?(role) }
153
+ end
154
+
155
+ # Best-effort PGMQ version from the tracking table. "unknown" on any error
156
+ # (missing table, DB down) so a boot log never fails on diagnostics.
157
+ def installed_pgmq_version
158
+ Pgbus.client.pgmq_schema_version || "unknown"
159
+ rescue StandardError => e
160
+ Pgbus.logger.debug { "[Pgbus] boot: pgmq_schema_version lookup failed: #{e.class}: #{e.message}" }
161
+ "unknown"
162
+ end
163
+
164
+ # Reduce the configured connection to "host/dbname" with the password
165
+ # never printed, across all three connection_options forms: a URL string,
166
+ # a libpq keyword hash, or the AR-derived hash. Falls back to "unknown"
167
+ # rather than leaking anything if the shape is unexpected.
168
+ def redacted_connection_target
169
+ target = config.database_url ? parse_url_target(config.database_url) : parse_hash_target(banner_connection_hash)
170
+ target || "unknown"
171
+ rescue StandardError => e
172
+ Pgbus.logger.debug { "[Pgbus] boot: connection target redaction failed: #{e.class}: #{e.message}" }
173
+ "unknown"
174
+ end
175
+
176
+ # connection_options returns the URL string when database_url is set, a
177
+ # Hash for connection_params or AR-derived config, or a Proc fallback. We
178
+ # only reach here for the non-URL cases, so a non-Hash (Proc) yields nil.
179
+ def banner_connection_hash
180
+ opts = config.connection_options
181
+ opts.is_a?(Hash) ? opts : nil
182
+ end
183
+
184
+ def parse_url_target(url)
185
+ uri = URI.parse(url)
186
+ host = uri.host
187
+ dbname = uri.path.to_s.sub(%r{\A/}, "")
188
+ return nil if host.nil? && dbname.empty?
189
+
190
+ [host, dbname].reject { |s| s.nil? || s.empty? }.join("/")
191
+ rescue URI::InvalidURIError
192
+ nil
193
+ end
194
+
195
+ def parse_hash_target(hash)
196
+ return nil unless hash.is_a?(Hash)
197
+
198
+ host = hash[:host] || hash["host"]
199
+ dbname = hash[:dbname] || hash["dbname"] || hash[:database] || hash["database"]
200
+ return nil if host.nil? && dbname.nil?
201
+
202
+ [host, dbname].compact.join("/")
203
+ end
204
+
205
+ # Evaluate a banner field that touches config/DB; any failure degrades the
206
+ # single field to "unknown" so one broken resolver can't blank the banner.
207
+ def banner_field
208
+ yield
209
+ rescue StandardError => e
210
+ Pgbus.logger.debug { "[Pgbus] boot: banner field failed: #{e.class}: #{e.message}" }
211
+ "unknown"
212
+ end
213
+
54
214
  def boot_processes
55
215
  # Boot workers (workers may be nil for scheduler-only or
56
216
  # dispatcher-only deployments via --workers-only / --scheduler-only /
57
217
  # --dispatcher-only CLI flags). Each role is gated by
58
218
  # config.role_enabled?, which returns true unless +config.roles+ has
59
219
  # been narrowed.
60
- Array(config.workers).each { |worker_config| fork_worker(worker_config) } if config.role_enabled?(:workers)
220
+ if config.role_enabled?(:workers)
221
+ # slot is the child's position in the config array — it keys the
222
+ # crash-streak tracking so identically-configured siblings don't
223
+ # share (and reset) each other's restart backoff.
224
+ Array(config.workers).each_with_index { |worker_config, slot| fork_worker(worker_config, slot: slot) }
225
+ end
61
226
 
62
227
  fork_dispatcher if config.role_enabled?(:dispatcher)
63
228
  boot_scheduler if config.role_enabled?(:scheduler)
@@ -65,35 +230,60 @@ module Pgbus
65
230
  boot_outbox_poller if config.role_enabled?(:outbox)
66
231
  end
67
232
 
68
- def fork_worker(worker_config)
69
- queues = worker_config[:queues] || worker_config["queues"] || [config.default_queue]
70
- threads = worker_config[:threads] || worker_config["threads"] || 5
71
- single_active = worker_config[:single_active_consumer] || worker_config["single_active_consumer"] || false
72
- priority = worker_config[:consumer_priority] || worker_config["consumer_priority"] || 0
233
+ def fork_worker(worker_config, slot: nil)
234
+ queues = worker_config[:queues] || [config.default_queue]
235
+ threads = worker_config[:threads] || 5
236
+ single_active = worker_config[:single_active_consumer] || false
237
+ priority = worker_config[:consumer_priority] || 0
73
238
  exec_mode = config.execution_mode_for(worker_config)
74
- grp_mode = worker_config[:group_mode] || worker_config["group_mode"] || config.group_mode
239
+ grp_mode = worker_config[:group_mode] || config.group_mode
240
+
241
+ # OS-level liveness channel: the child writes a byte each loop
242
+ # iteration, the parent drains the reader in monitor_loop. This lets
243
+ # the watchdog detect a wedged worker without the database.
244
+ liveness_reader, liveness_writer = IO.pipe
75
245
 
76
246
  pid = fork do
247
+ # Child owns the writer end. A fork copies the whole parent FD
248
+ # table, so this child also inherits every earlier sibling's
249
+ # liveness reader (they live in @forks). Close them — otherwise each
250
+ # new worker accumulates N-1 stale reader FDs. (Sibling *writers*
251
+ # are never inherited: the parent closes each writer below before
252
+ # forking the next worker, so a dead sibling's pipe still reaches
253
+ # EOF correctly.) Finally close this fork's own reader copy.
254
+ close_inherited_liveness_readers
255
+ liveness_reader.close
77
256
  restore_signals
78
257
  setup_child_process
79
258
  load_rails_app
80
- bootstrap_queues
259
+ bootstrap_queues!
81
260
  worker = Worker.new(
82
261
  queues: queues, threads: threads, config: config,
83
262
  single_active_consumer: single_active, consumer_priority: priority,
84
- execution_mode: exec_mode, group_mode: grp_mode
263
+ execution_mode: exec_mode, group_mode: grp_mode,
264
+ liveness_pipe: liveness_writer
85
265
  )
86
266
  worker.run
87
267
  end
88
268
 
89
269
  unless pid
270
+ close_pipe(liveness_reader)
271
+ close_pipe(liveness_writer)
90
272
  Pgbus.logger.error { "[Pgbus] Failed to fork worker for queues=#{queues.join(",")}" }
91
273
  return
92
274
  end
93
275
 
94
- @forks[pid] = { type: :worker, config: worker_config }
276
+ # Parent keeps the reader, discards its writer copy so the pipe reaches
277
+ # EOF once the (sole remaining) child writer closes.
278
+ close_pipe(liveness_writer)
279
+ @forks[pid] = {
280
+ type: :worker, config: worker_config, slot: slot, spawned_at: monotonic_now,
281
+ liveness_reader: liveness_reader, last_pipe_tick_at: monotonic_now, pipe_seen: false
282
+ }
95
283
  Pgbus.logger.info { "[Pgbus] Forked worker pid=#{pid} queues=#{queues.join(",")} mode=#{exec_mode}" }
96
284
  rescue Errno::EAGAIN, Errno::ENOMEM => e
285
+ close_pipe(liveness_reader)
286
+ close_pipe(liveness_writer)
97
287
  ErrorReporter.report(e, { action: "fork_worker", queues: queues })
98
288
  end
99
289
 
@@ -111,7 +301,7 @@ module Pgbus
111
301
  return
112
302
  end
113
303
 
114
- @forks[pid] = { type: :dispatcher }
304
+ @forks[pid] = { type: :dispatcher, spawned_at: monotonic_now }
115
305
  Pgbus.logger.info { "[Pgbus] Forked dispatcher pid=#{pid}" }
116
306
  rescue Errno::EAGAIN, Errno::ENOMEM => e
117
307
  ErrorReporter.report(e, { action: "fork_dispatcher" })
@@ -130,7 +320,7 @@ module Pgbus
130
320
  setup_child_process
131
321
  load_rails_app
132
322
  load_recurring_config
133
- bootstrap_queues
323
+ bootstrap_queues!
134
324
  scheduler = Recurring::Scheduler.new(config: config)
135
325
  scheduler.run
136
326
  end
@@ -140,7 +330,7 @@ module Pgbus
140
330
  return
141
331
  end
142
332
 
143
- @forks[pid] = { type: :scheduler }
333
+ @forks[pid] = { type: :scheduler, spawned_at: monotonic_now }
144
334
  Pgbus.logger.info { "[Pgbus] Forked scheduler pid=#{pid}" }
145
335
  rescue Errno::EAGAIN, Errno::ENOMEM => e
146
336
  ErrorReporter.report(e, { action: "fork_scheduler" })
@@ -182,14 +372,14 @@ module Pgbus
182
372
  def boot_consumers
183
373
  return unless config.event_consumers
184
374
 
185
- config.event_consumers.each do |consumer_config|
186
- fork_consumer(consumer_config)
375
+ config.event_consumers.each_with_index do |consumer_config, slot|
376
+ fork_consumer(consumer_config, slot: slot)
187
377
  end
188
378
  end
189
379
 
190
- def fork_consumer(consumer_config)
191
- topics = consumer_config[:topics] || consumer_config["topics"]
192
- threads = consumer_config[:threads] || consumer_config["threads"] || 3
380
+ def fork_consumer(consumer_config, slot: nil)
381
+ topics = consumer_config[:topics]
382
+ threads = consumer_config[:threads] || 3
193
383
 
194
384
  pid = fork do
195
385
  restore_signals
@@ -204,7 +394,7 @@ module Pgbus
204
394
  return
205
395
  end
206
396
 
207
- @forks[pid] = { type: :consumer, config: consumer_config }
397
+ @forks[pid] = { type: :consumer, config: consumer_config, slot: slot, spawned_at: monotonic_now }
208
398
  Pgbus.logger.info { "[Pgbus] Forked consumer pid=#{pid} topics=#{topics.join(",")}" }
209
399
  rescue Errno::EAGAIN, Errno::ENOMEM => e
210
400
  ErrorReporter.report(e, { action: "fork_consumer", topics: topics })
@@ -230,7 +420,7 @@ module Pgbus
230
420
  return
231
421
  end
232
422
 
233
- @forks[pid] = { type: :outbox_poller }
423
+ @forks[pid] = { type: :outbox_poller, spawned_at: monotonic_now }
234
424
  Pgbus.logger.info { "[Pgbus] Forked outbox poller pid=#{pid}" }
235
425
  rescue Errno::EAGAIN, Errno::ENOMEM => e
236
426
  ErrorReporter.report(e, { action: "fork_outbox_poller" })
@@ -242,7 +432,11 @@ module Pgbus
242
432
 
243
433
  process_signals
244
434
  reap_children
245
- check_stalled_workers unless @shutting_down
435
+ drain_liveness_pipes
436
+ unless @shutting_down
437
+ process_pending_restarts
438
+ check_stalled_workers
439
+ end
246
440
  interruptible_sleep(FORK_WAIT)
247
441
  end
248
442
  end
@@ -255,29 +449,69 @@ module Pgbus
255
449
  info = @forks.delete(pid)
256
450
  next unless info
257
451
 
452
+ # Close the liveness reader as the fork leaves @forks so a crash-loop
453
+ # (restart deferred up to RESTART_BACKOFF_MAX) can't leak an FD per
454
+ # crash. Scrub the key so the closed IO never rides into a restart.
455
+ close_pipe(info.delete(:liveness_reader))
456
+
258
457
  if @shutting_down
259
458
  Pgbus.logger.info { "[Pgbus] Child #{info[:type]} pid=#{pid} exited (status=#{status.exitstatus})" }
260
459
  else
261
460
  Pgbus.logger.warn do
262
- "[Pgbus] Child #{info[:type]} pid=#{pid} exited unexpectedly (status=#{status&.exitstatus}), restarting..."
461
+ "[Pgbus] Child #{info[:type]} pid=#{pid} exited unexpectedly (status=#{status&.exitstatus})"
263
462
  end
264
- restart_child(info)
463
+ schedule_restart(info, status)
265
464
  end
266
465
  rescue Errno::ECHILD
267
466
  break
268
467
  end
269
468
  end
270
469
 
470
+ # Restart policy: a clean exit (worker recycling) or a crash after a
471
+ # stable run restarts immediately with a fresh crash streak. A crash
472
+ # within RESTART_STABLE_UPTIME of forking is a crash loop — the child
473
+ # is dying on boot (bad config, unreachable DB, raising initializer) —
474
+ # so back off exponentially instead of fork-crash-forking at full speed.
475
+ # A child with no spawned_at (never set in practice) restarts
476
+ # immediately, preserving the pre-backoff behavior.
477
+ def schedule_restart(info, status)
478
+ # Keyed on [type, slot], NOT the config hash — identically-configured
479
+ # sibling workers would otherwise share one streak, letting one
480
+ # sibling's clean recycle reset another sibling's crash backoff.
481
+ key = [info[:type], info[:slot]]
482
+ uptime = info[:spawned_at] ? monotonic_now - info[:spawned_at] : nil
483
+
484
+ if status&.success? || uptime.nil? || uptime >= RESTART_STABLE_UPTIME
485
+ @crash_counts.delete(key)
486
+ return restart_child(info)
487
+ end
488
+
489
+ crashes = @crash_counts[key] += 1
490
+ backoff = [RESTART_BACKOFF_BASE * (2**(crashes - 1)), RESTART_BACKOFF_MAX].min
491
+ Pgbus.logger.warn do
492
+ "[Pgbus] Child #{info[:type]} crashed after #{uptime.round(1)}s uptime " \
493
+ "(crash ##{crashes}) — restarting in #{backoff}s"
494
+ end
495
+ @pending_restarts << { info: info, at: monotonic_now + backoff }
496
+ end
497
+
498
+ def process_pending_restarts
499
+ now = monotonic_now
500
+ due, pending = @pending_restarts.partition { |r| r[:at] <= now }
501
+ @pending_restarts = pending
502
+ due.each { |r| restart_child(r[:info]) }
503
+ end
504
+
271
505
  def restart_child(info)
272
506
  case info[:type]
273
507
  when :worker
274
- fork_worker(info[:config])
508
+ fork_worker(info[:config], slot: info[:slot])
275
509
  when :dispatcher
276
510
  fork_dispatcher
277
511
  when :scheduler
278
512
  fork_scheduler
279
513
  when :consumer
280
- fork_consumer(info[:config])
514
+ fork_consumer(info[:config], slot: info[:slot])
281
515
  when :outbox_poller
282
516
  fork_outbox_poller
283
517
  end
@@ -294,30 +528,91 @@ module Pgbus
294
528
  worker_pids = @forks.select { |_, info| info[:type] == :worker }.keys
295
529
  return if worker_pids.empty?
296
530
 
297
- rows = ProcessEntry.where(kind: "worker", pid: worker_pids).to_a
298
- rows.each do |entry|
531
+ db_ages = db_loop_tick_ages(worker_pids)
532
+
533
+ worker_pids.each do |pid|
534
+ info = @forks[pid]
535
+ next unless info
536
+
537
+ kill_stalled_worker(pid, threshold) if worker_stalled?(info, db_ages[pid], now, threshold)
538
+ end
539
+ rescue StandardError => e
540
+ Pgbus.logger.warn { "[Pgbus] Supervisor watchdog check failed: #{e.message}" }
541
+ end
542
+
543
+ # Read each worker's loop_tick_at from the process table and return a
544
+ # {pid => wall-clock age in seconds} map. Isolated in its own rescue so a
545
+ # database outage degrades to the OS-pipe fallback instead of skipping
546
+ # the whole watchdog — the exact failure this pipe channel exists to fix.
547
+ def db_loop_tick_ages(worker_pids)
548
+ ages = {}
549
+ ProcessEntry.where(kind: "worker", pid: worker_pids).to_a.each do |entry|
299
550
  meta = entry.metadata
300
551
  next unless meta.is_a?(Hash)
301
552
 
302
553
  loop_tick = meta["loop_tick_at"]
303
- next unless loop_tick
554
+ ages[entry.pid] = Time.current.to_f - loop_tick.to_f if loop_tick
555
+ end
556
+ ages
557
+ rescue StandardError => e
558
+ Pgbus.logger.warn { "[Pgbus] Supervisor watchdog DB read failed, using pipe fallback: #{e.message}" }
559
+ ages
560
+ end
304
561
 
305
- age = Time.current.to_f - loop_tick.to_f
306
- next unless age > threshold
562
+ # A worker is stalled only when EVERY liveness channel that has spoken
563
+ # agrees it is stale (min-age / fresh-wins): a fresh DB row OR a fresh
564
+ # pipe tick proves the loop is advancing. If no channel has any signal
565
+ # (a slow-booting worker with no DB row and an unarmed pipe), we do not
566
+ # kill — mirroring the pre-existing "no loop_tick_at → skip" tolerance.
567
+ def worker_stalled?(info, db_age, now, threshold)
568
+ pipe_age = (now - info[:last_pipe_tick_at] if info[:pipe_seen] && info[:last_pipe_tick_at])
569
+ ages = [db_age, pipe_age].compact
570
+ return false if ages.empty?
571
+
572
+ ages.min > threshold
573
+ end
307
574
 
308
- pid = entry.pid
309
- Pgbus.logger.error do
310
- "[Pgbus] Supervisor watchdog: worker pid=#{pid} claim loop stalled " \
311
- "(loop_tick_at age=#{age.round(1)}s > threshold=#{threshold}s), sending SIGKILL"
312
- end
575
+ def kill_stalled_worker(pid, threshold)
576
+ Pgbus.logger.error do
577
+ "[Pgbus] Supervisor watchdog: worker pid=#{pid} claim loop stalled " \
578
+ "(no liveness within threshold=#{threshold}s), sending SIGKILL"
579
+ end
580
+ ::Process.kill("KILL", pid)
581
+ rescue Errno::ESRCH
582
+ # already gone
583
+ end
584
+
585
+ # Drain each worker's liveness pipe. Any readable byte means the worker's
586
+ # loop advanced since the last drain, so we stamp arrival on the parent's
587
+ # own monotonic clock (never a worker timestamp — that would cross the
588
+ # fork's incomparable CLOCK_MONOTONIC) and arm pipe_seen. Bounded to a
589
+ # few reads per reader so a fast-writing worker can't wedge the 1s loop;
590
+ # "any bytes ⇒ alive" needs no full drain. Per-reader rescue so one
591
+ # closed reader (racing reap/shutdown) can't skip the rest.
592
+ def drain_liveness_pipes
593
+ now = monotonic_now
594
+ @forks.each_value do |info|
595
+ reader = info[:liveness_reader]
596
+ next unless reader
597
+
598
+ read_any = false
313
599
  begin
314
- ::Process.kill("KILL", pid)
315
- rescue Errno::ESRCH
316
- # already gone
600
+ 2.times do
601
+ reader.read_nonblock(4096)
602
+ read_any = true
603
+ end
604
+ rescue IO::WaitReadable, EOFError
605
+ # empty / drained, or writer closed (worker exiting — reap handles it)
606
+ rescue IOError, Errno::EBADF
607
+ # reader closed by a racing reap/shutdown this tick
608
+ next
609
+ end
610
+
611
+ if read_any
612
+ info[:last_pipe_tick_at] = now
613
+ info[:pipe_seen] = true
317
614
  end
318
615
  end
319
- rescue StandardError => e
320
- Pgbus.logger.warn { "[Pgbus] Supervisor watchdog check failed: #{e.message}" }
321
616
  end
322
617
 
323
618
  def signal_children(sig)
@@ -338,12 +633,33 @@ module Pgbus
338
633
  end
339
634
  end
340
635
 
636
+ # Lenient bootstrap for the parent (supervisor.rb#run). A transiently
637
+ # unavailable DB at boot must not kill the supervisor — children
638
+ # crash-and-backoff until it recovers — so every StandardError (including
639
+ # SchemaNotReady) is reported and swallowed.
341
640
  def bootstrap_queues
342
641
  Pgbus.client.ensure_all_queues
343
642
  rescue StandardError => e
344
643
  ErrorReporter.report(e, { action: "bootstrap_queues" })
345
644
  end
346
645
 
646
+ # Strict bootstrap for forked children (fork_worker, fork_scheduler). A
647
+ # genuinely missing schema (database absent, migrations not run) surfaces
648
+ # as Pgbus::SchemaNotReady — log its already-actionable message once and
649
+ # re-raise so the child exits non-zero. schedule_restart then paces the
650
+ # crash loop with exponential backoff (up to RESTART_BACKOFF_MAX) instead
651
+ # of letting children boot and drown in downstream "relation does not
652
+ # exist" errors. Other StandardErrors stay reported-and-swallowed, exactly
653
+ # as the lenient variant handles them.
654
+ def bootstrap_queues!
655
+ Pgbus.client.ensure_all_queues
656
+ rescue Pgbus::SchemaNotReady => e
657
+ Pgbus.logger.error { "[Pgbus] #{e.message}" }
658
+ raise
659
+ rescue StandardError => e
660
+ ErrorReporter.report(e, { action: "bootstrap_queues" })
661
+ end
662
+
347
663
  def load_rails_app
348
664
  return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
349
665
 
@@ -358,10 +674,39 @@ module Pgbus
358
674
  @heartbeat.start
359
675
  end
360
676
 
677
+ # Serve /livez and /readyz over a plain TCP server when health_port is
678
+ # configured. This gives orchestrators (Kubernetes) an HTTP probe surface
679
+ # on the supervisor itself — the process that forks and watches workers —
680
+ # without booting Rails or the dashboard. Disabled (nil) by default;
681
+ # host apps that already run Puma can mount Pgbus::Web::HealthApp instead.
682
+ def start_health_server
683
+ return unless config.health_port
684
+
685
+ @health_server = Pgbus::Web::HealthServer.new(port: config.health_port, bind: config.health_bind)
686
+ @health_server.start
687
+ end
688
+
361
689
  def monotonic_now
362
690
  ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
363
691
  end
364
692
 
693
+ # Close a pipe IO idempotently. nil (non-worker forks, already-scrubbed
694
+ # entries) and already-closed IOs are no-ops; a rare EBADF/IOError from a
695
+ # racing close is swallowed. FD management is single-threaded (main loop
696
+ # only), so the closed? check-then-close needs no lock.
697
+ def close_pipe(io)
698
+ io.close if io && !io.closed?
699
+ rescue IOError, Errno::EBADF
700
+ nil
701
+ end
702
+
703
+ # Close every sibling liveness reader this fork inherited from the
704
+ # parent's FD table. Called only inside a just-forked child, before it
705
+ # becomes a Worker, so the worker never holds its siblings' pipe ends.
706
+ def close_inherited_liveness_readers
707
+ @forks.each_value { |info| close_pipe(info[:liveness_reader]) }
708
+ end
709
+
365
710
  def shutdown
366
711
  # Wait for all children with timeout
367
712
  deadline = Time.now + 30
@@ -374,6 +719,11 @@ module Pgbus
374
719
  # Force kill any remaining
375
720
  signal_children("KILL") unless @forks.empty?
376
721
 
722
+ # Close any liveness readers still open on un-reaped children so the
723
+ # supervisor never leaks FDs across a restart of itself.
724
+ @forks.each_value { |info| close_pipe(info[:liveness_reader]) }
725
+
726
+ @health_server&.stop
377
727
  @heartbeat&.stop
378
728
  restore_signals
379
729
  Pgbus.logger.info { "[Pgbus] Supervisor stopped" }