Summary
PGMQ::Connection#connection_lost_error? (lib/pgmq/connection.rb:113)
matches exclusively on the exception message. A transient Postgres drop
surfaces at the SSL layer as
PQconsumeInput() SSL error: unexpected eof while reading — a message that
isn't in the list — and the single-retry path in with_connection
(line 79) is skipped. The error propagates as PGMQ::Errors::ConnectionError
even though the connection would recover on the very next checkout.
Observed with pgmq-ruby 0.6.1 behind pgbus 0.7.5 on Rails 8.1.3.
A parallel gap exists on the pgbus side — filed as
zoolutions/pgbus#143 — both fixes are needed for full coverage, but this
issue tracks the pgmq-ruby half.
Repro / production evidence
Three unrelated controllers hit the same enqueue-time exception in a
six-minute window after a brief Postgres/SSL blip. Identical message on all
three:
Database connection error: PQconsumeInput() SSL error: unexpected eof while reading
Stack tail (top frames are pgmq-ruby):
pgmq-ruby (0.6.1) lib/pgmq/connection.rb:81:in 'PGMQ::Connection#with_connection'
pgmq-ruby (0.6.1) lib/pgmq/client.rb:102:in 'PGMQ::Client#with_connection'
pgmq-ruby (0.6.1) lib/pgmq/client/queue_management.rb:23:in 'PGMQ::Client::QueueManagement#create'
An earlier cluster in the same app raised the same exception class with a
different message — PQsocket() can't get socket descriptor — which IS in
connection_lost_error? and was retried successfully. The machinery works;
only this specific message isn't covered.
Why verify_connection! doesn't catch it
verify_connection! runs before yield conn and checks
conn.finished? / conn.status == PG::CONNECTION_BAD. For an idle
connection silently killed by the SSL peer, PQstatus() often still
reports CONNECTION_OK until the next I/O, because libpq only flips the
status after a failed operation. The first I/O inside the yielded block is
what raises PQconsumeInput() SSL error: unexpected eof, and at that point
execution is already in the rescue PG::Error branch — where matching
depends on connection_lost_error?.
Root cause
lib/pgmq/connection.rb:
lost_connection_messages = [
"server closed the connection",
"connection not open",
"connection is closed",
"connection has been closed",
"no connection to the server",
"terminating connection",
"connection to server was lost",
"could not receive data from server",
"pqsocket() can't get socket descriptor"
]
message = error.message.to_s.downcase
lost_connection_messages.any? { |pattern| message.include?(pattern) }
This list already includes mid-flight signals like
"server closed the connection" and
"could not receive data from server", so the "retry might duplicate a
partial INSERT" tradeoff is already accepted at this layer. The SSL EOF
variant fits the same category and should be covered.
Proposed fix
Two angles — I'd recommend doing both. (1) fixes today's production
symptom in the smallest patch. (2) makes detection robust to future
SSL/TLS/TCP/pooler message variants.
1. Extend the message list (narrow)
lost_connection_messages = [
"server closed the connection",
"connection not open",
"connection is closed",
"connection has been closed",
"no connection to the server",
"terminating connection",
"connection to server was lost",
"could not receive data from server",
"pqsocket() can't get socket descriptor",
"ssl error: unexpected eof", # observed in production
"ssl syscall error" # related SSL-layer teardown
]
2. Prefer class-matching over message-matching (structural)
The pg gem already defines dedicated connection-failure classes:
PG::ConnectionBad < PG::Error
PG::UnableToSend < PG::Error
Matching them directly removes the need to enumerate every OS/TLS/pooler
message variant:
def connection_lost_error?(error)
return true if error.is_a?(PG::ConnectionBad) || error.is_a?(PG::UnableToSend)
message = error.message.to_s.downcase
LOST_CONNECTION_PATTERNS.any? { |pattern| message.include?(pattern) }
end
Caveat worth verifying: confirm the pg gem raises
PG::ConnectionBad/PG::UnableToSend for the SSL EOF path on modern
versions. If libpq raises a bare PG::Error there, the class check is a
no-op on this specific case and the message fallback still handles it — so
the combination is strictly safer than message-only.
Optional last-resort signal: error.connection&.status == PG::CONNECTION_BAD.
PG::Error#connection is nil for some errors, so this is a fallback rather
than primary.
Test strategy
connection_lost_error? returns true for a PG::Error with each new
message ("...SSL error: unexpected eof...", "...SSL SYSCALL error...").
connection_lost_error? returns true for a raised PG::ConnectionBad
and PG::UnableToSend regardless of message (including empty / non-
matching messages).
connection_lost_error? returns false for a generic
PG::Error.new("syntax error") — no behavior change for query-level
errors.
with_connection performs exactly one retry when the first yield raises
each new case, and raise PGMQ::Errors::ConnectionError still fires when
the retry also fails.
Version info
- pgmq-ruby:
0.6.1
- pg (gem): modern release that ships
PG::ConnectionBad / PG::UnableToSend
- Ruby:
4.0.2
- Postgres: managed, pooler in front
- Observed: three clustered failures in 6 minutes followed by automatic
recovery — consistent with transient SSL teardown on idle pooled
connections.
Cross-reference
Summary
PGMQ::Connection#connection_lost_error?(lib/pgmq/connection.rb:113)matches exclusively on the exception message. A transient Postgres drop
surfaces at the SSL layer as
PQconsumeInput() SSL error: unexpected eof while reading— a message thatisn't in the list — and the single-retry path in
with_connection(line 79) is skipped. The error propagates as
PGMQ::Errors::ConnectionErroreven though the connection would recover on the very next checkout.
Observed with
pgmq-ruby 0.6.1behind pgbus0.7.5on Rails8.1.3.A parallel gap exists on the pgbus side — filed as
zoolutions/pgbus#143 — both fixes are needed for full coverage, but this
issue tracks the pgmq-ruby half.
Repro / production evidence
Three unrelated controllers hit the same enqueue-time exception in a
six-minute window after a brief Postgres/SSL blip. Identical message on all
three:
Stack tail (top frames are pgmq-ruby):
An earlier cluster in the same app raised the same exception class with a
different message —
PQsocket() can't get socket descriptor— which IS inconnection_lost_error?and was retried successfully. The machinery works;only this specific message isn't covered.
Why
verify_connection!doesn't catch itverify_connection!runs beforeyield connand checksconn.finished?/conn.status == PG::CONNECTION_BAD. For an idleconnection silently killed by the SSL peer,
PQstatus()often stillreports
CONNECTION_OKuntil the next I/O, because libpq only flips thestatus after a failed operation. The first I/O inside the yielded block is
what raises
PQconsumeInput() SSL error: unexpected eof, and at that pointexecution is already in the
rescue PG::Errorbranch — where matchingdepends on
connection_lost_error?.Root cause
lib/pgmq/connection.rb:This list already includes mid-flight signals like
"server closed the connection"and"could not receive data from server", so the "retry might duplicate apartial INSERT" tradeoff is already accepted at this layer. The SSL EOF
variant fits the same category and should be covered.
Proposed fix
Two angles — I'd recommend doing both. (1) fixes today's production
symptom in the smallest patch. (2) makes detection robust to future
SSL/TLS/TCP/pooler message variants.
1. Extend the message list (narrow)
2. Prefer class-matching over message-matching (structural)
The
pggem already defines dedicated connection-failure classes:Matching them directly removes the need to enumerate every OS/TLS/pooler
message variant:
Caveat worth verifying: confirm the
pggem raisesPG::ConnectionBad/PG::UnableToSendfor the SSL EOF path on modernversions. If libpq raises a bare
PG::Errorthere, the class check is ano-op on this specific case and the message fallback still handles it — so
the combination is strictly safer than message-only.
Optional last-resort signal:
error.connection&.status == PG::CONNECTION_BAD.PG::Error#connectionis nil for some errors, so this is a fallback ratherthan primary.
Test strategy
connection_lost_error?returns true for aPG::Errorwith each newmessage (
"...SSL error: unexpected eof...","...SSL SYSCALL error...").connection_lost_error?returns true for a raisedPG::ConnectionBadand
PG::UnableToSendregardless of message (including empty / non-matching messages).
connection_lost_error?returns false for a genericPG::Error.new("syntax error")— no behavior change for query-levelerrors.
with_connectionperforms exactly one retry when the first yield raiseseach new case, and
raise PGMQ::Errors::ConnectionErrorstill fires whenthe retry also fails.
Version info
0.6.1PG::ConnectionBad/PG::UnableToSend4.0.2recovery — consistent with transient SSL teardown on idle pooled
connections.
Cross-reference
ensure_queueis outside the stale-connection retry zoolutions/pgbus#143 — pgbus has a parallel gap (same pattern missingfrom its own
STALE_CONNECTION_PATTERNS, plusensure_queuecalledoutside the retry). Fixing only one of the two gems leaves a narrower
but still real window.