pgbus 0.9.7 → 0.9.9

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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +367 -0
  3. data/README.md +454 -25
  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/generators/pgbus/{add_job_locks_generator.rb → add_uniqueness_keys_generator.rb} +5 -5
  20. data/lib/pgbus/active_job/adapter.rb +1 -1
  21. data/lib/pgbus/active_job/executor.rb +25 -4
  22. data/lib/pgbus/cli/dlq.rb +164 -0
  23. data/lib/pgbus/cli.rb +18 -1
  24. data/lib/pgbus/client/connection_health.rb +194 -0
  25. data/lib/pgbus/client.rb +592 -73
  26. data/lib/pgbus/config_loader.rb +50 -4
  27. data/lib/pgbus/configuration.rb +294 -79
  28. data/lib/pgbus/dedup_cache.rb +8 -0
  29. data/lib/pgbus/doctor.rb +275 -0
  30. data/lib/pgbus/engine.rb +15 -0
  31. data/lib/pgbus/execution_pools/async_pool.rb +9 -2
  32. data/lib/pgbus/execution_pools/thread_pool.rb +7 -0
  33. data/lib/pgbus/generators/config_converter.rb +7 -5
  34. data/lib/pgbus/generators/migration_detector.rb +20 -16
  35. data/lib/pgbus/instrumentation.rb +3 -1
  36. data/lib/pgbus/integrations/appsignal/probe.rb +23 -1
  37. data/lib/pgbus/integrations/appsignal/subscriber.rb +17 -2
  38. data/lib/pgbus/metrics/backend.rb +38 -0
  39. data/lib/pgbus/metrics/backends/prometheus.rb +123 -0
  40. data/lib/pgbus/metrics/backends/statsd.rb +64 -0
  41. data/lib/pgbus/metrics/prometheus_exporter.rb +34 -0
  42. data/lib/pgbus/metrics/subscriber.rb +214 -0
  43. data/lib/pgbus/metrics.rb +43 -0
  44. data/lib/pgbus/pgmq_schema/pgmq_v1.11.1.sql +2126 -0
  45. data/lib/pgbus/pgmq_schema.rb +7 -2
  46. data/lib/pgbus/process/consumer.rb +354 -18
  47. data/lib/pgbus/process/consumer_priority.rb +34 -0
  48. data/lib/pgbus/process/dispatcher.rb +265 -41
  49. data/lib/pgbus/process/heartbeat.rb +18 -5
  50. data/lib/pgbus/process/memory_usage.rb +48 -0
  51. data/lib/pgbus/process/notify_listener.rb +26 -7
  52. data/lib/pgbus/process/notify_probe.rb +96 -0
  53. data/lib/pgbus/process/primary_validator.rb +53 -0
  54. data/lib/pgbus/process/signal_handler.rb +6 -0
  55. data/lib/pgbus/process/supervisor.rb +423 -50
  56. data/lib/pgbus/process/worker.rb +288 -35
  57. data/lib/pgbus/recurring/schedule.rb +1 -2
  58. data/lib/pgbus/recurring/scheduler.rb +15 -1
  59. data/lib/pgbus/serializer.rb +4 -4
  60. data/lib/pgbus/streams/broadcastable_override.rb +0 -8
  61. data/lib/pgbus/streams/signed_name.rb +2 -2
  62. data/lib/pgbus/streams/turbo_broadcastable.rb +7 -5
  63. data/lib/pgbus/table_maintenance.rb +13 -2
  64. data/lib/pgbus/uniqueness.rb +11 -12
  65. data/lib/pgbus/version.rb +1 -1
  66. data/lib/pgbus/web/data_source.rb +36 -4
  67. data/lib/pgbus/web/health_app.rb +102 -0
  68. data/lib/pgbus/web/health_server.rb +144 -0
  69. data/lib/pgbus/web/payload_filter.rb +3 -3
  70. data/lib/pgbus/web/streamer/instance.rb +58 -2
  71. data/lib/pgbus/web/streamer/listener.rb +69 -21
  72. data/lib/pgbus.rb +77 -0
  73. data/lib/tasks/pgbus_doctor.rake +12 -0
  74. metadata +19 -4
  75. data/app/models/pgbus/job_lock.rb +0 -98
  76. data/lib/generators/pgbus/templates/add_job_locks.rb.erb +0 -21
