// The aggressive local precache (issue #48), started from `Library.open` with // no caller input. // // Two background fills run concurrently through the shared request pools (#45): // // - Thumbnails: every file in the account, newest first, through the // thumbnail pool until all are on disk. Never evicted. The pool is the same // one `thumbnails.ensure` uses, so a visible or ahead request always jumps // ahead of this background fill and a fileID both want is fetched once. // // - Originals (the pinned set): through the content pool, the favorites album // first, then every file whose `takenAt` falls in the latest // `originalsDays` window — the days ending at the newest file in the // account. The pinned set is the eviction predicate (#47): a pinned // original is never evicted, and a file that leaves the set (a favorite // removed, or the window moving past it on a later refresh) becomes an // ordinary, evictable original with its bytes left in place. // // Both fills yield to on-demand work: every fetch goes to its pool at // background priority, which the pool serves only after on-demand requests. A // file already cached costs one map lookup (`pathsFor`) and no fetch. Each // sweep is driven in bounded chunks so the pool's waiting queue never grows to // the whole account, keeping on-demand preemption and per-admit cost cheap on a // large library. Failures are not fatal: an uncached file is retried on the // next sweep, which `Library` re-kicks after every refresh. import type { EnsureResult } from "./content.js"; import type { DerivedRecords } from "./records.js"; const ONE_DAY_MS = 24 * 60 * 60 * 1000; export const DEFAULT_PRECACHE_ORIGINALS_DAYS = 7; // How many files a sweep submits to a pool before awaiting them. Bounds the // pool's waiting queue so on-demand work is never stuck behind the whole // account; the values track each pool's concurrency (#45). const THUMBNAIL_CHUNK = 25; const ORIGINAL_CHUNK = 5; // The precache metrics `Library.status()` surfaces. export interface PrecacheStatus { thumbnailsCached: number; thumbnailsTotal: number; originalsCached: number; originalsPinned: number; } // A background-fill progress event. Each active sweep fires "started" before // its fetches and "done" (with the count newly on disk) after; a sweep with // nothing left to fetch is silent. export interface PrecacheEvent { operation: "precacheThumbnails" | "precacheOriginals"; status: "started" | "done"; fetched?: number; } // The slice of the content cache the precache drives. The real `ContentCache` // satisfies it; tests inject a fake. export interface PrecacheCache { pathsFor(fileID: number): { originalPath?: string; thumbnailPath?: string }; ensureThumbnails(args: { fileIDs: number[]; priority: "background"; signal?: AbortSignal; }): Promise; ensureOriginals(args: { fileIDs: number[]; signal?: AbortSignal; }): Promise; } export interface PrecacheOptions { // Default true; false disables the fill and, for originals, the pinning. thumbnails?: boolean; originals?: boolean; // The latest-week window length in days; default 7. originalsDays?: number; onEvent?: (event: PrecacheEvent) => void; } export class Precache { private readonly doThumbnails: boolean; private readonly doOriginals: boolean; private readonly originalsDays: number; private readonly onEvent?: (event: PrecacheEvent) => void; private cache?: PrecacheCache; // Every file in the account, newest first (the thumbnail fill order). private thumbOrder: number[] = []; // The pinned originals in fetch order: favorites first, then the window. private originalsOrder: number[] = []; private pinned = new Set(); // A sweep runs at most once per fill at a time; a re-kick while one runs is // a no-op, and the next refresh re-kicks after it finishes. Each holds the // running sweep, so `close()` can wait for it. private thumbSweep?: Promise; private originalsSweep?: Promise; private readonly aborter = new AbortController(); private closed = false; constructor(opts: PrecacheOptions = {}) { this.doThumbnails = opts.thumbnails ?? true; this.doOriginals = opts.originals ?? true; this.originalsDays = opts.originalsDays ?? DEFAULT_PRECACHE_ORIGINALS_DAYS; this.onEvent = opts.onEvent; } // Attach the cache the fills fetch through. `isPinned` works before this is // called, so the cache can be constructed with `isPinned` wired in and then // bound here. bind(cache: PrecacheCache): void { this.cache = cache; } // Whether an original is pinned (favorites + the latest-week window), the // eviction predicate (#47). False for every file when originals precaching // is disabled. isPinned(fileID: number): boolean { return this.pinned.has(fileID); } // Recompute the fill orders and the pinned set from the current projection. // Called at open and after every refresh that changes the library. update(records: DerivedRecords): void { const photos = [...records.photos.values()].sort( (a, b) => b.takenAt - a.takenAt || b.fileID - a.fileID, ); this.thumbOrder = this.doThumbnails ? photos.map((p) => p.fileID) : []; const pinned = new Set(); const order: number[] = []; if (this.doOriginals) { // Favorites first, in the album's own newest-first order. for (const album of records.albums.values()) { if (album.type !== "favorites") continue; for (const id of album.fileIDs) { if (!pinned.has(id)) { pinned.add(id); order.push(id); } } } // Then the latest-week window, ending at the newest file. Photos // are newest first, so stop once one falls before the window start. const newest = photos[0]; if (newest !== undefined) { const windowStart = newest.takenAt - this.originalsDays * ONE_DAY_MS; for (const p of photos) { if (p.takenAt < windowStart) break; if (!pinned.has(p.fileID)) { pinned.add(p.fileID); order.push(p.fileID); } } } } this.pinned = pinned; this.originalsOrder = order; } status(): PrecacheStatus { const cache = this.cache; let thumbnailsCached = 0; let originalsCached = 0; if (cache) { for (const id of this.thumbOrder) if (cache.pathsFor(id).thumbnailPath !== undefined) thumbnailsCached++; for (const id of this.originalsOrder) if (cache.pathsFor(id).originalPath !== undefined) originalsCached++; } return { thumbnailsCached, thumbnailsTotal: this.thumbOrder.length, originalsCached, originalsPinned: this.originalsOrder.length, }; } // Kick both fills. Idempotent: a fill already sweeping is left alone. Safe // to call after every refresh; a finished fill starts a fresh sweep that // picks up new files and retries any that failed before. start(): void { if (this.closed || !this.cache) return; if (this.doThumbnails) this.kickThumbnails(); if (this.doOriginals) this.kickOriginals(); } // Stop the fills. In-flight fetches are left to settle; queued ones drop. // Resolves once both sweeps have finished, so nothing is still writing. async close(): Promise { this.closed = true; this.aborter.abort(); await Promise.all([ this.thumbSweep?.catch(() => {}), this.originalsSweep?.catch(() => {}), ]); } private kickThumbnails(): void { if (this.thumbSweep) return; this.thumbSweep = this.sweep( "precacheThumbnails", () => this.thumbOrder, (id) => this.cache!.pathsFor(id).thumbnailPath !== undefined, THUMBNAIL_CHUNK, (chunk) => this.cache!.ensureThumbnails({ fileIDs: chunk, priority: "background", signal: this.aborter.signal, }), ).finally(() => { this.thumbSweep = undefined; }); } private kickOriginals(): void { if (this.originalsSweep) return; this.originalsSweep = this.sweep( "precacheOriginals", () => this.originalsOrder, (id) => this.cache!.pathsFor(id).originalPath !== undefined, ORIGINAL_CHUNK, (chunk) => this.cache!.ensureOriginals({ fileIDs: chunk, signal: this.aborter.signal, }), ).finally(() => { this.originalsSweep = undefined; }); } // One fill sweep: skip files already on disk (one lookup each), fetch the // rest in bounded chunks, and report progress only when there was work. private async sweep( operation: PrecacheEvent["operation"], order: () => number[], present: (fileID: number) => boolean, chunkSize: number, fetch: (chunk: number[]) => Promise, ): Promise { const todo = order().filter((id) => !present(id)); if (todo.length === 0) return; this.emit({ operation, status: "started" }); let fetched = 0; for (let i = 0; i < todo.length && !this.closed; i += chunkSize) { const results = await fetch(todo.slice(i, i + chunkSize)); for (const r of results) if (r.path !== undefined) fetched++; } this.emit({ operation, status: "done", fetched }); } private emit(event: PrecacheEvent): void { if (!this.onEvent) return; // A misbehaving callback must not break the fill loop. try { this.onEvent(event); } catch { // ignore } } }