Skip to content

Add standby SQL sinks: --standby-lock and --stall-timeout (PostgreSQL) - #1013

Merged
sduchesneau merged 5 commits into
streamingfast:developfrom
aggris2:feature/sql-sink-standby
Oct 30, 2026
Merged

sduchesneau merged 5 commits into
streamingfast:developfrom
aggris2:feature/sql-sink-standby

Conversation

@aggris2

@aggris2 aggris2 commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Builds on #1007, #1011 and #1012. Until those are merged, this PR also shows their commits.

Today only one sink can safely write to a database, so running the sink on more than one machine needs something outside it to make sure only one runs (start/stop scripts tied to the database failover, for example). This adds a standby mode to the SQL sink (PostgreSQL): several sinks run against one database, one writes, and the others take over by themselves.

--standby-lock: before streaming, the sink takes a session advisory lock keyed on the cursors table (one writer per cursors table, whatever the module). It reads its cursor and does all its writes through that same session, so losing the session loses the lock and the ability to write together. Other sinks wait as standbys, without opening a Substreams stream, checking every --standby-lock-poll-interval (default 2s). The first to see the lock released takes over from the stored cursor.

  • A sink whose locked session is gone stops instead of retrying its flush.
  • A connection whose lock state is uncertain (an error while locking or unlocking) is closed rather than returned to the pool.
  • Standbys serve the --prometheus-addr endpoint while they wait; substreams_sink_postgres_writer_lock_held is 1 on the active sink.

--stall-timeout: stops the sink when the stream delivers no block or undo for that long once live, so a stuck sink can't keep the lock. Blocks held by an undo buffer count (new Sinker.LastBlockMessageAt). On PostgreSQL a flush now honours its context, so the stop cancels a database call in progress and its transaction rolls back. ClickHouse flushes still run to the end, since its writes aren't transactional.

The cursor check from #1012 keeps any other writer from corrupting data. The new docs/references/sql/standby-sinks.md covers the setup: server TCP keepalives so a dead client's session (and lock) is released, session connections (not a transaction-mode pooler), failover relying on the old primary being fenced, stalls and network cuts.

Tests, with real Postgres (PG_DSN):

  • TestWriterLock_Postgres: a standby waits, takes over when the active sink's session is terminated, the old writer can't write anymore, a sink of another module waits too, a standby stops when told to, closing releases the lock;
  • TestDiscardEndsLockedSession_Postgres and TestIsCancellation;
  • TestSinker_StallTimeout, and a cursor conflict while stopping is still reported.

End to end, over 20k mainnet blocks with a flush per block:

  • two supervised sinks with --standby-lock, killing the active one, cutting its database session, then killing the standby;
  • a primary and an async replica behind a proxy, failing over to the replica.

Final tables were identical to a clean run in every case, and the standby took over within about a second.

@dfuse-bot
dfuse-bot requested a review from sduchesneau October 23, 2026 13:53
Several sinks can now run against one database with a single writer, so a
sink can run on more than one machine and fail over without a human.

--standby-lock: before streaming, the sink takes a session advisory lock keyed
on the cursors table (one writer per cursors table, whatever the module), and
reads its cursor and writes through that same session, so losing the session
loses the lock and the ability to write together. Other sinks wait as
standbys, without opening a Substreams stream, checking every
--standby-lock-poll-interval (default 2s). The first to see the lock released
takes over from the stored cursor. A sink whose locked session is gone stops
instead of retrying its flush; a connection whose lock state is uncertain is
discarded rather than returned to the pool. Standbys serve the metrics
endpoint while they wait, with substreams_sink_postgres_writer_lock_held.

--stall-timeout: stops the sink when the stream delivers no block or undo for
that long once live (blocks held by an undo buffer count, see the new
Sinker.LastBlockMessageAt), so a stuck sink cannot keep the lock. On
PostgreSQL a flush now honours its context, so the stop cancels a database
call in progress and its transaction rolls back; ClickHouse flushes still
always run to the end, since its writes are not transactional.

The cursor check from the previous commit keeps any other writer from
corrupting data. docs/references/sql/standby-sinks.md describes the setup:
server TCP keepalives, session connections, failover with a fenced old
primary, stalls and network cuts.

@sduchesneau sduchesneau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should probably ping the connection every standby-lock-poll-interval so that, when processing large segments (initial loading) or on long stretches that yield no result (slow chain, sparse content...), the active connection doesn't get killed by a proxy or idle_session_timeout. -- this would cause some failover pingpong between instances.

it's the opposite of the idea of stall-timeout, it's when you want to support sparse data and prevent "spurious failovers".

The active sink can go a long time without writing: large segments while
catching up, or a slow chain or sparse module with nothing to flush. Its
locked session then sits idle, and a proxy's idle timeout or the server's
idle_session_timeout can end it. That hands the lock to a standby, the old
sink fails its next flush and comes back as a standby, and the sinks take
turns failing over on data that is merely sparse.

The active sink now runs SELECT 1 on the locked session every
--standby-lock-poll-interval. It skips the query while a transaction is open
on that session (a running flush keeps it busy anyway, and a query inside an
aborted transaction would fail), so writes and the keepalive never
interleave. A timed-out query only logs a warning; any other failure means
the session and the lock are gone, and the sink stops like it does when a
flush finds the session gone.

This also makes idle_session_timeout useful as a fence: a frozen sink stops
querying, its session ends, and a standby takes over. The standby sinks doc
explains both.
The keepalive skipped the writer connection only while a transaction was
open on it. The cursor read at startup runs outside a transaction on that
same connection, and lib/pq refuses a query while another query's rows are
still being read ("there is already a query being processed on this
connection"). A keepalive tick landing in that window failed, and the sink
stopped as if it had lost its session.

The mutex now also covers that read, so the keepalive skips its tick while
the rows are open. A test reads many cursor rows while the keepalive runs in
a tight loop; it fails without the fix.
@aggris2

aggris2 commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

@sduchesneau Good call, thanks. Done in c2aaffa: the active sink pings its locked session every --standby-lock-poll-interval, so idle stretches don't end it. c0dad19 fixes a race the ping introduced with the startup cursor read.

@aggris2
aggris2 requested a review from sduchesneau October 27, 2026 11:20
@sduchesneau

Copy link
Copy Markdown
Contributor

@aggris2 all good, please resolve conflicts and push, I will approve/merge it then

@aggris2

aggris2 commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

@sduchesneau conflicts resolved

@sduchesneau sduchesneau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@sduchesneau
sduchesneau merged commit 03b8800 into streamingfast:develop Oct 30, 2026
7 checks passed
@sduchesneau sduchesneau mentioned this pull request Oct 30, 2026
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.

2 participants