Uh oh!
There was an error while loading. Please reload this page.
fix(driver-sql): the first concurrent autonumber insert from two tenants no longer fails on Postgres with 25P02 - #8279
Conversation
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8269
On Postgres, two tenants inserting into the same autonumber-bearing object for the first time concurrently failed the whole batch with
25P02 current transaction is aborted, commands ignored until end of transaction block. The counters advanced anyway, so the numbers that attempt reserved were lost — a permanent gap at the start of both tenants' sequences.Cause
getNextSequenceValuehandled the first-insert race by catching the unique violation and recovering on the same transaction. On Postgres any statement error aborts the entire transaction, so the recoverySELECT … FOR UPDATEis the statement that raises the error the report observed — the recovery path could never run there. SQLite and MySQL do not abort a transaction on a statement error, which is why the idiom looked correct and why the SQLite-backed autonumber suite could not catch it.Fix
Both speculative statements now run under a
SAVEPOINT(attemptWithoutPoisoning, a knex nested transaction), released on success and rolled back to on failure, so a failed attempt leaves the surrounding transaction usable on every dialect. The race handler that was written for this case now actually runs: the loser blocks on the winner's row, reads the committed counter, and takes its number from the UPDATE path.Both catch-inside-transaction sites are fixed. The
SELECT … FOR UPDATEfallback above the measured site had the same shape and the same consequence. Its comment attributed it to dialects that "reject.forUpdate()on a missing row" — measured onpostgres:16, that does not happen (a missing row returns zero rows), so the catch was unreachable for the documented reason. It is reachable for lock-level failures (deadlock40P01, lock/statement timeout55P03/57014), and each of those was being masked as an uninformative25P02by the fallback read. Same savepoint, so the fallback runs on a clean transaction.Why savepoint over
INSERT … ON CONFLICT DO NOTHINGBoth candidates were measured;
ON CONFLICTlost on evidence:SELECT … FOR UPDATE, and there is noON CONFLICTfor a read.ON CONFLICT (object, tenant_id, field)— the columns the legacy key uses whensequencesHasKeyHashis false — raises42P10 there is no unique or exclusion constraint matching the ON CONFLICT specificationagainst an interim table whose primary key is the four columns(object, tenant_id, field, scope). That pairing is reachable: it is exactly what a failedensureSequencesKeyHashShapemigration leaves behind. It would have replaced this bug with a harder one on the deployments least able to absorb it.The helper also returns a discriminated result rather than rethrowing, so the caller keeps the original error — an
ON CONFLICT DO NOTHINGaffecting zero rows cannot tell "another writer raced me" apart from "the row was rejected for another reason".Refinement to the report: this is not multi-org-only
The report measured single-tenant bursts as safe. They are only flakier. Measured on
postgres:16before the fix, 5 rounds each of one tenant x N cold concurrent inserts:25P0225P0225P02Two tenants means two cold counter rows, which makes the window near-certain to be hit rather than occasional — an amplifier, not a precondition. Single-organization deployments were exposed too. The regression guard is still written cross-tenant because that is the shape that fails deterministically.
Tests
sql-driver-autonumber-cold-race.test.ts, reusing the existing live-dialect harness (live-dialect-matrix.testkit.ts+OS_TEST_POSTGRES_URL, theTemporal Conformance (live PG + MySQL)job's mechanism) — no second Postgres mechanism invented. The guard is Postgres-backed AND cross-tenant AND cold AND concurrent, all four; an unprovisioned pg cell is a reported skip that becomes a hard failure underOS_EXPECT_LIVE_DIALECT_MATRIX=1, so CI cannot silently degrade it. A SQLite cell runs the same burst as an explicitly-labelled control (it passed before the fix and must still pass), not as coverage.Reverse verification, prediction stated before running — revert
sql-driver.tsonly, keep the tests:The two SQLite rows staying green is the load-bearing half: it confirms the control is genuinely a control rather than a second copy of the guard.
Full
@objectstack/driver-sqlsuite against live Postgres 16: 91 files passed, 1629 tests passed, 0 failed;typecheckclean.Deliberately unchanged
What happens to numbers on a failed attempt. The reservation still commits in its own transaction (
runner.transaction,parentTrx ?? this.knex) and is not rolled back with the caller's insert — ordinary sequence semantics, out of scope here. No behaviour change for SQLite or MySQL: the savepoint makes Postgres behave the way those two already did.Generated by Claude Code