payloadcms / payloadcms/payload
Failed 'persist upload data' after a crop silently rolls back the whole update (200 OK, cropped file + pre-crop doc row)
Nobody has claimed this yet.
- 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:
updateByIDstarts a transaction onreq(updateByID.ts) and writes the new file data (correct cropped dimensions) inside it.- 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 nestedreq.payload.update({ ..., req })reusing the samereq. It clearsreq.file, but notreq.query.uploadEdits. - Because
uploadEdits.cropis still on the query, the nested update'sgenerateFileDatatakes theshouldReuploadpath (generateFileData.ts) andgetExternalFilere-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). - 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 callskillTransaction(args.req)(killTransaction.ts) — rolling back the transaction it shares with the outer update and deletingreq.transactionID. - The plugin catches the error and logs the WARN; the outer operation's
commitTransactionno-ops (notransactionIDleft); 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 refactoreduploadFileno longer passesallowOverwriteto@vercel/blob@2.x, so the crop's overwriteputthrowsVercel Blob: This blob already existsand 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.protocolalready contains the colon, producing an invalidhttp:://host/...for any client that doesn't send anOriginheader (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
- Clone the repro,
cp .env.example .envand fill in a PostgresDATABASE_URL, anyPAYLOAD_SECRET, and a Vercel BlobBLOB_READ_WRITE_TOKEN. pnpm install && pnpm dev- 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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