@@ -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,14 +301,14 @@ 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" })
118
308
  end
119
309
 
120
310
  def boot_scheduler
121
- return if config.skip_recurring
311
+ return unless config.recurring_enabled
122
312
  return unless recurring_tasks_configured?
123
313
 
124
314
  fork_scheduler
@@ -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,31 +372,52 @@ 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
383
+
384
+ # OS-level liveness channel: the consumer writes a byte each loop
385
+ # iteration, the parent drains the reader in monitor_loop. This lets the
386
+ # watchdog detect a wedged consumer without the database (issue #274),
387
+ # exactly as fork_worker does for workers.
388
+ liveness_reader, liveness_writer = IO.pipe
193
389
 
194
390
  pid = fork do
391
+ # Child owns the writer end. Close every earlier sibling's inherited
392
+ # liveness reader and this fork's own reader copy before becoming a
393
+ # Consumer (see fork_worker for the full rationale).
394
+ close_inherited_liveness_readers
395
+ liveness_reader.close
195
396
  restore_signals
196
397
  setup_child_process
197
398
  load_rails_app
198
- consumer = Consumer.new(topics: topics, threads: threads, config: config)
399
+ consumer = Consumer.new(topics: topics, threads: threads, config: config, liveness_pipe: liveness_writer)
199
400
  consumer.run
200
401
  end
201
402
 
202
403
  unless pid
404
+ close_pipe(liveness_reader)
405
+ close_pipe(liveness_writer)
203
406
  Pgbus.logger.error { "[Pgbus] Failed to fork consumer for topics=#{topics.join(",")}" }
204
407
  return
205
408
  end
206
409
 
207
- @forks[pid] = { type: :consumer, config: consumer_config }
410
+ # Parent keeps the reader, discards its writer copy so the pipe reaches
411
+ # EOF once the (sole remaining) child writer closes.
412
+ close_pipe(liveness_writer)
413
+ @forks[pid] = {
414
+ type: :consumer, config: consumer_config, slot: slot, spawned_at: monotonic_now,
415
+ liveness_reader: liveness_reader, last_pipe_tick_at: monotonic_now, pipe_seen: false
416
+ }
208
417
  Pgbus.logger.info { "[Pgbus] Forked consumer pid=#{pid} topics=#{topics.join(",")}" }
209
418
  rescue Errno::EAGAIN, Errno::ENOMEM => e
419
+ close_pipe(liveness_reader)
420
+ close_pipe(liveness_writer)
210
421
  ErrorReporter.report(e, { action: "fork_consumer", topics: topics })
211
422
  end
212
423
 
@@ -230,7 +441,7 @@ module Pgbus
230
441
  return
231
442
  end
232
443
 
233
- @forks[pid] = { type: :outbox_poller }
444
+ @forks[pid] = { type: :outbox_poller, spawned_at: monotonic_now }
234
445
  Pgbus.logger.info { "[Pgbus] Forked outbox poller pid=#{pid}" }
235
446
  rescue Errno::EAGAIN, Errno::ENOMEM => e
236
447
  ErrorReporter.report(e, { action: "fork_outbox_poller" })
@@ -242,7 +453,11 @@ module Pgbus
242
453
 
243
454
  process_signals
244
455
  reap_children
245
- check_stalled_workers unless @shutting_down
456
+ drain_liveness_pipes
457
+ unless @shutting_down
458
+ process_pending_restarts
459
+ check_stalled_workers
460
+ end
246
461
  interruptible_sleep(FORK_WAIT)
247
462
  end
248
463
  end
@@ -255,29 +470,69 @@ module Pgbus
255
470
  info = @forks.delete(pid)
256
471
  next unless info
257
472
 
473
+ # Close the liveness reader as the fork leaves @forks so a crash-loop
474
+ # (restart deferred up to RESTART_BACKOFF_MAX) can't leak an FD per
475
+ # crash. Scrub the key so the closed IO never rides into a restart.
476
+ close_pipe(info.delete(:liveness_reader))
477
+
258
478
  if @shutting_down
