Skip to content

fix(server): keep the ongoing tx when a statement fails before execution - #2137

Merged
vchaindz merged 2 commits into
masterfrom
fix/issue-2127-snapshot-leak
Aug 3, 2026
Merged

vchaindz merged 2 commits into
masterfrom
fix/issue-2127-snapshot-leak

Conversation

@vchaindz

Copy link
Copy Markdown
Contributor

Fixes #2127. Also very likely fixes #1998, which hits the same error through the pgsql adapter.

The bug

A statement rejected before the SQL engine executes it — a parse error being the common case — returns no transaction while leaving the caller's ongoing one open and uncancelled. The two callers that hold the only reference to that transaction overwrote it with the returned nil:

  • pkg/server/sessions/internal/transactions/transactions.go:124 — tx.sqlTx, _, err = tx.db.SQLExec(...)
  • pkg/pgsql/server/query_machine.go:1205 — s.tx = ntx, assigned even when err != nil

That orphans an OngoingTx still holding its tbtree snapshots. Snapshots are released only by Commit/Cancel, and tbtree.snapshots shrinks only via Snapshot.Close(), so each failure leaked one permanently. After maxActiveSnapshots (default 100) failures, every new transaction failed with tbtree: max active snapshots limit reached until the server was restarted. The reporter hit this four times in production, with a 1:1 correlation between failed statements and leaked snapshots.

On the session path it was unrecoverable: with sqlTx == nil, IsClosed() reports true, so TxSQLExec removed the transaction from the session — after which neither the client's Rollback nor Session.RollbackTransactions() on close/expiry could reach it. That also explains the reported client signature, where Rollback answers no transaction found.

The fix

Guard both assignment sites on what actually happened to the transaction, not on the error:

ntx, _, err := tx.db.SQLExec(ctx, tx.sqlTx, request)
if ntx != nil || tx.sqlTx.Closed() {
    tx.sqlTx = ntx
}
  • Pre-execution error — the engine never touched the tx, so it stays open and owned by the session. Rollback now succeeds instead of answering no transaction found, and close/expiry can reclaim it. Leak gone.
  • Execution error — the engine already called currTx.Cancel(), so the tx is closed, the reference is replaced and the session drops it. No behaviour change.
  • COMMIT; — returns ntx == nil with a closed tx, so the assignment still happens.

Why here and not in the engine

The issue proposed fixing Engine.Exec. That would not have fixed it: the session path parses in db.SQLExec (pkg/database/sql.go:364) and returns before the engine is ever reached. engine.go:756 is a second, independent instance of the same defect, reachable only by embedded users.

More importantly, the engine returning nil is not itself the leak — a caller that passes tx in still owns it. The leak is assigning that nil over the only reference to a still-open tx. Pre-execution returns that leave the tx open include:

  • pkg/database/sql.go:364 / :379 / :386 — parse error, empty statement list, replica
  • embedded/sql/engine.go:756 / :795 / :800 / :809 — parse error, empty list, normalizeParams failure, nil statement

Guarding the two reference-owning callers closes all of them at once. Patching individual early returns would keep regressing as new ones are added.

Tests

Three tests in transactions_test.go, written before the fix and confirmed to fail without it:

Test Without the fix
TestSQLExecPreExecutionErrorKeepsTxOpen fails — IsClosed() true, Rollback() returns ErrNoOngoingTx
TestSQLExecPreExecutionErrorDoesNotLeakSnapshots fails at iteration 4 of 4 (maxActiveSnapshots = 4) with the snapshot-limit error, reproducing the reported 1:1 correlation
TestSQLExecExecutionErrorClosesTx passes — pins existing behaviour so this change cannot silently alter the execution-error path

The leak test mirrors the reported production cycle, including the client carrying on after its rollback fails.

Verification

  • go build ./... clean
  • go test ./pkg/server/sessions/... ./pkg/server/ ./pkg/pgsql/server/... ./pkg/database/... — 10 packages pass
  • go test ./embedded/sql/... ./embedded/tbtree/... pass
  • Commit is GPG-signed

The pgsql suite matters here because the second hunk touches the extended-query path recently changed by ce64e941 and c4a5beed.

A statement rejected before the SQL engine runs it - a parse error being the
common case - returns no transaction while leaving the caller's ongoing one open
and uncancelled. Both callers that hold the only reference to that transaction
overwrote it with the returned nil, orphaning an OngoingTx that still held its
tbtree snapshots. Snapshots are released solely by Commit and Cancel, and
tbtree.snapshots only shrinks on Snapshot.Close, so each failure leaked one
permanently. After maxActiveSnapshots (default 100) failures every new
transaction failed with "tbtree: max active snapshots limit reached" until the
server was restarted.

The session path made this unrecoverable: with sqlTx nil, IsClosed reports true,
so TxSQLExec removed the transaction from the session and neither the client's
Rollback nor session close and expiry could reach it any more. That is also why
the reported client signature shows Rollback answering "no transaction found".

Guard both assignment sites on what actually happened to the transaction rather
than on the error: adopt the returned transaction only when the engine produced
one, or when the previous one is genuinely closed. Execution-stage errors are
unaffected because the engine cancels the transaction itself, so the reference is
still replaced and the session still drops it.

Note the leak did not originate in Engine.Exec: the session path parses in
db.SQLExec (pkg/database/sql.go) and returns before the engine is reached. Other
pre-execution returns leak the same way - empty statement lists, parameter
normalisation failures, replica rejections - so the fix belongs in the callers
that own the reference, not in the individual early returns.

Fixes #2127
@SimoneLazzaris
SimoneLazzaris self-requested a review July 31, 2026 08:42
SimoneLazzaris
SimoneLazzaris previously approved these changes Jul 31, 2026
TestTx_Errors asserted the symptom that d491954 removes: a parse error used
to leave sqlTx nil, so IsClosed reported true, the session dropped the
transaction and the next statement answered "no transaction found". The
transaction now stays open, so that statement reaches the parser and returns
its own syntax error instead.

Assert that error, and roll back at the end - on the old code the rollback is
exactly what failed, so it pins the fix through the full client and session
path rather than only at the transactions package.

The sessions import goes with the ErrTransactionNotFound reference.
@coveralls

Copy link
Copy Markdown
Collaborator

Coverage Status

coverage: 84.849% (+0.01%) from 84.839% — fix/issue-2127-snapshot-leak into master

@vchaindz
vchaindz merged commit 1a5f54e into master Aug 3, 2026
18 of 19 checks passed
@vchaindz
vchaindz deleted the fix/issue-2127-snapshot-leak branch August 3, 2026 06:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants