payloadcms / payloadcms/payload
plugin-cloud-storage: sequential Local API creates sharing one context object upload only the FIRST file (skipCloudStorage leaks onto the caller's context)
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
With any cloud-storage adapter enabled, calling payload.create() several times in one process on an upload collection while reusing a single context object (the common seed/import-script pattern) uploads only the first file's binary and image sizes. Every later create still returns success and writes a fully-populated document row (correct filename, mimeType, sizes, url), but zero bytes reach storage, with no error and nothing in the logs. The result is documents whose binaries never existed; on our staging environment this surfaced as 138 media documents with 4 objects in the bucket, every image a 404.
Passing a fresh object per call (context: { ...flags }) uploads everything correctly. REST/admin uploads are unaffected (each HTTP request builds its own context). Reproduced on v3.86.0 and on current main (the linked reproduction's failing test runs against main).
Mechanism (traced)
createLocalReqcallsgetRequestContext, which hands the caller'scontexttoreq.contextby reference when the fresh req has none (packages/payload/src/utilities/createLocalReq.ts).- The plugin's
afterChangehook uploads, then enters its metadata-update branch (storage-s3'shandleUploadreturnsdata, souploadMetadatais always non-empty): it setsreq.context.skipCloudStorage = true, mutating the caller's shared object, and callsawait req.payload.update({ ..., req })(packages/plugin-cloud-storage/src/hooks/afterChange.ts). - Inside that nested update,
createLocalReqruns again;req.contextis now non-empty, sogetRequestContextre-bindsreq.contextto a shallow copy ({ ...req.context, ...context }). - The hook's
finally { delete req.context.skipCloudStorage }deletes the flag from the copy. The caller's original object keepsskipCloudStorage: trueforever, which is directly observable: after the loop,ctx.skipCloudStorage === true. - Every later
createsharing that object hits the hook's early return (if (req.context?.skipCloudStorage) return doc) and silently skipshandleUpload.
Adjacent hygiene issue in the same area: preserveFileData (from #15570) stashes req.context._payloadCloudStorage = { file, uploadSizes } only when the key is absent, and nothing ever clears it, so a shared context also pins the first upload's buffers for the life of the object. It doesn't cause the missing uploads (fresh req.file wins the ?? in getIncomingFiles), but it's one fallback away from cross-attaching a stale file.
Relation to existing issues
Concrete, data-losing specialization of #10250 (context shared by reference across Local API calls, acknowledged there as current behavior). No existing issue covers the upload path: #15991 / #17294 / #16573 touch the same hook but are different failure modes.
Suggested fixes (any one suffices)
- In
afterChange, capture the context object into a local before the nested update and set/delete the flag on that same reference (the one-line fix). - Or stop using
req.contextfor recursion control: a request-scopedSymbolonreq, or aWeakSet<PayloadRequest>, can't leak to the caller. - Or make
createLocalReqalways clone the incomingcontext, which would also fix #10250's whole class.
Workaround for users: never reuse one context object across Local API writes to upload-enabled collections; build a fresh object per call.
Link to the code that reproduces this issue
https://github.com/edrpls/payload/tree/repro/cloud-storage-shared-context-upload-loss
Reproduction Steps
Reproduction is built into test/_community per the reproduction guide. No docker and no credentials are needed: storage points at an in-process HTTP sink, since the bug is observable at the transport layer and PutObject only needs a 200 with an ETag back.
-
Check out the branch,
pnpm install. -
Run:
PAYLOAD_DATABASE=sqlite pnpm test:int _community -
Observe the failing test (current
main):× uploads every file when sequential creates SHARE one context object AssertionError: original missing in bucket for shared-ctx-b.png ✓ control: identical creates with a FRESH context object each all uploadThe repro test loops
payload.create({ collection: 'media', filePath, context: sharedContext })over three distinctly-named copies oftest/uploads/image.pngand asserts (a) every original was PUT to storage and (b)sharedContextdid not gainskipCloudStorage. Both fail today from the second file on; the control test is identical except for a fresh context object per call, and passes.
Files: test/_community/config.ts (adds s3Storage pointed at the sink), test/_community/s3-sink.ts (records PUTs), test/_community/int.spec.ts (failing repro plus passing control).
Which area(s) are affected?
plugin: cloud-storage, plugin: storage-*, area: core
Environment Info
Binaries:
Node: 24.14.0
npm: 11.9.0
Yarn: 1.22.18
pnpm: 11.3.0
Relevant Packages:
payload: 3.86.0
next: 16.2.12
@payloadcms/db-sqlite: 3.86.0
@payloadcms/drizzle: 3.86.0
@payloadcms/graphql: 3.86.0
@payloadcms/next/utilities: 3.86.0
@payloadcms/plugin-cloud-storage: 3.86.0
@payloadcms/plugin-redirects: 3.86.0
@payloadcms/richtext-lexical: 3.86.0
@payloadcms/storage-s3: 3.86.0
@payloadcms/translations: 3.86.0
@payloadcms/ui/shared: 3.86.0
react: 19.2.7
react-dom: 19.2.7
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 25.6.0
Available memory (MB): 32768
Available CPU cores: 10
(Also reproduced against current main via the linked branch with PAYLOAD_DATABASE=sqlite. Storage backend is irrelevant: reproduced against Cloudflare R2 and the in-process sink.)
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 PAYLOAD_DATABASE=sqlite pnpm test:int _community on the reproduction branch and inspect the failing test in test/_community/int.spec.ts. Then trace context handling through packages/payload/src/utilities/createLocalReq.ts and packages/plugin-cloud-storage/src/hooks/afterChange.ts. Done means sequential shared-context creates upload every file and leave the shared context unchanged, while the fresh-context control still passes.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend, cloud, testing
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100