5b76ba01ba36

stop page views waiting on the bucket: list repos with one delimiter listing, read the sync's objects 16 at a time within a byte gate, show the sync's record on /admin and an hourly measurement on /admin/storage; bucket budget 1,010, ceiling 12,710 (v3.0.194)

time
2026-10-03 00:56 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
lightning quick kepr
parent
0c253117d56c
15 files package.json+1 −1scripts/budget.ts+3 −3src/admin/console.ts+26 −19src/admin/rename.ts+3 −9src/admin/storage.ts+158 −43src/bucket/reader.ts+39 −0src/bucket/repo.ts+26 −13src/bucket/sync.ts+111 −49src/main.ts+4 −6src/server/db.ts+6 −0tests/admin.test.ts+12 −2tests/bucket.test.ts+23 −3tests/fake-s3.ts+13 −2tests/storage.test.ts+20 −0tests/sync.test.ts+74 −2
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.193",3+	"version": "3.0.194",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

scripts/budget.ts+3 −3
@@ -1,10 +1,10 @@11 // §26: fails when any area passes its budget or the TypeScript total passes the2-// 12,600-line ceiling.3-const CEILING = 12_600;2+// 12,710-line ceiling.3+const CEILING = 12_710;44 55 // Prefixes are matched in order, so the specific files come before the directories.66 const TS_AREAS: [string, string[], number][] = [7-	["bucket", ["src/bucket/"], 900],7+	["bucket", ["src/bucket/"], 1010],88 	["mirror", ["src/mirror/"], 1100],99 	["describe", ["src/describe/"], 500],1010 	["pages", ["src/pages/"], 2600],

view file

