1import type { Database } from "bun:sqlite";2import {3 applyHidden,4 knownEventIds,5 knownObject,6 knownReleases,7 knownSave,8 land,9 mirrorState,10 openMirror,11 type StoredObject,12 storeObjects,13} from "../mirror/store.ts";14import type { BucketSource } from "../server/config.ts";15import {16 hiddenPaths,17 hiddenToHand,18 isRegistered,19 keepState,20 recordHiddenHanded,21 recordSync,22 registerRepo,23 renamedTo,24} from "../server/db.ts";25import { RESERVED_NAMES } from "../server/reserved.ts";26import { dropTarballs } from "../tarball/cache.ts";27import { type Bucket, inTurn, KEPR_PREFIX, openBucket } from "./reader.ts";28import {29 BucketError,30 checkFormat,31 type EventRecord,32 escapePath,33 isSafePath,34 listEventIds,35 listReleaseVersions,36 listRepos,37 OversizedObject,38 objectSize,39 type ReleaseRecord,40 readEvent,41 readHead,42 readObject,43 readRelease,44 readReleasesLatest,45 readSave,46 type SaveRecord,47} from "./repo.ts";4849const INTERVAL_MS = 2 * 60 * 1000;5051const LANDING_BYTES = 32 * 1024 * 1024;52const LANDING_SAVES = 100;535455const READS_AT_ONCE = 16;5657const READ_BYTES = 32 * 1024 * 1024;5859export type SyncTarget = {60 bucket: Bucket;61 kepr: Database;62 mirrorDir: string;63};6465let running: Promise<void> | null = null;666768export function syncAll(target: SyncTarget): Promise<void> {69 return running ?? holdSync(() => syncEach(target));70}717273747576export function holdSync(work: () => Promise<void>): Promise<void> {77 78 const mine = (running ?? Promise.resolve()).then(work).finally(() => {79 if (running === slot) running = null;80 });81 const slot = mine.catch(() => {});82 running = slot;83 return mine;84}85868788899091export type Syncer = {92 syncAll(): Promise<void>;93 hold(work: () => Promise<void>): Promise<void>;94 applyHidden(repos: string[]): Promise<void>;95 setBucket(source: BucketSource): void;96};979899export function localSyncer(target: SyncTarget): Syncer {100 return {101 syncAll: () => syncAll(target),102 hold: holdSync,103 applyHidden: async (repos) => {104 for (const asked of repos) {105 106 const repo = renamedTo(target.kepr, asked) ?? asked;107 if (!isRegistered(target.kepr, repo)) continue;108 applyHidden(openMirror(target.mirrorDir, repo), hiddenPaths(target.kepr, repo));109 }110 },111 setBucket: (source) => Object.assign(target.bucket, openBucket(source)),112 };113}114115116export type SyncRequest =117 | { kind: "start"; dataDir: string; source: BucketSource }118 | { kind: "sync" | "hold" | "release"; id: number }119 | { kind: "hidden"; id: number; repos: string[] }120 | { kind: "bucket"; source: BucketSource };121export type SyncReply = { kind: "done"; id: number; error?: string };122123124125126127128export function startSyncWorker(dataDir: string, source: BucketSource): Syncer {129 const worker = new Worker(new URL("./worker.ts", import.meta.url));130 131 worker.unref();132 const waiting = new Map<number, { resolve: () => void; reject: (error: Error) => void }>();133 let next = 0;134 worker.addEventListener("error", (event) => {135 console.error(`kepr: the sync worker failed: ${event.message}`);136 process.exit(1);137 });138 worker.addEventListener("message", ({ data }: MessageEvent<SyncReply>) => {139 const reply = waiting.get(data.id);140 waiting.delete(data.id);141 if (data.error === undefined) reply?.resolve();142 else reply?.reject(new Error(data.error));143 });144 const send = (request: SyncRequest) => worker.postMessage(request);145 const ask = (request: (id: number) => SyncRequest) =>146 new Promise<number>((resolve, reject) => {147 const id = next++;148 waiting.set(id, { resolve: () => resolve(id), reject });149 send(request(id));150 });151 send({ kind: "start", dataDir, source });152 return {153 syncAll: async () => {154 await ask((id) => ({ kind: "sync", id }));155 },156 async hold(work) {157 const id = await ask((id) => ({ kind: "hold", id }));158 try {159 await work();160 } finally {161 send({ kind: "release", id });162 }163 },164 applyHidden: async (repos) => {165 await ask((id) => ({ kind: "hidden", id, repos }));166 },167 setBucket: (source) => send({ kind: "bucket", source }),168 };169}170171export function startSyncLoop(target: SyncTarget): void {172 const tick = () => syncAll(target).catch((error) => console.error(`kepr: sync failed: ${error}`));173 tick();174 setInterval(tick, INTERVAL_MS);175}176177async function syncEach(target: SyncTarget): Promise<void> {178 let repos: string[];179 try {180 repos = await listRepos(target.bucket);181 } catch (error) {182 keepState(target.kepr, "bucket_error", String(error));183 throw error;184 }185 186 keepState(target.kepr, "bucket_error", null);187 keepState(target.kepr, "bucket_read_at", String(Math.floor(Date.now() / 1000)));188 keepState(target.kepr, "bucket_repos", JSON.stringify(repos));189 for (const repo of repos) {190 registerRepo(target.kepr, repo);191 let error: string | null = null;192 if (RESERVED_NAMES.has(repo)) {193 error = `not synced: the name "${repo}" clashes with Kepr's own /${repo} route; rename the repo`;194 } else {195 try {196 const mirror = openMirror(target.mirrorDir, repo);197 const events = await syncRepo(target.bucket, mirror, repo, hiddenPaths(target.kepr, repo));198 if (events.some((event) => event.kind === "strip")) await dropTarballs(target.bucket, repo);199 } catch (cause) {200 error = cause instanceof Error ? cause.message : String(cause);201 }202 }203 recordSync(target.kepr, repo, Math.floor(Date.now() / 1000), error);204 await handHidden(target, repo);205 }206}207208209210211212213214async function handHidden(target: SyncTarget, repo: string): Promise<void> {215 const list = hiddenToHand(target.kepr, repo);216 if (list === null) return;217 const paths = list.split("\n").filter(Boolean);218 const bytes = new TextEncoder().encode(paths.map((path) => `${path}\n`).join(""));219 220 221 recordHiddenHanded(target.kepr, repo, null);222 try {223 await target.bucket.put(`${KEPR_PREFIX}hidden/${repo}`, bytes, "text/plain");224 } catch (error) {225 console.error(`kepr: handing ${repo}'s hidden paths to Koh failed: ${error}`);226 return;227 }228 recordHiddenHanded(target.kepr, repo, list);229 230 await dropTarballs(target.bucket, repo).catch((error) =>231 console.error(`kepr: dropping ${repo}'s kept tarballs failed: ${error}`),232 );233}234235236237238239240241242243244export async function syncRepo(245 bucket: Bucket,246 mirror: Database,247 repo: string,248 hidden: string[] = [],249 landingBytes = LANDING_BYTES,250 readBytes = READ_BYTES,251): Promise<EventRecord[]> {252 applyHidden(mirror, hidden);253 await checkFormat(bucket, repo);254 const head = await readHead(bucket, repo);255 if (head === null) return [];256 const unknown = new Set<string>();257 for (let cursor = head; cursor !== "" && !knownSave(mirror, cursor); ) {258 if (unknown.has(cursor)) throw new BucketError(`${repo}: save ${cursor} is its own ancestor`);259 unknown.add(cursor);260 cursor = (await readSave(bucket, repo, cursor)).parent;261 }262 263 264 265 266 let saves: SaveRecord[] = [];267 let objects = new Map<string, StoredObject>();268 let quarantined: string[] = [];269 let fetched = 0;270 const oversized = new Set<string>();271 const landBatch = (batchHead: string) => {272 land(mirror, {273 head: batchHead,274 releasesLatest: mirrorState(mirror, "releases_latest") ?? "",275 saves,276 objects,277 events: [],278 releases: [],279 quarantined,280 });281 saves = [];282 objects = new Map();283 quarantined = [];284 fetched = 0;285 };286 const gate = byteGate(readBytes);287 let failure: unknown = null;288 289 290 const readInto = async (ahead: Map<string, StoredObject>, wanted: string[], hash: string) => {291 const size = await objectSize(bucket, repo, hash);292 await gate.enter(size);293 try {294 if (failure !== null || fetched >= landingBytes) {295 wanted.unshift(hash);296 return;297 }298 const object = await readObject(bucket, repo, hash);299 ahead.set(hash, object);300 fetched += object.compressed.length;301 } catch (cause) {302 if (!(cause instanceof OversizedObject)) throw cause;303 oversized.add(hash);304 } finally {305 gate.leave(size);306 }307 };308 const ids = [...unknown].reverse();309 for (let start = 0; start < ids.length; start += READS_AT_ONCE) {310 const records = await inTurn(ids.slice(start, start + READS_AT_ONCE), READS_AT_ONCE, (id) =>311 readSave(bucket, repo, id),312 );313 for (const save of records) {314 quarantined.push(...save.quarantined);315 316 317 318 const ahead = new Map<string, StoredObject>();319 const wanted = [...new Set(save.files.map((file) => file.hash))].filter(320 (hash) => !objects.has(hash) && !oversized.has(hash) && !knownObject(mirror, hash),321 );322 while (wanted.length > 0) {323 324 325 const reader = async () => {326 while (failure === null && fetched < landingBytes && wanted.length > 0) {327 await readInto(ahead, wanted, wanted.shift() as string).catch((cause) => {328 failure ??= cause;329 });330 }331 };332 333 await Promise.all(Array.from({ length: READS_AT_ONCE }, reader));334 if (failure !== null) throw failure;335 if (fetched >= landingBytes) {336 const last = saves.at(-1);337 if (last) landBatch(last.id);338 storeObjects(mirror, ahead);339 ahead.clear();340 fetched = 0;341 }342 }343 for (const [hash, object] of ahead) objects.set(hash, object);344 quarantined.push(345 ...save.files.filter((f) => oversized.has(f.hash)).map((f) => escapePath(f.path)),346 );347 save.files = save.files.filter((file) => !oversized.has(file.hash));348 saves.push(save);349 if (saves.length >= LANDING_SAVES && save.id !== head) landBatch(save.id);350 }351 }352 const known = knownEventIds(mirror);353 const newEvents = (await listEventIds(bucket, repo)).filter((id) => !known.has(id));354 const events = (355 await inTurn(newEvents, READS_AT_ONCE, (id) => readEvent(bucket, repo, id))356 ).filter(357 358 (event) => event.kind !== "strip" || isSafePath(event.path),359 );360 const releasesLatest = (await readReleasesLatest(bucket, repo)) ?? "";361 const moved =362 mirrorState(mirror, "head") !== head ||363 mirrorState(mirror, "releases_latest") !== releasesLatest;364 const releases = moved ? await newReleases(bucket, mirror, repo) : [];365 if (!moved && events.length === 0) return [];366 land(mirror, {367 head,368 releasesLatest,369 saves,370 objects,371 events,372 releases,373 quarantined,374 });375 return events;376}377378379function byteGate(limit: number) {380 let inFlight = 0;381 let waiting: (() => void)[] = [];382 return {383 async enter(size: number): Promise<void> {384 while (inFlight > 0 && inFlight + size > limit) {385 await new Promise<void>((resolve) => waiting.push(resolve));386 }387 inFlight += size;388 },389 leave(size: number): void {390 inFlight -= size;391 const woken = waiting;392 waiting = [];393 for (const wake of woken) wake();394 },395 };396}397398async function newReleases(399 bucket: Bucket,400 mirror: Database,401 repo: string,402): Promise<ReleaseRecord[]> {403 const known = knownReleases(mirror);404 const versions = (await listReleaseVersions(bucket, repo)).filter((v) => !known.has(v));405 return inTurn(versions, READS_AT_ONCE, (version) => readRelease(bucket, repo, version));406}