Skip to content

Commit 1321ee9

Browse files
authored
improvement(tables): bump rows version once at commit and serialize same-value unique writes (#8335)
* improvement(tables): bump a table's rows version once at commit * fix(tables): serialize concurrent writes of the same unique value with per-value advisory locks * fix(tables): take the batched advisory locks on a transaction * fix(tables): cap value locks at the per-connection lock budget and lock unique columns on every import batch Lower the per-transaction value-lock cap to 64 so concurrent writers cannot exhaust the shared lock table; larger writes keep the exclusive column-lock fallback. Import batches now take the unique-column locks and run their unique check inside their transaction, batchUpdateRows rejects two updates writing the same unique value and locks and checks only the unique columns each update changes, and the 0390 comment describes the mechanism only. * chore(tables): share one unique-value key between the check and the locks * fix(tables): lock a table's unique values under one table key so wide schemas stay within the lock budget * fix(tables): key unique JSON values by canonical form so key order never hides a batch duplicate
1 parent 9557c37 commit 1321ee9

13 files changed

Lines changed: 30545 additions & 117 deletions

‎apps/sim/lib/db/advisory-locks.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,30 @@ export async function tryAcquireAdvisoryXactLock(
4444
)
4545
return Boolean(lock?.acquired)
4646
}
47+
48+
/** One lock of an {@link acquireAdvisoryXactLocks} set. */
49+
export interface AdvisoryXactLockRequest {
50+
key: string
51+
/** Takes the lock in shared mode, which conflicts only with exclusive holders of `key`. */
52+
shared: boolean
53+
}
54+
55+
/**
56+
* Blocks until every transaction-scoped advisory lock in `locks` is held, in one round trip.
57+
* `jsonb_array_elements` emits elements in array order and each row's lock call runs before the
58+
* next row is read, so the locks are taken exactly in the order given: callers that must not
59+
* deadlock pass their locks in one global order. The locks release on commit or rollback.
60+
*/
61+
export async function acquireAdvisoryXactLocks(
62+
tx: DbTransaction,
63+
tag: string,
64+
locks: readonly AdvisoryXactLockRequest[]
65+
): Promise<void> {
66+
if (locks.length === 0) return
67+
await tx.execute(sql`
68+
SELECT CASE WHEN (lock ->> 'shared')::boolean
69+
THEN pg_advisory_xact_lock_shared(hashtextextended(lock ->> 'key', 0))
70+
ELSE pg_advisory_xact_lock(hashtextextended(lock ->> 'key', 0))
71+
END
72+
FROM jsonb_array_elements(${JSON.stringify(locks)}::jsonb) AS lock ${lockTag(tag)}`)
73+
}

