77 * values it writes, so the second writer's check runs after the first commits and sees its row.
88 * Writers of different values never wait on each other.
99 *
10- * Lock order, everywhere: the table's schema lock (when taken), then these locks, taken all at once
11- * in one sorted order, then the table's row-order lock, then the definition row.
10+ * Lock order, everywhere: the table's schema lock (when taken), then the table's unique lock and its
11+ * value locks in sorted order, in one statement, then the table's row-order lock, then the
12+ * definition row.
1213 */
1314
1415import { compareStrings } from '@sim/utils/string'
@@ -21,77 +22,72 @@ import { getUniqueColumns, uniqueValueKey } from '@/lib/table/validation'
2122const UNIQUE_LOCK_TAG = 'user_table_unique_value'
2223
2324/**
24- * Most value locks one transaction takes before it locks whole columns instead. Every advisory lock
25- * holds a slot in the server's shared lock table, which is sized at `max_locks_per_transaction`
26- * (64 by default) × connections and shared by every transaction, so concurrent writers each holding
27- * more than their per-connection share can exhaust it and fail unrelated queries with `out of
28- * shared memory`. Capping at that per-connection budget keeps ordinary writes on per-value locks
29- * while larger batches serialize on the column .
25+ * Most value locks one transaction takes before it locks the table's unique values as a whole.
26+ * Every advisory lock holds a slot in the server's shared lock table, which is sized at
27+ * `max_locks_per_transaction` (64 by default) × connections and shared by every transaction, so
28+ * concurrent writers each holding more than their per-connection share can exhaust it and fail
29+ * unrelated queries with `out of shared memory`. Capping at that per-connection budget keeps
30+ * ordinary writes on per-value locks while larger batches serialize on the table .
3031 */
3132const MAX_VALUE_LOCKS = 64
3233
33- function columnLockKey ( tableId : string , columnId : string ) : string {
34- return `user_table_unique:${ tableId } :${ columnId } `
34+ /**
35+ * One lock per table over all its unique columns: value writers hold it shared, and writers that
36+ * cannot lock by value hold it exclusively. A single key bounds a transaction at one lock plus its
37+ * value locks, however many unique columns the schema has.
38+ */
39+ function tableLockKey ( tableId : string ) : string {
40+ return `user_table_unique:${ tableId } `
3541}
3642
3743/**
3844 * Locks the unique-column values `rows` will write, before their unique check. Pass `columnIds` to
3945 * lock only the unique columns a patch changes; values it leaves alone are already stored.
4046 *
41- * Each written column takes a shared column lock, then an exclusive lock per value. The value key
42- * is `uniqueValueKey`, so two values the check treats as equal always share a key; null cells
43- * never conflict and take none.
44- * A column falls back to one exclusive column lock when a value is an object or array (a `json`
45- * column's check matches by containment, which no single key can express), and every column does
46- * when the transaction would exceed {@link MAX_VALUE_LOCKS}. An exclusive column lock waits for,
47- * and blocks, every shared holder, so the fallback still serializes against per-value writers.
47+ * The table lock is taken shared, then an exclusive lock per value. The value key includes
48+ * `uniqueValueKey`, so two values the check treats as equal always share a key; null cells never
49+ * conflict and take none. The table lock is taken exclusively instead, with no value locks, when a
50+ * value is an object or array (a `json` column's check matches by containment, which no single key
51+ * can express) or when the transaction would exceed {@link MAX_VALUE_LOCKS}. An exclusive holder
52+ * waits for, and blocks, every shared holder, so it still serializes against per-value writers.
4853 */
4954export async function lockUniqueValues (
5055 trx : DbTransaction ,
5156 table : TableDefinition ,
5257 rows : readonly RowData [ ] ,
5358 columnIds ?: ReadonlySet < string >
5459) : Promise < void > {
55- const columnLocks = new Map < string , boolean > ( )
60+ const tableKey = tableLockKey ( table . id )
5661 const valueKeys = new Set < string > ( )
5762 for ( const column of getUniqueColumns ( table . schema ) ) {
5863 const columnId = getColumnId ( column )
5964 if ( columnIds && ! columnIds . has ( columnId ) ) continue
60- const columnKey = columnLockKey ( table . id , columnId )
61- const keys : string [ ] = [ ]
62- let columnByValue = true
6365 for ( const row of rows ) {
6466 const value = row [ columnId ]
6567 if ( value === null || value === undefined ) continue
66- if ( typeof value === 'object' ) {
67- columnByValue = false
68- break
68+ if ( typeof value === 'object' ) return lockUniqueColumns ( trx , table )
69+ const key = `${ tableKey } :${ columnId } :${ uniqueValueKey ( value , column ) } `
70+ if ( ! valueKeys . has ( key ) && valueKeys . size === MAX_VALUE_LOCKS ) {
71+ return lockUniqueColumns ( trx , table )
6972 }
70- keys . push ( ` ${ columnKey } : ${ uniqueValueKey ( value , column ) } ` )
73+ valueKeys . add ( key )
7174 }
72- if ( columnByValue && keys . length === 0 ) continue
73- columnLocks . set ( columnKey , columnByValue )
74- if ( columnByValue ) for ( const key of keys ) valueKeys . add ( key )
7575 }
76+ if ( valueKeys . size === 0 ) return
7677
77- const byValue = valueKeys . size <= MAX_VALUE_LOCKS
78- const locks : AdvisoryXactLockRequest [ ] = [ ...columnLocks . entries ( ) ]
79- . sort ( ( [ a ] , [ b ] ) => compareStrings ( a , b ) )
80- . map ( ( [ key , columnByValue ] ) => ( { key, shared : byValue && columnByValue } ) )
81- if ( byValue ) {
82- for ( const key of [ ...valueKeys ] . sort ( compareStrings ) ) locks . push ( { key, shared : false } )
83- }
78+ const locks : AdvisoryXactLockRequest [ ] = [ { key : tableKey , shared : true } ]
79+ for ( const key of [ ...valueKeys ] . sort ( compareStrings ) ) locks . push ( { key, shared : false } )
8480 await acquireAdvisoryXactLocks ( trx , UNIQUE_LOCK_TAG , locks )
8581}
8682
8783/**
88- * Locks every unique column of `table` exclusively, for writers that replace the table's rows
89- * wholesale and so conflict with any concurrent write of a unique value.
84+ * Locks every unique value of `table` exclusively, for writers that replace the table's rows
85+ * wholesale and so conflict with any concurrent write of a unique value. It locks even a table with
86+ * no unique columns yet: a writer holding an older schema may still hold the lock shared, and a
87+ * whole-table writer that adds a unique column must already hold it before the row-order lock.
9088 */
9189export async function lockUniqueColumns ( trx : DbTransaction , table : TableDefinition ) : Promise < void > {
92- const locks = getUniqueColumns ( table . schema )
93- . map ( ( column ) => columnLockKey ( table . id , getColumnId ( column ) ) )
94- . sort ( compareStrings )
95- . map ( ( key ) => ( { key, shared : false } ) )
96- await acquireAdvisoryXactLocks ( trx , UNIQUE_LOCK_TAG , locks )
90+ await acquireAdvisoryXactLocks ( trx , UNIQUE_LOCK_TAG , [
91+ { key : tableLockKey ( table . id ) , shared : false } ,
92+ ] )
9793}
0 commit comments