Transaction- and savepoint-scoping macros: withTransaction, withSavepoint, and their deadline-bounded variants.
Consts
rollbackGrace = 5000000000
- Per-call timeout for ROLLBACK / RELEASE cleanup in *Deadline macros when the main deadline has expired. Bounds how long a failed-body cleanup can hold a connection. Derived from rollbackGraceMs. Exported (with *) because pg_pool's withTransactionDeadline macro binds it via bindSym — treat as an internal knob, not user API.
rollbackGraceMs {.intdefine: "asyncPgRollbackGraceMs".}: int = 5000
- Compile-time override (milliseconds) for the per-call ROLLBACK / RELEASE cleanup timeout used by *Deadline macros after the main deadline has expired. Set via -d:asyncPgRollbackGraceMs=<ms> (default 5000). Must be > 0; values <= 0 fall back to the default.
Procs
proc buildRetryDeadlineLoop(bodyFnSym, retryOptsSym, deadlineMomentSym, connForStateCheck: NimNode; timeoutElse, catchableCleanup: NimNode): NimNode {. ...raises: [], tags: [], forbids: [].}
-
Build the shared retry loop for the deadline-bounded retry macros (withTransactionRetryDeadline, conn and pool). The caller defines bodyFnSym (an async proc(): Future[void] that runs BEGIN/body/COMMIT for one attempt) and binds retryOptsSym / deadlineMomentSym in scope.
Per-variant hooks:
- timeoutElse: statements run when wait times out and the body future did not complete (conn invalidates its connection; pool invalidates the in-flight handle or raises an acquire-timeout). A timeout exhausts the shared budget, so it is never retried.
- catchableCleanup: statements run on a non-timeout error before the retry decision (conn rolls back here; pool already did so inside bodyFn, so it passes an empty list).
- connForStateCheck: when non-nil, the retry is additionally gated on the connection being back to csReady + tsIdle (conn reuses its single connection; the pool variant acquires a fresh one per attempt and omits it).
A retry (including its backoff sleep) is taken only when it still fits before the deadline, so all attempts together finish within deadline.
proc buildRetryTxLoop(connSym, retryOptsSym, beginSql, txTimeout, body: NimNode): NimNode {. ...raises: [], tags: [], forbids: [].}
-
Build the shared BEGIN/body/COMMIT/ROLLBACK retry loop reused by every withTransactionRetry variant (conn / pool / cluster). The caller binds connSym (a standalone connection, or a pooled/primary handle it acquired) and retryOptsSym (a RetryOptions) in the surrounding scope, then splices the returned loop in.
On a failed attempt the connection is cleaned up with the shared onCleanupSkipped-wired ROLLBACK (buildRollbackCleanup): ROLLBACK is skipped on an invalidated connection or when the server already ended the transaction, and both the skip and any swallowed ROLLBACK failure are surfaced through onCleanupSkipped.
A retry is taken only when attempts remain, the error is retryable, and the connection is back to a clean reusable state (csReady + tsIdle) — so a csClosed (timeout) connection or a failed ROLLBACK ends the loop. Between attempts it sleeps for backoffDelayMs.
proc buildRollbackCleanup(connSym, rollbackTimeout: NimNode): NimNode {. ...raises: [], tags: [], forbids: [].}
- Build the shared onCleanupSkipped-wired ROLLBACK cleanup used on a failed attempt by the conn/pool/cluster transaction macros: skip ROLLBACK on an invalidated connection or when the server already ended the transaction (reporting both via onCleanupSkipped), otherwise ROLLBACK with rollbackTimeout as the per-call timeout and report a swallowed failure.
proc buildTxBeginAndTimeout(arg: NimNode; macroName = "withTransaction"): tuple[ beginSql, txTimeout: NimNode] {....raises: [], tags: [], forbids: [].}
- Shared helper for withTransaction macros. Uses when ... is to dispatch on the argument type at compile time. macroName is interpolated into the type-mismatch error so callers like withTransactionRetry report their own name rather than withTransaction.
proc hasReturnStmt(n: NimNode): bool {....raises: [], tags: [], forbids: [].}
- Check whether an AST contains a return statement (excluding nested proc/func/method/iterator definitions where return is valid).
Macros
macro withSavepoint(conn: PgConnection; args: varargs[untyped]): untyped
-
Execute body inside a SAVEPOINT. On exception, ROLLBACK TO SAVEPOINT is issued automatically. Using return inside the body is a compile-time error.
Usage: conn.withSavepoint: await conn.exec(...) conn.withSavepoint("my_sp"): await conn.exec(...) conn.withSavepoint(seconds(5)): await conn.exec(...) conn.withSavepoint("my_sp", seconds(5)): await conn.exec(...)
Note: The savepoint name must be a string literal, not a variable (the macro uses AST node kind to distinguish name from timeout).
Timeout semantics: The timeout argument applies per-call to SAVEPOINT, RELEASE SAVEPOINT, and ROLLBACK TO SAVEPOINT only — it does not bound body operations. Use withSavepointDeadline for a single wall-clock deadline covering all three plus the body.
macro withSavepointDeadline(conn: PgConnection; args: varargs[untyped]): untyped
-
Execute body inside a SAVEPOINT bounded by a single wall-clock deadline covering SAVEPOINT, the body, and RELEASE SAVEPOINT together.
Usage: conn.withSavepointDeadline(seconds(5)): await conn.exec(...) conn.withSavepointDeadline("my_sp", seconds(5)): await conn.exec(...)
On deadline exceeded: the connection is invalidated; ROLLBACK TO SAVEPOINT is not attempted (see withTransactionDeadline rationale). Because the connection itself becomes csClosed, the outer transaction is voided as well — this macro is not a fine-grained "roll back only the savepoint on timeout" primitive. If you need the outer transaction to survive a savepoint timeout, use withSavepoint(timeout = ...) (per-call timeout) instead of this deadline-bounded variant.
On other body exceptions: ROLLBACK TO SAVEPOINT runs with rollbackGrace per-call timeout.
Note: Unlike withSavepoint, the savepoint name is positional and may be any string expression (literal or variable) — disambiguation by AST kind is not needed because (name, deadline, body) and (deadline, body) differ in arity. Using return inside the body is a compile-time error.
macro withTransaction(conn: PgConnection; args: varargs[untyped]): untyped
-
Execute body inside a BEGIN/COMMIT transaction. On exception, ROLLBACK is issued automatically. Using return inside the body is a compile-time error.
Usage: conn.withTransaction: await conn.exec(...) conn.withTransaction(seconds(5)): await conn.exec(...) conn.withTransaction(TransactionOptions(isolation: ilSerializable)): await conn.exec(...) conn.withTransaction(TransactionOptions(...), seconds(5)): await conn.exec(...)
Timeout semantics: The timeout argument applies per-call to BEGIN, COMMIT, and ROLLBACK only — it does not bound body operations. Worst-case wall-clock = BEGIN(≤timeout) + body(unbounded) + COMMIT(≤timeout) [+ ROLLBACK(≤timeout) on failure]. Use withTransactionDeadline for a single wall-clock deadline covering BEGIN, body, and COMMIT together.
On per-call timeout (BEGIN/COMMIT/in-body): simpleExec invalidates the connection via invalidateOnTimeout (marked csClosed, server-side CancelRequest dispatched) and raises PgTimeoutError. ROLLBACK is not attempted on an already-closed connection — txStatus may still read tsInTransaction (stale, because no ReadyForQuery was received), but the csReady guard prevents a futile cleanup call. Standalone callers must await conn.close() after this error; pooled connections are dropped on release.
macro withTransactionDeadline(conn: PgConnection; args: varargs[untyped]): untyped
-
Execute body inside a BEGIN/COMMIT transaction bounded by a single wall-clock deadline that covers BEGIN, the body, and COMMIT together. Unlike withTransaction, the timeout does not reset between calls.
Usage: conn.withTransactionDeadline(seconds(5)): await conn.exec(...) conn.withTransactionDeadline(TransactionOptions(...), seconds(5)): await conn.exec(...)
On deadline exceeded (AsyncTimeoutError from the outer wait): the connection is invalidated via invalidateOnTimeout (marked csClosed and a server-side CancelRequest is dispatched), then PgTimeoutError is raised. ROLLBACK is not attempted — the in-flight body operation may still own the socket under asyncdispatch, so reusing it would corrupt the protocol stream. The closed connection is dropped by the pool on release.
Standalone connections (not pooled): callers using PgConnection directly must await conn.close() after this error. Otherwise the server-side transaction lingers until the TCP connection drop is detected, holding locks and bloating tx state. The pool variant handles this automatically when the connection is released.
On other exceptions from the body: ROLLBACK is issued with rollbackGrace (5s) as a per-call timeout so cleanup runs even past the main deadline. A failed ROLLBACK is swallowed.
Using return inside the body is a compile-time error.
macro withTransactionRetry(conn: PgConnection; retryOpts: RetryOptions; args: varargs[untyped]): untyped
-
Execute body inside a BEGIN/COMMIT transaction, re-running the whole transaction when it fails with a retryable error (by default the serialization_failure / deadlock_detected SQLSTATEs — see RetryOptions). On a non-retryable error, or once maxAttempts is exhausted, the last exception propagates. The body always runs at least once, so maxAttempts <= 1 means "no retry". Using return inside the body is a compile-time error.
Usage: conn.withTransactionRetry(RetryOptions(maxAttempts: 3)): await conn.exec(...) conn.withTransactionRetry(RetryOptions(...), seconds(5)): await conn.exec(...) conn.withTransactionRetry(RetryOptions(...), TransactionOptions(isolation: ilSerializable)): await conn.exec(...) conn.withTransactionRetry(RetryOptions(...), opts, seconds(5)): await conn.exec(...)
Idempotency: body is executed once per attempt, so it must be safe to re-run. Side effects outside the database (sending email, mutating local state, enqueuing jobs) are repeated on every retry — keep them out of the body or make them idempotent.
Timeout semantics: identical to withTransaction — the optional timeout argument is per-call to BEGIN/COMMIT/ROLLBACK only and does not bound body. A per-call timeout invalidates the connection (csClosed), which suppresses any further retry (the connection is no longer reusable).
Retry condition: a retry happens only when the caught error is retryable and the connection is back to a clean, reusable state (csReady + tsIdle) after cleanup. This holds both when the body raised (ROLLBACK restores tsIdle) and when COMMIT itself raised a serialization failure (PostgreSQL has already ended the transaction). Between attempts the macro sleeps for backoffDelayMs.
macro withTransactionRetryDeadline(conn: PgConnection; retryOpts: RetryOptions; args: varargs[untyped]): untyped
-
Execute body inside a BEGIN/COMMIT transaction bounded by a single wall-clock deadline that is shared across all retry attempts, re-running the whole transaction on a retryable error (by default serialization_failure / deadlock_detected — see RetryOptions) while budget remains.
Usage: conn.withTransactionRetryDeadline(RetryOptions(maxAttempts: 3), seconds(5)): await conn.exec(...) conn.withTransactionRetryDeadline(RetryOptions(...), TransactionOptions(...), seconds(5)): await conn.exec(...)
Deadline budget: the deadline covers BEGIN + body + COMMIT of every attempt together. Each attempt is bounded by the remaining budget, and a retry (including its backoff sleep) is only taken when it fits before the deadline. Worst-case wall-clock is therefore deadline, not maxAttempts * deadline.
On deadline exceeded (AsyncTimeoutError): the connection is invalidated via invalidateOnTimeout (csClosed) and PgTimeoutError is raised — a timeout is never retried (the connection is no longer reusable). Standalone callers must await conn.close() afterwards; see withTransactionDeadline.
On a retryable body/COMMIT error: ROLLBACK runs with rollbackGrace, and the transaction is retried if the connection is back to csReady/tsIdle and budget remains. Idempotency: body runs once per attempt; non-database side effects repeat. Using return inside the body is a compile-time error.