Skip to content

Commit eb0742b

Browse files
authored
fix(secondary-sync): clean abandoned staging objects
Prevent failed or interrupted secondary sync workflows from exhausting the IHEP S3 quota by cleaning the current staging prefix on failure and pruning abandoned staging objects and multipart uploads.
1 parent 04f391d commit eb0742b

5 files changed

Lines changed: 221 additions & 42 deletions

File tree

cloudflare/secondary-sync/README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,19 @@ hosted runner.
88

99
1. GitHub publishes the Release and finishes the R2 `latest/*` sync.
1010
2. GitHub calls `POST /sync/start` with the release tag.
11-
3. The Workflow reads R2 in ranges and uploads objects to an IHEP staging prefix.
11+
3. The Workflow removes abandoned staging objects and multipart uploads older
12+
than `SECONDARY_SYNC_STAGE_GRACE_HOURS`, then reads R2 in ranges and uploads
13+
objects to an IHEP staging prefix.
1214
4. It copies staging objects to `latest/*` on IHEP, committing `latest/manifest`
1315
after installers, Sparkle archives, checksums, and appcasts.
1416
5. It removes stale latest aliases that are absent from the current manifest,
1517
prunes stale `latest/mac/*` Sparkle archives outside the grace window, and
16-
removes staging objects.
18+
removes staging objects. If upload or commit fails, it also attempts to
19+
remove the current instance's staging objects before preserving the original
20+
Workflow error.
21+
22+
The default staging grace window is two hours. It protects recent concurrent
23+
instances while bounding storage leaked by interrupted or terminated Workflows.
1724

1825
## Required secrets
1926

cloudflare/secondary-sync/src/core.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const SHORT_CACHE_CONTROL = "public, max-age=600";
55
const DEFAULT_STAGE_PREFIX = "staging/secondary-sync";
66
const ALIAS_ROLLBACK_SUFFIX = ".rollback";
77
const DEFAULT_PRUNE_GRACE_DAYS = 1;
8+
const DEFAULT_STAGE_GRACE_HOURS = 2;
89

