import { env } from "cloudflare:workers"; import { all } from "../db"; import { isArtifactsError } from "./artifacts"; import { artifactsCall } from "./artifacts-call"; import { allShards, shardOfName } from "./artifacts-shards"; /** 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_-]+$/; type SweepMode = "off" | "report" | "delete"; function isLocalUrl(raw: string | undefined): boolean { try { const host = new URL(raw ?? "").hostname; return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host.endsWith(".localhost"); } catch { return true; } } /** * `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 }): SweepMode { if (vars.mode === "off") return "off"; if (vars.mode !== "delete") return "report"; if (vars.exclusive !== "true" || isLocalUrl(vars.publicUrl)) { 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); } async function sweepShard(shard: number, binding: Artifacts, mode: SweepMode): 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()); 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)); 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, }); if (mode === "off") return; for (const { shard, binding } of allShards()) { try { await sweepShard(shard, binding, mode); } catch (error) { console.warn("Artifacts orphan sweep failed", shard, error); } } }