Skip to content

fix(plugin-cloud-storage): don't leave skipCloudStorage on a reused context - #17700

Open
luantaraschi wants to merge 1 commit into
payloadcms:mainfrom
luantaraschi:fix/cloud-storage-context-leak
Open

fix(plugin-cloud-storage): don't leave skipCloudStorage on a reused context#17700
luantaraschi wants to merge 1 commit into
payloadcms:mainfrom
luantaraschi:fix/cloud-storage-context-leak

Conversation

@luantaraschi

Copy link
Copy Markdown

Fixes #17546.

What

Passing one context object across several Local API create() calls on an upload collection uploads only the first file. Every later create succeeds, writes a fully-populated document (filename, mimeType, sizes, url), and puts nothing in storage — no error, no log line.

@edrpls traced the mechanism precisely in the issue; this confirms it and fixes the last link.

The chain

  1. createLocalReq assigns the caller's context to req.context by reference when the fresh req has none — getRequestContext returns context unchanged in that branch.
  2. The hook sets req.context.skipCloudStorage = true, so it is now set on the caller's object.
  3. The nested payload.update({ ..., req }) runs createLocalReq again on that same req. req.context is non-empty now, so line 139 reassigns req.context = { ...req.context, ...context } — a new object.
  4. finally { delete req.context.skipCloudStorage } deletes it from that new object.
  5. The caller's object keeps the flag. The next create() binds it to req.context again, the hook returns early at its first line, and no upload happens.

Step 3 is the one that's easy to miss: the reassignment happens on the same req, so req.context silently stops pointing at what step 2 mutated.

The fix

Capture the reference the flag is set on, and clear it from that:

+ const contextWithFlag = req.context
+ contextWithFlag.skipCloudStorage = true
  ...
  } finally {
-   delete req.context.skipCloudStorage
+   delete contextWithFlag.skipCloudStorage
  }

The recursion guard still works — the nested createLocalReq copies req.context including the flag, so the nested hook still sees it and returns early. Only the cleanup changes.

I deliberately did not touch getRequestContext. Handing the caller's object over by reference is what lets hooks write back into a caller's context, and changing that would be a much broader behavioural change than this bug warrants.

Verified by running it

Added uploads every file when one context object is reused across creates to test/plugin-cloud-storage/int.spec.ts. Three sequential creates sharing one context, then HeadObject against the bucket for every file and every generated size.

Run against LocalStack + MongoDB in Docker:

Result
With the fix Tests 1 passed
Reverting only afterChange.ts Tests 1 failed

The control failure is the reported damage, not a proxy for it:

Error verifying uploads: [ 'image-1-400x400.png', 'image-1-900x450.png', 'image-1.png' ] NotFound

The first file (image.png) verifies fine; the second one and its sizes are absent from the bucket while its document row exists. Reverting the finally line alone is enough to reproduce.

Full suite after the change: Test Files 2 passed · Tests 35 passed | 3 todo (38).

Note on the assertion order

The test checks the bucket before asserting that sharedContext is clean. I had it the other way round first, and the control then failed on the context assertion — technically correct, but it would have let a future regression report the internal symptom instead of the missing object. The bucket check is the one that describes what users lose.

What I did not verify

I ran the plugin-cloud-storage suite only, on MongoDB with the S3 adapter against LocalStack. I did not run the other storage-* suites (azure, gcs, r2, vercel-blob) — the change is in the shared hook, so they exercise the same path, but I'm flagging it rather than implying I checked. Same for a real S3/R2 endpoint; the reporter covered R2.

Checklist

  • Tests added
  • Documentation — no API change

…ontext

The afterChange hook sets `skipCloudStorage` on `req.context`, then calls a
nested `payload.update()`. That runs `createLocalReq`, which reassigns
`req.context` to a fresh copy — so the `finally` cleared the flag off the copy
and left it on the original object.

When a caller passes one `context` object across several Local API creates,
`createLocalReq` binds that same object to `req.context`, so the leftover flag
makes every later upload return early. The documents are written with correct
filenames and sizes, but no bytes reach storage and nothing is logged.
@luantaraschi
luantaraschi requested a review from denolfe as a code owner August 6, 2026 22:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant