Skip to content

Commit 2e40e70

Browse files
exekiasclaude
andcommitted
Sweep orphaned Xata branches in the cleanup cron
The nightly cleanup only deleted branches still tracked by a userBranch row. Branches orphaned in Xata — delete failures swallowed on the user path after the row was removed, or a crash between createBranch and the row insert — were never revisited and lingered indefinitely. Add an orphan sweep to cleanupIdleBranches: delete any branch that is not the parent (main), not a tpl-* template, and not referenced by a userBranch row, with a one-hour createdAt grace period so an in-flight create is never swept mid-provisioning. Also make the manual scripts/xata-cleanup.ts keep tpl-* templates instead of deleting everything except main. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f1857f1 commit 2e40e70

2 files changed

Lines changed: 66 additions & 6 deletions

File tree

‎lib/branch-manager.ts‎

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,7 @@ export type CleanupResult = {
474474
dropped: number;
475475
errors: { branchId: string; message: string }[];
476476
templates: { scanned: number; dropped: number };
477+
orphans: { scanned: number; dropped: number };
477478
};
478479

479480
/**
@@ -519,10 +520,57 @@ async function pruneStaleTemplates(
519520
return { scanned: templates.length, dropped };
520521
}
521522

523+
// Untracked branches younger than this are skipped: an in-flight
524+
// ensureBranchForLesson creates the Xata branch up to ~2 minutes before the
525+
// userBranch row lands, and must not be swept mid-create.
526+
const ORPHAN_GRACE_MS = 60 * 60 * 1000;
527+
528+
/**
529+
* Delete orphaned Xata branches: anything that isn't the parent (`main`), a
530+
* `tpl-*` template (owned by pruneStaleTemplates), or referenced by a
531+
* `userBranch` row. Orphans accumulate when a delete fails after its row was
532+
* removed (delete errors are deliberately swallowed on the user path) or when
533+
* a create dies between createBranch and the row insert — the row-driven idle
534+
* pass never sees them again.
535+
*/
536+
async function sweepOrphanBranches(
537+
errors: { branchId: string; message: string }[],
538+
): Promise<{ scanned: number; dropped: number }> {
539+
const parentId = await getParentBranchId();
540+
const parentName = process.env.XATA_PARENT_BRANCH ?? "main";
541+
const trackedRows = await db
542+
.select({ id: userBranch.xataBranchId })
543+
.from(userBranch);
544+
const tracked = new Set(trackedRows.map((r) => r.id));
545+
546+
const branches = await listBranches();
547+
const cutoff = Date.now() - ORPHAN_GRACE_MS;
548+
549+
let scanned = 0;
550+
let dropped = 0;
551+
for (const b of branches) {
552+
if (b.id === parentId || b.name === parentName) continue;
553+
if (b.name.startsWith("tpl-")) continue;
554+
if (tracked.has(b.id)) continue;
555+
scanned++;
556+
const createdAt = Date.parse(b.createdAt);
557+
if (!Number.isFinite(createdAt) || createdAt > cutoff) continue;
558+
try {
559+
await deleteBranch(b.id);
560+
console.log(`[cleanup] dropped orphan branch ${b.id} (${b.name})`);
561+
dropped++;
562+
} catch (err) {
563+
errors.push({ branchId: b.id, message: (err as Error).message });
564+
}
565+
}
566+
return { scanned, dropped };
567+
}
568+
522569
/**
523570
* Cron-driven cleanup: drop user branches that haven't been touched in
524-
* `idleDays`, then prune template branches no longer referenced by any lesson
525-
* or live user branch.
571+
* `idleDays`, prune template branches no longer referenced by any lesson or
572+
* live user branch, then sweep orphaned branches Xata knows about but the
573+
* local DB doesn't.
526574
*/
527575
export async function cleanupIdleBranches(
528576
idleDays = 7,
@@ -538,6 +586,7 @@ export async function cleanupIdleBranches(
538586
dropped: 0,
539587
errors: [],
540588
templates: { scanned: 0, dropped: 0 },
589+
orphans: { scanned: 0, dropped: 0 },
541590
};
542591

543592
for (const row of idle) {
@@ -570,5 +619,14 @@ export async function cleanupIdleBranches(
570619
});
571620
}
572621

622+
try {
623+
result.orphans = await sweepOrphanBranches(result.errors);
624+
} catch (err) {
625+
result.errors.push({
626+
branchId: "<orphan-sweep>",
627+
message: (err as Error).message,
628+
});
629+
}
630+
573631
return result;
574632
}

‎scripts/xata-cleanup.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*
55
* Usage:
66
* npx tsx scripts/xata-cleanup.ts # dry-run; lists what would be deleted
7-
* npx tsx scripts/xata-cleanup.ts --force # actually delete every branch except main
7+
* npx tsx scripts/xata-cleanup.ts --force # delete every branch except main and tpl-* templates
88
*
99
* Standalone — doesn't import lib/xata.ts (which uses next/server-only and
1010
* can't be loaded outside the Next build).
@@ -69,12 +69,14 @@ async function main() {
6969
(noParent.length === 1 ? noParent[0] : undefined) ??
7070
branches.find((b) => referencedParents.has(b.id));
7171

72+
const keep = (b: Branch) =>
73+
(parent && b.id === parent.id) || b.name.startsWith("tpl-");
74+
7275
console.log(
7376
`Project ${projectId} has ${branches.length} branch(es).\n`,
7477
);
7578
for (const b of branches) {
76-
const isParent = parent && b.id === parent.id;
77-
const tag = isParent ? " KEEP" : "DELETE";
79+
const tag = keep(b) ? " KEEP" : "DELETE";
7880
console.log(
7981
` [${tag}] ${b.id} ${b.name} parent=${b.parentID ?? "(none)"}`,
8082
);
@@ -92,7 +94,7 @@ async function main() {
9294

9395
console.log(`\nParent (kept): ${parent.name} [${parent.id}]`);
9496

95-
const toDelete = branches.filter((b) => b.id !== parent.id);
97+
const toDelete = branches.filter((b) => !keep(b));
9698
if (toDelete.length === 0) {
9799
console.log("\nNothing to delete.");
98100
return;

0 commit comments

Comments
 (0)