File
Blob: src/worker/do/repo/maintenance.ts
| 1 | /** |
| 2 | * Repository idle cleanup |
| 3 | * |
| 4 | * This module handles idle cleanup and R2 mirror purging |
| 5 | * to maintain repository health when repos are no longer in use. |
| 6 | */ |
| 7 | |
| 8 | import type { RepoStateSchema } from "./repoState"; |
| 9 | import type { Logger } from "@/worker/common/logger"; |
| 10 | |
| 11 | import { asTypedStorage } from "./repoState"; |
| 12 | import { getDb } from "./db/client"; |
| 13 | import { getActivePackCatalogCount } from "./db"; |
| 14 | import { doPrefix } from "@/worker/keys"; |
| 15 | import { ensureScheduled } from "./scheduler"; |
| 16 | import { getConfig } from "./repoConfig"; |
| 17 | |
| 18 | type IdleCleanupDecision = |
| 19 | | { |
| 20 | kind: "active"; |
| 21 | lastAccess: number; |
| 22 | nextIdleAt: number; |
| 23 | } |
| 24 | | { |
| 25 | kind: "empty-idle" | "nonempty-idle"; |
| 26 | lastAccess: number | undefined; |
| 27 | refsCount: number; |
| 28 | hasHead: boolean; |
| 29 | headUnborn: boolean; |
| 30 | hasHeadTarget: boolean; |
| 31 | activePackCount: number; |
| 32 | }; |
| 33 | |
| 34 | /** |
| 35 | * Handles idle cleanup after alarm fires. |
| 36 | * Checks if the repository should be cleaned up due to idleness |
| 37 | * and reschedules only when the repository has not reached its idle deadline. |
| 38 | * @param ctx - Durable Object state context |
| 39 | * @param env - Worker environment |
| 40 | * @param logger - Logger instance |
| 41 | */ |
| 42 | export async function handleIdleAndMaintenance( |
| 43 | ctx: DurableObjectState, |
| 44 | env: Env, |
| 45 | logger?: Logger |
| 46 | ): Promise<void> { |
| 47 | try { |
| 48 | const cfg = getConfig(env); |
| 49 | const now = Date.now(); |
| 50 | const store = asTypedStorage<RepoStateSchema>(ctx.storage); |
| 51 | const lastAccess = await store.get("lastAccessMs"); |
| 52 | const decision = await decideIdleCleanup(ctx, cfg.idleMs, lastAccess, now); |
| 53 | |
| 54 | if (decision.kind === "active") { |
| 55 | logger?.debug("cleanup:active-rearm", { |
| 56 | lastAccess: decision.lastAccess, |
| 57 | nextIdleAt: decision.nextIdleAt, |
| 58 | }); |
| 59 | await ensureScheduled(ctx, env, now); |
| 60 | return; |
| 61 | } |
| 62 | |
| 63 | if (decision.kind === "empty-idle") { |
| 64 | logger?.info("cleanup:empty-idle", decision); |
| 65 | await performIdleCleanup(ctx, env, logger); |
| 66 | return; |
| 67 | } |
| 68 | |
| 69 | logger?.info("cleanup:nonempty-idle-skip", decision); |
| 70 | await clearIdleAlarm(ctx, logger); |
| 71 | } catch (e) { |
| 72 | logger?.error("alarm:error", { error: String(e) }); |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | /** |
| 77 | * Determines whether the idle alarm should clean up, re-arm, or stop. |
| 78 | * A repo is considered for cleanup if it's been idle beyond the threshold |
| 79 | * AND appears empty (no refs, unborn/missing HEAD, no active packs in catalog). |
| 80 | * @param ctx - Durable Object state context |
| 81 | * @param idleMs - Idle threshold in milliseconds |
| 82 | * @param lastAccess - Last access timestamp |
| 83 | * @param now - Current timestamp |
| 84 | * @returns cleanup decision for the alarm handler |
| 85 | */ |
| 86 | async function decideIdleCleanup( |
| 87 | ctx: DurableObjectState, |
| 88 | idleMs: number, |
| 89 | lastAccess: number | undefined, |
| 90 | now: number |
| 91 | ): Promise<IdleCleanupDecision> { |
| 92 | const idleExceeded = !lastAccess || now - lastAccess >= idleMs; |
| 93 | if (!idleExceeded) { |
| 94 | return { |
| 95 | kind: "active", |
| 96 | lastAccess, |
| 97 | nextIdleAt: lastAccess + idleMs, |
| 98 | }; |
| 99 | } |
| 100 | |
| 101 | // Check if repo looks empty |
| 102 | const store = asTypedStorage<RepoStateSchema>(ctx.storage); |
| 103 | const refs = (await store.get("refs")) ?? []; |
| 104 | const head = await store.get("head"); |
| 105 | const db = getDb(ctx.storage); |
| 106 | const catalogCount = await getActivePackCatalogCount(db); |
| 107 | const empty = refs.length === 0 && (!head || head.unborn || !head.target) && catalogCount === 0; |
| 108 | |
| 109 | return { |
| 110 | kind: empty ? "empty-idle" : "nonempty-idle", |
| 111 | lastAccess, |
| 112 | refsCount: refs.length, |
| 113 | hasHead: head !== undefined, |
| 114 | headUnborn: head?.unborn === true, |
| 115 | hasHeadTarget: typeof head?.target === "string" && head.target.length > 0, |
| 116 | activePackCount: catalogCount, |
| 117 | }; |
| 118 | } |
| 119 | |
| 120 | async function clearIdleAlarm(ctx: DurableObjectState, logger?: Logger): Promise<void> { |
| 121 | try { |
| 122 | await ctx.storage.deleteAlarm(); |
| 123 | } catch (e) { |
| 124 | logger?.warn("cleanup:delete-alarm-failed", { error: String(e) }); |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | /** |
| 129 | * Performs complete cleanup of an idle repository. |
| 130 | * Deletes all DO storage and purges the R2 mirror. |
| 131 | * @param ctx - Durable Object state context |
| 132 | * @param env - Worker environment |
| 133 | * @param logger - Logger instance |
| 134 | */ |
| 135 | async function performIdleCleanup( |
| 136 | ctx: DurableObjectState, |
| 137 | env: Env, |
| 138 | logger?: Logger |
| 139 | ): Promise<void> { |
| 140 | const storage = ctx.storage; |
| 141 | |
| 142 | // Purge DO storage. The 2026-05-13 compatibility date includes |
| 143 | // `delete_all_deletes_alarm`, so this also clears any pending alarm. |
| 144 | try { |
| 145 | await storage.deleteAll(); |
| 146 | } catch (e) { |
| 147 | logger?.error("cleanup:delete-storage-failed", { error: String(e) }); |
| 148 | } |
| 149 | |
| 150 | // Purge R2 mirror |
| 151 | const prefix = doPrefix(ctx.id.toString()); |
| 152 | await purgeR2Mirror(env, prefix, logger); |
| 153 | } |
| 154 | |
| 155 | /** |
| 156 | * Purges all R2 objects under this DO's prefix. |
| 157 | * Continues even if individual deletes fail. |
| 158 | * @param env - Worker environment |
| 159 | * @param prefix - Repository prefix (do/<id>) |
| 160 | * @param logger - Logger instance |
| 161 | */ |
| 162 | async function purgeR2Mirror(env: Env, prefix: string, logger?: Logger): Promise<void> { |
| 163 | try { |
| 164 | const pfx = `${prefix}/`; |
| 165 | let cursor: string | undefined = undefined; |
| 166 | |
| 167 | do { |
| 168 | const res: R2Objects = await env.REPO_BUCKET.list({ prefix: pfx, cursor }); |
| 169 | const objects: R2Object[] = (res && res.objects) || []; |
| 170 | |
| 171 | for (const obj of objects) { |
| 172 | try { |
| 173 | await env.REPO_BUCKET.delete(obj.key); |
| 174 | } catch (e) { |
| 175 | logger?.warn("cleanup:delete-r2-object-failed", { |
| 176 | key: obj.key, |
| 177 | error: String(e), |
| 178 | }); |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | cursor = res.truncated ? res.cursor : undefined; |
| 183 | } while (cursor); |
| 184 | } catch (e) { |
| 185 | logger?.error("cleanup:purge-r2-failed", { error: String(e) }); |
| 186 | } |
| 187 | } |