File
Blob: test/alarm-cleanup.worker.test.ts
| 1 | import { it, expect } from "vitest"; |
| 2 | import { env } from "cloudflare:workers"; |
| 3 | import { asTypedStorage, type RepoStateSchema } from "@/worker/do/repo/repoState"; |
| 4 | import { runAlarmWithRetry, runDOWithRetry, withEnvOverrides } from "./util/test-helpers"; |
| 5 | |
| 6 | function makeRepoId(suffix: string) { |
| 7 | return `alarm/${suffix}-${Math.random().toString(36).slice(2, 8)}`; |
| 8 | } |
| 9 | |
| 10 | function 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 | |
| 18 | it("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 | |
| 63 | it("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 | |
| 107 | it("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 | |
| 146 | it("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 | |
| 172 | it("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 | }); |