Summary
When the connection between the replication worker and the source Postgres becomes half-open (e.g. a DB restart / failover / network drop that leaves the TCP connection hung with no RST/FIN), the WAL streaming read blocks indefinitely without throwing. As a result:
- PowerSync never reconnects — the replication stream is dead but no retry is attempted.
- The process keeps running and the liveness probe stays green ("healthy"), so nothing (k8s, alerting) notices.
replication_lag stays 0 (it only reflects received messages), so it is also blind.
- The only way to recover is to restart the replication service.
This matches a production incident where sync silently stopped after a same-day DB restart and only a manual restart restored it.
Environment
journeyapps/powersync-service v1.21.0 (Open Edition). Behaviour also reviewed against main.
- Source: PostgreSQL (AWS RDS in production).
- Run mode: dedicated replication worker (
start -r sync).
- Storage: Postgres.
Steps to reproduce
Minimal docker stack: postgres (source, wal_level=logical) + storage Postgres + powersync-service:1.21.0 replicating one table.
- Start the stack; confirm replication is healthy (rows replicate, slot
active=t, behind≈0).
- Black-hole all traffic between the PowerSync container and the source Postgres without sending RST (simulates a DB restart / failover / network partition):
# inside the powersync container's network namespace
iptables -A OUTPUT -d <source_ip> -j DROP
iptables -A INPUT -s <source_ip> -j DROP
(Using DROP, not REJECT, is important — REJECT would send an RST and PowerSync would error & reconnect. The silent failure needs the no-RST case.)
INSERT a row into the replicated table on the source.
Observed
- The new row is never replicated.
rows_replicated does not increase.
- No fatal error, no reconnect attempt. The main WAL read just hangs.
- Liveness/health probe stays PASS (green) the whole time.
replication_lag stays 0.
- The only non-fatal noise is a few
warn lines from the periodic keepAlive() side-query hitting a TCP retransmit timeout:
warn Query error, retrying postgres query failed
warn [<slot>] KeepAlive failed, unable to post to WAL postgres query failed
These are caught and retried forever; the replication connection is never recreated.
- Source-side
pg_replication_slots shows the truth: confirmed_flush_lsn frozen, active flips t→f, lag grows.
Recovery
Restarting the replication service reconnects, replays the missed rows, and returns to healthy.
Expected behaviour
PowerSync should not be able to silently sit on a dead replication stream while reporting healthy. At minimum one of:
- A read/socket timeout on the WAL streaming read (or
tcp_user_timeout / TCP keepalive on the replication connection) so a half-open connection surfaces as an error → triggers the existing retry/reconnect path.
- A liveness signal tied to replication progress (e.g. confirmed-flush / received-LSN advancing or a keepalive round-trip), instead of the run-loop touching the probe unconditionally — so the health check fails when the stream is actually dead.
Possible cause (pointers into the source)
Line numbers are against main at the time of writing; the behaviour was observed on v1.21.0.
-
The WAL read blocks here and never throws —
WalStream.ts L709:
for await (const chunk of replicationStream.pgoutputDecode()) {
this.touch();
...
When the source connection is half-open, this for await simply never yields another chunk and never rejects, so control stays inside the loop forever.
-
So the retry path is never reached —
WalStreamReplicationJob.ts L45-L88:
replicate() → await this.replicateOnce() (L46); the catch that calls this.rateLimiter.reportError(e) (L77) and the finally { this.abortController.abort() } (L88) only run if the read throws — which it doesn't here. No error → no reportError → no reconnect.
-
Meanwhile the liveness probe is touched unconditionally —
AbstractReplicator.ts L99-L149:
the runLoop calls await container.probes.touch(); (L108) on every iteration, independent of whether the WAL stream is progressing; the only failure it catches is refresh() throwing (L120-L121, logged as Failed to refresh replication jobs). So the probe reflects "the loop is spinning", not "data is flowing" → stays green while the stream is dead.
Note: there is also keepalive-handling logic in WalStream.ts (around L528) that may be relevant to how RDS keepalives interact with a hung stream — worth a maintainer's eye, but I couldn't pin an exact root-cause line there.
Impact
Silent, undetected sync outage. No alert fires because every built-in signal (probe, replication_lag) reports healthy; users hit stale data until someone manually restarts the service.
Summary
When the connection between the replication worker and the source Postgres becomes half-open (e.g. a DB restart / failover / network drop that leaves the TCP connection hung with no RST/FIN), the WAL streaming read blocks indefinitely without throwing. As a result:
replication_lagstays 0 (it only reflects received messages), so it is also blind.This matches a production incident where sync silently stopped after a same-day DB restart and only a manual restart restored it.
Environment
journeyapps/powersync-servicev1.21.0 (Open Edition). Behaviour also reviewed againstmain.start -r sync).Steps to reproduce
Minimal docker stack:
postgres(source,wal_level=logical) + storage Postgres +powersync-service:1.21.0replicating one table.active=t,behind≈0).DROP, notREJECT, is important —REJECTwould send an RST and PowerSync would error & reconnect. The silent failure needs the no-RST case.)INSERTa row into the replicated table on the source.Observed
rows_replicateddoes not increase.replication_lagstays 0.warnlines from the periodickeepAlive()side-query hitting a TCP retransmit timeout:pg_replication_slotsshows the truth:confirmed_flush_lsnfrozen,activeflipst→f, lag grows.Recovery
Restarting the replication service reconnects, replays the missed rows, and returns to healthy.
Expected behaviour
PowerSync should not be able to silently sit on a dead replication stream while reporting healthy. At minimum one of:
tcp_user_timeout/ TCP keepalive on the replication connection) so a half-open connection surfaces as an error → triggers the existing retry/reconnect path.Possible cause (pointers into the source)
Line numbers are against
mainat the time of writing; the behaviour was observed on v1.21.0.The WAL read blocks here and never throws —
WalStream.tsL709:When the source connection is half-open, this
for awaitsimply never yields another chunk and never rejects, so control stays inside the loop forever.So the retry path is never reached —
WalStreamReplicationJob.tsL45-L88:replicate()→await this.replicateOnce()(L46); thecatchthat callsthis.rateLimiter.reportError(e)(L77) and thefinally { this.abortController.abort() }(L88) only run if the read throws — which it doesn't here. No error → noreportError→ no reconnect.Meanwhile the liveness probe is touched unconditionally —
AbstractReplicator.tsL99-L149:the
runLoopcallsawait container.probes.touch();(L108) on every iteration, independent of whether the WAL stream is progressing; the only failure it catches isrefresh()throwing (L120-L121, logged asFailed to refresh replication jobs). So the probe reflects "the loop is spinning", not "data is flowing" → stays green while the stream is dead.Impact
Silent, undetected sync outage. No alert fires because every built-in signal (probe,
replication_lag) reports healthy; users hit stale data until someone manually restarts the service.