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 {15 hiddenPaths,16 hiddenToHand,17 keepState,18 recordHiddenHanded,19 recordSync,20 registerRepo,21} from "../server/db.ts";22import { RESERVED_NAMES } from "../server/reserved.ts";23import { dropTarballs } from "../tarball/cache.ts";24import { type Bucket, BucketWriteError, inTurn, KEPR_PREFIX } from "./reader.ts";25import {26 BucketError,27 checkFormat,28 type EventRecord,29 escapePath,30 isSafePath,31 listEventIds,32 listReleaseVersions,33 listRepos,34 OversizedObject,35 objectSize,36 type ReleaseRecord,37 readEvent,38 readHead,39 readObject,40 readRelease,41 readReleasesLatest,42 readSave,43 type SaveRecord,44} from "./repo.ts";4546const INTERVAL_MS = 2 * 60 * 1000;4748const LANDING_BYTES = 32 * 1024 * 1024;49const LANDING_SAVES = 100;505152const READS_AT_ONCE = 16;5354const READ_BYTES = 32 * 1024 * 1024;5556export type SyncTarget = {57 bucket: Bucket;58 kepr: Database;59 mirrorDir: string;60 61 synced?: (repo: string) => void;62};6364let running: Promise<void> | null = null;656667export function syncAll(target: SyncTarget): Promise<void> {68 return running ?? holdSync(() => syncEach(target));69}707172737475export function holdSync(work: () => Promise<void>): Promise<void> {76 77 const mine = (running ?? Promise.resolve()).then(work).finally(() => {78 if (running === slot) running = null;79 });80 const slot = mine.catch(() => {});81 running = slot;82 return mine;83}8485export function startSyncLoop(target: SyncTarget): void {86 const tick = () => syncAll(target).catch((error) => console.error(`kepr: sync failed: ${error}`));87 tick();88 setInterval(tick, INTERVAL_MS);89}9091async function syncEach(target: SyncTarget): Promise<void> {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 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) {104 registerRepo(target.kepr, repo);105 let error: string | null = null;106 if (RESERVED_NAMES.has(repo)) {107 error = `not synced: the name "${repo}" clashes with Kepr's own /${repo} route; rename the repo`;108 } else {109 try {110 const mirror = openMirror(target.mirrorDir, repo);111 const events = await syncRepo(target.bucket, mirror, repo, hiddenPaths(target.kepr, repo));112 if (events.some((event) => event.kind === "strip")) await dropTarballs(target.bucket, repo);113 } catch (cause) {114 error = cause instanceof Error ? cause.message : String(cause);115 }116 }117 recordSync(target.kepr, repo, Math.floor(Date.now() / 1000), error);118 if (error === null) target.synced?.(repo);119 await handHidden(target, repo);120 }121}122123124125126127128129130async function handHidden(target: SyncTarget, repo: string): Promise<void> {131 const list = hiddenToHand(target.kepr, repo);132 if (list === null) return;133 const paths = list.split("\n").filter(Boolean);134 const bytes = new TextEncoder().encode(paths.map((path) => `${path}\n`).join(""));135 136 137 recordHiddenHanded(target.kepr, repo, null);138 try {139 await target.bucket.put(`${KEPR_PREFIX}hidden/${repo}`, bytes, "text/plain");140 } catch (error) {141 if (!(error instanceof BucketWriteError)) {142 console.error(`kepr: handing ${repo}'s hidden paths to Koh failed: ${error}`);143 }144 return;145 }146 recordHiddenHanded(target.kepr, repo, list);147 148 await dropTarballs(target.bucket, repo).catch((error) =>149 console.error(`kepr: dropping ${repo}'s kept tarballs failed: ${error}`),150 );151}152153154155156157158159160161162export async function syncRepo(163 bucket: Bucket,164 mirror: Database,165 repo: string,166 hidden: string[] = [],167 landingBytes = LANDING_BYTES,168 readBytes = READ_BYTES,169): Promise<EventRecord[]> {170 applyHidden(mirror, hidden);171 await checkFormat(bucket, repo);172 const head = await readHead(bucket, repo);173 if (head === null) return [];174 const unknown = new Set<string>();175 for (let cursor = head; cursor !== "" && !knownSave(mirror, cursor); ) {176 if (unknown.has(cursor)) throw new BucketError(`${repo}: save ${cursor} is its own ancestor`);177 unknown.add(cursor);178 cursor = (await readSave(bucket, repo, cursor)).parent;179 }180 181 182 183 184 let saves: SaveRecord[] = [];185 let objects = new Map<string, StoredObject>();186 let quarantined: string[] = [];187 let fetched = 0;188 const oversized = new Set<string>();189 const landBatch = (batchHead: string) => {190 land(mirror, {191 head: batchHead,192 releasesLatest: mirrorState(mirror, "releases_latest") ?? "",193 saves,194 objects,195 events: [],196 releases: [],197 quarantined,198 });199 saves = [];200 objects = new Map();201 quarantined = [];202 fetched = 0;203 };204 const gate = byteGate(readBytes);205 let failure: unknown = null;206 207 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;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);224 }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),230 );231 for (const save of records) {232 quarantined.push(...save.quarantined);233 234 235 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 242 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 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 }269 }270 const known = knownEventIds(mirror);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(275 276 (event) => event.kind !== "strip" || isSafePath(event.path),277 );278 const releasesLatest = (await readReleasesLatest(bucket, repo)) ?? "";279 const moved =280 mirrorState(mirror, "head") !== head ||281 mirrorState(mirror, "releases_latest") !== releasesLatest;282 const releases = moved ? await newReleases(bucket, mirror, repo) : [];283 if (!moved && events.length === 0) return [];284 land(mirror, {285 head,286 releasesLatest,287 saves,288 objects,289 events,290 releases,291 quarantined,292 });293 return events;294}295296297function 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}315316async function newReleases(317 bucket: Bucket,318 mirror: Database,319 repo: string,320): Promise<ReleaseRecord[]> {321 const known = knownReleases(mirror);322 const versions = (await listReleaseVersions(bucket, repo)).filter((v) => !known.has(v));323 return inTurn(versions, READS_AT_ONCE, (version) => readRelease(bucket, repo, version));324}