Skip to content

Commit dc03e4d

Browse files
committed
Add a daily cron to sweep expired preview artifacts
Refs self-expire from the indexes (skipped on read, pruned on the next write), but their per-version R2 objects (meta/<name>/<version>.json and the tarball) were never auto-deleted, only on explicit purge, so R2 storage grew with every ref ever published. Add cleanupExpiredArtifacts: it lists the meta/ and tarball/ prefixes and bulk-deletes objects whose uploaded age exceeds REF_TTL_MS. Each object is re-uploaded on republish, so uploaded age tracks the ref's age and the cutoff maps exactly to the ref TTL, an active ref is never touched, and the live indexes (refs/, meta-index/) are under different prefixes and left alone. A Void cron (crons/cleanup-expired.ts, daily) runs it; the generated Worker already dispatches scheduled events, so this composes with the routes/ app entry.
1 parent 5cc5af6 commit dc03e4d

5 files changed

Lines changed: 92 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ are uploaded with `void secret put`:
220220

221221
Bindings/secrets:
222222

223-
- `STORAGE` (R2) - generated tarballs, rewritten metadata (incl. integrity), and the runtime-registered refs index. Auto-provisioned by Void on deploy (no manual bucket creation); the binding is declared in `void.json` (`inference.bindings.storage`). The runtime refs index self-expires after 90 days (in-code TTL).
223+
- `STORAGE` (R2) - generated tarballs, rewritten metadata (incl. integrity), and the runtime-registered refs index. Auto-provisioned by Void on deploy (no manual bucket creation); the binding is declared in `void.json` (`inference.bindings.storage`). The runtime refs index self-expires after 90 days (in-code TTL), and a daily Void cron (`crons/cleanup-expired.ts`) sweeps the per-version meta/tarball objects once their ref TTL lapses, so R2 storage stays bounded to the active-ref window.
224224
- `ADMIN_TOKEN` (secret) - guards the admin endpoints. Set with `void secret put ADMIN_TOKEN`.
225225

226226
## Develop

crons/cleanup-expired.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { defineScheduled } from 'void'
2+
import { cleanupExpiredArtifacts } from '../src/preview/cleanupExpired'
3+
4+
// Daily maintenance. Refs self-expire from the indexes (skipped on read, pruned
5+
// on the next write), but their per-version R2 objects (meta + tarball) would
6+
// otherwise orphan forever. This deletes the ones past the ref TTL so storage
7+
// stays bounded to the active-ref window, "in advance" of any manual purge.
8+
export const cron = '37 3 * * *' // 03:37 UTC daily (off the hour)
9+
10+
export default defineScheduled(async (_controller, env) => {
11+
const { deleted } = await cleanupExpiredArtifacts(env)
12+
console.log(`cleanup-expired: deleted ${deleted} expired preview artifact(s)`)
13+
})

src/preview/cleanupExpired.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { Env } from '../config'
2+
import { REF_TTL_MS } from './getConfiguredRefs'
3+
4+
// R2 key prefixes for the per-version preview artifacts. Must match `metaKey` /
5+
// `tarballKey` in ../cache/r2Cache. NOT `meta-index/` or `refs/`: those are the
6+
// live indexes (self-pruned on write), not per-version orphans.
7+
const ARTIFACT_PREFIXES = ['meta/', 'tarball/']
8+
9+
/**
10+
* Delete per-version preview artifacts (meta + tarball) whose ref TTL has lapsed,
11+
* so R2 storage stays bounded to the active-ref window rather than growing with
12+
* every ref ever published.
13+
*
14+
* A ref's objects are (re-)uploaded on each publish, so an object's `uploaded`
15+
* age equals its ref's age, and the ref TTL maps exactly to
16+
* `uploaded < now - REF_TTL_MS`. An active ref (published within the window) is
17+
* therefore never touched; an expired ref's objects, which are no longer served
18+
* (the packument skips expired refs), are the orphans this removes. `now` is
19+
* injectable for tests.
20+
*/
21+
export async function cleanupExpiredArtifacts(
22+
env: Pick<Env, 'STORAGE'>,
23+
now: number = Date.now(),
24+
): Promise<{ deleted: number }> {
25+
const cutoff = now - REF_TTL_MS
26+
let deleted = 0
27+
for (const prefix of ARTIFACT_PREFIXES) {
28+
let cursor: string | undefined
29+
do {
30+
const listing = await env.STORAGE.list({ prefix, cursor })
31+
const expired = listing.objects
32+
.filter((o) => o.uploaded.getTime() < cutoff)
33+
.map((o) => o.key)
34+
if (expired.length > 0) {
35+
// R2 bulk delete takes up to 1000 keys; a list page is <= 1000.
36+
await env.STORAGE.delete(expired)
37+
deleted += expired.length
38+
}
39+
cursor = listing.truncated ? listing.cursor : undefined
40+
} while (cursor)
41+
}
42+
return { deleted }
43+
}

test/worker.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { SELF, env } from 'cloudflare:test'
22
import { metaIndexKey, metaKey } from '../src/cache/r2Cache'
3+
import { cleanupExpiredArtifacts } from '../src/preview/cleanupExpired'
4+
import { REF_TTL_MS } from '../src/preview/getConfiguredRefs'
35
import {
46
afterAll,
57
beforeAll,
@@ -1094,3 +1096,35 @@ describe('health', () => {
10941096
expect(await res.json()).toEqual({ status: 'ok' })
10951097
})
10961098
})
1099+
1100+
describe('cleanup of expired artifacts (scheduled)', () => {
1101+
it('deletes per-version meta + tarball past the ref TTL', async () => {
1102+
await env.STORAGE.put('meta/vite-plus/0.0.0-commit.old00001.json', '{}')
1103+
await env.STORAGE.put('tarball/vite-plus/0.0.0-commit.old00001.tgz', 'x')
1104+
// A future "now" makes every object older than the TTL, so all are expired.
1105+
const { deleted } = await cleanupExpiredArtifacts(
1106+
env,
1107+
Date.now() + 2 * REF_TTL_MS,
1108+
)
1109+
expect(deleted).toBeGreaterThanOrEqual(2)
1110+
expect(
1111+
await env.STORAGE.get('meta/vite-plus/0.0.0-commit.old00001.json'),
1112+
).toBeNull()
1113+
expect(
1114+
await env.STORAGE.get('tarball/vite-plus/0.0.0-commit.old00001.tgz'),
1115+
).toBeNull()
1116+
})
1117+
1118+
it('keeps artifacts within the TTL and never touches the indexes', async () => {
1119+
await env.STORAGE.put('meta/vite-plus/0.0.0-commit.fresh001.json', '{}')
1120+
// beforeEach published commit.a832a55, so the refs + meta indexes exist.
1121+
const { deleted } = await cleanupExpiredArtifacts(env) // now = Date.now()
1122+
expect(deleted).toBe(0)
1123+
expect(
1124+
await env.STORAGE.get('meta/vite-plus/0.0.0-commit.fresh001.json'),
1125+
).not.toBeNull()
1126+
// The live indexes live under different prefixes and must be untouched.
1127+
expect(await env.STORAGE.get('refs/index.json')).not.toBeNull()
1128+
expect(await env.STORAGE.get(metaIndexKey('vite-plus'))).not.toBeNull()
1129+
})
1130+
})

tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"include": [
2222
"src",
2323
"routes",
24+
"crons",
2425
"env.ts",
2526
"test",
2627
"vite.config.ts",

0 commit comments

Comments
 (0)