pgbus 0.12.3 → 0.12.4
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 +1 -0
- data/lib/pgbus/process/notify_listener.rb +56 -24
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/streamer/listener.rb +49 -30
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 916f349cd5f3740aca0e462acdadeb3ad7322b0ce8f0363fa78d2328ac3c7c37
|
|
4
|
+
data.tar.gz: 13100607efcb25262f7d4708355d600009aff7f66e961d197d00fe2013ab9a88
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 325b359be3de92b27add05e70ac9437b813df2e464ad1ceca2fbd3fe2114c4a52471e7e5f23564765e74d6c8330068cf0d9ab9ccaddd9443d98ab69088a1ee34
|
|
7
|
+
data.tar.gz: 5fb4ee93c03a463cddcfa490a438ae6d267fc12a23c633a7ee82f45754fde6232a23f5da2772df45f4653efbee4018b7b336adb79f50bf4e1ccc0438c4718971
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
### Fixed
|
|
8
8
|
|
|
9
|
+
- **SIGTERM no longer segfaults the process: both LISTEN listeners stopped closing their PG connection from the stopping thread (issue #375).** `Process::NotifyListener#stop` (supervisor thread, on SIGTERM) closed the listener's dedicated `PG::Connection` to interrupt the blocking `wait_for_notify`, but left `@conn` pointing at it — so the listener thread unwound into `run_loop`'s `ensure` and ran `safe_unlisten_all`, exec'ing `UNLISTEN` on a connection the other thread was concurrently freeing. `PG::Connection#close` is `PQfinish`: it frees the PGconn **and its OpenSSL objects**, so `PQsendQuery` walked into freed TLS state and the whole worker died with `[BUG] Segmentation fault` — reproducibly, on essentially every container-replacement deploy against a TLS Postgres, sometimes in several forked workers at once. `rescue PG::Error` cannot catch a C-level SEGV, so the "safe" in `safe_unlisten_all` never held. **The connection is now single-owner**: the listener thread is the only thread that may exec, wait, or close on it, from build through teardown. `#stop` signals by clearing `@running` and joining — nothing more — and the listener thread closes its own connection in `run_loop`'s `ensure`. The stop is observed within one `wait_for_notify` timeout (`health_check_ms`, **250 ms** in the default configuration since workers/consumers derive it from `polling_interval`), so `#stop`'s join budget is now `health_check_ms + 5s` instead of a flat 5 s — a flat timeout could expire before a listener with a large `health_check_ms` had even one chance to observe the stop. The teardown `UNLISTEN` round-trip is **dropped entirely** rather than merely made safe: it ran immediately before the close, and closing a session deregisters every `LISTEN` server-side, so it bought nothing at any time. The identical defect in `Web::Streamer::Listener` — same close-from-`#stop`, same `UNLISTEN`-in-`ensure`, and there without even a mutex around `@conn` — is fixed the same way, so the dashboard's Puma worker stops crashing on the same deploy; its listener thread now also closes its own connection (previously only `#stop` did). Cost: shutdown can take up to one health-check cycle longer per listener — measured against a real PostgreSQL at the default `health_check_ms` of 250: **~207 ms on a fully idle queue, ~5 ms when the queue has any NOTIFY traffic** (a notification returns the wait immediately, and the loop then sees the cleared flag). 40 start/stop cycles under concurrent NOTIFY load leaked zero `LISTEN` backends. Thanks to the reporter for the crash dumps and the root-cause analysis. Refs #375.
|
|
9
10
|
- **The health verdict no longer emits false STALLED reports — `max_read_ct` was never populated, and the wedge signal counted queues no capsule drains (issue #367).** Two correctness defects in `Pgbus::MCP::HealthAnalyzer` (surfaced through `pgbus doctor` / `pgbus_health` / the MCP health tool). **(1)** The `all_unread?` wedge check read `:max_read_ct`, but the metrics query never selected it — so the guard always degenerated to "never claimed" and a busy-but-healthy queue caught mid-burst produced a STALLED verdict with a factually wrong "read_ct=0 (never claimed)" reason. The metrics query now exposes a per-queue **`visible_unread_length`** (`count(*) WHERE vt <= NOW() AND read_ct = 0`) and the analyzer keys the wedge off *visible, never-claimed* messages. Counting per visible message (not `max(read_ct)` over the whole table) means one retried message left in-queue after its backoff, or an in-flight message claimed by a peer, can no longer veto the signal for a pile of genuinely-unclaimed jobs. **(2)** The verdict reasoned about **every** non-DLQ/non-stream queue against the global worker fleet — but a worker can only claim from queues its capsule subscribes to, so "M workers alive but never claimed" was vacuous for a queue nobody drains (ad-hoc queues, unregistered stream queues — see #366). The analyzer now intersects the STALLED backlog with `Web::DataSource#drained_queue_names` (each configured capsule's queues, priority `_pN` sub-tables expanded via the client's queue strategy, unioned with EventBus handler queues; `nil` for a `*` wildcard = drains everything, fail-open on error). Queues nobody drains get their own DEGRADED signal — *"N queue(s) hold messages but no capsule is configured to drain them"* — instead of being folded into the worker-wedge verdict. A heart-beating-but-`:stalled` worker is still reported STALLED regardless of which queue holds the backlog. Reason strings now truncate to the first 10 queue names with `(+N more)` so a flagged fleet of hundreds of queues doesn't produce a multi-KB log line. Refs #367, #366.
|
|
10
11
|
- **`allowed_global_id_models` now actually guards ActiveJob arguments, not only EventBus payloads (issue #368).** The doctor warned in production that `nil` means "allow-all GlobalID arguments", but the allowlist was only enforced in `Serializer.locate_global_id` — reached from EventBus `_global_id` payloads — while the ordinary job path (`Executor` → `ActiveJob::Base.deserialize` → Rails' unrestricted `GlobalID::Locator`) never checked it. Operators who set an allowlist after following the doctor had a false sense of security; the common `SomeJob.perform_later(record)` pattern was unguarded. Job deserialization now goes through `Serializer.deserialize_job_data`, which walks `_aj_globalid` keys (including nested arrays/hashes) and reuses the same gate as EventBus when the allowlist is set; `nil` remains zero-cost allow-all. Rejected models raise `Pgbus::SerializationError` and are treated as a normal job failure. Apps with ActiveStorage attachments should include `ActiveStorage::Blob` (and related models) on the allowlist. Docs + doctor copy updated. Refs #368.
|
|
11
12
|
- **Dormant pre-registry stream queues no longer false-STALL the doctor or escape the orphan sweep (issue #366).** Stream queues created before the `pgbus_stream_queues` migration only register on their next broadcast — completed checkout-style flows, ended chats, and one-shot progress streams never broadcast again, so they stay unregistered forever. Health/doctor then treated their permanent `read_ct=0` visible backlog as a worker wedge (STALLED on ~hundreds of healthy stream queues), and the orphan sweep skipped them because it only iterated the registry. Detection is fingerprint-based (the archive `a_<queue>_msg_id_idx` index that only `ensure_stream_queue` creates — no name heuristics). `StreamQueue.known_names` = registry ∪ fingerprints, used by health exclusion, orphan sweep / stream-archive prune, and wildcard workers; `rake pgbus:streams:backfill_registry` persists the missing rows; doctor surfaces a DEGRADED hint pointing at the backfill when unregistered fingerprints remain; `add_stream_queues` generator post-install mentions the task. Refs #366.
|
|
@@ -30,12 +30,24 @@ module Pgbus
|
|
|
30
30
|
# blocking IO call where the mutex MUST NOT be held), so wait_once reads
|
|
31
31
|
# the connection out of the mutex first and operates on a local. Reconnect
|
|
32
32
|
# publishes the new connection + channel set under the mutex.
|
|
33
|
+
#
|
|
34
|
+
# The mutex only makes the ivar READ safe. PG::Connection itself is not
|
|
35
|
+
# thread-safe, so the connection is single-owner: the listener thread is
|
|
36
|
+
# the ONLY thread that may exec, wait, or close on it, from build through
|
|
37
|
+
# teardown. #stop signals by clearing @running and joining — it never
|
|
38
|
+
# touches the connection, because #close is PQfinish and freeing the
|
|
39
|
+
# PGconn under a concurrent libpq call is a process-killing SEGV, not a
|
|
40
|
+
# rescuable PG::Error (issue #375).
|
|
33
41
|
class NotifyListener
|
|
34
42
|
CHANNEL_PREFIX = "pgmq.q_"
|
|
35
43
|
CHANNEL_SUFFIX = ".INSERT"
|
|
36
44
|
|
|
37
45
|
RECONNECT_BACKOFF_SECONDS = 0.5
|
|
38
46
|
|
|
47
|
+
# Grace added to one health-check cycle when #stop joins the listener
|
|
48
|
+
# thread. See #stop_join_timeout.
|
|
49
|
+
STOP_JOIN_GRACE_SECONDS = 5
|
|
50
|
+
|
|
39
51
|
def initialize(physical_queues:, on_wake:, connection_options:,
|
|
40
52
|
health_check_ms: 1000, logger: Pgbus.logger)
|
|
41
53
|
@physical_queues = Array(physical_queues)
|
|
@@ -80,22 +92,20 @@ module Pgbus
|
|
|
80
92
|
end
|
|
81
93
|
|
|
82
94
|
def stop
|
|
83
|
-
conn_to_close = nil
|
|
84
95
|
@state_mutex.synchronize do
|
|
85
96
|
return self unless @running
|
|
86
97
|
|
|
87
98
|
@running = false
|
|
88
|
-
conn_to_close = @conn
|
|
89
99
|
end
|
|
90
100
|
@commands << [:stop]
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
@thread&.join(
|
|
101
|
+
# Deliberately does NOT touch @conn. PG::Connection is not thread-safe
|
|
102
|
+
# and #close is PQfinish: it frees the PGconn and its OpenSSL objects
|
|
103
|
+
# out from under whatever libpq call the listener thread is making on
|
|
104
|
+
# the same connection. That is a use-after-free — a process-killing
|
|
105
|
+
# SEGV, not a rescuable PG::Error (issue #375). The listener thread is
|
|
106
|
+
# the sole owner of @conn for its entire life and closes it in
|
|
107
|
+
# run_loop's ensure; clearing @running above is the whole stop signal.
|
|
108
|
+
@thread&.join(stop_join_timeout)
|
|
99
109
|
@thread = nil
|
|
100
110
|
self
|
|
101
111
|
end
|
|
@@ -153,9 +163,26 @@ module Pgbus
|
|
|
153
163
|
# Clear @running so #start can spawn a fresh thread after a fatal exit
|
|
154
164
|
# (e.g. build_connection raising at boot). Without this, the dead
|
|
155
165
|
# thread's @running stays true and #start returns early forever.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
166
|
+
#
|
|
167
|
+
# The LISTEN set is dropped as bookkeeping only — no UNLISTEN
|
|
168
|
+
# round-trip. We close on the next line and closing the session
|
|
169
|
+
# deregisters every LISTEN server-side, so the exec bought nothing
|
|
170
|
+
# while being the statement that raced #stop into a SEGV (issue #375).
|
|
171
|
+
#
|
|
172
|
+
# Capturing @conn in the SAME critical section that clears @running is
|
|
173
|
+
# what makes restart safe. #start is public and guarded only by
|
|
174
|
+
# @running, so a caller watching running? may spawn a fresh thread the
|
|
175
|
+
# instant it flips. If teardown read @conn in a later critical section
|
|
176
|
+
# it could pick up the NEW thread's connection and PQfinish it mid-use
|
|
177
|
+
# — the same use-after-free, reached through restart instead of #stop.
|
|
178
|
+
conn = @state_mutex.synchronize do
|
|
179
|
+
@running = false
|
|
180
|
+
@listening_to.clear
|
|
181
|
+
c = @conn
|
|
182
|
+
@conn = nil
|
|
183
|
+
c
|
|
184
|
+
end
|
|
185
|
+
close_quietly(conn)
|
|
159
186
|
end
|
|
160
187
|
|
|
161
188
|
def wait_once
|
|
@@ -166,7 +193,10 @@ module Pgbus
|
|
|
166
193
|
got_notify = conn.wait_for_notify(timeout_s) do |_channel, _pid, _payload|
|
|
167
194
|
@on_wake.call
|
|
168
195
|
end
|
|
169
|
-
|
|
196
|
+
# Skip the keepalive when a stop landed during the wait: the loop is
|
|
197
|
+
# about to exit and close this connection anyway, so the round-trip
|
|
198
|
+
# would only add latency to shutdown.
|
|
199
|
+
run_health_check(conn) if !got_notify && running?
|
|
170
200
|
rescue IOError, PG::Error => e
|
|
171
201
|
return unless running?
|
|
172
202
|
|
|
@@ -270,14 +300,14 @@ module Pgbus
|
|
|
270
300
|
end
|
|
271
301
|
end
|
|
272
302
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
@
|
|
303
|
+
# How long #stop waits for the listener thread to notice the cleared
|
|
304
|
+
# @running and finish teardown. The thread can be parked in
|
|
305
|
+
# wait_for_notify for one full health-check cycle before it re-checks the
|
|
306
|
+
# flag, so the budget is that cycle plus grace — a flat timeout would
|
|
307
|
+
# expire before a listener with a large health_check_ms had even one
|
|
308
|
+
# chance to observe the stop.
|
|
309
|
+
def stop_join_timeout
|
|
310
|
+
(@health_check_ms / 1000.0) + STOP_JOIN_GRACE_SECONDS
|
|
281
311
|
end
|
|
282
312
|
|
|
283
313
|
def safe_close
|
|
@@ -289,8 +319,10 @@ module Pgbus
|
|
|
289
319
|
close_quietly(conn)
|
|
290
320
|
end
|
|
291
321
|
|
|
292
|
-
# Close
|
|
293
|
-
#
|
|
322
|
+
# Close a PG::Connection we are done with — a half-built reconnect
|
|
323
|
+
# attempt that never made it into @conn, or the connection captured out
|
|
324
|
+
# of @conn during teardown. Always called on the listener thread, which
|
|
325
|
+
# owns the connection. Best-effort.
|
|
294
326
|
def close_quietly(conn)
|
|
295
327
|
conn&.close if conn.respond_to?(:close)
|
|
296
328
|
rescue StandardError
|
data/lib/pgbus/version.rb
CHANGED
|
@@ -15,7 +15,12 @@ module Pgbus
|
|
|
15
15
|
# only running `wait_for_notify` — all LISTEN/UNLISTEN SQL goes
|
|
16
16
|
# through a command queue that the listener thread drains between
|
|
17
17
|
# notifies
|
|
18
|
-
# - #stop joins the
|
|
18
|
+
# - #stop clears @running and joins; it never touches the connection
|
|
19
|
+
# - the connection is SINGLE-OWNER: the listener thread is the only
|
|
20
|
+
# thread that may exec, wait, or close on it, from construction
|
|
21
|
+
# through teardown. PG::Connection is not thread-safe and #close is
|
|
22
|
+
# PQfinish — freeing the PGconn under a concurrent libpq call is a
|
|
23
|
+
# process-killing SEGV, not a rescuable PG::Error (issue #375)
|
|
19
24
|
#
|
|
20
25
|
# Health check: `wait_for_notify(timeout)` returns nil on timeout. When
|
|
21
26
|
# it does, the listener runs `SELECT 1` as a TCP keepalive. If that
|
|
@@ -41,6 +46,10 @@ module Pgbus
|
|
|
41
46
|
|
|
42
47
|
RECONNECT_BACKOFF_SECONDS = 0.5
|
|
43
48
|
|
|
49
|
+
# Grace added to one health-check cycle when #stop joins the listener
|
|
50
|
+
# thread. See #stop_join_timeout.
|
|
51
|
+
STOP_JOIN_GRACE_SECONDS = 5
|
|
52
|
+
|
|
44
53
|
attr_reader :listening_to
|
|
45
54
|
|
|
46
55
|
# @param connection_factory [#call] builds a FRESH PG connection on each
|
|
@@ -98,18 +107,16 @@ module Pgbus
|
|
|
98
107
|
|
|
99
108
|
@running = false
|
|
100
109
|
@commands << [:stop]
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
end
|
|
112
|
-
@thread&.join(5)
|
|
110
|
+
# Deliberately does NOT touch @conn. PG::Connection is not
|
|
111
|
+
# thread-safe and #close is PQfinish: it frees the PGconn and its
|
|
112
|
+
# OpenSSL objects out from under whatever libpq call the listener
|
|
113
|
+
# thread is making on the same connection. That is a use-after-free —
|
|
114
|
+
# a process-killing SEGV, not a rescuable PG::Error (issue #375).
|
|
115
|
+
# The listener thread is the sole owner of @conn and closes it in
|
|
116
|
+
# run_loop's ensure; clearing @running above is the whole stop signal.
|
|
117
|
+
# It is observed within one wait_for_notify timeout (health_check_ms),
|
|
118
|
+
# which is what the join budget below is sized against.
|
|
119
|
+
@thread&.join(stop_join_timeout)
|
|
113
120
|
@thread = nil
|
|
114
121
|
self
|
|
115
122
|
end
|
|
@@ -148,13 +155,18 @@ module Pgbus
|
|
|
148
155
|
|
|
149
156
|
timeout_s = @health_check_ms / 1000.0
|
|
150
157
|
begin
|
|
151
|
-
@conn.wait_for_notify(timeout_s) do |channel, _pid, payload|
|
|
158
|
+
got_notify = @conn.wait_for_notify(timeout_s) do |channel, _pid, payload|
|
|
152
159
|
handle_notify(channel, payload)
|
|
153
|
-
end
|
|
160
|
+
end
|
|
161
|
+
# Skip the keepalive when a stop landed during the wait: the loop
|
|
162
|
+
# is about to exit and close this connection anyway, so the
|
|
163
|
+
# round-trip would only add latency to shutdown.
|
|
164
|
+
run_health_check if !got_notify && @running
|
|
154
165
|
rescue IOError => e
|
|
155
|
-
# #stop closes the
|
|
156
|
-
#
|
|
157
|
-
#
|
|
166
|
+
# #stop no longer closes the connection to interrupt the wait
|
|
167
|
+
# (issue #375), so an IOError here is a genuine socket failure,
|
|
168
|
+
# not the expected shutdown signal. Reconnect unless we're
|
|
169
|
+
# stopping, in which case exit cleanly.
|
|
158
170
|
break unless @running
|
|
159
171
|
|
|
160
172
|
@logger.warn { "[Pgbus::Streamer::Listener] IO error (#{e.class}: #{e.message}) — reconnecting" }
|
|
@@ -167,7 +179,15 @@ module Pgbus
|
|
|
167
179
|
end
|
|
168
180
|
end
|
|
169
181
|
ensure
|
|
170
|
-
|
|
182
|
+
# Bookkeeping only — no UNLISTEN round-trip. We close the connection
|
|
183
|
+
# on the next line and closing the session deregisters every LISTEN
|
|
184
|
+
# server-side, so the exec bought nothing while being the statement
|
|
185
|
+
# that raced #stop's PQfinish into a SEGV (issue #375).
|
|
186
|
+
@listening_to.clear
|
|
187
|
+
# The listener thread owns this connection, so the listener thread is
|
|
188
|
+
# the one that closes it — #stop only clears @running.
|
|
189
|
+
close_quietly(@conn)
|
|
190
|
+
@conn = nil
|
|
171
191
|
end
|
|
172
192
|
|
|
173
193
|
def drain_commands
|
|
@@ -204,6 +224,16 @@ module Pgbus
|
|
|
204
224
|
(@health_check_ms / 1000.0) + 1.0
|
|
205
225
|
end
|
|
206
226
|
|
|
227
|
+
# How long #stop waits for the listener thread to notice the cleared
|
|
228
|
+
# @running and finish teardown. The thread can be parked in
|
|
229
|
+
# wait_for_notify for one full health-check cycle before it re-checks
|
|
230
|
+
# the flag, so the budget is that cycle plus grace — a flat timeout
|
|
231
|
+
# would expire before a listener with a large health_check_ms had even
|
|
232
|
+
# one chance to observe the stop.
|
|
233
|
+
def stop_join_timeout
|
|
234
|
+
(@health_check_ms / 1000.0) + STOP_JOIN_GRACE_SECONDS
|
|
235
|
+
end
|
|
236
|
+
|
|
207
237
|
def do_listen(queue_name)
|
|
208
238
|
channel = channel_for(queue_name)
|
|
209
239
|
return if @listening_to.include?(channel)
|
|
@@ -322,17 +352,6 @@ module Pgbus
|
|
|
322
352
|
nil
|
|
323
353
|
end
|
|
324
354
|
|
|
325
|
-
def safe_unlisten_all
|
|
326
|
-
@listening_to.each do |channel|
|
|
327
|
-
# @conn may be nil if #stop interrupted the reconnect loop between
|
|
328
|
-
# closing the old connection and publishing a new one.
|
|
329
|
-
@conn&.exec(%(UNLISTEN "#{channel}"))
|
|
330
|
-
rescue PG::Error
|
|
331
|
-
# connection may be dead; nothing we can do
|
|
332
|
-
end
|
|
333
|
-
@listening_to.clear
|
|
334
|
-
end
|
|
335
|
-
|
|
336
355
|
def channel_for(queue_name)
|
|
337
356
|
"#{CHANNEL_PREFIX}#{queue_name}#{CHANNEL_SUFFIX}"
|
|
338
357
|
end
|