259
479
  Pgbus.logger.info { "[Pgbus] Child #{info[:type]} pid=#{pid} exited (status=#{status.exitstatus})" }
260
480
  else
261
481
  Pgbus.logger.warn do
262
- "[Pgbus] Child #{info[:type]} pid=#{pid} exited unexpectedly (status=#{status&.exitstatus}), restarting..."
482
+ "[Pgbus] Child #{info[:type]} pid=#{pid} exited unexpectedly (status=#{status&.exitstatus})"
263
483
  end
264
- restart_child(info)
484
+ schedule_restart(info, status)
265
485
  end
266
486
  rescue Errno::ECHILD
267
487
  break
268
488
  end
269
489
  end
270
490
 
491
+ # Restart policy: a clean exit (worker recycling) or a crash after a
492
+ # stable run restarts immediately with a fresh crash streak. A crash
493
+ # within RESTART_STABLE_UPTIME of forking is a crash loop — the child
494
+ # is dying on boot (bad config, unreachable DB, raising initializer) —
495
+ # so back off exponentially instead of fork-crash-forking at full speed.
496
+ # A child with no spawned_at (never set in practice) restarts
497
+ # immediately, preserving the pre-backoff behavior.
498
+ def schedule_restart(info, status)
499
+ # Keyed on [type, slot], NOT the config hash — identically-configured
500
+ # sibling workers would otherwise share one streak, letting one
501
+ # sibling's clean recycle reset another sibling's crash backoff.
502
+ key = [info[:type], info[:slot]]
503
+ uptime = info[:spawned_at] ? monotonic_now - info[:spawned_at] : nil
504
+
505
+ if status&.success? || uptime.nil? || uptime >= RESTART_STABLE_UPTIME
506
+ @crash_counts.delete(key)
507
+ return restart_child(info)
508
+ end
509
+
510
+ crashes = @crash_counts[key] += 1
511
+ backoff = [RESTART_BACKOFF_BASE * (2**(crashes - 1)), RESTART_BACKOFF_MAX].min
512
+ Pgbus.logger.warn do
513
+ "[Pgbus] Child #{info[:type]} crashed after #{uptime.round(1)}s uptime " \
514
+ "(crash ##{crashes}) — restarting in #{backoff}s"
515
+ end
516
+ @pending_restarts << { info: info, at: monotonic_now + backoff }
517
+ end
518
+
519
+ def process_pending_restarts
520
+ now = monotonic_now
521
+ due, pending = @pending_restarts.partition { |r| r[:at] <= now }
522
+ @pending_restarts = pending
523
+ due.each { |r| restart_child(r[:info]) }
524
+ end
525
+
271
526
  def restart_child(info)
272
527
  case info[:type]
273
528
  when :worker
274
- fork_worker(info[:config])
529
+ fork_worker(info[:config], slot: info[:slot])
275
530
  when :dispatcher
276
531
  fork_dispatcher
277
532
  when :scheduler
278
533
  fork_scheduler
279
534
  when :consumer
280
- fork_consumer(info[:config])
535
+ fork_consumer(info[:config], slot: info[:slot])
281
536
  when :outbox_poller
282
537
  fork_outbox_poller
283
538
  end
@@ -291,33 +546,96 @@ module Pgbus
291
546
  threshold = config.stall_threshold
292
547
  return unless threshold&.positive?
293
548
 
294
- worker_pids = @forks.select { |_, info| info[:type] == :worker }.keys
295
- return if worker_pids.empty?
549
+ # Workers AND consumers both stamp a loop beacon and carry a liveness
550
+ # pipe (issue #274), so both are watched for a wedged claim/consume loop.
551
+ watched_pids = @forks.select { |_, info| %i[worker consumer].include?(info[:type]) }.keys
552
+ return if watched_pids.empty?
553
+
554
+ db_ages = db_loop_tick_ages(watched_pids)
296
555
 
