Payload Transactions and Deadlock Prevention
Nested Payload operations must receive the caller’s req to participate in its
transaction. This applies to reads as well as writes, including operations inside
validation and access helpers.
Keep nested operations in the same transaction
Without req, a nested read may not see an uncommitted record or ownership change.
A nested write may wait for a lock held by the outer transaction while the outer
operation waits for that write. Not every missing request causes a deadlock, but
it breaks the intended transaction boundary.
For example, a scraper’s ownership hook reads its repository this way:
const repo = await req.payload.findByID({ collection: "scraper-repos", id: repoId, overrideAccess: true, req });The helper receives the original request, not just req.payload or req.user.
See apps/web/lib/collections/scrapers/validation.ts and
apps/web/lib/collections/scheduled-ingests/index.ts for ownership checks that
must see records created earlier in the same transaction.
Passing req shares an existing transaction; it does not guarantee that every
caller has started one. Code that owns a multi-operation transaction must use
Payload’s initTransaction, commitTransaction, and killTransaction, and must
not commit a transaction owned by its caller. Raw SQL must use
getTransactionAwareDrizzle with that request rather than the root connection.
Retry the whole transaction after a failed write
Payload rolls back a failed create and removes req.transactionID, including
when the transaction belongs to the caller. Catching that error and retrying
with the same req can therefore commit a new write outside the original
transaction while its earlier writes remain rolled back.
When a caller supplied a transaction, propagate the failure so it can retry the
whole operation. Capture transaction ownership before the failing operation;
checking req.transactionID in the catch block is too late. Local retries are
appropriate only when the operation is independent of an outer transaction.
See apps/web/lib/ingest/schema-versioning.ts and its regression test in
apps/web/tests/integration/services/schema-versioning-concurrency.test.ts.
Audit failures follow the transaction boundary
auditLog logs and suppresses failures only when called without an active caller
transaction. When an audit write joins an existing transaction through options.req,
its failure propagates: Payload has rolled back the primary writes too, so the
caller must not report success or continue writing with that request.
Pass the original request rather than a copy. Preserving a transaction ID on a copied object cannot preserve a database transaction that Payload has rolled back. Collection hooks must also propagate failures from audit-related owner lookups, not catch the entire audit path as best-effort.
See apps/web/lib/services/audit-log-service.ts and the rollback regression tests
in apps/web/tests/integration/collections/visibility-sync-cascade.test.ts.
Access control and recursion are separate concerns
overrideAccesscontrols authorization, not transaction reuse. Payload’s Local API bypasses access checks by default. UseoverrideAccess: falsewhen enforcing client permissions; use internal access deliberately, not as a deadlock fix.- Context flags work only when the affected hook explicitly checks them. Do not
add an arbitrary
skipYourHooksflag and assume Payload interprets it. - Reuse an established context flag only when the nested operation should skip that specific behavior. Do not suppress validation, quota enforcement, or all hooks merely to make a test finish.
- Prefer Payload ownership filters over a separate authorization lookup when
the permission can be expressed as a
Wherecondition.
Quotas require current database state
Usage lives in the user-usage collection, not in a cached user.usage property.
The quota service reads and updates that state through the caller’s transaction.
For an atomic limit check and reservation, use its checkAndIncrementUsage
operation rather than a separate check followed by an increment. A cached count
cannot prevent two concurrent requests from both consuming the remaining quota.
See apps/web/lib/services/quota-service.ts and the scheduled-ingest quota hooks
for the current signatures and rollback handling. Do not replace these operations
with in-memory checks to avoid database queries.
Diagnose the actual wait
A timeout is a symptom, not proof of a deadlock. PostgreSQL lock waits, recursive hooks, a missing database connection, and slow setup require different fixes.
- Trace the original
reqthrough helpers and nested Payload operations. - Check whether a transaction is active and whether raw SQL uses its connection.
- Inspect database lock waits with
pg_stat_activityandpg_blocking_pids. - Check that any recursion flag is read by the hook it is supposed to affect.
- Use structured logging around the operation without logging credentials or sensitive record contents.
Do not fix a lock wait by increasing timeouts or disabling authorization.
Verify with real transactions
Run focused integration tests against PostgreSQL, for example:
make test-ai FILTER="pipeline-field-immutability scraper-collections"The regression tests create related records within one uncommitted transaction and verify that nested ownership checks can read them. Cleanup rolls the transaction back. Concurrency tests should observe actual lock contention and verify committed effects, rather than relying on a sleep to guess execution order.
Passing the request prevents accidental transaction splitting; it does not remove every possible deadlock. Transactions acquiring multiple locks still need a consistent lock order and appropriate failure handling.