Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions convex/imageAssetLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ const sweepDeletedImageAssets = makeFunctionReference<"action">(
"images:sweepDeletedImageAssets",
);
const completeUpload = makeFunctionReference<"action">("images:completeUpload");
const generateUploadUrl = makeFunctionReference<"action">(
"images:generateUploadUrl",
);
const getAssetUrl = makeFunctionReference<"query">("images:getAssetUrl");

type Harness = TestConvexForDataModel<DataModel>;
Expand Down Expand Up @@ -551,6 +554,51 @@ describe("image asset lifecycle", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});

test("an upload deleted while its URL still works keeps its row until the URL dies", async () => {
vi.useFakeTimers();
const fetchMock = mockR2Deletes();
const { t, owner } = await createHarness();
await seedStrategy(owner);
const upload = (await owner.action(generateUploadUrl, {
clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION,
strategyPublicId,
assetPublicId: "in-flight",
mimeType: "image/png",
fileExtension: "png",
})) as { objectKey: string; expiresAt: number };
expect(await allAssets(t)).toMatchObject([
{ uploadStatus: "pending", uploadUrlExpiresAt: upload.expiresAt },
]);

// The strategy goes while the client's PUT may still be sending bytes.
await owner.mutation(deleteStrategy, {
clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION,
strategyPublicId,
expectedRevision: 0,
});
await t.finishAllScheduledFunctions(vi.runAllTimers);
expect(fetchMock).not.toHaveBeenCalled();
expect(await allAssets(t)).toMatchObject([
{ uploadStatus: "deleted", objectKey: upload.objectKey },
]);

// Well after expiry, a PUT that started in time may still be sending
// over a slow connection.
vi.setSystemTime(upload.expiresAt + 23 * 60 * 60 * 1000);
await expect(
t.action(sweepDeletedImageAssets, {}),
).resolves.toMatchObject({ deleted: 0, failed: 0 });

vi.setSystemTime(upload.expiresAt + 24 * 60 * 60 * 1000);
await expect(
t.action(sweepDeletedImageAssets, {}),
).resolves.toMatchObject({ deleted: 1, failed: 0 });
expect(await allAssets(t)).toEqual([]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(String(fetchMock.mock.calls[0]?.[0])).toContain(upload.objectKey);
await t.finishAllScheduledFunctions(vi.runAllTimers);
});

test("legacy reads survive while completion inserts an exact-owned replacement", async () => {
const { t, owner } = await createHarness();
await seedStrategy(owner);
Expand Down
24 changes: 23 additions & 1 deletion convex/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
deleteR2Object,
expectedMimeTypeForExtension,
getR2Config,
getUploadUrlExpiresSeconds,
headR2Object,
normalizeImageExtension,
presignR2PutUrl,
Expand Down Expand Up @@ -74,6 +75,11 @@ const physicalDeletionBatch = 25;
// size, so a batch stays far below Convex's per-transaction limits.
const reclaimCandidateBatch = 25;
const staleDeletionClaimAgeMs = 15 * 60 * 1000;
// R2 checks a presigned URL when the PUT starts, so a PUT that started just
// before expiry can still be sending bytes after it, for as long as a slow
// connection takes. A day covers any real upload, and waiting costs little:
// a deleted element's image already stays for its 30-day tombstone.
const uploadInFlightGraceMs = 24 * 60 * 60 * 1000;
const deletionRetryDelayMs = 60 * 1000;

export const markDeletedStrategyImageAssetsRef =
Expand Down Expand Up @@ -269,6 +275,7 @@ export const generateUploadUrl = action({
uploadId: Id<"imageAssets">;
uploadAttemptPublicId: string;
objectKey: string;
issuedAt: number;
} =
await ctx.runMutation(internal.images.createR2UploadIntent, {
strategyPublicId: args.strategyPublicId,
Expand All @@ -281,10 +288,13 @@ export const generateUploadUrl = action({
width: args.width,
height: args.height,
});
// Signed at the intent's time, so the URL expires exactly when the
// intent's uploadUrlExpiresAt says it does.
const signed = await presignR2PutUrl({
config,
objectKey: intent.objectKey,
mimeType: validated.mimeType,
now: new Date(intent.issuedAt),
});

return {
Expand Down Expand Up @@ -337,6 +347,7 @@ export const createR2UploadIntent = internalMutation({
width: args.width,
height: args.height,
byteSize: args.byteSize,
uploadUrlExpiresAt: now + getUploadUrlExpiresSeconds() * 1000,
updatedAt: now,
};
// Content that shows this image may have landed first and left a
Expand Down Expand Up @@ -367,6 +378,7 @@ export const createR2UploadIntent = internalMutation({
uploadId,
uploadAttemptPublicId: args.uploadAttemptPublicId,
objectKey: args.objectKey,
issuedAt: now,
};
},
});
Expand Down Expand Up @@ -1052,6 +1064,10 @@ export const claimDeletedImageAssets = internalMutation({
1,
Math.min(args.limit ?? physicalDeletionBatch, physicalDeletionBatch),
);
const now = Date.now();
// An upload deleted while its PUT may still be in flight waits for the
// URL to die. Deleting first would let the bytes land afterwards with no
// row left to reclaim them; the hourly sweep picks the asset up later.
const assets = await ctx.db
.query("imageAssets")
.withIndex("by_uploadStatus_and_updatedAt", (q) =>
Expand All @@ -1061,10 +1077,16 @@ export const claimDeletedImageAssets = internalMutation({
q.and(
q.neq(q.field("strategyId"), undefined),
q.eq(q.field("cleanupClaimedAt"), undefined),
q.or(
q.eq(q.field("uploadUrlExpiresAt"), undefined),
q.lte(
q.field("uploadUrlExpiresAt"),
now - uploadInFlightGraceMs,
),
Comment on lines +1082 to +1085

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Long uploads outlast grace

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.

Before-change delayed PUT run

  • 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.

View artifacts

T-Rex Ran code and verified through T-Rex

),
),
)
.take(limit);
const now = Date.now();
for (const asset of assets) {
await ctx.db.patch(asset._id, { cleanupClaimedAt: now });
}
Expand Down
14 changes: 9 additions & 5 deletions convex/lib/r2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ function optionalPositiveIntEnv(
return parsed;
}

export function getUploadUrlExpiresSeconds(): number {
return optionalPositiveIntEnv(
"R2_UPLOAD_URL_EXPIRES_SECONDS",
defaultUploadUrlExpiresSeconds,
{ min: 1, max: 604800 },
);
}

export function getR2Config(): R2Config {
const accountId = requiredEnv("R2_ACCOUNT_ID");
const bucket = requiredEnv("R2_BUCKET");
Expand All @@ -82,11 +90,7 @@ export function getR2Config(): R2Config {
accessKeyId: requiredEnv("R2_ACCESS_KEY_ID"),
secretAccessKey: requiredEnv("R2_SECRET_ACCESS_KEY"),
publicBaseUrl: getR2PublicBaseUrl(),
uploadUrlExpiresSeconds: optionalPositiveIntEnv(
"R2_UPLOAD_URL_EXPIRES_SECONDS",
defaultUploadUrlExpiresSeconds,
{ min: 1, max: 604800 },
),
uploadUrlExpiresSeconds: getUploadUrlExpiresSeconds(),
maxImageBytes: optionalPositiveIntEnv(
"R2_MAX_IMAGE_BYTES",
defaultMaxImageBytes,
Expand Down
3 changes: 3 additions & 0 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,9 @@ export default defineSchema({
byteSize: v.optional(v.number()),
etag: v.optional(v.string()),
uploadedAt: v.optional(v.number()),
// When the presigned PUT URL for this upload attempt stops working. Until
// then the bytes can still land, so they are not deleted before it.
uploadUrlExpiresAt: v.optional(v.number()),
deletedAt: v.optional(v.number()),
cleanupClaimedAt: v.optional(v.number()),
createdAt: v.optional(v.number()),
Expand Down
Loading