check / check (push) Successful in 27s
Library.close() now returns a promise that resolves once the work it started has finished: an in-flight refresh with its cache write, the ML data fetch, and running precache sweeps. The interval test could see a refresh's new state, close, and remove the directory while the write was still running. Every library test and CLI command now awaits close(), and tests hold each of the three writes open to prove close() waits for it. The precache test waited for its stub source to be called, but the cache records a file only after checking it on disk, so status() could lag. It now waits for both fills to report "done". Model: opus-5-5
270 lines
10 KiB
TypeScript
270 lines
10 KiB
TypeScript
// 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<EnsureResult[]>;
|
|
ensureOriginals(args: {
|
|
fileIDs: number[];
|
|
signal?: AbortSignal;
|
|
}): Promise<EnsureResult[]>;
|
|
}
|
|
|
|
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<number>();
|
|
|
|
// 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<void>;
|
|
private originalsSweep?: Promise<void>;
|
|
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<number>();
|
|
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<void> {
|
|
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<EnsureResult[]>,
|
|
): Promise<void> {
|
|
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
|
|
}
|
|
}
|
|
}
|