sync.ts

6.1 KB · raw

1import type { Database } from "bun:sqlite";2import {3	clearMirror,4	knownEventIds,5	knownObject,6	knownReleases,7	knownSave,8	land,9	mirrorState,10	openMirror,11	type StoredObject,12} from "../mirror/store.ts";13import { hiddenPaths, recordSync, registerRepo } from "../server/db.ts";14import { RESERVED_NAMES } from "../server/reserved.ts";15import { dropTarballs } from "../tarball/cache.ts";16import type { Bucket } from "./reader.ts";17import {18	type ArrivingSave,19	BucketError,20	checkFormat,21	type EventRecord,22	escapePath,23	isSafePath,24	listEventIds,25	listReleaseVersions,26	listRepos,27	OversizedObject,28	type ReleaseRecord,29	readEvent,30	readHead,31	readObject,32	readRelease,33	readReleasesLatest,34	readSave,35	type SaveRecord,36} from "./repo.ts";3738const INTERVAL_MS = 2 * 60 * 1000;39// Compressed bytes a sync holds before landing them; sized for a 2 GB host.40const LANDING_BYTES = 32 * 1024 * 1024;4142export type SyncTarget = {43	bucket: Bucket;44	kepr: Database;45	mirrorDir: string;46};4748let running: Promise<void> | null = null;4950/** Syncs every repo in the bucket; the admin console's "sync now" calls this too. Never overlaps itself. */51export function syncAll(target: SyncTarget): Promise<void> {52	running ??= syncEach(target).finally(() => {53		running = null;54	});55	return running;56}5758/** Rebuilds one repo's mirror from the bucket, once any sync under way has finished. */59export async function rebuildRepo(target: SyncTarget, repo: string): Promise<void> {60	await syncAll(target);61	clearMirror(openMirror(target.mirrorDir, repo));62	await syncAll(target);63}6465export function startSyncLoop(target: SyncTarget): void {66	const tick = () => syncAll(target).catch((error) => console.error(`kepr: sync failed: ${error}`));67	tick();68	setInterval(tick, INTERVAL_MS);69}7071async function syncEach(target: SyncTarget): Promise<void> {72	for (const repo of await listRepos(target.bucket)) {73		registerRepo(target.kepr, repo);74		let error: string | null = null;75		if (RESERVED_NAMES.has(repo)) {76			error = `not synced: the name "${repo}" clashes with Kepr's own /${repo} route; rename the repo`;77		} else {78			try {79				const mirror = openMirror(target.mirrorDir, repo);80				const events = await syncRepo(target.bucket, mirror, repo, hiddenPaths(target.kepr, repo));81				if (events.some((event) => event.kind === "strip")) await dropTarballs(target.bucket, repo);82			} catch (cause) {83				error = cause instanceof Error ? cause.message : String(cause);84			}85		}86		recordSync(target.kepr, repo, Math.floor(Date.now() / 1000), error);87	}88}8990/**91 * Koh's steal flow (§9.4) against a mirror: walk back from the remote head to a save the mirror has,92 * fetch what is missing, and land it, in batches oldest first. Events are listed every time, since a93 * flag or a strip arrives without moving `head`; releases only when `head` or `releases/latest`94 * moved (§18), since publishing one moves both. A path §5.5 refuses, or one whose object inflates95 * past Koh's file limit, is quarantined: the save lands without it, as if stripped. Returns the96 * events it landed.97 */98export async function syncRepo(99	bucket: Bucket,100	mirror: Database,101	repo: string,102	hidden: string[] = [],103	landingBytes = LANDING_BYTES,104): Promise<EventRecord[]> {105	await checkFormat(bucket, repo);106	const head = await readHead(bucket, repo);107	if (head === null) return [];108	const unknown: ArrivingSave[] = [];109	const seen = new Set<string>();110	for (let cursor = head; cursor !== "" && !knownSave(mirror, cursor); ) {111		if (seen.has(cursor)) throw new BucketError(`${repo}: save ${cursor} is its own ancestor`);112		seen.add(cursor);113		const save = await readSave(bucket, repo, cursor);114		unknown.push(save);115		cursor = save.parent;116	}117	// Oldest first, landing whenever the fetched objects pass `landingBytes`: a first sync of a big118	// repo never holds its whole history in memory, and one cut short resumes from the last batch.119	let saves: SaveRecord[] = [];120	let objects = new Map<string, StoredObject>();121	let quarantined: string[] = [];122	let fetched = 0;123	const oversized = new Set<string>();124	for (const save of unknown.reverse()) {125		quarantined.push(...save.quarantined);126		for (const file of save.files) {127			if (objects.has(file.hash) || oversized.has(file.hash) || knownObject(mirror, file.hash)) {128				continue;129			}130			try {131				const { compressed, bytes } = await readObject(bucket, repo, file.hash);132				objects.set(file.hash, { compressed, size: bytes.length });133				fetched += compressed.length;134			} catch (cause) {135				if (!(cause instanceof OversizedObject)) throw cause;136				oversized.add(file.hash);137			}138		}139		quarantined.push(140			...save.files.filter((f) => oversized.has(f.hash)).map((f) => escapePath(f.path)),141		);142		save.files = save.files.filter((file) => !oversized.has(file.hash));143		saves.push(save);144		if (fetched >= landingBytes && save.id !== head) {145			land(mirror, {146				head: save.id,147				releasesLatest: mirrorState(mirror, "releases_latest") ?? "",148				saves,149				objects,150				events: [],151				releases: [],152				quarantined,153				hidden,154			});155			saves = [];156			objects = new Map();157			quarantined = [];158			fetched = 0;159		}160	}161	const known = knownEventIds(mirror);162	const events: EventRecord[] = [];163	for (const id of await listEventIds(bucket, repo)) {164		if (known.has(id)) continue;165		const event = await readEvent(bucket, repo, id);166		// Koh ignores it too: no save can hold an illegal path (§5.5).167		if (event.kind !== "strip" || isSafePath(event.path)) events.push(event);168	}169	const releasesLatest = (await readReleasesLatest(bucket, repo)) ?? "";170	const moved =171		mirrorState(mirror, "head") !== head ||172		mirrorState(mirror, "releases_latest") !== releasesLatest;173	const releases = moved ? await newReleases(bucket, mirror, repo) : [];174	if (!moved && events.length === 0) return [];175	land(mirror, {176		head,177		releasesLatest,178		saves,179		objects,180		events,181		releases,182		quarantined,183		hidden,184	});185	return events;186}187188async function newReleases(189	bucket: Bucket,190	mirror: Database,191	repo: string,192): Promise<ReleaseRecord[]> {193	const known = knownReleases(mirror);194	const releases: ReleaseRecord[] = [];195	for (const version of await listReleaseVersions(bucket, repo)) {196		if (!known.has(version)) releases.push(await readRelease(bucket, repo, version));197	}198	return releases;199}