Skip to content

Check the stored cursor before each SQL sink flush (PostgreSQL) - #1012

Merged
sduchesneau merged 2 commits into
streamingfast:developfrom
aggris2:feature/sql-sink-cursor-check
Oct 26, 2026
Merged

sduchesneau merged 2 commits into
streamingfast:developfrom
aggris2:feature/sql-sink-cursor-check

Conversation

@aggris2

@aggris2 aggris2 commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

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

The sink saves its cursor with a plain UPDATE cursors SET … WHERE id = '<module>', without checking that the stored cursor is still the one it last wrote. So a few things can write on top of the wrong state:

A flush retry can apply a batch twice. If a COMMIT goes through but its reply is lost (a restart, a network blip), flushWithRetry replays the same buffer in a new transaction. Inserts fail on duplicate keys, but ADD fields are applied twice and the history records a wrong previous value.

A failover can leave a silent hole. With asynchronous replication, the new primary can be missing the last commits. A sink connected through a proxy reconnects and keeps writing from where it was. In a test with a primary, an async replica and a proxy, the stock sink ended with 1,481 transactions missing and exited 0.

Two sinks of the same module overwrite each other's work and move the cursor back.

Fix (PostgreSQL): once the sink has read or written its cursor, each flush and revert transaction starts with

SELECT 1 FROM cursors WHERE id = '<module>' AND cursor = '<last read or written>' COLLATE "C" FOR UPDATE

If no row matches, the transaction is rolled back before anything is written. The sink stops with the stored cursor is not the one this sink last read or wrote, and on restart resumes from the stored cursor. A conflict is not retried. A concurrent writer waits on the row lock, then fails the same check.

Only the sink's own row is read and locked (it already reads and updates that row). The cursor UPDATE stays the last statement, so triggers on the cursors table see the same thing as before. ClickHouse is unchanged.

Added TestSinker_CursorCheck. With real Postgres (PG_DSN), added:

  • TestCursorCheck_Postgres: a second writer, a replayed commit, other modules' rows, a restart;
  • TestLostCommitReply_Postgres: a proxy in front of Postgres drops the reply to a COMMIT after the server applied it. The retry is refused and the ADD isn't doubled.

In the same failover test as above, the sink stops, replays from the new primary's cursor, and ends with tables identical to a clean run.

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

Good, and performance impact does not seem too bad since it's just one per flush.
I just dislike the 'setExpectedCursor' inside a Get, please take it out and make it explicit from the SQLSinker.Run after it loads the cursor.

Other than that, good to go when rebased.

Comment thread sink/sql/db_changes/db/cursor.go Outdated
activeCursor, found := cursors[outputModuleHash]
if found {
return activeCursor, false, err
l.setExpectedCursor(rows[outputModuleHash].Cursor)

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.

setting this here is not pretty (side-effect of activating a check inside a "Get"...)

add the rawCursor to the output of GetCursor and call it like this in sinker.go:

cursor, rawCursor, mismatchDetected, err := s.loader.GetCursor(ctx, s.OutputModuleHash())
  //  error checking (...)
s.loader.SetExpectedCursor(rawCursor)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done: GetCursor now returns the stored cursor as is (rawCursor), and SQLSinker.Run calls SetExpectedCursor once it has read or inserted the cursor. InsertCursor no longer sets it either, so the loader never turns the check on by itself.

The call sits after the two InsertCursor branches rather than right after the error check: on a module hash mismatch, GetCursor returns no rawCursor (the row found belongs to another module) and Run inserts cursor.String() under its own module, so that branch sets rawCursor to what it wrote.

@sduchesneau

Copy link
Copy Markdown
Contributor

I also noted that this does not cover the 'from-proto' sink. that sink has more interfaces that were added when optimizing for (insert-only) performance, so it might be trickier to add there -- so I'm fine with this PR only covering the 'databaseChanges' usecase/codepath.

The final flush when the stream ends (stopped, or at the stop block) treated
the stream's latest block as final. It pruned the undo history of blocks that
were still reversible, so a reorg across a sink restart left orphaned rows in
the database, and with an undo buffer it saved a cursor past the blocks still
in the buffer, which were then skipped. It now writes the handled blocks with
the last handled block's cursor and the stream's final block height, writes
nothing if a block failed to be handled, and the CLI waits for it before
exiting and reports a failure that happens while stopping.

Sink library: a handler error wrapping io.EOF (a lost database connection, for
example) ended the run as if the stop block was reached, so a live sink exited
with status 0 and was not restarted on failure. A handler error is never taken
for the end of the stream anymore. When a handler fails while the sinker is
stopping, the completion handler no longer runs on top of the block it did not
finish, and the error is reported unless the stop itself caused it. The
completion handler gets the cursor of the last block the handler accepted;
Sinker.LastReceivedCursor still gives the stream's.

An undo whose transaction failed was not rolled back: the deferred rollback
checked a different error variable.
Once the sink has read or written its cursor, each flush and revert
transaction starts by locking the sink's cursor row and checking it still
holds the cursor this sink last read or wrote:

    SELECT 1 FROM cursors WHERE id = '<module>' AND cursor = '<last>' COLLATE "C" FOR UPDATE

Otherwise nothing is written and the sink stops with "the stored cursor is not
the one this sink last read or wrote", to be restarted from the stored cursor.
A conflict is not retried.

This stops:
- a flush retry applying a batch twice after a COMMIT whose reply was lost
  (the retry replayed the same buffer, doubling ADD fields);
- a sink writing on top of a database that lost commits, after a restore or a
  failover with asynchronous replication reached through a proxy, which left
  a silent hole;
- a second sink of the same module writing to the same database.

Only the sink's own row is read and locked, and the cursor UPDATE stays the
last statement of the transaction, so triggers on the cursors table are
unaffected. ClickHouse is unchanged.
@aggris2
aggris2 force-pushed the feature/sql-sink-cursor-check branch from 9862ad1 to 0063172 Compare October 23, 2026 20:46
@aggris2

aggris2 commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on develop (after #1007), along with #1011 and #1013 on top.

Checked end to end with two sinks of one module resuming from the same cursor: one stops at its first flush with the cursor conflict, and with the new SetExpectedCursor call removed both keep writing.

Agreed on from-proto: this only covers the DatabaseChanges path, as the changelog entry says.

@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 595d9b0 into streamingfast:develop Oct 26, 2026
7 checks passed
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