Skip to content
File

Blob: test/alarm-cleanup.worker.test.ts

typescript203 lines
1import { it, expect } from "vitest";
2import { env } from "cloudflare:workers";
3import { asTypedStorage, type RepoStateSchema } from "@/worker/do/repo/repoState";
4import { runAlarmWithRetry, runDOWithRetry, withEnvOverrides } from "./util/test-helpers";
5 
6function makeRepoId(suffix: string) {
7 return `alarm/${suffix}-${Math.random().toString(36).slice(2, 8)}`;
8}
9 
10function manualAlarmTime() {
11 // `runDurableObjectAlarm()` dispatches any scheduled alarm without checking
12 // whether the timestamp is due. Keep manually-fired test alarms far enough in
13 // the future that Miniflare's automatic alarm dispatch cannot consume them
14 // before the test helper asserts it ran one.
15 return Date.now() + 60 * 60 * 1000;
16}
17 
18it("alarm: deletes empty repo storage and R2 objects when idle", async () => {
19 const repoId = makeRepoId("empty");
20 const id = env.REPO_DO.idFromName(repoId);
21 const getStub = () => env.REPO_DO.get(id);
22 
23 // Discover prefix from the instance and prepare state as empty but with stale access
24 const { prefix } = await runDOWithRetry(
25 getStub,
26 async (_instance: any, state: DurableObjectState) => {
27 // Ensure looksEmpty condition: refs=[], head unborn, no lastPackKey
28 await state.storage.put("refs", []);
29 await state.storage.put("head", { target: "refs/heads/main", unborn: true });
30 // Simulate idle long ago
31 await state.storage.put("lastAccessMs", Date.now() - 60 * 60 * 1000);
32 await state.storage.setAlarm(manualAlarmTime());
33 const pfx = `do/${state.id.toString()}`;
34 return { prefix: pfx };
35 }
36 );
37 
38 // Place a couple of R2 objects under this DO's namespace to verify deletion
39 await env.REPO_BUCKET.put(`${prefix}/objects/pack/tmp.pack`, new Uint8Array([1, 2, 3]));
40 await env.REPO_BUCKET.put(`${prefix}/objects/pack/tmp.idx`, new Uint8Array([4, 5, 6]));
41 await env.REPO_BUCKET.put(`${prefix}/note.txt`, "hello");
42 
43 const ran1 = await runAlarmWithRetry(getStub);
44 expect(ran1).toBe(true);
45 
46 // Verify R2 namespace is empty
47 const listed = await env.REPO_BUCKET.list({ prefix: `${prefix}/` });
48 expect((listed.objects || []).length).toBe(0);
49 
50 // Verify known keys are removed from storage
51 await runDOWithRetry(getStub, async (_instance: any, state: DurableObjectState) => {
52 const refs = await state.storage.get("refs");
53 const head = await state.storage.get("head");
54 const last = await state.storage.get("lastAccessMs");
55 const alarm = await state.storage.getAlarm();
56 expect(refs).toBeUndefined();
57 expect(head).toBeUndefined();
58 expect(last).toBeUndefined();
59 expect(alarm).toBeNull();
60 });
61});
62 
63it("alarm: does not delete a non-empty repo", async () => {
64 const repoId = makeRepoId("nonempty");
65 const id = env.REPO_DO.idFromName(repoId);
66 const getStub = () => env.REPO_DO.get(id);
67 
68 // Seed the repo to create refs/head and objects
69 await runDOWithRetry(getStub, async (instance) => {
70 await instance.seedMinimalRepo();
71 });
72 
73 // Retrieve prefix
74 const { prefix } = await runDOWithRetry(
75 getStub,
76 async (_instance: any, state: DurableObjectState) => {
77 const pfx = `do/${state.id.toString()}`;
78 return { prefix: pfx };
79 }
80 );
81 
82 // Add a marker object under this DO's prefix
83 await env.REPO_BUCKET.put(`${prefix}/objects/pack/keep.pack`, new Uint8Array([9, 9, 9]));
84 
85 // Make it look idle
86 const staleAccess = Date.now() - 60 * 60 * 1000;
87 await runDOWithRetry(getStub, async (_instance: any, state: DurableObjectState) => {
88 await state.storage.put("lastAccessMs", staleAccess);
89 await state.storage.setAlarm(manualAlarmTime());
90 });
91 
92 const ran2 = await runAlarmWithRetry(getStub);
93 expect(ran2).toBe(true);
94 
95 // The repo is non-empty; R2 object should remain
96 const listed = await env.REPO_BUCKET.list({ prefix: `${prefix}/objects/pack/` });
97 const keys = (listed.objects || []).map((o: any) => o.key);
98 expect(keys.some((k: string) => k.endsWith("keep.pack"))).toBe(true);
99 await runDOWithRetry(getStub, async (_instance: any, state: DurableObjectState) => {
100 const last = await state.storage.get("lastAccessMs");
101 const alarm = await state.storage.getAlarm();
102 expect(last).toBe(staleAccess);
103 expect(alarm).toBeNull();
104 });
105});
106 
107it("alarm: does not delete repo with no refs but active pack catalog rows", async () => {
108 const repoId = makeRepoId("packs-no-refs");
109 const id = env.REPO_DO.idFromName(repoId);
110 const getStub = () => env.REPO_DO.get(id);
111 
112 // Seed a repo with pack data via seedMinimalRepo (creates refs, head, pack catalog)
113 await runDOWithRetry(getStub, async (instance) => {
114 await instance.seedMinimalRepo();
115 });
116 
117 const { prefix } = await runDOWithRetry(
118 getStub,
119 async (_instance: any, state: DurableObjectState) => {
120 const pfx = `do/${state.id.toString()}`;
121 return { prefix: pfx };
122 }
123 );
124 
125 // Clear refs and set HEAD unborn, but leave pack_catalog rows intact.
126 // This is the critical case: the repo has data (active packs) but no refs.
127 await runDOWithRetry(getStub, async (_instance: any, state: DurableObjectState) => {
128 await state.storage.put("refs", []);
129 await state.storage.put("head", { target: "refs/heads/main", unborn: true });
130 await state.storage.put("lastAccessMs", Date.now() - 60 * 60 * 1000);
131 await state.storage.setAlarm(manualAlarmTime());
132 });
133 
134 const ran = await runAlarmWithRetry(getStub);
135 expect(ran).toBe(true);
136 
137 // R2 pack data must survive — active pack catalog rows protect against purge
138 const listed = await env.REPO_BUCKET.list({ prefix: `${prefix}/objects/pack/` });
139 expect((listed.objects || []).length).toBeGreaterThan(0);
140 await runDOWithRetry(getStub, async (_instance: any, state: DurableObjectState) => {
141 const alarm = await state.storage.getAlarm();
142 expect(alarm).toBeNull();
143 });
144});
145 
146it("alarm: re-arms recently active repo for its idle deadline", async () => {
147 await withEnvOverrides(env, { REPO_DO_IDLE_MINUTES: "1" }, async () => {
148 const repoId = makeRepoId("recent");
149 const id = env.REPO_DO.idFromName(repoId);
150 const getStub = () => env.REPO_DO.get(id);
151 const lastAccess = Date.now();
152 
153 await runDOWithRetry(getStub, async (_instance: any, state: DurableObjectState) => {
154 await state.storage.put("refs", []);
155 await state.storage.put("head", { target: "refs/heads/main", unborn: true });
156 await state.storage.put("lastAccessMs", lastAccess);
157 await state.storage.setAlarm(manualAlarmTime());
158 });
159 
160 const ran = await runAlarmWithRetry(getStub);
161 expect(ran).toBe(true);
162 
163 await runDOWithRetry(getStub, async (_instance: any, state: DurableObjectState) => {
164 const alarm = await state.storage.getAlarm();
165 expect(typeof alarm).toBe("number");
166 expect(alarm).toBeGreaterThanOrEqual(lastAccess + 50_000);
167 expect(alarm).toBeLessThanOrEqual(lastAccess + 70_000);
168 });
169 });
170});
171 
172it("alarm: re-arms compaction via queue when compactionWantedAt is set", async () => {
173 const repoId = makeRepoId("compact-rearm");
174 const id = env.REPO_DO.idFromName(repoId);
175 const getStub = () => env.REPO_DO.get(id);
176 
177 // Seed a repo so it has refs/head/packs (non-empty, won't idle-purge)
178 await runDOWithRetry(getStub, async (instance) => {
179 await instance.seedMinimalRepo();
180 });
181 
182 // Set compactionWantedAt to signal a pending compaction request
183 await runDOWithRetry(getStub, async (_instance: any, state: DurableObjectState) => {
184 const store = asTypedStorage<RepoStateSchema>(state.storage);
185 await store.put("compactionWantedAt", Date.now());
186 await state.storage.setAlarm(manualAlarmTime());
187 });
188 
189 // Fire the alarm — compaction rearm path should dispatch a queue message
190 // and return before reaching the idle cleanup path
191 const ran = await runAlarmWithRetry(getStub);
192 expect(ran).toBe(true);
193 
194 // compactionWantedAt should still be set (cleared by the queue consumer after
195 // successful compaction, not by the alarm rearm itself)
196 await runDOWithRetry(getStub, async (_instance: any, state: DurableObjectState) => {
197 const store = asTypedStorage<RepoStateSchema>(state.storage);
198 const wantedAt = await store.get("compactionWantedAt");
199 expect(wantedAt).not.toBeUndefined();
200 expect(typeof wantedAt).toBe("number");
201 });
202});