Skip to content

fix(postgres): keep postgres-js transactions committable after a recovered savepoint refusal - #788

Open
pdlug wants to merge 2 commits into
mainfrom
fix/postgres-js-savepoints
Open

pdlug wants to merge 2 commits into
mainfrom
fix/postgres-js-savepoints

Conversation

@pdlug

@pdlug pdlug commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

postgres-js records the first failed statement of each begin or savepoint scope and rethrows it when that scope ends, unless the statement ran through one of the driver's own nested savepoint scopes. TypeGraph recovers deliberate statement failures (a fused write program's aborting statement after a lost unique key, an imported edge row the database refuses) with raw SAVEPOINT / ROLLBACK TO SAVEPOINT statements on the outer transaction handle. So on postgres-js, a UniquenessError caught inside store.transaction(...) still failed the transaction at commit with the recovered driver error, and an importGraph whose per-row edge create was refused rolled back the whole import. node-postgres and PGlite committed the same transactions.

What changed

Savepoint control now has one owner, runSessionSavepoint (src/backend/session-savepoint.ts), used by the session atomic program, recorded-capture savepoints (runRecordedTransactionSavepoint) and the schema-adoption probe. It issues SAVEPOINT / RELEASE / ROLLBACK TO on the session's own control channel. When the session's driver attributes failures to scopes, it enters that driver's own savepoint scope in between. A scope's rollback decision, which still returns a value, is carried out of the driver scope as a private throw, because the driver rolls back only on a throw.

The PostgreSQL transaction session (postgres-transaction-session.ts) exposes that driver scope on postgres-js through Drizzle's nested transaction(), which is postgres-js sql.savepoint(). While a scope is open, both of the session's execution surfaces send every statement through the nested handle. PostgreSQL already places those statements inside the savepoint whichever handle sent them, so routing them keeps the driver's failure attribution in step with the server, and it needs no second backend object. Other drivers keep the plain adapters and the same raw SQL as before.

The driver scope sits inside TypeGraph's savepoint rather than replacing it, because postgres-js never releases its own savepoints. Without the enclosing release, every recovered scope nests one level deeper for the rest of the transaction: 10,000 scopes took 30 s and 147 MB of backend memory on PostgreSQL 18, against 6 s and 47 MB with the enclosing release, which costs one extra round trip per scope. The remaining memory is postgres-js preparing one statement per distinct savepoint name, bounded by the most savepoints a single transaction opens on that connection.

Driver scopes on one transaction must nest strictly. Two savepoint-protected writes running concurrently on one transaction (for example an importGraph and a bulkReplaceById under one Promise.all) can overlap without nesting, which raw savepoints already mishandle on node-postgres. On postgres-js, the scope that exits out of order now throws CompilerInvariantError (OVERLAPPING_SAVEPOINT_SCOPES), and every later statement on that transaction refuses with the same error. Without this, statements would silently run outside their savepoint or on a scope that has already ended.

A savepoint recovery that itself fails is reported as SavepointRecoveryFailure (an AggregateError naming both failures). The atomic program still wraps it in CompilerInvariantError. A failed RELEASE is now rolled back before it propagates, which recorded capture previously skipped.

The new shared tests (savepoint-recovery.ts) fail on the postgres-js lane on main, with the issue's 23502 for bulkReplaceById and with 23505 for the import, and pass on every lane with the fix. Disabling the driver scope at either site brings its failure back. A new shared adoption test refuses a native transaction handle whose transaction already ended, and fails on all three PostgreSQL lanes when the probe is removed. A unit test overlaps two driver scopes and asserts the invariant error, along with the refusal of every later statement; it fails against the previous single-slot session.

Closes #787

pdlug added 2 commits October 23, 2026 14:00
…vered savepoint refusal

postgres-js records the first failed statement of each begin or savepoint scope and rethrows it when that scope ends, unless the statement ran through one of the driver's own nested savepoint scopes. TypeGraph recovered deliberate statement failures with raw SAVEPOINT and ROLLBACK TO statements on the outer transaction handle, so on postgres-js the caller's transaction rejected at commit with the already-recovered driver error.

One savepoint primitive, runSessionSavepoint in src/backend/session-savepoint.ts, now owns savepoint control for the session atomic program, recorded-capture savepoints, and the schema-adoption probe. On a postgres-js transaction it enters the driver's own savepoint scope (Drizzle's nested transaction) inside TypeGraph's savepoint, and the transaction session routes every statement through the innermost open scope's handle while it is open. Releasing TypeGraph's savepoint also releases the driver's, which the driver never releases itself.
…saction

The postgres-js transaction session kept its open driver scope in one slot and restored the enclosing scope on exit, which is correct only while scopes nest strictly. Two savepoint-protected writes running concurrently on one transaction can overlap without nesting; when the earlier scope exited first, statements of the still-open scope were routed outside it, and its own exit then restored a scope that had already ended.

Open scopes are now a stack. An exit that is not the innermost open scope throws CompilerInvariantError (code OVERLAPPING_SAVEPOINT_SCOPES), and once an ended scope is uncovered every later statement on the session refuses with the same error instead of running on an ended scope or the outer handle.
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.

postgres-js: a refusal recovered through a raw savepoint fails the caller's transaction at commit

1 participant