improvement(tables): bump rows version once at commit and serialize same-value unique writes
Summary
bump_user_table_rows_versionran as a statement-level trigger inside every row-update statement, so each writer locked its table'suser_table_definitionsrow across the rest of its transaction (executions patch, provenance write and capture, synchronous commit), and concurrent writers to one table queued behind each other. Provenance-only updates bumped it too, even though they don't change the table's content- The UPDATE bump is now a row-level
DEFERRABLE INITIALLY DEFERREDconstraint triggerAFTER UPDATE OF data, order_key, with aWHENon real changes. It bumps once per table per transaction at commit, deduped through one transaction-local setting. INSERT/DELETE statement bumps are unchanged. The only readers (the CSV snapshot cache and mount safety) read outside write transactions, so a commit-time bump keeps their read, scan, re-read checks intact user_table_rows.table_id→user_table_definitionsFK becomesDEFERRABLE INITIALLY DEFERRED(catalog-only). The write path updates a row twice in one transaction (data, then the provenance marker, which the rolling-deploy demote trigger requires to be separate), and the second update re-checked the FK, taking a key-share lock on the definition row that the commit-time bump then waited behind.ON DELETE CASCADEstays immediate, and nothing catches this FK's error inline- The trigger swap runs inside an explicit
BEGIN, so it stays atomic when an earlier migration in the same deploy used aCOMMITbreakpoint CREATE STATISTICS (dependencies) ON workspace_id, table_idplusANALYZE, so the planner stops underestimating per-table row counts and servesdata @>filters from the tenant GIN- Unique columns are an application-level check, so two concurrent writes of the same value could both pass it and both commit. Every write path now takes transaction-scoped advisory locks (tag
user_table_unique_value) before its check: the table's unique lock shared, then an exclusive lock per value, keyed on the same normalization the check uses (so'8'and8share a key). Writers of different values, on any column, never wait on each other- Each transaction takes all its locks at once, in one sorted order. The global order is schema lock, then unique locks, then row-order lock, then definition row
- A
jsonobject or array value, or a transaction that would take more than 64 value locks, takes the table's unique lock exclusively instead. Whole-table replace and every import batch always do. A transaction holds at most 1 + 64 unique locks, however many unique columns the table has. 64 is the default per-connection budget of Postgres's shared lock table, so wide schemas and large batches can't exhaust it and fail unrelated queries - The unique check for
updateRow,batchUpdateRows, and single-row filter updates now runs inside the write transaction, under the locks.batchUpdateRowsalso rejects two updates in one batch that set the same value, and it locks and checks only the unique columns each update changes - Tables without unique columns take no extra round trips
Type of Change
- Improvement
Testing
-
A/B against staging on identical fresh DBs, real service functions, runs alternated:
- 20 concurrent
updateRowwriters on one table: 943–1071 → 1585–1606 ops/s, p99 77–107 → ~18 ms, lock wait 521–525 → 4–8 backend-s - 50 writers: 886–937 → 1398–1450 ops/s, p99 274–292 → 124–159 ms
- Provenance-only writes: 5185–5482 → 7979–8713 ops/s, p99 ~15 → ~4 ms
- Inserts with a unique column and executions-only writes: within noise
- Large single-transaction rewrites are ~20% faster, with ~0.6s of commit-time trigger work at 1M rows
- 20 concurrent
-
Filter queries on 4,000 tables with skewed sizes: large-table
data @>filters 1.7–5.7 → 0.5 ms -
Migration-during-writes: 0 missed bumps across 3 runs (without the
BEGIN, 40/40 missed) -
row-writes.integration.ts: data and reorder edits bump once per transaction, including two tables in one transaction; provenance-only, timestamp-only, no-op, and executions-only writes don't bump; a second writer commits while the first is uncommitted; the snapshot re-keys when a writer commits mid-materialization. These fail on the old trigger -
Fresh DB migrate plus replay, secret-provenance and table integration suites, table unit suites, type-check, lint,
check:audits, andcheck:migrations origin/stagingpass -
Unique races (deterministic, driven by
pg_lockswait states), all ending with one row:- same-value single inserts
'8'vs8- batch vs single insert
updateRowvs insert- import batch vs insert
- a batch over the value-lock cap vs a per-value writer
- a replace adding the first unique column vs a writer on the older schema (no deadlock)
- a 100-unique-column table holding a single lock
- two updates in one batch setting the same value
Different values don't wait on each other. Each race test fails on the pre-fix code
-
Unique-column throughput vs staging is within noise: distinct-value inserts, batch inserts, and batch upserts at 10 and 50 writers.
updateRowkeeps the gains above -
Migration is 0390 on top of staging
Checklist
- Code follows project style guidelines
- Self-reviewed my changes
- Tests added/updated and passing
- No new warnings introduced
- I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)