Transactions
transaction() and its seven shortcuts, one per isolation level: the SQL they send, their retries and the types of the client they give. Then the errors: isDatabaseError() for those from Postgres, and those pgcast throws before any query.
transaction()
ts
db.transaction(txnClientOrQueryable, isolationLevel, callback)Runs callback in one transaction, then commits it, retries it or rolls it back. It returns a promise of what callback resolves to.
txnClientOrQueryable: apg.Pool, from which it checks out a client, released at the end; a client,pg.Client,pg.native.Clientor apg.PoolClientalready checked out, used and left connected; or the client of a transaction already open, passed through.isolationLevel: anIsolationLevel.callback:async txnClient => …, given aTxnClientof that level. The queries of the transaction are those.run(txnClient); one run on the pool, or on any other client, is not in it.
db.serializable(pool, callback) is db.transaction(pool, db.IsolationLevel.Serializable, callback). Two customers book seat F7 of screening 1000 at once:
ts
const bookSeat = (seat: string, customer: string) =>
db.serializable(pool, async txnClient => {
const taken = await db.selectOne('seat_bookings', { screening_id: 1000, seat }).run(txnClient);
if (taken) return false;
await db.insert('seat_bookings', { screening_id: 1000, seat, customer }).run(txnClient);
return true;
});
const [ines, barnaby] = await Promise.all([
bookSeat('F7', 'Ines Calloway'),
bookSeat('F7', 'Barnaby Quill'),
]);One of ines and barnaby is true, the other false. When both read before either commits, Postgres refuses one of the two INSERTs with 40001, and pgcast runs that callback again, which finds the seat taken. That transaction sends:
sql
-- attempt 1
START TRANSACTION ISOLATION LEVEL SERIALIZABLE
-- the callback's selectOne(), then insert(), which Postgres refuses with 40001
ROLLBACK
-- attempt 2, 25 to 250 ms later
START TRANSACTION ISOLATION LEVEL SERIALIZABLE
-- the callback's selectOne(), which returns the other booking
COMMITThe SQL of the callback's queries: selectOne() and insert().
Given a client you checked out, transaction() runs on it and leaves it connected; releasing it is yours:
ts
const client = await pool.connect();
let before: number, after: number;
try {
before = await db.count('seat_bookings', { screening_id: 1000 }).run(client);
await db.readCommitted(client, txnClient =>
db.insert('seat_bookings', { screening_id: 1000, seat: 'G2', customer: 'Tamsin Rowe' }).run(txnClient));
after = await db.count('seat_bookings', { screening_id: 1000 }).run(client);
} finally {
client.release();
}The two count()s run on client, outside the transaction: after the block, before is 0, after 1.
The SQL it sends
| When | SQL |
|---|---|
| before the callback | START TRANSACTION ISOLATION LEVEL SERIALIZABLE: the level's value, written as it is |
| the callback resolves | COMMIT |
START TRANSACTION, the callback or COMMIT rejects | ROLLBACK, then a retry or the rejection |
- These three run on the transaction's client, as the callback's queries do. Each reaches
queryListenerandresultListenerwith the transaction's id: a counter from 0, one per transaction opened, kept across retries; a transaction passed through keeps the outer one's. - No
SAVEPOINT: a transaction inside another is the outer one (Passing a transaction through). - After a query error that the callback catches, Postgres answers the
COMMITwithROLLBACK, not an error, and nothing is saved.transaction()resolves, as in Zapatos 6.6.1, and warns once (PGCAST_DEP003); withtransactionRequiresCommit, it rejects.
Retries
When START TRANSACTION, the callback or COMMIT rejects with a code of 40001 (serialization_failure) or 40P01 (deadlock_detected), at any level, transaction() sends ROLLBACK, waits, then starts again: START TRANSACTION, the whole callback, COMMIT.
Config option | Default | What it sets |
|---|---|---|
transactionAttemptsMax | 5 | the attempts in all, the first included; 0 or 1 gives one |
transactionRetryDelay | { minMs: 25, maxMs: 250 } | the wait before each retry: a whole number of ms, at random between the two |
- After the last attempt, it rejects with that attempt's error.
- Any other rejection:
ROLLBACK, then it rejects with it, at once. - A
COMMITthat Postgres answers withROLLBACKis not retried: pgcast doesn't know which error the callback caught. transactionListenergets each message with the transaction's id:
| Message | When |
|---|---|
Transaction rollback (code ${err.code}) on attempt ${attempt} of ${maxAttempts}, retrying in ${delayBeforeRetry}ms | before the wait |
Retrying transaction, attempt ${attempt} of ${maxAttempts} | before START TRANSACTION again |
Transaction rollback (code ${err.code}) on attempt ${attempt} of ${maxAttempts}, giving up | before it rejects |
The callback may run several times: Retries.
Passing a transaction through
Given the client of a transaction that transaction() has open, it sends nothing: it calls the callback with that client, at once, and returns its promise. No START TRANSACTION, COMMIT, ROLLBACK nor retry of its own; the outer transaction does them. So a function can run in its own transaction, or in the caller's:
ts
const bookSeat = (seat: string, customer: string, txnClientOrPool: typeof pool | db.TxnClientForSerializable) =>
db.serializable(txnClientOrPool, txnClient =>
db.insert('seat_bookings', { screening_id: 1001, seat, customer }).run(txnClient));
const pair = await db.serializable(pool, txnClient => Promise.all([
bookSeat('G1', 'Wren Abernathy', txnClient),
bookSeat('G2', 'Wren Abernathy', txnClient),
]));One transaction, both seats or neither: START TRANSACTION ISOLATION LEVEL SERIALIZABLE, the two INSERTs, COMMIT.
- The callback runs at the outer transaction's level, and that level is not checked, at run time or by the type: every
TxnClientis aQueryable, sodb.serializable(txnClient, …)type-checks with a client ofdb.readCommitted(), and its callback runs in thatREAD COMMITTEDtransaction. - A function whose parameter is a
TxnClientFor…type is checked:IsolationSatisfying. - What marks the client is its own
_zapatosproperty (TxnClient).
Errors
| Rejects with | When | Since |
|---|---|---|
Isolation level must be an IsolationLevel value (e.g. IsolationLevel.Serializable), not '…' | isolationLevel is not a value of IsolationLevel; thrown before a client is checked out or a query sent | pgcast 6.6.2 |
| the callback's error | after ROLLBACK; for 40001 and 40P01, after the last attempt | Zapatos |
a TypeError, Cannot destructure property 'code' of 'err' as it is null. (or undefined) | the callback throws null or undefined, after ROLLBACK | Zapatos |
| "the transaction was rolled back, not committed: a query in it failed and the callback caught the error, so Postgres answered COMMIT with ROLLBACK; let the error out of the callback, or ROLLBACK TO a SAVEPOINT before going on" | with transactionRequiresCommit, Postgres answered the COMMIT with ROLLBACK; not retried, and no ROLLBACK of its own | pgcast 6.7.0 |
The type refuses a level that is not an IsolationLevel (2345), a string included:
ts
await db.transaction(pool, 'SERIALIZABLE', async txnClient => true);Zapatos 6.6.1 wrote any value after ISOLATION LEVEL as it was, where it could run a second statement (changelog). A transaction passed through is not checked.
serializable()
ts
db.serializable(txnClientOrQueryable, callback)transaction() at IsolationLevel.Serializable: START TRANSACTION ISOLATION LEVEL SERIALIZABLE. Its callback gets a TxnClientForSerializable.
repeatableRead()
ts
db.repeatableRead(txnClientOrQueryable, callback)transaction() at IsolationLevel.RepeatableRead: START TRANSACTION ISOLATION LEVEL REPEATABLE READ. Its callback gets a TxnClientForRepeatableRead.
readCommitted()
ts
db.readCommitted(txnClientOrQueryable, callback)transaction() at IsolationLevel.ReadCommitted: START TRANSACTION ISOLATION LEVEL READ COMMITTED. Its callback gets a TxnClientForReadCommitted.
serializableRO()
ts
db.serializableRO(txnClientOrQueryable, callback)transaction() at IsolationLevel.SerializableRO: START TRANSACTION ISOLATION LEVEL SERIALIZABLE, READ ONLY. Its callback gets a TxnClientForSerializableRO. Postgres refuses a write in it (25006).
repeatableReadRO()
ts
db.repeatableReadRO(txnClientOrQueryable, callback)transaction() at IsolationLevel.RepeatableReadRO: START TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY. Its callback gets a TxnClientForRepeatableReadRO. Postgres refuses a write in it (25006).
readCommittedRO()
ts
db.readCommittedRO(txnClientOrQueryable, callback)transaction() at IsolationLevel.ReadCommittedRO: START TRANSACTION ISOLATION LEVEL READ COMMITTED, READ ONLY. Its callback gets a TxnClientForReadCommittedRO. Postgres refuses a write in it (25006).
serializableRODeferrable()
ts
db.serializableRODeferrable(txnClientOrQueryable, callback)transaction() at IsolationLevel.SerializableRODeferrable: START TRANSACTION ISOLATION LEVEL SERIALIZABLE, READ ONLY, DEFERRABLE. Its callback gets a TxnClientForSerializableRODeferrable. Postgres refuses a write in it (25006).
With DEFERRABLE, Postgres may wait for a snapshot, then runs the transaction without the overhead of a serializable one and with no risk of a serialisation failure (40001) (SET TRANSACTION): suited to a long report or a backup.
A report whose two counts read the same snapshot:
ts
const report = await db.serializableRODeferrable(pool, async txnClient => ({
films: await db.count('films', db.all).run(txnClient),
bookings: await db.count('seat_bookings', db.all).run(txnClient),
}));report is { films: 4, bookings: 0 } on the docs schema. The transaction sends:
sql
START TRANSACTION ISOLATION LEVEL SERIALIZABLE, READ ONLY, DEFERRABLE
-- the callback's two count() queries
COMMITThe SQL of each query: count(). The other read-only shortcuts send the same, with their level; in readCommittedRO(), each query reads its own snapshot, so a booking committed between the two counts is in the second.
Types
IsolationLevel
ts
enum IsolationLevel {
Serializable = 'SERIALIZABLE',
RepeatableRead = 'REPEATABLE READ',
ReadCommitted = 'READ COMMITTED',
SerializableRO = 'SERIALIZABLE, READ ONLY',
RepeatableReadRO = 'REPEATABLE READ, READ ONLY',
ReadCommittedRO = 'READ COMMITTED, READ ONLY',
SerializableRODeferrable = 'SERIALIZABLE, READ ONLY, DEFERRABLE',
}The second argument of transaction(). Each value is written after START TRANSACTION ISOLATION LEVEL; each member has its shortcut:
| Member | Shortcut | Its client |
|---|---|---|
Serializable | serializable() | TxnClientForSerializable |
RepeatableRead | repeatableRead() | TxnClientForRepeatableRead |
ReadCommitted | readCommitted() | TxnClientForReadCommitted |
SerializableRO | serializableRO() | TxnClientForSerializableRO |
RepeatableReadRO | repeatableReadRO() | TxnClientForRepeatableReadRO |
ReadCommittedRO | readCommittedRO() | TxnClientForReadCommittedRO |
SerializableRODeferrable | serializableRODeferrable() | TxnClientForSerializableRODeferrable |
IsolationSatisfying
ts
type IsolationSatisfying<T extends IsolationLevel>The levels whose client is accepted where a client of level T is asked for: T and the stricter levels. A read-write level stands for its read-only one, not the reverse; SerializableRODeferrable stands only for itself.
T | IsolationSatisfying<T> |
|---|---|
Serializable | Serializable |
RepeatableRead | Serializable, RepeatableRead |
ReadCommitted | Serializable, RepeatableRead, ReadCommitted |
SerializableRO | Serializable, SerializableRO |
RepeatableReadRO | Serializable, RepeatableRead, SerializableRO, RepeatableReadRO |
ReadCommittedRO | every level but SerializableRODeferrable |
SerializableRODeferrable | Serializable, SerializableRO, SerializableRODeferrable |
A function that takes a TxnClientForRepeatableRead can be given the client of serializable(), not that of readCommitted() (2345, with strict on or off):
ts
const countBookings = (txnClient: db.TxnClientForRepeatableRead) =>
db.count('seat_bookings', { screening_id: 1000 }).run(txnClient);
await db.serializable(pool, txnClient => countBookings(txnClient));
await db.readCommitted(pool, txnClient => countBookings(txnClient));TxnClient
ts
interface TxnClient<T extends IsolationLevel> extends pg.PoolClient {
_zapatos?: { isolationLevel: T; txnId: number };
}The client a transaction() callback gets: the one checked out from the pool, or the client given.
_zapatosis set while the transaction runs: itsisolationLevel, and itstxnId, the id the listeners get. Once the transaction ends,transaction()deletes it, and releases a client it checked out.T: the levels the client stands for, anIsolationSatisfyingunion in the seven types below._zapatosis optional in the type, so anypg.PoolClientis accepted as aTxnClient, with or without a transaction open on it.
TxnClientForSerializable
TxnClient<IsolationSatisfying<IsolationLevel.Serializable>>: the client of serializable().
TxnClientForRepeatableRead
TxnClient<IsolationSatisfying<IsolationLevel.RepeatableRead>>: the client of repeatableRead(); takes that of serializable() too.
TxnClientForReadCommitted
TxnClient<IsolationSatisfying<IsolationLevel.ReadCommitted>>: the client of readCommitted(); takes that of serializable() and repeatableRead() too.
TxnClientForSerializableRO
TxnClient<IsolationSatisfying<IsolationLevel.SerializableRO>>: the client of serializableRO(); takes that of serializable() too.
TxnClientForRepeatableReadRO
TxnClient<IsolationSatisfying<IsolationLevel.RepeatableReadRO>>: the client of repeatableReadRO(); takes that of serializable(), repeatableRead() and serializableRO() too.
TxnClientForReadCommittedRO
TxnClient<IsolationSatisfying<IsolationLevel.ReadCommittedRO>>: the client of readCommittedRO(); takes that of every shortcut but serializableRODeferrable().
TxnClientForSerializableRODeferrable
TxnClient<IsolationSatisfying<IsolationLevel.SerializableRODeferrable>>: the client of serializableRODeferrable(); takes that of serializable() and serializableRO() too.
isDatabaseError()
ts
db.isDatabaseError(err, ...types)true when the code of err, five characters, starts with the code of one of types; otherwise false, and always with no types.
err: an object with an optionalcodestring;nullorundefinedthrows aTypeError. On an error from Postgres,.run()rejects with node-postgres'sDatabaseError, whosecodeis the SQLSTATE; it also hasconstraint,detail,tableand the other fields Postgres sends.- The type accepts
any,pg.DatabaseErrorand{ code?: string }, and refusesunknownand the globalError(below);err instanceof pg.DatabaseErrornarrows acatchvariable to one it accepts.
- The type accepts
types: names, any number; with none,false. Two kinds:
| Name | Code | Matches |
|---|---|---|
a class: IntegrityConstraintViolation | 23 | every code of the class: 23505, 23514, 23503… |
a condition, Class_Condition: IntegrityConstraintViolation_UniqueViolation | 23505 | that code |
The names are those of Postgres's error codes, in PascalCase: unique_violation in the class integrity_constraint_violation is IntegrityConstraintViolation_UniqueViolation. The type refuses any other name.
ts
const booking = { screening_id: 1002, seat: 'C4', customer: 'Tamsin Rowe' };
await db.insert('seat_bookings', booking).run(pool);
try {
await db.insert('seat_bookings', booking).run(pool);
} catch (err: any) {
if (!db.isDatabaseError(err, 'IntegrityConstraintViolation_UniqueViolation')) throw err;
console.log(err.constraint); // seat_bookings_pkey
}On the docs schema:
| Query | code | Condition name |
|---|---|---|
| a second booking of the same seat | 23505 | IntegrityConstraintViolation_UniqueViolation |
the seat 'Z100', which the domain seat_label refuses | 23514 | IntegrityConstraintViolation_CheckViolation |
| a booking of a screening that does not exist | 23503 | IntegrityConstraintViolation_ForeignKeyViolation |
an insert() in serializableRO() | 25006 | InvalidTransactionState_ReadOnlySqlTransaction |
two concurrent bookings of a seat, at SERIALIZABLE (above) | 40001 | TransactionRollback_SerializationFailure |
DiskFull,OutOfMemory,TooManyConnectionsandConfigurationLimitExceededhave the class's code,53, asInsufficientResourcesdoes: each matches every code of the class.InsufficientResources_DiskFullmatches 53100 only.- The errors pgcast throws itself have no
code:falsefor each (below). - With
stricton, acatchvariable isunknown, which the type refuses (2345):
ts
try {
await db.insert('seat_bookings', { screening_id: 1002, seat: 'C4', customer: 'Tamsin Rowe' }).run(pool);
} catch (err) {
db.isDatabaseError(err, 'IntegrityConstraintViolation_UniqueViolation');}- A value typed as the global
Error, asinstanceof Errornarrows it, is refused too, withstricton or off (2559): the parameter's type is{ code?: string }, andErrorhas no property in common with it.
ts
try {
await db.insert('seat_bookings', { screening_id: 1002, seat: 'C4', customer: 'Tamsin Rowe' }).run(pool);
} catch (err) {
if (err instanceof Error && db.isDatabaseError(err, 'IntegrityConstraintViolation_UniqueViolation')) {}}Handling them in an app: Errors.
Errors pgcast throws before any query
A plain Error, with no code, before anything is sent. A shortcut throws its own synchronously, from the call itself: .run() is never reached, so a .catch() chained on it does not get them.
The others are thrown as .compile() compiles the query, so .run() rejects with them; transaction() rejects with its own. Each is listed with what throws it:
| Thrown by | How | Messages |
|---|---|---|
select(), selectOne(), selectExactlyOne() and the aggregates, given a bad order, lock or aggregate | the call throws | select(), Errors |
upsert() of rows with different keys, with upsertRequiresSameKeys | the call throws | Rows with different keys |
update() with no column to set | the call throws | update() |
truncate() with another option | the call throws | truncate() |
an interpolation that has no meaning in SQL: a number, null, db.all, the global object | .run() rejects | Interpolations |
db.self with no column; db.parent() with no parent table, so as an insert(), upsert() or update() value | .run() rejects | self, parent() |
db.Default, db.self, db.raw(), db.cols() or db.vals() as a filter value; db.raw(), db.cols() or db.vals() as an insert(), upsert() or update() value, or in any vals({…}) | .run() rejects | Whereable objects, Conditions, insert(), vals() |
transaction() with another isolation level | its promise rejects | transaction(), Errors |
After a query, pgcast rejects in three cases of its own: selectExactlyOne() with no row, or a second one under selectExactlyOneChecksForSecondRow, with a NotExactlyOneError; count() and the other aggregates with no row to read, with a TypeError (count()); transaction() whose COMMIT Postgres answers with ROLLBACK, with transactionRequiresCommit (Errors).