297
- rows = ProcessEntry.where(kind: "worker", pid: worker_pids).to_a
298
- rows.each do |entry|
556
+ watched_pids.each do |pid|
557
+ info = @forks[pid]
558
+ next unless info
559
+
560
+ kill_stalled_worker(pid, threshold) if worker_stalled?(info, db_ages[pid], now, threshold)
561
+ end
562
+ rescue StandardError => e
563
+ Pgbus.logger.warn { "[Pgbus] Supervisor watchdog check failed: #{e.message}" }
564
+ end
565
+
566
+ # Read each watched fork's loop_tick_at from the process table and return
567
+ # a {pid => wall-clock age in seconds} map. Isolated in its own rescue so a
568
+ # database outage degrades to the OS-pipe fallback instead of skipping
569
+ # the whole watchdog — the exact failure this pipe channel exists to fix.
570
+ def db_loop_tick_ages(worker_pids)
571
+ ages = {}
572
+ ProcessEntry.where(kind: %w[worker consumer], pid: worker_pids).to_a.each do |entry|
299
573
  meta = entry.metadata
300
574
  next unless meta.is_a?(Hash)
301
575
 
302
576
  loop_tick = meta["loop_tick_at"]
303
- next unless loop_tick
577
+ ages[entry.pid] = Time.current.to_f - loop_tick.to_f if loop_tick
578
+ end
579
+ ages
580
+ rescue StandardError => e
581
+ Pgbus.logger.warn { "[Pgbus] Supervisor watchdog DB read failed, using pipe fallback: #{e.message}" }
582
+ ages
583
+ end
304
584
 
305
- age = Time.current.to_f - loop_tick.to_f
306
- next unless age > threshold
585
+ # A worker is stalled only when EVERY liveness channel that has spoken
586
+ # agrees it is stale (min-age / fresh-wins): a fresh DB row OR a fresh
587
+ # pipe tick proves the loop is advancing. If no channel has any signal
588
+ # (a slow-booting worker with no DB row and an unarmed pipe), we do not
589
+ # kill — mirroring the pre-existing "no loop_tick_at → skip" tolerance.
590
+ def worker_stalled?(info, db_age, now, threshold)
591
+ pipe_age = (now - info[:last_pipe_tick_at] if info[:pipe_seen] && info[:last_pipe_tick_at])
592
+ ages = [db_age, pipe_age].compact
593
+ return false if ages.empty?
594
+
595
+ ages.min > threshold
596
+ end
307
597
 
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
598
+ def kill_stalled_worker(pid, threshold)
599
+ Pgbus.logger.error do
600
+ "[Pgbus] Supervisor watchdog: worker pid=#{pid} claim loop stalled " \
601
+ "(no liveness within threshold=#{threshold}s), sending SIGKILL"
602
+ end
603
+ ::Process.kill("KILL", pid)
604
+ rescue Errno::ESRCH
605
+ # already gone
606
+ end
607
+
608
+ # Drain each worker's liveness pipe. Any readable byte means the worker's
609
+ # loop advanced since the last drain, so we stamp arrival on the parent's
610
+ # own monotonic clock (never a worker timestamp — that would cross the
611
+ # fork's incomparable CLOCK_MONOTONIC) and arm pipe_seen. Bounded to a
612
+ # few reads per reader so a fast-writing worker can't wedge the 1s loop;
613
+ # "any bytes ⇒ alive" needs no full drain. Per-reader rescue so one
614
+ # closed reader (racing reap/shutdown) can't skip the rest.
615
+ def drain_liveness_pipes
616
+ now = monotonic_now
617
+ @forks.each_value do |info|
618
+ reader = info[:liveness_reader]
619
+ next unless reader
620
+
621
+ read_any = false
313
622
  begin
314
- ::Process.kill("KILL", pid)
315
- rescue Errno::ESRCH
316
- # already gone
623
+ 2.times do
624
+ reader.read_nonblock(4096)
625
+ read_any = true
626
+ end
627
+ rescue IO::WaitReadable, EOFError
628
+ # empty / drained, or writer closed (worker exiting — reap handles it)
629
+ rescue IOError, Errno::EBADF
630
+ # reader closed by a racing reap/shutdown this tick
631
+ next
632
+ end
633
+
634
+ if read_any
635
+ info[:last_pipe_tick_at] = now
636
+ info[:pipe_seen] = true
317
637
  end
318
638
  end
319
- rescue StandardError => e
320
- Pgbus.logger.warn { "[Pgbus] Supervisor watchdog check failed: #{e.message}" }
321
639
  end
