import { env } from "cloudflare:workers"; import { all } from "../db"; import { isArtifactsError } from "./artifacts"; import { artifactsCall } from "./artifacts-call"; import { allShards, shardOfName } from "./artifacts-shards"; import { isLoopbackUrl } from "../deployment"; /** Artifacts repos that `createRepository` creates before inserting their row can be younger than this. */ const GRACE_MS = 24 * 60 * 60 * 1000; const PAGE_SIZE = 200; const PAGES_PER_RUN = 2; const CURSOR_TTL_SECONDS = 7 * 24 * 60 * 60; /** D1 binds at most 100 parameters per statement. */ const D1_MAX_PARAMS = 90; const OWN_NAME = /^r\d*-[A-Za-z0-9_-]+$/; /** Delete mode stops for a human once a run finds more than this many orphans in total... */ const MAX_DELETES_PER_RUN = 20; /** ...or once a page's orphans exceed this share of its own-looking repositories (GIT-P3). */ const MAX_ORPHAN_SHARE = 0.05; /** Below this many orphans a page never trips the share rule, so a sparse shard's few failed creates still clear. */ const MIN_ORPHANS_FOR_SHARE_RULE = 3; type SweepMode = "off" | "report" | "delete"; /** * `delete` also needs ARTIFACTS_NAMESPACE_EXCLUSIVE=true and a non-local PUBLIC_URL: a namespace shared with * another deployment (or a local dev database) would make every repository of the other side look orphaned. */ export function sweepMode(vars: { mode?: string; exclusive?: string; publicUrl?: string; local?: string }): SweepMode { if (vars.mode === "off") return "off"; if (vars.mode !== "delete") return "report"; if (vars.exclusive !== "true" || isLoopbackUrl(vars.publicUrl) || vars.local === "true") { console.error( "ARTIFACTS_ORPHAN_SWEEP=delete ignored: it requires ARTIFACTS_NAMESPACE_EXCLUSIVE=true and a non-local PUBLIC_URL; reporting only", ); return "report"; } return "delete"; } function cursorKey(shard: number): string { return `artifacts-sweep-cursor:${shard}`; } async function knownNames(names: string[]): Promise> { const known = new Set(); for (let start = 0; start < names.length; start += D1_MAX_PARAMS) { const chunk = names.slice(start, start + D1_MAX_PARAMS); const rows = await all<{ artifacts_name: string }>( `SELECT artifacts_name FROM repos WHERE artifacts_name IN (${chunk.map(() => "?").join(", ")})`, ...chunk, ); for (const row of rows) known.add(row.artifacts_name); } return known; } /** Names in one listing page that look like ours, are past the grace window and have no repos row. */ export function orphanCandidates( repos: Array<{ name: string; createdAt: string }>, shard: number, known: Set, now: number, ): string[] { return repos .filter((repo) => OWN_NAME.test(repo.name) && shardOfName(repo.name) === shard) .filter((repo) => now - Date.parse(repo.createdAt) > GRACE_MS) .filter((repo) => !known.has(repo.name)) .map((repo) => repo.name); } /** * Why a delete-mode page must not delete, or null. Many orphans at once means the database and namespace * disagree wholesale (a wiped or wrong D1, a shared namespace), not a few failed creates. */ export function deletionHalt(orphans: number, ownOnPage: number, deletedThisRun: number): string | null { if (deletedThisRun + orphans > MAX_DELETES_PER_RUN) { return `more than ${MAX_DELETES_PER_RUN} orphans in one run`; } if (orphans > Math.max(MIN_ORPHANS_FOR_SHARE_RULE, Math.floor(ownOnPage * MAX_ORPHAN_SHARE))) { return `${orphans} of ${ownOnPage} repositories on one page look orphaned (over ${MAX_ORPHAN_SHARE * 100}%)`; } return null; } class SweepHalted extends Error {} async function sweepShard(shard: number, binding: Artifacts, mode: SweepMode, run: { deleted: number }): Promise { let cursor = (await env.KV.get(cursorKey(shard))) ?? undefined; for (let page = 0; page < PAGES_PER_RUN; page++) { const listing = await artifactsCall("list repositories", `shard ${shard}`, () => binding.list({ limit: PAGE_SIZE, cursor }), ); const own = listing.repos.filter((repo) => OWN_NAME.test(repo.name)).map((repo) => repo.name); const orphans = orphanCandidates(listing.repos, shard, await knownNames(own), Date.now()); const halt = mode === "delete" ? deletionHalt(orphans.length, own.length, run.deleted) : null; if (halt) { console.error( `ARTIFACTS ORPHAN SWEEP HALTED: ${halt}; nothing was deleted from this page. Check that D1 and the Artifacts namespace belong to the same deployment.`, { shard, orphans: orphans.slice(0, 50) }, ); throw new SweepHalted(halt); } for (const name of orphans) { if (mode === "report") { console.warn("orphaned Artifacts repository (set ARTIFACTS_ORPHAN_SWEEP=delete to remove)", { shard, name }); continue; } try { await artifactsCall("delete an orphaned repository", name, () => binding.delete(name)); run.deleted++; console.warn("deleted orphaned Artifacts repository", { shard, name }); } catch (error) { if (!isArtifactsError(error) || error.code !== "NOT_FOUND") console.warn("orphan delete failed", name, error); } } cursor = listing.cursor; if (!cursor) break; } if (cursor) await env.KV.put(cursorKey(shard), cursor, { expirationTtl: CURSOR_TTL_SECONDS }); else await env.KV.delete(cursorKey(shard)); } /** * Cron: walks each namespace a few pages at a time and finds Artifacts repositories with no repos row, * which keep accruing storage until deleted (e.g. a create whose row insert failed). Reports by default; * deleting requires ARTIFACTS_ORPHAN_SWEEP=delete plus the guards in `sweepMode`. */ export async function sweepOrphanedArtifacts(): Promise { const mode = sweepMode({ mode: env.ARTIFACTS_ORPHAN_SWEEP, exclusive: env.ARTIFACTS_NAMESPACE_EXCLUSIVE, publicUrl: env.PUBLIC_URL, local: env.GITFOR_LOCAL, }); if (mode === "off") return; const run = { deleted: 0 }; for (const { shard, binding } of allShards()) { try { await sweepShard(shard, binding, mode, run); } catch (error) { if (error instanceof SweepHalted) continue; console.warn("Artifacts orphan sweep failed", shard, error); } } }