check / check (push) Successful in 26s
Precaches aggressively inside open(): every thumbnail (newest first, through the shared thumbnail pool, never evicted; visible/ahead requests preempt the background fill) and the pinned originals — the favorites album plus every file within precacheOriginalsDays (default 7) of the newest takenAt — through the content pool. The pinned set integrates with the #47 eviction hook; when the window moves or a favorite is removed, files become ordinary evictable originals. open() options precacheThumbnails/precacheOriginals/precacheOriginalsDays; progress via onProgress/status(). Model: opus-4-8
266 lines
10 KiB
TypeScript
266 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.
|
|
private thumbRunning = false;
|
|
private originalsRunning = false;
|
|
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.
|
|
close(): void {
|
|
this.closed = true;
|
|
this.aborter.abort();
|
|
}
|
|
|
|
private kickThumbnails(): void {
|
|
if (this.thumbRunning) return;
|
|
this.thumbRunning = true;
|
|
void 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.thumbRunning = false;
|
|
});
|
|
}
|
|
|
|
private kickOriginals(): void {
|
|
if (this.originalsRunning) return;
|
|
this.originalsRunning = true;
|
|
void 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.originalsRunning = false;
|
|
});
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|