910
export class NonRetryableMirrorError extends Error {
1011
constructor(message) {
@@ -606,6 +607,47 @@ export async function pruneStaleLatestMacObjects(env, keepKeys, options = {}) {
606607
return { pruned, graceDays };
607608
}
608609

610+
export async function pruneAbandonedStageObjects(env, keepStagePrefix = "", options = {}) {
611+
const s3 = createS3Client(env);
612+
const stageRoot = `${cleanPrefix(env.SECONDARY_SYNC_STAGE_PREFIX || DEFAULT_STAGE_PREFIX)}/`;
613+
const graceHours = parseNonNegativeInteger(
614+
options.graceHours ?? env.SECONDARY_SYNC_STAGE_GRACE_HOURS,
615+
DEFAULT_STAGE_GRACE_HOURS,
616+
);
617+
const cutoff = Date.now() - graceHours * 60 * 60 * 1000;
618+
const deleted = [];
619+
const aborted = [];
620+
621+
for (const object of await s3.listObjects(stageRoot)) {
622+
if (!isAbandonedStageEntry(object.key, object.lastModified, keepStagePrefix, cutoff)) {
623+
continue;
624+
}
625+
await s3.deleteObject(object.key);
626+
deleted.push(object.key);
627+
}
628+
629+
for (const upload of await s3.listMultipartUploads(stageRoot)) {
630+
if (!isAbandonedStageEntry(upload.key, upload.initiated, keepStagePrefix, cutoff)) {
631+
continue;
632+
}
633+
await s3.abortMultipartUpload(upload.key, upload.uploadId);
634+
aborted.push({ key: upload.key, uploadId: upload.uploadId });
635+
}
636+
637+
return { deleted, aborted, graceHours };
638+
}
639+
640+
function isAbandonedStageEntry(key, timestamp, keepStagePrefix, cutoff) {
641+
if (!key || !timestamp || Number.isNaN(timestamp.getTime())) {
642+
return false;
643+
}
644+
const keepPrefix = cleanPrefix(keepStagePrefix);
645+
if (keepPrefix && (key === keepPrefix || key.startsWith(`${keepPrefix}/`))) {
646+
return false;
647+
}
648+
return timestamp.getTime() < cutoff;
649+
}
650+
609651
export function decoratePlanWithStage(plan, instanceId, env = {}) {
610652
const safeTag = safeKeySegment(plan.releaseTag);
611653
const safeInstance = safeKeySegment(instanceId);
@@ -852,6 +894,35 @@ export class S3Client {
852894
return response;
853895
}
854896

897+
async listMultipartUploads(prefix) {
898+
const uploads = [];
899+
let keyMarker = "";
900+
let uploadIdMarker = "";
901+
do {
902+
const query = [
903+
["uploads", ""],
904+
["prefix", prefix],
905+
];
906+
if (keyMarker) {
907+
query.push(["key-marker", keyMarker]);
908+
}
909+
if (uploadIdMarker) {
910+
query.push(["upload-id-marker", uploadIdMarker]);
911+
}
912+
const response = await this.request({
913+
method: "GET",
914+
key: "",
915+
query,
916+
});
917+
await assertOk(response, "LIST MULTIPART", prefix);
918+
const xml = await response.text();
919+
uploads.push(...xmlMultipartUploads(xml));
920+
keyMarker = xmlText(xml, "NextKeyMarker");
921+
uploadIdMarker = xmlText(xml, "NextUploadIdMarker");
922+
} while (keyMarker);
923+
return uploads;
924+
}
925+
855926
async copyObject(sourceKey, destinationKey) {
856927
const response = await this.request({
857928
method: "PUT",
@@ -1237,6 +1308,26 @@ function xmlContents(xml) {
12371308
return entries;
12381309
}
12391310

1311+
function xmlMultipartUploads(xml) {
1312+
const entries = [];
1313+
const uploadRegex = /<Upload>([\s\S]*?)<\/Upload>/g;
1314+
let match;
1315+
while ((match = uploadRegex.exec(xml))) {
1316+
const block = match[1];
1317+
const key = xmlText(block, "Key");
1318+
const uploadId = xmlText(block, "UploadId");
1319+
if (!key || !uploadId) {
1320+
continue;
1321+
}
1322+
entries.push({
1323+
key,
1324+
uploadId,
1325+
initiated: parseHttpDate(xmlText(block, "Initiated")),
1326+
});
1327+
}
1328+
return entries;
1329+
}
1330+
12401331
function decodeXmlText(value) {
12411332
return String(value)
12421333
.replace(/&lt;/g, "<")

cloudflare/secondary-sync/src/index.js

Lines changed: 65 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
decoratePlanWithStage,
1010
discoverSourceManifest,
1111
handleApiRequest,
12+
pruneAbandonedStageObjects,
1213
pruneStaleLatestMacObjects,
1314
reconcileSync,
1415
uploadObjectToStage,
@@ -46,51 +47,76 @@ export class SecondaryMirrorWorkflow extends WorkflowEntrypoint {
4647
this.env,
4748
);
4849

49-
const uploadResults = [];
50-
for (const item of plan.items) {
51-
uploadResults.push(
52-
await step.do(stepName("upload", item), UPLOAD_STEP, () =>
53-
nonRetryableGuard(() =>
54-
uploadObjectToStage(this.env, item, {
55-
forceUpload: Boolean(payload.force),
56-
}),
50+
const abandonedStageResult = await step.do(
51+
"prune abandoned secondary staging",
52+
HOUSEKEEPING_STEP,
53+
() => nonRetryableGuard(() => pruneAbandonedStageObjects(this.env, plan.stagePrefix)),
54+
);
55+
56+
try {
57+
const uploadResults = [];
58+
for (const item of plan.items) {
59+
uploadResults.push(
60+
await step.do(stepName("upload", item), UPLOAD_STEP, () =>
61+
nonRetryableGuard(() =>
62+
uploadObjectToStage(this.env, item, {
63+
forceUpload: Boolean(payload.force),
64+
}),
65+
),
5766
),
58-
),
59-
);
60-
}
67+
);
68+
}
6169

62-
const commitResults = [];
63-
for (const item of commitOrder(plan.items)) {
64-
commitResults.push(
65-
await step.do(stepName("commit", item), COMMIT_STEP, () =>
66-
nonRetryableGuard(() => commitObjectToAliases(this.env, item)),
67-
),
68-
);
69-
}
70+
const commitResults = [];
71+
for (const item of commitOrder(plan.items)) {
72+
commitResults.push(
73+
await step.do(stepName("commit", item), COMMIT_STEP, () =>
74+
nonRetryableGuard(() => commitObjectToAliases(this.env, item)),
75+
),
76+
);
77+
}
7078

71-
const staleAliasResult = await step.do("delete stale latest aliases", HOUSEKEEPING_STEP, () =>
72-
nonRetryableGuard(() => deleteStaleAliasObjects(this.env, plan.staleAliasKeys)),
73-
);
79+
const staleAliasResult = await step.do("delete stale latest aliases", HOUSEKEEPING_STEP, () =>
80+
nonRetryableGuard(() => deleteStaleAliasObjects(this.env, plan.staleAliasKeys)),
81+
);
7482

75-
const pruneResult = await step.do("prune stale Sparkle archives", HOUSEKEEPING_STEP, () =>
76-
nonRetryableGuard(() => pruneStaleLatestMacObjects(this.env, plan.keepLatestMacKeys)),
77-
);
83+
const pruneResult = await step.do("prune stale Sparkle archives", HOUSEKEEPING_STEP, () =>
84+
nonRetryableGuard(() => pruneStaleLatestMacObjects(this.env, plan.keepLatestMacKeys)),
85+
);
7886

79-
const cleanupResult = await step.do("cleanup secondary staging objects", HOUSEKEEPING_STEP, () =>
80-
nonRetryableGuard(() => cleanupStageObjects(this.env, plan.items)),
81-
);
87+
const cleanupResult = await step.do("cleanup secondary staging objects", HOUSEKEEPING_STEP, () =>
88+
nonRetryableGuard(() => cleanupStageObjects(this.env, plan.items)),
89+
);
8290

83-
return {
84-
releaseTag: source.releaseTag,
85-
manifestSha256: source.manifestSha256,
86-
stagePrefix: plan.stagePrefix,
87-
uploaded: uploadResults.length,
88-
committed: commitResults.reduce((count, result) => count + result.aliases.length, 0),
89-
staleAliasesDeleted: staleAliasResult.deleted.length,
90-
pruned: pruneResult.pruned.length,
91-
cleaned: cleanupResult.deleted.length,
92-
completedAt: new Date().toISOString(),
93-
};
91+
return {
92+
releaseTag: source.releaseTag,
93+
manifestSha256: source.manifestSha256,
94+
stagePrefix: plan.stagePrefix,
95+
abandonedStageObjectsDeleted: abandonedStageResult.deleted.length,
96+
abandonedMultipartUploadsAborted: abandonedStageResult.aborted.length,
97+
uploaded: uploadResults.length,
98+
committed: commitResults.reduce((count, result) => count + result.aliases.length, 0),
99+
staleAliasesDeleted: staleAliasResult.deleted.length,
100+
pruned: pruneResult.pruned.length,
101+
cleaned: cleanupResult.deleted.length,
102+
completedAt: new Date().toISOString(),
103+
};
104+
} catch (error) {
105+
try {
106+
await step.do("cleanup failed secondary staging objects", HOUSEKEEPING_STEP, () =>
107+
nonRetryableGuard(() => cleanupStageObjects(this.env, plan.items)),
108+
);
109+
} catch (cleanupError) {
110+
console.error(
111+
JSON.stringify({
112+
event: "secondary_sync_failure_cleanup_failed",
113+
stagePrefix: plan.stagePrefix,
114+
error: cleanupError.message,
115+
}),
116+
);
117+
}
118+
throw error;
119+
}
94120
}
95121
}
96122

cloudflare/secondary-sync/test/core.test.mjs

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
deleteStaleAliasObjects,
99
decoratePlanWithStage,
1010
deriveReleaseTag,
11+
pruneAbandonedStageObjects,
1112
pruneStaleLatestMacObjects,
1213
uploadObjectToStage,
1314
} from "../src/core.js";
@@ -188,6 +189,45 @@ test("prunes stale unreferenced Sparkle archives but keeps current and recent ob
188189
assert.equal(s3.objects.has("latest/mac/intel/recent.zip"), true);
189190
});
190191

192+
test("prunes abandoned staging objects and multipart uploads without touching active work", async () => {
193+
const s3 = createMockS3();
194+
const old = new Date("2020-01-01T00:00:00Z");
195+
const recent = new Date(Date.now() + 60_000);
196+
const activePrefix = "staging/secondary-sync/release/active";
197+
s3.objects.set("staging/secondary-sync/release/old/objects/archive", objectEntry("old", old));
198+
s3.objects.set("staging/secondary-sync/release/recent/objects/archive", objectEntry("recent", recent));
199+
s3.objects.set(`${activePrefix}/objects/archive`, objectEntry("active", old));
200+
s3.objects.set("latest/win", objectEntry("published", old));
201+
s3.uploads.set("upload-old", {
202+
key: "staging/secondary-sync/release/old/objects/partial",
203+
parts: new Map(),
204+
initiated: old,
205+
});
206+
s3.uploads.set("upload-active", {
207+
key: `${activePrefix}/objects/partial`,
208+
parts: new Map(),
209+
initiated: old,
210+
});
211+
globalThis.fetch = s3.fetch;
212+
213+
const result = await pruneAbandonedStageObjects(fixtureEnv(new Map()), activePrefix, {
214+
graceHours: 1,
215+
});
216+
217+
assert.deepEqual(result.deleted, ["staging/secondary-sync/release/old/objects/archive"]);
218+
assert.deepEqual(result.aborted, [
219+
{
220+
key: "staging/secondary-sync/release/old/objects/partial",
221+
uploadId: "upload-old",
222+
},
223+
]);
224+
assert.equal(s3.objects.has("staging/secondary-sync/release/recent/objects/archive"), true);
225+
assert.equal(s3.objects.has(`${activePrefix}/objects/archive`), true);
226+
assert.equal(s3.objects.has("latest/win"), true);
227+
assert.equal(s3.uploads.has("upload-old"), false);
228+
assert.equal(s3.uploads.has("upload-active"), true);
229+
});
230+
191231
test("falls back to ListObjectsV2 when nested HEAD returns 403", async () => {
192232
const s3 = createMockS3();
193233
globalThis.fetch = s3.fetch;
@@ -354,6 +394,20 @@ function createMockS3(options = {}) {
354394
return new Response(`<ListBucketResult>${contents}</ListBucketResult>`, { status: 200 });
355395
}
356396

397+
if (method === "GET" && url.searchParams.has("uploads")) {
398+
const prefix = url.searchParams.get("prefix") || "";
399+
const entries = [...uploads.entries()]
400+
.filter(([, upload]) => upload.key.startsWith(prefix))
401+
.map(
402+
([uploadId, upload]) =>
403+
`<Upload><Key>${xmlEscape(upload.key)}</Key><UploadId>${xmlEscape(uploadId)}</UploadId><Initiated>${upload.initiated.toISOString()}</Initiated></Upload>`,
404+
)
405+
.join("");
406+
return new Response(`<ListMultipartUploadsResult>${entries}</ListMultipartUploadsResult>`, {
407+
status: 200,
408+
});
409+
}
410+
357411
if (method === "HEAD") {
358412
if (key.includes("head-403")) {
359413
return new Response("forbidden", { status: 403 });
@@ -385,7 +439,7 @@ function createMockS3(options = {}) {
385439

386440
if (method === "POST" && url.searchParams.has("uploads")) {
387441
const uploadId = `upload-${++uploadCounter}`;
388-
uploads.set(uploadId, { key, parts: new Map() });
442+
uploads.set(uploadId, { key, parts: new Map(), initiated: new Date() });
389443
return new Response(`<InitiateMultipartUploadResult><UploadId>${uploadId}</UploadId></InitiateMultipartUploadResult>`);
390444
}
391445

cloudflare/secondary-sync/wrangler.jsonc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"SECONDARY_SYNC_PART_SIZE_BYTES": "16777216",
3333
"SECONDARY_SYNC_SINGLE_PUT_MAX_BYTES": "33554432",
3434
"SECONDARY_SYNC_PRUNE_GRACE_DAYS": "1",
35+
"SECONDARY_SYNC_STAGE_GRACE_HOURS": "2",
3536
"WORKFLOW_SUCCESS_RETENTION": "1 day",
3637
"WORKFLOW_ERROR_RETENTION": "7 days"
3738
},

0 commit comments

Comments
 (0)