Skip to content
File

Blob: src/worker/do/repo/maintenance.ts

typescript188 lines
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 
8import type { RepoStateSchema } from "./repoState";
9import type { Logger } from "@/worker/common/logger";
10 
11import { asTypedStorage } from "./repoState";
12import { getDb } from "./db/client";
13import { getActivePackCatalogCount } from "./db";
14import { doPrefix } from "@/worker/keys";
15import { ensureScheduled } from "./scheduler";
16import { getConfig } from "./repoConfig";
17 
18type 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 */
42export 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 */
86async 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 
120async 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 */
135async 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 */
162async 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}