shashank created CAMEL-25654:
--------------------------------
Summary: camel-pg-replication-slot - consumer never connects again
after the PostgreSQL server closed the connection (restart, shutdown), so the
route fails every poll for as long as it runs
Key: CAMEL-25654
URL: https://issues.apache.org/jira/browse/CAMEL-25654
Project: Camel
Issue Type: Bug
Components: camel-pg-replicationslot
Reporter: shashank
Assignee: shashank
The consumer is meant to connect again when its connection to PostgreSQL is
lost: {{PgReplicationSlotConsumer.poll()}} (lines at main 1de5ec5135f8) catches
the {{SQLException}} of the stream read (117-125) and calls {{connect()}} - but
only when {{e.getCause() instanceof SocketException}}. Everything else is
rethrown and the connection is kept. {{getStream()}} (92) is called before that
{{try}}, so nothing it throws leads to a reconnect.
h3. 1. A server restart or shutdown is never followed by a reconnect
When the server is restarted or shut down, it closes the connection of the
walsender. The PostgreSQL JDBC driver (42.7.13, read with javap) behaves like
this:
* {{QueryExecutorImpl.readFromCopy}} reports the failed read as
{{PSQLException}} "Database connection failed when reading from copy", SQL
state {{08006}}, with the {{IOException}} of the socket as cause - an
{{EOFException}} when the server closed the connection, not a
{{SocketException}}. Before throwing it releases the copy operation, so
{{V3PGReplicationStream.isClosed()}} ({{closeFlag || !copyDual.isActive()}}) is
true from then on. The consumer rethrows and keeps the connection.
* The next poll's {{getStream()}} sees the closed stream and runs the slot
query ({{isSlotActive}}) on the dead connection: {{QueryExecutorImpl.execute}}
fails with "An I/O error occurred while sending to the backend." ({{08006}})
and aborts the connection. This is outside the {{try}}, so it is just rethrown.
* Every later poll fails in {{getStream()}} with "This connection has been
closed." ({{08003}}, {{PgConnection.checkClosed}}).
The route stays started, the scheduled poll consumer logs a WARN for every poll
(every 500 ms with the default {{delay}}), and nothing is consumed again, also
long after the server is back. Only a restart of the route helps. A server that
ends the replication (the read returns null and the stream is closed) and then
closes the connection takes the same {{getStream()}} path.
h3. 2. A reconnect that fails once is never tried again
When the cause is a {{SocketException}} (connection reset), {{connect()}}
(252-264) closes the old connection first and then opens a new one. If the
server is still down - which is normal right after a connection reset -
{{newDbConnection()}} throws, and the field keeps the closed connection and its
closed stream. Every later poll fails in {{getStream()}} with "This connection
has been closed." ({{08003}}, no {{SocketException}}), so again no further
reconnect.
How common: any restart of the PostgreSQL server (minor version upgrade,
configuration change that needs a restart, failover, maintenance,
{{pg_terminate_backend}} of the walsender) with default options. A consumer of
a replication slot typically runs for a long time, so it will see such
restarts. It is not silent (a WARN with stack trace per poll), but it never
recovers.
h3. Reproduction
New {{PgReplicationSlotConsumerReconnectTest}}: the JDBC objects are Mockito
mocks that fail the way the driver fails (a read after the server went away:
{{08006}} with the socket's exception as cause, and the stream is closed; a
query on such a connection: {{08006}} "An I/O error occurred while sending to
the backend." and the connection is closed; a closed connection: {{08003}}
"This connection has been closed."; the slot sends again what was not flushed).
The route uses {{startScheduler=false}} and the test calls {{poll()}} itself,
so nothing depends on timing. On main:
* server stopped (read fails with an {{EOFException}} cause), polled twice,
started again, a new message published, polled up to five times: {{the consumer
did not consume again after the PostgreSQL server was back; received [first],
poll failures [Database connection failed when reading from copy, An I/O error
occurred while sending to the backend., This connection has been closed.,
...]}};
* the same with a {{SocketException}} cause, so the first reconnect runs while
the server is still down: {{received [first], poll failures [Connection to
localhost:5432 refused., This connection has been closed., ...]}};
* the server ends the replication and closes the connection: {{received
[first], poll failures [An I/O error occurred while sending to the backend.,
This connection has been closed., ...]}};
* the first delivery of a message fails while the server goes away: {{received
[first], poll failures [An I/O error occurred while sending to the backend.,
This connection has been closed., ...]}}.
h3. Proposed fix
* A connection is lost when the SQL state is of class {{08}} (connection
exception) or {{57P}} (operator intervention, e.g. {{57P01}} admin shutdown;
this only notices the loss one poll earlier), when the cause is a
{{SocketException}} (as before), or when the connection is closed. Then the
consumer closes the connection (ignoring errors), forgets it, its stream and
the payload that was read but not processed successfully, and rethrows.
* {{poll()}} connects first when there is no connection, and {{getStream()}} is
inside the same {{try}}. A connect that fails after the connection was opened
closes it again, so the next poll starts from the beginning. The poll delay and
the backoff options of the scheduled poll consumer apply as before.
* The INFO line about the lost connection is logged only when a connection was
dropped, not for every failed connect while the server is down.
* Other errors keep the connection, as before (a test pins that: an error with
SQL state {{42704}} does not open a second connection).
Delivery: the fix relies on the slot resuming at its {{confirmed_flush_lsn}}.
The stream is started without a start position and the consumer sets the
flushed LSN only after a successful processing, so the server sends a dropped
(unflushed) payload again on the new connection. Delivery stays at-least-once,
as the component documentation says ("the same message might come more than
once"); dropping the payload avoids routing it once from the old connection and
once more from the server. A test pins it: a message whose first delivery
failed while the connection was lost is delivered exactly once after the
reconnect.
No option, default or output changes, so no upgrade guide entry. While the
server is down there is one connect attempt per poll, with the WARN line of the
failed poll that main logs already.
camel-pg-replication-slot tests with the fix: 7 run, 0 failures, 0 skipped. Not
run: a real PostgreSQL server (the module's integration test needs a
container); which message a restart produces first (ErrorResponse {{57P01}},
end of the replication, or end of the socket) was not observed.
How it was found: a sweep of the reconnect code of the components, then a small
TLA+ model of the consumer's connection field (server up / down, connection ok
/ broken / closed / none, cause of the failed read). For main it reaches a lost
connection that no poll replaces, for both causes, and a run that never
reconnects; with the fix the properties hold for two outages (126 states). The
model is a sketch; the tests are the evidence.
Affected: main (1de5ec5135f8; tested). camel-4.23.x, camel-4.22.x, camel-4.18.x
and camel-4.14.x have the same {{SocketException}} check in {{poll()}} and the
same {{connect()}} (read, not run).
Duplicate check (2026-10-14): JIRA component camel-pg-replicationslot (only
CAMEL-15614, automate the integration test), text "replication slot reconnect"
(nothing); GitHub pull requests "PgReplicationSlotConsumer",
"pg-replication-slot" (nothing related).
_Filed with Claude Code on behalf of allthingssecurity._
--
This message was sent by Atlassian Jira
(v8.20.10#820010)