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