Skip to content

Postgres replication silently stops (no reconnect, no error) after a half-open source connection; health probe stays green #700

Description

@embune99

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.

  1. Start the stack; confirm replication is healthy (rows replicate, slot active=t, behind≈0).
  2. 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.)
  3. 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:

  1. 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.
  2. 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.

  1. 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.

  2. 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.

  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions