Skip to content

Keep the replication connection single-threaded#92

Open
lukashes wants to merge 3 commits into
mainfrom
bugfix/replication-conn-data-race
Open

Keep the replication connection single-threaded#92
lukashes wants to merge 3 commits into
mainfrom
bugfix/replication-conn-data-race

Conversation

@lukashes

Copy link
Copy Markdown
Owner

Problem

The flush/commit worker ran on its own thread (io.concurrent under std.Io.Threaded) and called both producer.flush() (Kafka) and source.sendFeedback() (libpq) on the same PGconn the main receive loop reads from via PQgetCopyData. libpq forbids using one connection from two threads.

Under load the race wedged the connection. When Kafka's local queue filled (Local: Queue full), the produce error propagated into the graceful teardown (errdefer flush_future.cancel(io)), which awaits the worker's final sendFeedback — a PQflush that blocked in select() on the corrupted connection with no timeout. The process hung instead of failing fast; only SIGINT (EINTR) unblocked it, which is why a manual restart was needed and no exit(1) fired on its own.

Solution

Keep all libpq access on the receive thread:

  • The worker flushes Kafka only (slow, must stay off the main thread) and records how far it flushed in an atomic flushed_lsn. It never touches libpq.
  • The main thread sends standby feedback. receiveBatch now takes the confirmed LSN and reports it to Postgres before reading more WAL, so feedback rides the same single-threaded replication loop as the read — the way the native walreceiver works. commitFlushed is gone.
  • Graceful shutdown confirms the worker's final flush explicitly.
  • The flush interval becomes a Processor field (default from the constant).

Test

New e2e test: after a batch is received and flushed, a subsequent receive with no new changes still advances the slot's confirmed_flush_lsn — the idle-commit guarantee.

Closes #81

The flush/commit worker ran on its own thread and called both
producer.flush() (Kafka) and source.sendFeedback() (libpq) on the same
PGconn the main receive loop reads from. libpq forbids using one
connection from two threads; under load the race wedged the connection,
so a Kafka queue-full stall hung the graceful teardown in PQflush until
SIGINT (EINTR) broke it loose, instead of failing fast.

Keep the worker for the slow Kafka flush only: it records how far it has
flushed in an atomic flushed_lsn and never touches libpq. The main
thread sends standby feedback -- receiveBatch now takes the confirmed
LSN and reports it before reading more WAL, so feedback rides the same
single-threaded replication loop as the read, like the native
walreceiver. Graceful shutdown confirms the worker's final flush.

The flush interval becomes a Processor field (default from the
constant). Add an e2e test asserting confirmed_flush_lsn advances when a
receive finds no new changes.
@lukashes lukashes added this to the v0.3.0: GA release milestone Jul 11, 2026
@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

Current run is the minimum over 3 passes, compared against the base branch (main @ 872e5be), built on the same runner.

Benchmark Baseline Current Δ Time Allocs Status
Converter DELETE 125.82μs 125.56μs -0.2% 12 → 12 ➡️
Converter INSERT 120.26μs 121.07μs +0.7% 12 → 12 ➡️
Converter UPDATE 171.36μs 171.98μs +0.4% 21 → 21 ➡️
JsonSerializer 41.52μs 41.04μs -1.2% 3 → 3 ➡️
KafkaProducer produce 303.08μs 303.96μs +0.3% 1 → 1 ➡️
KafkaProducer sendMessage 340.21μs 332.03μs -2.4% 0 → 0 ➡️
PgOutputDecoder 94.14μs 92.68μs -1.5% 6 → 6 ➡️
getPartitionKeyValue boolean 15.93μs 15.91μs -0.1% 1 → 1 ➡️
getPartitionKeyValue integer 50.13μs 49.40μs -1.5% 3 → 3 ➡️
getPartitionKeyValue not found 0.03μs 0.03μs +0.0% 0 → 0 ⚪ noise
getPartitionKeyValue string 17.97μs 17.88μs -0.5% 1 → 1 ➡️
matchStreams found 17.35μs 16.88μs -2.7% 1 → 1 ➡️
matchStreams not found 0.05μs 0.05μs -1.9% 0 → 0 ⚪ noise

Summary: ➡️ 11 neutral · ⚪ 2 ignored (sub-μs)

Thresholds: <1μs ignore · 1–20μs 15% · 20–50μs 10% · ≥50μs 5%. Measured on a shared CI runner — treat small deltas as noise. Informational only; this check never fails the build.

@lukashes

Copy link
Copy Markdown
Owner Author

/bench

Two failures surfaced under load: Postgres killed the walsender with
'terminating walsender due to replication timeout', and outboxx then hung
in teardown instead of exiting, so it never restarted.

Standby feedback only went out from receiveBatch when flushed_lsn
advanced, and keepalives were never answered. During a startup backlog,
where the worker's first successful flush + feedback did not land within
wal_sender_timeout (60s), no status update was sent and Postgres dropped
the connection. Now reply to a keepalive's reply_requested with the last
durably-confirmed position (last_feedback_lsn, never the received LSN, so
at-least-once holds), which resets the timeout even when flushed_lsn is
not advancing yet.

The teardown hung because errdefer flush_future.cancel awaited the
worker's final producer.flush() into a wedged/backlogged broker. Drop the
worker's final flush so cancel returns promptly; do the final flush on the
main thread in the graceful path and confirm pending_lsn (durable after a
full flush). On a fatal error the process now exits fast instead of
hanging forever.
@lukashes

Copy link
Copy Markdown
Owner Author

/bench

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Producer queue full crashes the process instead of applying backpressure

1 participant