322
640
 
323
641
  def signal_children(sig)
@@ -338,12 +656,33 @@ module Pgbus
338
656
  end
339
657
  end
340
658
 
659
+ # Lenient bootstrap for the parent (supervisor.rb#run). A transiently
660
+ # unavailable DB at boot must not kill the supervisor — children
661
+ # crash-and-backoff until it recovers — so every StandardError (including
662
+ # SchemaNotReady) is reported and swallowed.
341
663
  def bootstrap_queues
342
664
  Pgbus.client.ensure_all_queues
343
665
  rescue StandardError => e
344
666
  ErrorReporter.report(e, { action: "bootstrap_queues" })
345
667
  end
346
668
 
669
+ # Strict bootstrap for forked children (fork_worker, fork_scheduler). A
670
+ # genuinely missing schema (database absent, migrations not run) surfaces
671
+ # as Pgbus::SchemaNotReady — log its already-actionable message once and
672
+ # re-raise so the child exits non-zero. schedule_restart then paces the
673
+ # crash loop with exponential backoff (up to RESTART_BACKOFF_MAX) instead
674
+ # of letting children boot and drown in downstream "relation does not
675
+ # exist" errors. Other StandardErrors stay reported-and-swallowed, exactly
676
+ # as the lenient variant handles them.
677
+ def bootstrap_queues!
678
+ Pgbus.client.ensure_all_queues
679
+ rescue Pgbus::SchemaNotReady => e
680
+ Pgbus.logger.error { "[Pgbus] #{e.message}" }
681
+ raise
682
+ rescue StandardError => e
683
+ ErrorReporter.report(e, { action: "bootstrap_queues" })
684
+ end
685
+
347
686
  def load_rails_app
348
687
  return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
349
688
 
@@ -358,10 +697,39 @@ module Pgbus
358
697
  @heartbeat.start
359
698
  end
360
699
 
700
+ # Serve /livez and /readyz over a plain TCP server when health_port is
701
+ # configured. This gives orchestrators (Kubernetes) an HTTP probe surface
702
+ # on the supervisor itself — the process that forks and watches workers —
703
+ # without booting Rails or the dashboard. Disabled (nil) by default;
704
+ # host apps that already run Puma can mount Pgbus::Web::HealthApp instead.
705
+ def start_health_server
706
+ return unless config.health_port
707
+
708
+ @health_server = Pgbus::Web::HealthServer.new(port: config.health_port, bind: config.health_bind)
709
+ @health_server.start
710
+ end
711
+
361
712
  def monotonic_now
362
713
  ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
363
714
  end
364
715
 
716
+ # Close a pipe IO idempotently. nil (non-worker forks, already-scrubbed
717
+ # entries) and already-closed IOs are no-ops; a rare EBADF/IOError from a
718
+ # racing close is swallowed. FD management is single-threaded (main loop
719
+ # only), so the closed? check-then-close needs no lock.
720
+ def close_pipe(io)
721
+ io.close if io && !io.closed?
722
+ rescue IOError, Errno::EBADF
723
+ nil
724
+ end
725
+
726
+ # Close every sibling liveness reader this fork inherited from the
727
+ # parent's FD table. Called only inside a just-forked child, before it
728
+ # becomes a Worker, so the worker never holds its siblings' pipe ends.
729
+ def close_inherited_liveness_readers
730
+ @forks.each_value { |info| close_pipe(info[:liveness_reader]) }
731
+ end
732
+
365
733
  def shutdown
366
734
  # Wait for all children with timeout
367
735
  deadline = Time.now + 30
@@ -374,6 +742,11 @@ module Pgbus
374
742
  # Force kill any remaining
375
743
  signal_children("KILL") unless @forks.empty?
376
744
 
745
+ # Close any liveness readers still open on un-reaped children so the
746
+ # supervisor never leaks FDs across a restart of itself.
747
+ @forks.each_value { |info| close_pipe(info[:liveness_reader]) }
748
+
749
+ @health_server&.stop
377
750
  @heartbeat&.stop
378
751
  restore_signals
379
752
  Pgbus.logger.info { "[Pgbus] Supervisor stopped" }