On Postgres, when two tenants insert into the same autonumber-bearing object for the first time concurrently, the whole batch fails with 25P02 current transaction is aborted. The counters are advanced anyway, so the numbers the failed attempt reserved are lost — a permanent gap at the start of both tenants' sequences.
Single-tenant concurrency is fine at every level I tried. The tenant boundary is what breaks it, which makes this specific to multi-org deployments — and the EE stack runs Postgres.
Measured
Driver-level probe, SqlDriver on postgres:16, object { organization_id, code: autonumber 'TK-{0000}', name }, one Promise.all burst per case, each case on a fresh driver and a fresh table:
| case |
total inserts |
result |
| 1 tenant × 2 / 4 / 6 / 8 / 12 |
up to 12 |
OK — TK-0001…TK-0012, contiguous, no duplicates |
| 2 tenants × 2 |
4 |
FAILED 25P02 |
| 2 tenants × 4 |
8 |
FAILED 25P02 |
| 2 tenants × 6 |
12 |
FAILED 25P02 |
2 tenants × 6, pool max: 20 |
12 |
FAILED 25P02 — not pool exhaustion |
| 2 tenants, strictly serial |
8 |
OK — A:TK-0001 B:TK-0001 A:TK-0002 B:TK-0002 … |
The failing statement is the sequence reservation itself:
select * from "_objectstack_sequences" where "key_hash" = $1 limit $2 for update
- current transaction is aborted, commands ignored until end of transaction block
Cold vs warm is the discriminator. Seed one serial row per tenant first (so both counter rows exist), then run the exact burst that failed cold:
[W] warm-up => orgA:TK-0001 orgB:TK-0001
[W] counter rows now => [{"tenant_id":"orgA","last_value":"1"},{"tenant_id":"orgB","last_value":"1"}]
[W] warm concurrent => OK orgA=TK-0002..TK-0005 orgB=TK-0002..TK-0005
[W] cold concurrent => FAILED code=25P02
Retry "recovers" but loses numbers. Three successive bursts on a cold object:
[R] attempt 1 => FAILED code=25P02
[R] counters after 1 => [{"tenant_id":"orgA","last_value":"3"},{"tenant_id":"orgB","last_value":"1"}]
[R] attempt 2 => OK orgA:TK-0004 TK-0005 TK-0006 orgB:TK-0002 TK-0003 TK-0004
[R] attempt 3 => OK orgA:TK-0007 TK-0008 TK-0009 orgB:TK-0005 TK-0006 TK-0007
TK-0001…TK-0003 (orgA) and TK-0001 (orgB) exist in no row — the counters were advanced by the attempt whose transaction then aborted. So the user-visible story is: "creating the first records failed; I retried, it worked, and my numbering starts at 0004."
SQLite is unaffected — the identical cold cross-tenant burst succeeds:
[R] sqlite cold burst => OK orgA:TK-0001 TK-0002 TK-0003 orgB:TK-0001 TK-0002 TK-0003
which is why the existing autonumber suite (sqlite-backed) does not catch it.
Cause
reserveSequenceValue handles the first-insert race by catching the unique violation and continuing inside the same transaction (packages/drivers/driver-sql/src/sql-driver.ts, ~line 4209):
try {
await trx(SEQUENCES_TABLE).insert({ ...insertRow, last_value: initial });
return initial;
} catch (err) {
// Another writer raced us to the first INSERT. Fall through to
// the UPDATE path with the now-present row.
existing = await trx(SEQUENCES_TABLE).where(key).forUpdate().first(); // ← 25P02 here
if (!existing) throw err;
}
In Postgres any statement error poisons the whole transaction: every subsequent statement returns 25P02 until rollback. So this recovery path can never run on Postgres — the SELECT … FOR UPDATE in the catch is itself the statement that raises the error we observe. The pattern is only valid on SQLite/MySQL, where a statement error does not abort the transaction. The comment a few lines above (Postgres/MySQL behave normally here) is the assumption that does not hold.
Suggested fix
Make the first-insert race dialect-safe, e.g.
- wrap the speculative INSERT in a
SAVEPOINT and roll back to it before falling through, or
- do the insert as
INSERT … ON CONFLICT DO NOTHING (the driver already uses onConflict at sql-driver.ts:4596) and then re-select, so no statement ever errors.
A regression test needs to be Postgres-backed and cross-tenant + cold + concurrent — the three conditions together. Single-tenant or warm variants pass regardless.
Environment
Reproduced against postgres:16 in Docker, via SqlDriver directly (branch claude/multi-org-service-testing-a75937, deps built from the current worktree). Same shape as the objectos-ee-deploy stack serving http://localhost:8080, which is single-database with organization_id partitioning. The probe files were temporary and are not part of this report; the tables above are the full output.
On Postgres, when two tenants insert into the same autonumber-bearing object for the first time concurrently, the whole batch fails with
25P02 current transaction is aborted. The counters are advanced anyway, so the numbers the failed attempt reserved are lost — a permanent gap at the start of both tenants' sequences.Single-tenant concurrency is fine at every level I tried. The tenant boundary is what breaks it, which makes this specific to multi-org deployments — and the EE stack runs Postgres.
Measured
Driver-level probe,
SqlDriveronpostgres:16, object{ organization_id, code: autonumber 'TK-{0000}', name }, onePromise.allburst per case, each case on a fresh driver and a fresh table:TK-0001…TK-0012, contiguous, no duplicates25P0225P0225P02max: 2025P02— not pool exhaustionA:TK-0001 B:TK-0001 A:TK-0002 B:TK-0002 …The failing statement is the sequence reservation itself:
Cold vs warm is the discriminator. Seed one serial row per tenant first (so both counter rows exist), then run the exact burst that failed cold:
Retry "recovers" but loses numbers. Three successive bursts on a cold object:
TK-0001…TK-0003(orgA) andTK-0001(orgB) exist in no row — the counters were advanced by the attempt whose transaction then aborted. So the user-visible story is: "creating the first records failed; I retried, it worked, and my numbering starts at 0004."SQLite is unaffected — the identical cold cross-tenant burst succeeds:
which is why the existing autonumber suite (sqlite-backed) does not catch it.
Cause
reserveSequenceValuehandles the first-insert race by catching the unique violation and continuing inside the same transaction (packages/drivers/driver-sql/src/sql-driver.ts, ~line 4209):In Postgres any statement error poisons the whole transaction: every subsequent statement returns
25P02until rollback. So this recovery path can never run on Postgres — theSELECT … FOR UPDATEin thecatchis itself the statement that raises the error we observe. The pattern is only valid on SQLite/MySQL, where a statement error does not abort the transaction. The comment a few lines above (Postgres/MySQL behave normally here) is the assumption that does not hold.Suggested fix
Make the first-insert race dialect-safe, e.g.
SAVEPOINTand roll back to it before falling through, orINSERT … ON CONFLICT DO NOTHING(the driver already usesonConflictatsql-driver.ts:4596) and then re-select, so no statement ever errors.A regression test needs to be Postgres-backed and cross-tenant + cold + concurrent — the three conditions together. Single-tenant or warm variants pass regardless.
Environment
Reproduced against
postgres:16in Docker, viaSqlDriverdirectly (branchclaude/multi-org-service-testing-a75937, deps built from the current worktree). Same shape as theobjectos-ee-deploystack servinghttp://localhost:8080, which is single-database withorganization_idpartitioning. The probe files were temporary and are not part of this report; the tables above are the full output.