Skip to Content
⚠️Active Development Notice: TimeTiles is under active development. Information may be placeholder content or not up-to-date.
DevelopmentContributingPayload CMS Deadlock Prevention

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

  • overrideAccess controls authorization, not transaction reuse. Payload’s Local API bypasses access checks by default. Use overrideAccess: false when 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 skipYourHooks flag 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 Where condition.

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.

  1. Trace the original req through helpers and nested Payload operations.
  2. Check whether a transaction is active and whether raw SQL uses its connection.
  3. Inspect database lock waits with pg_stat_activity and pg_blocking_pids.
  4. Check that any recursion flag is read by the hook it is supposed to affect.
  5. 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.

Last updated on