‎apps/sim/lib/table/import-data.ts‎

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
mutateTableRowsWithSecretProvenance,
2626
} from '@/lib/table/rows/secret-provenance'
2727
import { batchInsertRowsWithTx, replaceTableRowsWithTx } from '@/lib/table/rows/service'
28+
import { lockUniqueColumns } from '@/lib/table/rows/unique-locks'
2829
import { addTableColumnsWithTx, auditTableColumnsAdded, getTableById } from '@/lib/table/service'
2930
import type {
3031
ReplaceRowsResult,
@@ -58,10 +59,12 @@ export interface BulkImportBatch {
5859
* Inserts one batch of rows for an async import in a single committed statement.
5960
*
6061
* Differs from {@link batchInsertRowsWithTx} for the bulk-load case: caller-supplied
61-
* contiguous order keys (no `acquireRowOrderLock` scan — an
62-
* import owns its hidden table as the sole writer), no `RETURNING`, and **no
63-
* `fireTableTrigger` / `runWorkflowColumn`** (a 1M-row import must not dispatch a
64-
* workflow run per row). `row_count` is maintained set-based by the statement-level
62+
* contiguous order keys (no `acquireRowOrderLock` scan; the caller threads each batch's
63+
* anchor from the previous one), no `RETURNING`, and **no `fireTableTrigger` /
64+
* `runWorkflowColumn`** (a 1M-row import must not dispatch a workflow run per row).
65+
* Append and replace imports run this against the live table, so other writers can
66+
* race it: the batch holds the table's unique columns exclusively while it checks and
67+
* inserts. `row_count` is maintained set-based by the statement-level
6568
* trigger. There is no surrounding transaction and no rollback: each batch commits on
6669
* its own, so committed batches persist even if a later batch fails.
6770
*
@@ -99,25 +102,9 @@ export async function bulkInsertImportBatch(
99102
}
100103
}
101104

102-
const uniqueColumns = getUniqueColumns(table.schema)
103-
if (uniqueColumns.length > 0) {
104-
const uniqueResult = await checkBatchUniqueConstraintsDb(
105-
data.tableId,
106-
data.rows,
107-
table.schema,
108-
db
109-
)
110-
if (!uniqueResult.valid) {
111-
throw new OrchestrationError(
112-
'validation',
113-
uniqueResult.errors.map((e) => `Row ${e.row + 1}: ${e.errors.join(', ')}`).join('; ')
114-
)
115-
}
116-
}
117-
118105
const now = new Date()
119-
// Import worker is the table's sole writer; append keys after the anchor the caller threads
120-
// from the previous batch's last key — no per-batch max(order_key) scan over a growing table.
106+
// Append keys after the anchor the caller threads from the previous batch's last key — no
107+
// per-batch max(order_key) scan over a growing table.
121108
const orderKeys = nKeysBetween(data.afterOrderKey ?? null, null, data.rows.length)
122109
const rowsToInsert = data.rows.map((rowData, i) => ({
123110
id: `row_${generateId().replace(/-/g, '')}`,
@@ -133,6 +120,22 @@ export async function bulkInsertImportBatch(
133120

134121
const inserted = await db.transaction(async (trx) => {
135122
await guardBatch(trx, data.tableId, revalidate)
123+
if (getUniqueColumns(table.schema).length > 0) {
124+
// The whole-table unique lock, not per-value: a batch is far more values than the value-lock cap.
125+
await lockUniqueColumns(trx, table)
126+
const uniqueResult = await checkBatchUniqueConstraintsDb(
127+
data.tableId,
128+
data.rows,
129+
table.schema,
130+
trx
131+
)
132+
if (!uniqueResult.valid) {
133+
throw new OrchestrationError(
134+
'validation',
135+
uniqueResult.errors.map((e) => `Row ${e.row + 1}: ${e.errors.join(', ')}`).join('; ')
136+
)
137+
}
138+
}
136139
return mutateTableRowsWithSecretProvenance(trx, {
137140
rows: rowsToInsert.map((row) => ({
138141
rowId: row.id,
@@ -282,6 +285,9 @@ export async function importAppendRows(
282285
})
283286
const result = await db.transaction(async (trx) => {
284287
let working = await refreshUnderLock(trx, table)
288+
// Lock the unique columns whole, ahead of the row-order lock: per-value locks for every row of
289+
// an import would flood the server's lock table.
290+
await lockUniqueColumns(trx, working)
285291
if (additions.length > 0) {
286292
// Take the row-order lock before creating columns so this path uses the
287293
// same rows_pos → user_table_definitions order as plain inserts. Creating
@@ -305,7 +311,8 @@ export async function importAppendRows(
305311
secretProvenance: batch.map(createExactEmptyTableRowSecretProvenance),
306312
},
307313
working,
308-
generateId().slice(0, 8)
314+
generateId().slice(0, 8),
315+
{ uniqueColumnsLocked: true }
309316
)
310317
inserted.push(...batchInserted)
311318
}
@@ -347,6 +354,7 @@ export async function importReplaceRows(
347354
})
348355
const result = await db.transaction(async (trx) => {
349356
let working = await refreshUnderLock(trx, table)
357+
await lockUniqueColumns(trx, working)
350358
if (additions.length > 0) {
351359
await acquireRowOrderLock(trx, table.id)
352360
working = await addTableColumnsWithTx(trx, working, additions, requestId)

‎apps/sim/lib/table/rows/ordering.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,11 @@ export async function insertOrderedRow(params: {
332332
secretProvenance?: TableRowSecretProvenanceWrite
333333
/** Proof the caller asserted the insert lock (see `mutation-locks.ts`). */
334334
proof: MutationProof<'insert'>
335+
/**
336+
* Runs first in the transaction, before the row-order lock: the caller's unique-value locks and
337+
* unique check (see `unique-locks.ts`), so the check sees any concurrent insert of the same value.
338+
*/
339+
assertUnique?: (trx: DbTransaction) => Promise<void>
335340
}): Promise<{
336341
id: string
337342
data: RowData
@@ -354,6 +359,7 @@ export async function insertOrderedRow(params: {
354359
} = params
355360
const [row] = await db.transaction(async (trx) => {
356361
await setTableTxTimeouts(trx)
362+
await params.assertUnique?.(trx)
357363
await acquireRowOrderLock(trx, tableId)
358364

359365
// Resolve the authoritative order key from neighbor ids when given, else from the requested

0 commit comments

Comments
 (0)