pgbus 0.9.6 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +119 -1
- data/Rakefile +10 -1
- data/app/helpers/pgbus/application_helper.rb +45 -6
- data/app/views/pgbus/jobs/show.html.erb +1 -1
- data/app/views/pgbus/processes/_processes_table.html.erb +4 -1
- data/config/locales/da.yml +4 -0
- data/config/locales/de.yml +4 -0
- data/config/locales/en.yml +4 -0
- data/config/locales/es.yml +4 -0
- data/config/locales/fi.yml +4 -0
- data/config/locales/fr.yml +4 -0
- data/config/locales/it.yml +4 -0
- data/config/locales/ja.yml +4 -0
- data/config/locales/nb.yml +4 -0
- data/config/locales/nl.yml +4 -0
- data/config/locales/pt.yml +4 -0
- data/config/locales/sv.yml +4 -0
- data/lib/pgbus/active_job/executor.rb +25 -4
- data/lib/pgbus/cli/dlq.rb +164 -0
- data/lib/pgbus/cli.rb +18 -1
- data/lib/pgbus/client/connection_health.rb +194 -0
- data/lib/pgbus/client.rb +592 -73
- data/lib/pgbus/config_loader.rb +23 -4
- data/lib/pgbus/configuration.rb +102 -13
- data/lib/pgbus/dedup_cache.rb +8 -0
- data/lib/pgbus/doctor.rb +250 -0
- data/lib/pgbus/engine.rb +15 -0
- data/lib/pgbus/execution_pools/async_pool.rb +7 -0
- data/lib/pgbus/execution_pools/thread_pool.rb +7 -0
- data/lib/pgbus/instrumentation.rb +1 -0
- data/lib/pgbus/integrations/appsignal/probe.rb +23 -1
- data/lib/pgbus/metrics/backend.rb +38 -0
- data/lib/pgbus/metrics/backends/prometheus.rb +123 -0
- data/lib/pgbus/metrics/backends/statsd.rb +64 -0
- data/lib/pgbus/metrics/prometheus_exporter.rb +34 -0
- data/lib/pgbus/metrics/subscriber.rb +190 -0
- data/lib/pgbus/metrics.rb +42 -0
- data/lib/pgbus/process/consumer.rb +215 -8
- data/lib/pgbus/process/consumer_priority.rb +34 -0
- data/lib/pgbus/process/dispatcher.rb +265 -41
- data/lib/pgbus/process/heartbeat.rb +18 -5
- data/lib/pgbus/process/memory_usage.rb +48 -0
- data/lib/pgbus/process/notify_listener.rb +26 -7
- data/lib/pgbus/process/notify_probe.rb +96 -0
- data/lib/pgbus/process/primary_validator.rb +53 -0
- data/lib/pgbus/process/signal_handler.rb +6 -0
- data/lib/pgbus/process/supervisor.rb +396 -46
- data/lib/pgbus/process/worker.rb +298 -35
- data/lib/pgbus/recurring/scheduler.rb +15 -1
- data/lib/pgbus/streams/turbo_broadcastable.rb +7 -5
- data/lib/pgbus/table_maintenance.rb +13 -2
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +20 -4
- data/lib/pgbus/web/health_app.rb +102 -0
- data/lib/pgbus/web/health_server.rb +144 -0
- data/lib/pgbus/web/payload_filter.rb +68 -0
- data/lib/pgbus/web/streamer/instance.rb +55 -1
- data/lib/pgbus/web/streamer/listener.rb +72 -9
- data/lib/pgbus.rb +37 -0
- data/lib/rubocop/cop/pgbus/no_ruby_timeout.rb +42 -0
- data/lib/rubocop/pgbus.rb +5 -0
- data/lib/tasks/pgbus_doctor.rake +12 -0
- metadata +19 -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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] ||
|
|
70
|
-
threads = worker_config[:threads] ||
|
|
71
|
-
single_active = worker_config[:single_active_consumer] ||
|
|
72
|
-
priority = worker_config[:consumer_priority] ||
|
|
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] ||
|
|
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
|
-
|
|
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.
|
|
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]
|
|
192
|
-
threads = consumer_config[:threads] ||
|
|
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
|
-
|
|
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})
|
|
461
|
+
"[Pgbus] Child #{info[:type]} pid=#{pid} exited unexpectedly (status=#{status&.exitstatus})"
|
|
263
462
|
end
|
|
264
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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" }
|