payloadcms / payloadcms/payload

Failed 'persist upload data' after a crop silently rolls back the whole update (200 OK, cropped file + pre-crop doc row)

Open
#17,294 0 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

area: core db: postgres plugin: cloud-storage plugin: storage-*
Dominant language
TypeScript
Stars
44.8k
Forks
4.2k
Avg merge
2d 21h
Merged PRs (30d)
53

Description

Describe the Bug

Cropping an image on a collection that uses @payloadcms/plugin-cloud-storage (reproduced with storage-vercel-blob + db-postgres) can leave the system in a split state:

  • the stored file IS cropped (old blobs deleted, new cropped file + regenerated sizes uploaded),
  • but the media document keeps its exact pre-crop width/height/filesize/sizes,
  • the request returns 200 OK with the correct-looking doc in the response body,
  • the only trace is a WARN: Failed to persist upload data for collection media document N: ...

In the admin this reads as "the crop didn't apply"; on the frontend the doc hands out wrong size hints for a file that no longer has those dimensions.

Root cause — the metadata-persist nested update can roll back the whole outer update:

  1. updateByID starts a transaction on req (updateByID.ts) and writes the new file data (correct cropped dimensions) inside it.
  2. The plugin's afterChange (plugin-cloud-storage/src/hooks/afterChange.ts) uploads the files (plain HTTP — outside the transaction), then persists upload metadata via a nested req.payload.update({ ..., req }) reusing the same req. It clears req.file, but not req.query.uploadEdits.
  3. Because uploadEdits.crop is still on the query, the nested update's generateFileData takes the shouldReupload path (generateFileData.ts) and getExternalFile re-downloads the file that was just replaced — a pointless second fetch + full re-encode even when it works (the uploadEdits pixel dims always equal the new file's dims by then).
  4. Right after an overwrite, that re-download is fragile: a stale cached body served with the new content-length produces a truncated stream (Error cropping image: pngload_buffer: end of stream) or an empty body (ValidationError: The following field is invalid: file). The nested operation throws, and its catch calls killTransaction(args.req) (killTransaction.ts) — rolling back the transaction it shares with the outer update and deleting req.transactionID.
  5. The plugin catches the error and logs the WARN; the outer operation's commitTransaction no-ops (no transactionID left); the response is 200 built from the in-memory doc. Every DB effect of the crop is gone; every storage effect remains.

Reproduced on payload@3.78.0: 7 of 8 admin-equivalent REST crops ended with a cropped blob + rolled-back doc row. The relevant afterChange code is unchanged on current main.

Two adjacent observations while building the repro (can split into separate issues if useful):

  • On 3.86.0, storage-vercel-blob's refactored uploadFile no longer passes allowOverwrite to @vercel/blob@2.x, so the crop's overwrite put throws Vercel Blob: This blob already exists and every crop 500s outright — which masks this bug there (5/5 in the same harness; the original file survives).
  • getExternalFile's fallback base URL is built as ${req.protocol}://${req.headers.get('host')}req.protocol already contains the colon, producing an invalid http:://host/... for any client that doesn't send an Origin header (browsers do, server-to-server clients often don't).

Suggested direction: the metadata persist shouldn't re-enter the upload pipeline at all (strip uploadEdits / mark the nested req), and/or a nested operation reusing an outer req shouldn't kill the shared transaction on failure while the outer operation reports success.

Workaround we're shipping meanwhile (works, 6/6): a beforeOperation hook that deletes req.query.uploadEdits when context.skipCloudStorage is set, turning the nested update into a plain data write.

Possibly related: #15991 (create-path variant of the failed metadata persist).

Link to the code that reproduces this issue

https://github.com/mishery97/payload-crop-transaction-rollback-repro

Reproduction Steps
  1. Clone the repro, cp .env.example .env and fill in a Postgres DATABASE_URL, any PAYLOAD_SECRET, and a Vercel Blob BLOB_READ_WRITE_TOKEN.
  2. pnpm install && pnpm dev
  3. In a second terminal: node repro.mjs 5

The script uploads a 1600x1000 PNG and crops it to 640x400 via the exact request the admin Crop drawer sends (PATCH /api/media/:id?uploadEdits[crop][x]=...&uploadEdits[widthInPixels]=640... with the full doc as JSON body and an Origin header). It then re-reads the doc.

Expected: doc reads 640x400.
Actual (5/15 iterations on this run): patch=200, response body says 640x400, DB row still says 1600x1000 with the original filesize, dev server logs Failed to persist upload data for collection media document N, and the blob now holds the cropped 640x400 file.

Which area(s) are affected?

plugin: cloud-storage, plugin: storage-*, area: core, db: postgres

Environment Info
payload: 3.78.0 (afterChange code verified unchanged on main; on 3.86.0 masked by the storage-vercel-blob allowOverwrite regression noted above)
@payloadcms/db-postgres: 3.78.0
@payloadcms/storage-vercel-blob: 3.78.0
@payloadcms/next: 3.78.0
next: 15.4.11
node: 22.14.0
os: macOS (darwin 25.3.0) — also observed in the originating project on Vercel-deployed instances' logs
db: PostgreSQL (Neon)

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

Run node repro.mjs 5 first, then inspect plugin-cloud-storage/src/hooks/afterChange.ts alongside packages/payload/src/uploads/generateFileData.ts and packages/payload/src/utilities/killTransaction.ts. Trace the nested metadata update from updateByID.ts and add regression coverage for the crop path. Done means the cropped file and document metadata remain consistent, the request succeeds without the persistence warning, and the transaction is not incorrectly rolled back.

Written by the indexing model from the issue text.

Assessment

Tech stack
nextjs, postgresql, typescript
Domain
backend, cloud, databases
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.