Wait for an upload's URL to expire before deleting its bytes - #218
Conversation
An image deleted while its presigned PUT was still in flight lost its row before the bytes landed, so the object stayed in R2 with nothing left to reclaim it. Intents now record when their upload URL expires, and the physical-deletion claim skips an asset until that time plus a 15 minute grace for a PUT that started just before expiry. The hourly sweep picks it up afterwards. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| q.lte( | ||
| q.field("uploadUrlExpiresAt"), | ||
| now - uploadInFlightGraceMs, | ||
| ), |
There was a problem hiding this comment.
If R2 allows a PUT started before URL expiry to finish more than 15 minutes afterward, this cutoff lets the sweep delete the object and remove its asset row while the PUT is still in flight. The PUT can then write bytes to the public URL with no row left for a later sweep to reclaim them. Cleanup needs a reliable completion bound or a reclaimable record rather than this fixed grace period.
Artifacts
Delayed PUT reproduction source
- The authored Vitest test runs the actual Convex handlers against a stateful R2 mock and controls when the accepted PUT commits.
Reproduction test configuration
- The authored configuration runs the focused test in the repository's edge-runtime environment.
- The executed test against HEAD^ shows immediate HTTP 204 No Content deletion followed by HTTP 200 OK PUT and public GET with no asset row.
After-change deferred sweep run
- The executed test against HEAD shows deferred HTTP 204 No Content deletion followed by HTTP 200 OK PUT and public GET with no asset row.
This comment has been minimized.
This comment has been minimized.
A PUT that R2 accepted just before its URL expired can keep sending for as long as the connection takes: a 15 MB image at 12 KB/s needs about 20 minutes. Deleting its object after a fixed 15 minutes could still orphan the bytes. A day covers any real upload, and the wait costs little since a deleted element's image already stays for its 30-day tombstone. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Addressed the long-upload finding in d4759c3: the grace after URL expiry is now 24 hours instead of 15 minutes, so a slow PUT that R2 accepted before expiry finishes long before its object and row are deleted. @greptileai |
Closes the race noted in #184:
convex/images.tscould delete an upload intent while a presigned PUT against it was still in flight.Bug
generateUploadUrlcreates a pendingimageAssetsrow and hands the client a PUT URL that works for 15 minutes (R2_UPLOAD_URL_EXPIRES_SECONDS, up to 7 days). If the asset is marked deleted while that PUT is running (the strategy is deleted, the image is removed and reclaimed, or the stale-upload cron fires), a sweep is scheduled right away:finalizeDeletedImageAssetdeletes the row.Users don't see this directly. It leaks storage, and the bytes of an image the user deleted stay at their public URL.
Fix
uploadUrlExpiresAt(new optional field onimageAssets). The URL is signed with the intent's own timestamp, so the stored expiry and the URL's real expiry are the same number.claimDeletedImageAssets, which every physical deletion goes through, skips an asset untiluploadUrlExpiresAt+ 24 hours. R2 only checks the signature when the request starts, so a PUT that started just before expiry can keep sending for as long as a slow connection takes (a 15 MB image at 12 KB/s needs about 20 minutes). A day covers any real upload. Waiting costs little: a deleted element's image already stays for its 30-day tombstone. The hourlysweep-deleted-image-assetscron picks the asset up afterwards, and whatever bytes landed go with it. (The first version used 15 minutes; Greptile pointed out a slow upload can outlast that.)createR2UploadIntent's args are unchanged, sofunction_spec.jsondoesn't move.Compatibility
Tests
convex/imageAssetLifecycle.test.ts: an upload requested throughgenerateUploadUrlstores the expiry it returned. After the strategy is deleted mid-upload, nothing is sent to R2 and the row stays. 23 hours after expiry the sweep still leaves it. At 24 hours it deletes the object once and removes the row. Without the claim guard this test fails ("expected spy to not be called").npx tsc --noEmitpasses.npm run test:convexpasses: 168 tests in 10 files.npm run audit:convex-contractpasses.🤖 Generated with Claude Code
No outstanding findings block merging.
Findings
Summary
Image uploads now have a 24-hour grace period after their signed URL expires before cleanup can delete the asset. The lifecycle test checks the new cutoff.
Reviews (2) · Last reviewed commit: "Give an in-flight upload a day, not 15 m..."