payloadcms / payloadcms/payload

payload.delete({ where }) silently fails to delete records — concurrent deleteOne race condition (regression since 3.41.0)

Open
#16,075 2 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

stale
Dominant language
TypeScript
Stars
44.8k
Forks
4.2k
Avg merge
2d 21h
Merged PRs (30d)
53

Description

Bug Description

payload.delete({ collection, where }) reports success (returns correct IDs) but silently fails to delete 1 or more matching records. Verified with raw SQL immediately after the call — the records still exist in the database.

This is a regression introduced between 3.41.0 and 3.80.0. We confirmed 3.41.0 does not have this bug.

Root Cause

In dist/collections/operations/delete.js, the bulk delete operation uses .map(async ...) to create promises for each document, then either Promise.all or for...of await to process them:

// Line 82: .map() eagerly starts ALL async callbacks
const promises = docs.map(async (doc) => {
    // ...
    await payload.db.deleteOne({ where: { id: { equals: id } } });
});

// Line 234-242: "Sequential" mode still doesn't help
if (req.payload.db.bulkOperationsSingleTransaction) {
    for (const promise of promises) {   // promises already started!
        awaitedDocs.push(await promise);
    }
} else {
    awaitedDocs = await Promise.all(promises);
}

The issue: .map(async ...) starts ALL async callbacks immediately. The for...of await loop only controls the order results are collected, not the order of execution. All deleteOne calls run concurrently on the same transaction connection, each performing 3 queries (selectDistinct → findFirst → deleteWhere) that interleave and corrupt each other.

Setting bulkOperationsSingleTransaction: true does not fix the issue because of this same .map() antipattern.

Fix

Replace .map() + for...of await with a proper sequential loop:

const processDoc = async (doc) => { /* existing callback body */ };

let awaitedDocs = [];
for (const doc of docs) {
    awaitedDocs.push(await processDoc(doc));
}

This ensures each deleteOne completes before the next one starts.

Reproduction

Self-contained script (uses vehicle-requirements — a collection with zero hooks):

import { getPayload } from 'payload';
import config from './src/payload.config';
import { sql } from 'drizzle-orm';

const payload = await getPayload({ config });
const db = payload.db.drizzle;

// Create 5 records
const testIds = [];
for (let i = 0; i < 5; i++) {
  const doc = await payload.create({
    collection: 'vehicle-requirements',
    data: { jobPlan: PLAN_ID, jobPlanDay: DAY_ID, vehicleType: `test-${i}`, quantity: 1 },
  });
  testIds.push(doc.id);
}

// Delete via where
const result = await payload.delete({
  collection: 'vehicle-requirements',
  where: { id: { in: testIds } },
});
console.log('Payload says deleted:', result.docs.length); // 5

// Verify via raw SQL
const remaining = await db.execute(
  sql`SELECT id FROM vehicle_requirements WHERE id IN (${sql.join(testIds.map(id => sql`${id}`), sql`, `)})`
);
console.log('Actually remaining:', remaining.rows.length); // 1 (BUG)

Reproduces within 1-3 iterations consistently on Payload 3.80.0 with @payloadcms/db-postgres.

Control test: Drizzle direct db.delete(table).where(inArray(...)) works perfectly — 0 failures across all iterations. The bug is in Payload's abstraction layer, not Drizzle or Postgres.

Environment

  • payload: 3.80.0
  • @payloadcms/db-postgres: 3.80.0
  • @payloadcms/drizzle: 3.80.0
  • PostgreSQL 15
  • Node 22
  • macOS

Related

  • #15100 (closed as can't-reproduce, but we can reproduce consistently)

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in dist/collections/operations/delete.js, especially the bulk delete promise creation and the single-transaction branch. Run the supplied reproduction against Payload 3.80.0 with @payloadcms/db-postgres, then verify that the matching records are absent through the raw SQL query. Done means where-based deletion no longer leaves records behind when multiple documents are deleted.

Written by the indexing model from the issue text.

Assessment

Tech stack
node.js, postgresql, typescript
Domain
backend, databases
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.