src/admin/console.ts+26 −19
@@ -1,5 +1,4 @@11 import type { Context, Hono } from "hono";2-import { checkFormat, listRepos } from "../bucket/repo.ts";32 import type { SyncTarget } from "../bucket/sync.ts";43 import { operatorIssues } from "../issues/store.ts";54 import { type Html, html } from "../pages/html.ts";@@ -132,28 +131,36 @@132131 <p class="muted">To revoke access, rotate the write key in Scaleway and update it under <a href="/admin/bucket">bucket</a>.</p>`;133132 }134133 134+/**135+ * The bucket as the sync last read it, and each repo's format and sync result: the sync checks the136+ * format of every repo on every pass, so the console never waits on the bucket itself.137+ */138+function bucketStatus(ctx: AdminContext): Html {139+	const error = setting(ctx.kepr, "bucket_error");140+	if (error) {141+		return html`<p class="issues-error" role="alert">Bucket unreachable at the last sync: ${error}</p>`;142+	}143+	const readAt = Number(setting(ctx.kepr, "bucket_read_at") ?? 0);144+	if (readAt === 0) return html`<p class="muted">The sync has not read the bucket yet.</p>`;145+	// The repos that listing found; Kepr keeps the rows of repos gone from the bucket.146+	const seen = JSON.parse(setting(ctx.kepr, "bucket_repos") ?? "[]") as string[];147+	const errors = new Map(allRepos(ctx.kepr).map((repo) => [repo.name, repo.last_sync_error]));148+	return html`<p>Bucket read by the sync ${relativeTime(readAt)}: ${plural(seen.length, "repo")}.</p>149+<ul>${seen.map((name) => {150+		const failure = errors.get(name);151+		return html`<li><span class="mono">${name}</span> ${152+			failure153+				? html`<span class="issues-error">${failure}</span>`154+				: html`<span class="muted">format ok</span>`155+		}</li>`;156+	})}</ul>`;157+}158+135159 /** §24's Status section at `/admin`: the bucket, each repo's format, backups, the running version. */136160 export function registerStatus(app: Hono, ctx: AdminContext): void {137161 	app.get("/admin", async (c) => {138162 		if (c.req.query("section") === "backups") return c.html(await backupsPage(c, ctx));139-		let bucket: Html;140-		try {141-			const repos = await listRepos(ctx.sync.bucket);142-			const formats = await Promise.all(143-				repos.map(async (repo) => {144-					try {145-						await checkFormat(ctx.sync.bucket, repo);146-						return html`<li><span class="mono">${repo}</span> <span class="muted">format ok</span></li>`;147-					} catch (error) {148-						return html`<li><span class="mono">${repo}</span>149-<span class="issues-error">${String(error)}</span></li>`;150-					}151-				}),152-			);153-			bucket = html`<p>Bucket reachable: ${plural(repos.length, "repo")}.</p><ul>${formats}</ul>`;154-		} catch (error) {155-			bucket = html`<p class="issues-error" role="alert">Bucket unreachable: ${String(error)}</p>`;156-		}163+		const bucket = bucketStatus(ctx);157164 		const { version } = (await Bun.file(`${import.meta.dir}/../../package.json`).json()) as {158165 			version: string;159166 		};

view file

src/admin/rename.ts+3 −9
@@ -1,6 +1,6 @@11 import { existsSync, renameSync } from "node:fs";22 import { join } from "node:path";3-import { type Bucket, KEPR_PREFIX } from "../bucket/reader.ts";3+import { type Bucket, inTurn, KEPR_PREFIX } from "../bucket/reader.ts";44 import { holdSync, type SyncTarget } from "../bucket/sync.ts";55 import { closeMirror } from "../mirror/store.ts";66 import { RESERVED_NAMES } from "../server/reserved.ts";@@ -63,7 +63,7 @@6363 		const keys = (await bucket.list(`${from}/`)).filter(6464 			(key) => key !== `${from}/head` && !(IMMUTABLE.test(key) && copied.has(key)),6565 		);66-		await inTurn(keys, (key) => bucket.copy(key, `${to}/${key.slice(from.length + 1)}`));66+		await inTurn(keys, AT_ONCE, (key) => bucket.copy(key, `${to}/${key.slice(from.length + 1)}`));6767 		for (const key of keys) copied.add(key);6868 		await bucket.copy(`${from}/head`, `${to}/head`);6969 		head = now;@@ -85,16 +85,10 @@8585 		const path = join(mirrorDir, `${from}.db${suffix}`);8686 		if (existsSync(path)) renameSync(path, join(mirrorDir, `${to}.db${suffix}`));8787 	}88-	await inTurn(await bucket.list(`${from}/`), (key) => bucket.removeRenamed(key));88+	await inTurn(await bucket.list(`${from}/`), AT_ONCE, (key) => bucket.removeRenamed(key));8989 }9090 9191 async function text(bucket: Bucket, key: string): Promise<string | null> {9292 	const bytes = await bucket.get(key);9393 	return bytes && new TextDecoder().decode(bytes).trim();94-}95-96-async function inTurn(keys: string[], each: (key: string) => Promise<void>): Promise<void> {97-	for (let i = 0; i < keys.length; i += AT_ONCE) {98-		await Promise.all(keys.slice(i, i + AT_ONCE).map(each));99-	}10094 }

view file

src/admin/storage.ts+158 −43
@@ -1,48 +1,85 @@11 import type { Context, Hono } from "hono";22 import { BucketWriteError, KEPR_PREFIX } from "../bucket/reader.ts";33 import { listRepos } from "../bucket/repo.ts";4+import { holdSync, type SyncTarget } from "../bucket/sync.ts";45 import { tarballGeneration } from "../mirror/queries.ts";56 import { openMirror } from "../mirror/store.ts";67 import { html } from "../pages/html.ts";7-import { formatSize, plural } from "../pages/layout.ts";8+import { formatSize, plural, relativeTime } from "../pages/layout.ts";9+import { keepState, setting } from "../server/db.ts";810 import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";11+import { allRepos } from "./store.ts";912 1013 const TARBALL = /^\.kepr\/tarballs\/([^/]+)\/([^/]+)\/([0-9a-f]{6}|[0-9a-f]{12})\.tar\.gz$/;1114 const HIDDEN = /^\.kepr\/hidden\/([^/]+)$/;1215 1316 type RepoUse = { history: number; builds: number; tarballs: number };1417 type Tarball = { key: string; repo: string; save: string; size: number };18+type Survey = {19+	/** When it was measured, in Unix seconds. */20+	at: number;21+	use: [repo: string, use: RepoUse][];22+	kept: Tarball[];23+	orphans: [key: string, size: number][];24+	kepr: number;25+	total: number;26+};1527 28+// A full listing of the bucket takes seconds (3.2 s for 22k keys on Tylee, 2026-10-02), so it is29+// measured in the background and the page shows the last measurement.30+const MEASURE_MS = 60 * 60 * 1000;31+32+// Measuring and deleting take turns, so neither stores a survey the other has made stale.33+let turn: Promise<unknown> = Promise.resolve();34+function inStorageTurn<T>(work: () => Promise<T>): Promise<T> {35+	const mine = turn.then(work);36+	turn = mine.catch(() => {});37+	return mine;38+}39+40+export function startStorageLoop(target: SyncTarget): void {41+	const tick = () =>42+		measureStorage(target).catch((error) =>43+			console.error(`kepr: measuring storage failed: ${error}`),44+		);45+	tick();46+	setInterval(tick, MEASURE_MS);47+}48+1649 /**17- * The bucket sorted into what §24's Storage section shows. Orphans are only Kepr's own files that18- * nothing reads again; Koh's never are, since a build's files land before its record (§11.2).50+ * Sorts the bucket into what §24's Storage section shows, and keeps it for the page. Orphans are51+ * only Kepr's own files that nothing reads again; Koh's never are, since a build's files land52+ * before its record (§11.2).1953  */20-async function survey(ctx: AdminContext) {21-	const sizes = await ctx.sync.bucket.sizes("");22-	const repos = await listRepos(ctx.sync.bucket);23-	const generations = new Map(24-		repos.map((repo) => [repo, tarballGeneration(openMirror(ctx.mirrorDir, repo))]),25-	);54+export function measureStorage(target: SyncTarget): Promise<void> {55+	return inStorageTurn(() => measure(target));56+}57+58+async function measure(target: SyncTarget): Promise<void> {59+	const sizes = await target.bucket.sizes("");60+	const repos = await listRepos(target.bucket);61+	const isOrphan = orphanTest(target, repos);2662 	const use = new Map<string, RepoUse>(2763 		repos.map((repo) => [repo, { history: 0, builds: 0, tarballs: 0 }]),2864 	);29-	const kept: Tarball[] = [];30-	const orphans: string[] = [];31-	let kepr = 0;32-	let total = 0;65+	const survey: Survey = {66+		at: Math.floor(Date.now() / 1000),67+		use: [],68+		kept: [],69+		orphans: [],70+		kepr: 0,71+		total: 0,72+	};3373 	for (const [key, size] of sizes) {34-		total += size;74+		survey.total += size;3575 		const tarball = TARBALL.exec(key);36-		const hidden = HIDDEN.exec(key);37-		if (tarball) {38-			const [, repo = "", generation, save = ""] = tarball;39-			const owner = use.get(repo);40-			if (owner && generations.get(repo) === generation) {41-				owner.tarballs += size;42-				kept.push({ key, repo, save, size });43-			} else orphans.push(key);44-		} else if (hidden && !use.has(hidden[1] ?? "")) orphans.push(key);45-		else if (key.startsWith(KEPR_PREFIX)) kepr += size;76+		if (isOrphan(key)) survey.orphans.push([key, size]);77+		else if (tarball) {78+			const [, repo = "", , save = ""] = tarball;79+			// Not an orphan, so its repo is in the bucket and in `use`.80+			(use.get(repo) as RepoUse).tarballs += size;81+			survey.kept.push({ key, repo, save, size });82+		} else if (key.startsWith(KEPR_PREFIX)) survey.kepr += size;4683 		else {4784 			const [repo = "", kind] = key.split("/");4885 			const owner = use.get(repo);@@ -50,20 +87,101 @@5087 			else if (owner) owner.history += size;5188 		}5289 	}53-	const orphanBytes = orphans.reduce((sum, key) => sum + (sizes.get(key) ?? 0), 0);54-	return { use, kept, orphans, orphanBytes, kepr, total };90+	survey.use = [...use];91+	keepState(target.kepr, "storage", JSON.stringify(survey));5592 }5693 57-async function storagePage(c: Context, ctx: AdminContext, error: string) {58-	const { use, kept, orphans, orphanBytes, kepr, total } = await survey(ctx);94+/**95+ * Whether a key is an orphan given the repos now in the bucket: a tarball of an older generation,96+ * or Kepr's file of a repo no longer there. Only mirrors of repos Kepr has registered are opened, as97+ * pages do: a rename's new name is registered only once its mirror has moved into place.98+ */99+function orphanTest(target: SyncTarget, repos: string[]): (key: string) => boolean {100+	const registered = new Set(allRepos(target.kepr).map((repo) => repo.name));101+	const generations = new Map(102+		repos103+			.filter((repo) => registered.has(repo))104+			.map((repo) => [repo, tarballGeneration(openMirror(target.mirrorDir, repo))]),105+	);106+	return (key) => {107+		const tarball = TARBALL.exec(key);108+		const [, repo = "", generation] = tarball ?? HIDDEN.exec(key) ?? [];109+		if (!repos.includes(repo)) return tarball !== null || HIDDEN.test(key);110+		// A repo not registered yet has no tarballs of its own making; its keys are left alone.111+		return tarball !== null && generations.has(repo) && generations.get(repo) !== generation;112+	};113+}114+115+function storedSurvey(ctx: AdminContext): Survey | null {116+	const stored = setting(ctx.kepr, "storage");117+	return stored ? (JSON.parse(stored) as Survey) : null;118+}119+120+/** The survey without keys just deleted, so the page never lists what is gone. */121+function withoutKeys(survey: Survey, gone: Set<string>): Survey {122+	const sizeOf = new Map([...survey.orphans, ...survey.kept.map((t) => [t.key, t.size] as const)]);123+	const freed = [...gone].reduce((sum, key) => sum + (sizeOf.get(key) ?? 0), 0);124+	const use = survey.use.map(([repo, u]): [string, RepoUse] => {125+		const dropped = survey.kept.filter((t) => t.repo === repo && gone.has(t.key));126+		return [repo, { ...u, tarballs: u.tarballs - dropped.reduce((sum, t) => sum + t.size, 0) }];127+	});128+	return {129+		...survey,130+		use,131+		kept: survey.kept.filter((t) => !gone.has(t.key)),132+		orphans: survey.orphans.filter(([key]) => !gone.has(key)),133+		total: survey.total - freed,134+	};135+}136+137+/**138+ * Deletes the orphans, or one kept tarball, from the stored survey; returns "unknown" for a key it139+ * doesn't list, the bucket's refusal, or "" when done.140+ */141+async function deleteKeys(ctx: AdminContext, fields: Record<string, string>): Promise<string> {142+	const survey = storedSurvey(ctx);143+	if (!survey) return "unknown";144+	let doomed: string[];145+	if (fields.action === "delete_orphans") {146+		// Checked again now: an older generation can become current again, as unhiding does.147+		const isOrphan = orphanTest(ctx.sync, await listRepos(ctx.sync.bucket));148+		doomed = survey.orphans.map(([key]) => key).filter(isOrphan);149+	} else {150+		doomed = survey.kept.filter((t) => t.key === fields.key).map((t) => t.key);151+		if (doomed.length === 0) return "unknown";152+	}153+	const gone = new Set<string>();154+	try {155+		for (const key of doomed) {156+			await ctx.sync.bucket.remove(key);157+			gone.add(key);158+		}159+	} catch (error) {160+		if (!(error instanceof BucketWriteError)) throw error;161+		return error.message;162+	} finally {163+		keepState(ctx.kepr, "storage", JSON.stringify(withoutKeys(survey, gone)));164+	}165+	return "";166+}167+168+function storagePage(c: Context, ctx: AdminContext, error: string) {169+	const survey = storedSurvey(ctx);170+	if (!survey) {171+		const body = html`${notice(error)}172+<p class="admin-intro">Not measured yet: Kepr measures the bucket when it starts and every hour.</p>`;173+		return adminPage(c, ctx, "storage", body);174+	}175+	const { use, kept, orphans, kepr, total } = survey;176+	const orphanBytes = orphans.reduce((sum, [, size]) => sum + size, 0);59177 	const body = html`${notice(error)}60178 <p class="admin-intro">The bucket holds ${formatSize(total)}; Kepr's own folder ${formatSize(kepr)}61-besides its kept tarballs.</p>179+besides its kept tarballs. Measured ${relativeTime(survey.at)}; Kepr measures it every hour.</p>62180 <section class="repo-section"><h2 class="section-label mono">by repository</h2>63181 <table class="admin-table mono">64182 <thead><tr><th>repo</th><th>history</th><th>builds</th><th>kept tarballs</th></tr></thead>65183 <tbody>66-${[...use].map(184+${use.map(67185 	([repo, u]) => html`<tr>68186 <td>${repo}</td>69187 <td>${formatSize(u.history)}</td>@@ -99,22 +217,19 @@99217 100218 /** §24's Storage section: sizes, kept tarballs and orphan cleanup. */101219 export function registerStorage(app: Hono, ctx: AdminContext): void {102-	app.get("/admin/storage", async (c) => c.html(await storagePage(c, ctx, "")));220+	app.get("/admin/storage", (c) => c.html(storagePage(c, ctx, "")));103221 104222 	app.post("/admin/storage", async (c) => {105223 		const fields = await textFields(c);106-		const { kept, orphans } = await survey(ctx);107-		const doomed =108-			fields.action === "delete_orphans"109-				? orphans110-				: kept.filter((t) => t.key === fields.key).map((t) => t.key);111-		if (doomed.length === 0 && fields.action !== "delete_orphans") return c.notFound();112-		try {113-			for (const key of doomed) await ctx.sync.bucket.remove(key);114-		} catch (error) {115-			if (!(error instanceof BucketWriteError)) throw error;116-			return c.html(await storagePage(c, ctx, error.message), 503);117-		}224+		// While no sync or rename runs: a rename can make a listed orphan live again mid-delete.225+		let outcome = "";226+		await holdSync(() =>227+			inStorageTurn(async () => {228+				outcome = await deleteKeys(ctx, fields);229+			}),230+		);231+		if (outcome === "unknown") return c.notFound();232+		if (outcome) return c.html(storagePage(c, ctx, outcome), 503);118233 		return c.redirect("/admin/storage?saved=1", 303);119234 	});120235 }

view file

src/bucket/reader.ts+39 −0
@@ -14,6 +14,10 @@1414 export type Bucket = {1515 	get(key: string): Promise<Uint8Array | null>;1616 	list(prefix: string): Promise<string[]>;17+	/** The top-level folders' names: one listing, however many keys they hold. */18+	folders(): Promise<string[]>;19+	/** A key's size in bytes, asked without reading it. */20+	size(key: string): Promise<number>;1721 	/** Every key under `prefix` with its size in bytes, for the console's storage view. */1822 	sizes(prefix: string): Promise<Map<string, number>>;1923 	/** A URL that serves `key` for five minutes, as an attachment with this filename and content type. */@@ -35,6 +39,19 @@3539 3640 export const KEPR_PREFIX = ".kepr/";3741 42+/** `each` over `items`, `atOnce` of them in flight at a time; the results in order. */43+export async function inTurn<T, R>(44+	items: T[],45+	atOnce: number,46+	each: (item: T) => Promise<R>,47+): Promise<R[]> {48+	const results: R[] = [];49+	for (let i = 0; i < items.length; i += atOnce) {50+		results.push(...(await Promise.all(items.slice(i, i + atOnce).map(each))));51+	}52+	return results;53+}54+3855 /** A write Kepr can't make: outside its folder, or without the `[bucket.write]` key. */3956 export class BucketWriteError extends Error {}4057 @@ -131,6 +148,17 @@131148 			} while (continuationToken);132149 			return keys;133150 		},151+		size: (key) => client.size(key),152+		async folders() {153+			const names: string[] = [];154+			let continuationToken: string | undefined;155+			do {156+				const page = await client.list({ delimiter: "/", continuationToken });157+				for (const { prefix } of page.commonPrefixes ?? []) names.push(prefix.slice(0, -1));158+				continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;159+			} while (continuationToken);160+			return names;161+		},134162 		async sizes(prefix) {135163 			const sizes = new Map<string, number>();136164 			let continuationToken: string | undefined;@@ -191,6 +219,17 @@191219 			return (await file.exists()) ? await file.bytes() : null;192220 		},193221 		list,222+		async size(key) {223+			if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);224+			return Bun.file(join(root, key)).size;225+		},226+		async folders() {227+			const entries = await readdir(root, { withFileTypes: true });228+			return entries229+				.filter((entry) => entry.isDirectory())230+				.map((entry) => entry.name)231+				.sort();232+		},194233 		async sizes(prefix) {195234 			return new Map((await list(prefix)).map((key) => [key, Bun.file(join(root, key)).size]));196235 		},

view file

src/bucket/repo.ts+26 −13
@@ -67,8 +67,8 @@6767 /** A save record as it arrived, less the files §5.5 quarantines, named here escaped. */6868 export type ArrivingSave = SaveRecord & { quarantined: string[] };6969 70-/** An object's stored zlib bytes and the inflated bytes they were verified from. */71-export type VerifiedObject = { compressed: Uint8Array; bytes: Uint8Array };70+/** An object's stored zlib bytes, verified against its hash, and its inflated size. */71+export type VerifiedObject = { compressed: Uint8Array; size: number };7272 7373 export class BucketError extends Error {}7474 @@ -95,13 +95,14 @@9595 const JSON_ESCAPE =9696 	/\\u[dD][89abAB][0-9a-fA-F]{2}\\u[dD][c-fC-F][0-9a-fA-F]{2}|\\u[dD][89a-fA-F][0-9a-fA-F]{2}|\\[\s\S]/g;9797 98-// ponytail: one full listing per call; switch to a delimiter listing if the bucket grows large.98+/**99+ * Every repo in the bucket: a top-level folder with a `head`. A folder without one is not a repo100+ * yet, as when a first offer or a rename (which copies `head` last) is still under way.101+ */99102 export async function listRepos(bucket: Bucket): Promise<string[]> {100-	const keys = await bucket.list("");101-	return keys102-		.filter((key) => key.endsWith("/head") && key.split("/").length === 2)103-		.map((key) => key.slice(0, -"/head".length))104-		.filter(isRepoName);103+	const names = (await bucket.folders()).filter(isRepoName);104+	const heads = await Promise.all(names.map((name) => bucket.get(`${name}/head`)));105+	return names.filter((_, i) => heads[i] !== null);105106 }106107 107108 function isRepoName(name: string): boolean {@@ -177,15 +178,27 @@177178 	);178179 }179180 180-/** Fetches an object, inflates it and checks its Blake3 against its key, as steal does (§9.4). */181+function objectKey(repo: string, hash: string): string {182+	requireRepo(repo);183+	if (!HEX64.test(hash)) throw new BucketError(`not an object hash: "${hash}"`);184+	return `${repo}/objects/${hash}`;185+}186+187+/** An object's stored, compressed size, asked without reading it. */188+export function objectSize(bucket: Bucket, repo: string, hash: string): Promise<number> {189+	return bucket.size(objectKey(repo, hash));190+}191+192+/**193+ * Fetches an object, inflates it and checks its Blake3 against its key, as steal does (§9.4). The194+ * inflated bytes never leave this call, so reads in parallel hold one inflated object at a time.195+ */181196 export async function readObject(182197 	bucket: Bucket,183198 	repo: string,184199 	hash: string,185200 ): Promise<VerifiedObject> {186-	requireRepo(repo);187-	if (!HEX64.test(hash)) throw new BucketError(`not an object hash: "${hash}"`);188-	const compressed = await bucket.get(`${repo}/objects/${hash}`);201+	const compressed = await bucket.get(objectKey(repo, hash));189202 	if (compressed === null) throw new BucketError(`${repo}/objects/${hash} is missing`);190203 	let bytes: Uint8Array;191204 	try {@@ -199,7 +212,7 @@199212 	if (bytesToHex(blake3(bytes)) !== hash) {200213 		throw new BucketError(`${repo}/objects/${hash} does not match its hash`);201214 	}202-	return { compressed, bytes };215+	return { compressed, size: bytes.length };203216 }204217 205218 export async function listEventIds(bucket: Bucket, repo: string): Promise<string[]> {

view file

src/bucket/sync.ts+111 −49
@@ -14,13 +14,14 @@1414 import {1515 	hiddenPaths,1616 	hiddenToHand,17+	keepState,1718 	recordHiddenHanded,1819 	recordSync,1920 	registerRepo,2021 } from "../server/db.ts";2122 import { RESERVED_NAMES } from "../server/reserved.ts";2223 import { dropTarballs } from "../tarball/cache.ts";23-import { type Bucket, BucketWriteError, KEPR_PREFIX } from "./reader.ts";24+import { type Bucket, BucketWriteError, inTurn, KEPR_PREFIX } from "./reader.ts";2425 import {2526 	BucketError,2627 	checkFormat,@@ -31,6 +32,7 @@3132 	listReleaseVersions,3233 	listRepos,3334 	OversizedObject,35+	objectSize,3436 	type ReleaseRecord,3537 	readEvent,3638 	readHead,@@ -45,6 +47,11 @@4547 // Compressed bytes a sync holds before landing them; sized for a 2 GB host.4648 const LANDING_BYTES = 32 * 1024 * 1024;4749 const LANDING_SAVES = 100;50+// Bucket reads in flight at once. Each waits about 170 ms on Tylee's round trip to the bucket51+// (measured 2026-10-02), so reading one at a time left the line idle.52+const READS_AT_ONCE = 16;53+// Compressed object bytes being read at once; a larger object waits and is read alone.54+const READ_BYTES = 32 * 1024 * 1024;4855 4956 export type SyncTarget = {5057 	bucket: Bucket;@@ -82,7 +89,18 @@8289 }8390 8491 async function syncEach(target: SyncTarget): Promise<void> {85-	for (const repo of await listRepos(target.bucket)) {92+	let repos: string[];93+	try {94+		repos = await listRepos(target.bucket);95+	} catch (error) {96+		keepState(target.kepr, "bucket_error", String(error));97+		throw error;98+	}99+	// What the console's status shows, so it never asks the bucket itself.100+	keepState(target.kepr, "bucket_error", null);101+	keepState(target.kepr, "bucket_read_at", String(Math.floor(Date.now() / 1000)));102+	keepState(target.kepr, "bucket_repos", JSON.stringify(repos));103+	for (const repo of repos) {86104 		registerRepo(target.kepr, repo);87105 		let error: string | null = null;88106 		if (RESERVED_NAMES.has(repo)) {@@ -147,6 +165,7 @@147165 	repo: string,148166 	hidden: string[] = [],149167 	landingBytes = LANDING_BYTES,168+	readBytes = READ_BYTES,150169 ): Promise<EventRecord[]> {151170 	applyHidden(mirror, hidden);152171 	await checkFormat(bucket, repo);@@ -182,54 +201,80 @@182201 		quarantined = [];183202 		fetched = 0;184203 	};185-	for (const id of [...unknown].reverse()) {186-		const save = await readSave(bucket, repo, id);187-		quarantined.push(...save.quarantined);188-		// One save can hold more than a batch, as a first import does: its objects are stored ahead189-		// of it, after the saves before it have landed, so no landing's cleanup sees them unreferenced.190-		const ahead = new Map<string, StoredObject>();191-		for (const file of save.files) {192-			const { hash } = file;193-			if (194-				objects.has(hash) ||195-				ahead.has(hash) ||196-				oversized.has(hash) ||197-				knownObject(mirror, hash)198-			) {199-				continue;204+	const gate = byteGate(readBytes);205+	let failure: unknown = null;206+	// Reads one object into `ahead`. A read the gate admits after the batch filled, or after another207+	// read failed, puts its hash back for the next batch instead.208+	const readInto = async (ahead: Map<string, StoredObject>, wanted: string[], hash: string) => {209+		const size = await objectSize(bucket, repo, hash);210+		await gate.enter(size);211+		try {212+			if (failure !== null || fetched >= landingBytes) {213+				wanted.unshift(hash);214+				return;200215 			}201-			try {202-				const { compressed, bytes } = await readObject(bucket, repo, hash);203-				ahead.set(hash, { compressed, size: bytes.length });204-				fetched += compressed.length;205-			} catch (cause) {206-				if (!(cause instanceof OversizedObject)) throw cause;207-				oversized.add(hash);208-			}209-			if (fetched >= landingBytes) {210-				const last = saves.at(-1);211-				if (last) landBatch(last.id);212-				storeObjects(mirror, ahead);213-				ahead.clear();214-				fetched = 0;215-			}216+			const object = await readObject(bucket, repo, hash);217+			ahead.set(hash, object);218+			fetched += object.compressed.length;219+		} catch (cause) {220+			if (!(cause instanceof OversizedObject)) throw cause;221+			oversized.add(hash);222+		} finally {223+			gate.leave(size);216224 		}217-		for (const [hash, object] of ahead) objects.set(hash, object);218-		quarantined.push(219-			...save.files.filter((f) => oversized.has(f.hash)).map((f) => escapePath(f.path)),225+	};226+	const ids = [...unknown].reverse();227+	for (let start = 0; start < ids.length; start += READS_AT_ONCE) {228+		const records = await inTurn(ids.slice(start, start + READS_AT_ONCE), READS_AT_ONCE, (id) =>229+			readSave(bucket, repo, id),220230 		);221-		save.files = save.files.filter((file) => !oversized.has(file.hash));222-		saves.push(save);223-		if (saves.length >= LANDING_SAVES && save.id !== head) landBatch(save.id);231+		for (const save of records) {232+			quarantined.push(...save.quarantined);233+			// One save can hold more than a batch, as a first import does: its objects are stored234+			// ahead of it, after the saves before it have landed, so no landing's cleanup sees them235+			// unreferenced.236+			const ahead = new Map<string, StoredObject>();237+			const wanted = [...new Set(save.files.map((file) => file.hash))].filter(238+				(hash) => !objects.has(hash) && !oversized.has(hash) && !knownObject(mirror, hash),239+			);240+			while (wanted.length > 0) {241+				// No read starts once the batch is full, so a batch passes `landingBytes` by at most242+				// the reads under way, which the gate holds to `readBytes` or one larger object.243+				const reader = async () => {244+					while (failure === null && fetched < landingBytes && wanted.length > 0) {245+						await readInto(ahead, wanted, wanted.shift() as string).catch((cause) => {246+							failure ??= cause;247+						});248+					}249+				};250+				// Every reader settles before a failure is reported, so none outlives this sync.251+				await Promise.all(Array.from({ length: READS_AT_ONCE }, reader));252+				if (failure !== null) throw failure;253+				if (fetched >= landingBytes) {254+					const last = saves.at(-1);255+					if (last) landBatch(last.id);256+					storeObjects(mirror, ahead);257+					ahead.clear();258+					fetched = 0;259+				}260+			}261+			for (const [hash, object] of ahead) objects.set(hash, object);262+			quarantined.push(263+				...save.files.filter((f) => oversized.has(f.hash)).map((f) => escapePath(f.path)),264+			);265+			save.files = save.files.filter((file) => !oversized.has(file.hash));266+			saves.push(save);267+			if (saves.length >= LANDING_SAVES && save.id !== head) landBatch(save.id);268+		}224269 	}225270 	const known = knownEventIds(mirror);226-	const events: EventRecord[] = [];227-	for (const id of await listEventIds(bucket, repo)) {228-		if (known.has(id)) continue;229-		const event = await readEvent(bucket, repo, id);271+	const newEvents = (await listEventIds(bucket, repo)).filter((id) => !known.has(id));272+	const events = (273+		await inTurn(newEvents, READS_AT_ONCE, (id) => readEvent(bucket, repo, id))274+	).filter(230275 		// Koh ignores it too: no save can hold an illegal path (§5.5).231-		if (event.kind !== "strip" || isSafePath(event.path)) events.push(event);232-	}276+		(event) => event.kind !== "strip" || isSafePath(event.path),277+	);233278 	const releasesLatest = (await readReleasesLatest(bucket, repo)) ?? "";234279 	const moved =235280 		mirrorState(mirror, "head") !== head ||@@ -248,15 +293,32 @@248293 	return events;249294 }250295 296+/** Admits reads while their bytes in flight stay within `limit`; a larger one waits to go alone. */297+function byteGate(limit: number) {298+	let inFlight = 0;299+	let waiting: (() => void)[] = [];300+	return {301+		async enter(size: number): Promise<void> {302+			while (inFlight > 0 && inFlight + size > limit) {303+				await new Promise<void>((resolve) => waiting.push(resolve));304+			}305+			inFlight += size;306+		},307+		leave(size: number): void {308+			inFlight -= size;309+			const woken = waiting;310+			waiting = [];311+			for (const wake of woken) wake();312+		},313+	};314+}315+251316 async function newReleases(252317 	bucket: Bucket,253318 	mirror: Database,254319 	repo: string,255320 ): Promise<ReleaseRecord[]> {256321 	const known = knownReleases(mirror);257-	const releases: ReleaseRecord[] = [];258-	for (const version of await listReleaseVersions(bucket, repo)) {259-		if (!known.has(version)) releases.push(await readRelease(bucket, repo, version));260-	}261-	return releases;322+	const versions = (await listReleaseVersions(bucket, repo)).filter((v) => !known.has(v));323+	return inTurn(versions, READS_AT_ONCE, (version) => readRelease(bucket, repo, version));262324 }

view file

src/main.ts+4 −6
@@ -1,6 +1,7 @@11 import { join } from "node:path";22 import type { Hono } from "hono";33 import { createSetupApp } from "./admin/setup.ts";4+import { startStorageLoop } from "./admin/storage.ts";45 import { openBucket } from "./bucket/reader.ts";56 import { startSyncLoop } from "./bucket/sync.ts";67 import { warmAtlas } from "./pages/atlas.ts";@@ -23,12 +24,9 @@2324 	startMailLoop(kepr, config);2425 	const mirrorDir = join(config.dataDir, "mirror");2526 	// The first visitor after a new save would otherwise wait a second or two on a big repo.26-	startSyncLoop({27-		bucket,28-		kepr,29-		mirrorDir,30-		synced: (repo) => warmAtlas({ kepr, mirrorDir }, repo),31-	});27+	const sync = { bucket, kepr, mirrorDir };28+	startSyncLoop({ ...sync, synced: (repo) => warmAtlas({ kepr, mirrorDir }, repo) });29+	startStorageLoop(sync);3230 	return createApp(config, kepr, bucket).fetch;3331 }3432 

view file

src/server/db.ts+6 −0
@@ -317,6 +317,12 @@317317 	return [...paths, ...ignoredNames(db).map((name) => `${ANY_DEPTH}${name}`)];318318 }319319 320+/** Keeps a piece of Kepr's own state in settings, never shown as a setting; null removes it. */321+export function keepState(db: Database, key: string, value: string | null): void {322+	if (value === null) db.query("DELETE FROM settings WHERE key = ?").run(key);323+	else db.query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(key, value);324+}325+320326 /** A repo's hidden paths unless they are known to be the list last handed to Koh, else null. */321327 export function hiddenToHand(db: Database, repo: string): string | null {322328 	const row = db

view file

tests/admin.test.ts+12 −2
@@ -68,14 +68,24 @@6868 		expect(admin.status).toBe(200);6969 		expect(admin.headers.get("cache-control")).toBe("no-store");7070 		const text = await admin.text();71-		expect(text).toContain("Bucket reachable: 1 repo.");71+		// As the sync recorded it: the console never waits on the bucket.72+		expect(text).toMatch(/Bucket read by the sync (just now|\d+ \w+ ago): 1 repo\./);7273 		expect(text).toContain("format ok");74+		// A repo gone from the bucket keeps its row, but the last listing didn't find it.75+		inst.kepr.query("INSERT INTO repos (name) VALUES ('gone')").run();76+		expect(await (await browser.get("/admin")).text()).toMatch(/: 1 repo\./);77+		inst.kepr.query("DELETE FROM repos WHERE name = 'gone'").run();78+		inst.kepr.query("INSERT INTO settings (key, value) VALUES ('bucket_error', 'timed out')").run();79+		expect(await (await browser.get("/admin")).text()).toContain(80+			"Bucket unreachable at the last sync: timed out",81+		);82+		inst.kepr.query("DELETE FROM settings WHERE key = 'bucket_error'").run();7383 		expect(text).toMatch(/Running kepr 3\.0\.\d+: [\d,]+ lines of TypeScript/);7484 		expect(text).toContain('href="/admin?section=backups"');7585 		const backupPage = await (await browser.get("/admin?section=backups")).text();7686 		expect(backupPage).toContain("<h1>backups</h1>");7787 		expect(backupPage).toContain("Sessions and bucket credentials are not included");78-		expect(backupPage).not.toContain("Bucket reachable:");88+		expect(backupPage).not.toContain("Bucket read by the sync");7989 	});8090 8191 	test("a wrong password, a foreign origin or a missing sign-in cookie is refused", async () => {

view file

tests/bucket.test.ts+23 −3
@@ -1,6 +1,7 @@11 import { beforeAll, describe, expect, test } from "bun:test";2-import { writeFile } from "node:fs/promises";2+import { cp, writeFile } from "node:fs/promises";33 import { join } from "node:path";4+import { inflateSync } from "node:zlib";45 import { openBucket } from "../src/bucket/reader.ts";56 import {67 	checkFormat,@@ -26,6 +27,15 @@2627 		expect(await listRepos(bucket())).toEqual([fx.repo]);2728 	});2829 30+	test("a folder without a head, or Kepr's own, is not a repo", async () => {31+		// As a rename leaves the new name until it copies `head`, last.32+		await cp(join(fx.bucketDir, fx.repo, "saves"), join(fx.bucketDir, "half", "saves"), {33+			recursive: true,34+		});35+		await Bun.write(join(fx.bucketDir, ".kepr/hidden", fx.repo), "secret.txt\n");36+		expect(await listRepos(bucket())).toEqual([fx.repo]);37+	});38+2939 	test("reads format, head, the save record and one verified object end to end", async () => {3040 		const b = bucket();3141 		await checkFormat(b, fx.repo);@@ -41,8 +51,10 @@4151 		expect([first.agent, first.session]).toEqual(["test-agent", "fixture"]);4252 4353 		const main = save.files.find((file) => file.path === "src/main.ts");44-		const { bytes } = await readObject(b, fx.repo, main?.hash as string);45-		expect(new TextDecoder().decode(bytes)).toBe("// Entry point.\nconsole.log('hello');\n");54+		const { compressed, size } = await readObject(b, fx.repo, main?.hash as string);55+		const text = "// Entry point.\nconsole.log('hello');\n";56+		expect(new TextDecoder().decode(inflateSync(compressed))).toBe(text);57+		expect(size).toBe(text.length);4658 	});4759 4860 	test("a missing key reads as null and a listing is prefix-scoped", async () => {@@ -152,6 +164,8 @@152164 			{153165 				get: async () => bytes,154166 				list: async () => [],167+				folders: async () => [],168+				size: async () => 0,155169 				sizes: async () => new Map(),156170 				downloadUrl: () => "",157171 				viewUrl: () => null,@@ -207,6 +221,8 @@207221 			{208222 				get: async () => bytes,209223 				list: async () => [],224+				folders: async () => [],225+				size: async () => 0,210226 				sizes: async () => new Map(),211227 				downloadUrl: () => "",212228 				viewUrl: () => null,@@ -288,6 +304,8 @@288304 		const reader = {289305 			get: async () => accepted,290306 			list: async () => [],307+			folders: async () => [],308+			size: async () => 0,291309 			sizes: async () => new Map(),292310 			downloadUrl: () => "",293311 			viewUrl: () => null,@@ -311,6 +329,8 @@311329 			const tampered = {312330 				get: async () => bytes,313331 				list: async () => [],332+				folders: async () => [],333+				size: async () => 0,314334 				sizes: async () => new Map(),315335 				downloadUrl: () => "",316336 				viewUrl: () => null,

view file

tests/fake-s3.ts+13 −2
@@ -18,15 +18,26 @@1818 	if (bucket !== BUCKET) return new Response("", { status: 404 });1919 	if (url.searchParams.get("list-type") === "2") {2020 		const prefix = url.searchParams.get("prefix") ?? "";21+		const delimiter = url.searchParams.get("delimiter");2122 		const keys = [...objects.keys()].filter((key) => key.startsWith(prefix)).sort();22-		const contents = keys23+		// With a delimiter, keys sharing a folder after the prefix roll up into one CommonPrefixes.24+		const folders = new Set<string>();25+		const listed = keys.filter((key) => {26+			const at = delimiter ? key.indexOf(delimiter, prefix.length) : -1;27+			if (at >= 0) folders.add(key.slice(0, at + 1));28+			return at < 0;29+		});30+		const contents = listed2331 			.map(2432 				(key) =>2533 					`<Contents><Key>${key}</Key><Size>${objects.get(key)?.length ?? 0}</Size></Contents>`,2634 			)2735 			.join("");36+		const common = [...folders]37+			.map((folder) => `<CommonPrefixes><Prefix>${folder}</Prefix></CommonPrefixes>`)38+			.join("");2839 		return new Response(29-			`<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><IsTruncated>false</IsTruncated>${contents}</ListBucketResult>`,40+			`<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><IsTruncated>false</IsTruncated>${contents}${common}</ListBucketResult>`,3041 			{ headers: { "content-type": "application/xml" } },3142 		);3243 	}

view file

tests/storage.test.ts+20 −0
@@ -4,6 +4,8 @@44 import { beforeAll, describe, expect, test } from "bun:test";55 import { mkdir, writeFile } from "node:fs/promises";66 import { dirname, join } from "node:path";7+import { measureStorage } from "../src/admin/storage.ts";8+import { openBucket } from "../src/bucket/reader.ts";79 import { Browser, type Instance, instance } from "./browser.ts";810 import { makeRepos, writeRelease } from "./fixture.ts";911 @@ -38,6 +40,7 @@3840 		inst = await instance(bucketDir);3941 		operator = new Browser(inst.app);4042 		await operator.signIn();43+		expect(await (await operator.get("/admin/storage")).text()).toContain("Not measured yet");4144 		await operator.post("/admin/repos/app", { public: "1" });4245 		expect((await inst.app.request(`/app/archive/${save}.tar.gz`)).status).toBe(200);4346 		const listed = new Bun.Glob(`.kepr/tarballs/app/*/${save}.tar.gz`).scanSync({@@ -46,13 +49,30 @@4649 		});4750 		generation = (Array.from(listed)[0] as string).split("/")[3] as string;4851 		for (const key of [...orphans, unlisted]) await put(key);52+		await measure();4953 	});54+	const measure = () =>55+		measureStorage({56+			bucket: openBucket({ kind: "folder", path: bucketDir }),57+			kepr: inst.kepr,58+			mirrorDir: join(inst.dataDir, "mirror"),59+		});5060 5161 	test("shows each repo's history, builds and kept tarballs, and the orphans", async () => {5262 		const page = await (await operator.get("/admin/storage")).text();5363 		expect(page).toMatch(/<td>app<\/td>\s*<td>[\d.]+ K?B<\/td>\s*<td>2\.\d KB<\/td>/);5464 		expect(page).toContain(`${save}.tar.gz`);5565 		expect(page).toContain("3 orphans");66+		expect(page).toContain("Measured just now");67+	});68+69+	test("shows the last measurement, not the bucket as it is now", async () => {70+		const late = ".kepr/tarballs/app/0-000000000000/bbbbbbbbbbbb.tar.gz";71+		await put(late);72+		expect(await (await operator.get("/admin/storage")).text()).toContain("3 orphans");73+		await measure();74+		expect(await (await operator.get("/admin/storage")).text()).toContain("4 orphans");75+		orphans.push(late);5676 	});5777 5878 	test("deleting orphans removes only Kepr's stale files", async () => {

view file

tests/sync.test.ts+74 −2
@@ -5,6 +5,7 @@55 import { join } from "node:path";66 import { deflateSync } from "node:zlib";77 import { type Bucket, openBucket } from "../src/bucket/reader.ts";8+import { readSave } from "../src/bucket/repo.ts";89 import { syncAll, syncRepo } from "../src/bucket/sync.ts";910 import {1011 	fileAt,@@ -192,11 +193,14 @@192193 	const fx = await makeKohBucket("ahead");193194 	const bucket = openBucket({ kind: "folder", path: fx.bucketDir });194195 	const mirror = openMirror(await mkdtemp(join(tmpdir(), "kepr-mirror-")), fx.repo);196+	// Reads go out in groups, so the cut falls on the first object the second save adds.197+	const first = await readSave(bucket, fx.repo, fx.saveIds.at(-1) as string);198+	const firstObjects = new Set(first.files.map((file) => file.hash)).size;195199 	let objectGets = 0;196200 	const failing: Bucket = {197201 		...bucket,198202 		get: (key) =>199-			key.includes("/objects/") && ++objectGets > 2203+			key.includes("/objects/") && ++objectGets > firstObjects200204 				? Promise.reject(new Error("connection reset"))201205 				: bucket.get(key),202206 	};@@ -204,7 +208,7 @@204208 	const stored = () =>205209 		mirror.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM objects").get()?.n;206210 	expect(mirrorState(mirror, "head")).toBeNull();207-	expect(stored()).toBe(2);211+	expect(stored()).toBe(firstObjects);208212 209213 	await syncRepo(bucket, mirror, fx.repo, [], 1);210214 	expect(headId(mirror)).toBe(fx.saveIds[0] as string);@@ -213,6 +217,74 @@213217 	expect(text(objectBytes(mirror, guide?.hash as string))).toBe(214218 		"# Guide\n\nRead me. It is short.\n",215219 	);220+});221+222+test("object reads overlap, but never past the bytes allowed in flight", async () => {223+	const fx = await makeKohBucket("in-flight");224+	const bucket = openBucket({ kind: "folder", path: fx.bucketDir });225+	const peak = async (readBytes?: number) => {226+		let reading = 0;227+		let most = 0;228+		const counting: Bucket = {229+			...bucket,230+			get: async (key) => {231+				if (!key.includes("/objects/")) return bucket.get(key);232+				most = Math.max(most, ++reading);233+				await Bun.sleep(5);234+				reading--;235+				return bucket.get(key);236+			},237+		};238+		const mirror = openMirror(await mkdtemp(join(tmpdir(), "kepr-mirror-")), fx.repo);239+		await syncRepo(counting, mirror, fx.repo, [], undefined, readBytes);240+		expect(headId(mirror)).toBe(fx.saveIds[0] as string);241+		return most;242+	};243+	expect(await peak()).toBeGreaterThan(1);244+	// Every object is larger than one byte, so each is read alone.245+	expect(await peak(1)).toBe(1);246+});247+248+test("a read the gate admits after the batch filled waits for the next batch", async () => {249+	const fx = await makeKohBucket("deferred");250+	const bucket = openBucket({ kind: "folder", path: fx.bucketDir });251+	const mirror = openMirror(await mkdtemp(join(tmpdir(), "kepr-mirror-")), fx.repo);252+	const stored = () =>253+		mirror.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM objects").get()?.n ?? 0;254+	let read = 0;255+	const unstored: number[] = [];256+	const counting: Bucket = {257+		...bucket,258+		get: async (key) => {259+			// Each object fills a one-byte batch, so it is stored before the next read starts.260+			if (key.includes("/objects/")) unstored.push(read++ - stored());261+			return bucket.get(key);262+		},263+	};264+	await syncRepo(counting, mirror, fx.repo, [], 1, 1);265+	expect(read).toBeGreaterThan(2);266+	expect(new Set(unstored)).toEqual(new Set([0]));267+});268+269+test("a failed read reports only once every other read has settled", async () => {270+	const fx = await makeKohBucket("settled");271+	const bucket = openBucket({ kind: "folder", path: fx.bucketDir });272+	const mirror = openMirror(await mkdtemp(join(tmpdir(), "kepr-mirror-")), fx.repo);273+	let reading = 0;274+	let reads = 0;275+	const failing: Bucket = {276+		...bucket,277+		get: async (key) => {278+			if (!key.includes("/objects/")) return bucket.get(key);279+			if (reads++ === 0) throw new Error("connection reset");280+			reading++;281+			await Bun.sleep(30);282+			reading--;283+			return bucket.get(key);284+		},285+	};286+	await expect(syncRepo(failing, mirror, fx.repo)).rejects.toThrow("connection reset");287+	expect(reading).toBe(0);216288 });217289 218290 test("a new save syncs incrementally and a bad format is recorded as the last sync error", async () => {

view file