Precache all thumbnails and pinned originals inside open() (closes #48)
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
This commit was merged in pull request #73.
This commit is contained in:
2026-09-22 21:21:34 +02:00
parent e8575780e4
commit 2e00139d3c
6 changed files with 835 additions and 23 deletions
+84 -14
View File
@@ -49,6 +49,7 @@ import {
type EnsureResult,
} from "./content.js";
import { makeMLDataAPI, type MLDataAPI } from "./mlsearch.js";
import { Precache } from "./precache.js";
export {
Album,
@@ -87,6 +88,23 @@ export {
export const DEFAULT_REFRESH_INTERVAL_SECONDS = 3;
// Project a metadata store into by-id records, filling each record's cache
// paths from the content cache when one is given. Shared by the live read
// projection and the precache's initial seeding at open().
const deriveRecordsFromStore = (
store: MetadataStore,
cache?: ContentCache,
): DerivedRecords => {
const collections = store.listCollections();
const files: EnteFile[] = [];
for (const c of collections) files.push(...store.listFiles(c.id));
return deriveRecords(
collections,
files,
cache ? (fileID) => cache.pathsFor(fileID) : undefined,
);
};
// The slice of `Client` the library depends on. Narrowing to an interface lets
// tests drive a mock with no crypto or network; the real `Client` satisfies it
// structurally.
@@ -115,9 +133,15 @@ export interface LibraryClient {
// A progress event for one unit of background work. A metadata "refresh" or an
// ML "fetchMLData" pass each fire "started" before their network work and then
// exactly one of "done" or "failed"; "failed" carries the error message and
// an ML "done" reports how many payloads it stored.
// an ML "done" reports how many payloads it stored. The precache fills
// ("precacheThumbnails"/"precacheOriginals", #48) fire "started"/"done" around
// each sweep that has work, "done" reporting the count newly cached.
export interface RefreshEvent {
operation: "refresh" | "fetchMLData";
operation:
| "refresh"
| "fetchMLData"
| "precacheThumbnails"
| "precacheOriginals";
status: "started" | "done" | "failed";
error?: string;
fetched?: number;
@@ -147,9 +171,17 @@ export interface LibraryOptions {
// down as the disk fills; `status().originalsLimitBytes` reports it.
cacheOriginalsMaxBytes?: number;
freeBelowBytes?: number;
// Whether an original is pinned and so never evicted (favorites + latest
// week; the precache unit #48 supplies the set).
// An extra pinned predicate OR-ed with the precache's own pinned set
// (favorites + latest week, #48). Pinned originals are never evicted.
isOriginalPinned?: (fileID: number) => boolean;
// The aggressive local precache (#48), all starting inside `open()` with no
// caller input. Thumbnails: every file, newest first, until all are on
// disk. Originals: the favorites album then the latest `precacheOriginalsDays`
// window (the days ending at the newest file). Both default on; the days
// default to 7.
precacheThumbnails?: boolean;
precacheOriginals?: boolean;
precacheOriginalsDays?: number;
}
export interface LibraryStatus {
@@ -175,6 +207,13 @@ export interface LibraryStatus {
// last write or open; undefined when no content cache is open.
originalsUsedBytes?: number;
originalsLimitBytes?: number;
// Precache progress (#48); undefined when no content cache is open. Totals
// are the files targeted (0 when a fill is disabled); "cached" is how many
// of them are on disk.
thumbnailsCached?: number;
thumbnailsTotal?: number;
originalsCached?: number;
originalsPinned?: number;
closed: boolean;
}
@@ -207,6 +246,8 @@ export class Library {
private readonly pools: RequestPools;
// The ML-data cache, present only when the client can fetch ML data.
private readonly mlStore?: MLDataStore;
// The local precache (#48), present only when the content cache is.
private readonly precache?: Precache;
private timer?: ReturnType<typeof setTimeout>;
private refreshing = false;
@@ -240,6 +281,7 @@ export class Library {
pools: RequestPools;
mldata?: MLDataStore;
cache?: ContentCache;
precache?: Precache;
}) {
this.client = args.client;
this.store = args.store;
@@ -251,6 +293,7 @@ export class Library {
this.pools = args.pools;
this.mlStore = args.mldata;
this.cache = args.cache;
this.precache = args.precache;
this.lastRecords = this.deriveNow();
// The read namespaces derive fresh from the store on each call, so they
@@ -310,7 +353,21 @@ export class Library {
// the start and the first refresh raises no spurious path-change diff.
const source = opts.contentSource ?? opts.client.contentSource?.();
let cache: ContentCache | undefined;
let precache: Precache | undefined;
if (source) {
// The precache owns the pinned set (favorites + latest week), which
// is the cache's eviction predicate. It is built and seeded from
// the loaded store first so the cache can wire `isPinned` to it, and
// then bound to the cache it fills. A caller-supplied predicate is
// OR-ed in so both survive.
precache = new Precache({
thumbnails: opts.precacheThumbnails,
originals: opts.precacheOriginals,
originalsDays: opts.precacheOriginalsDays,
onEvent: opts.onProgress,
});
precache.update(deriveRecordsFromStore(store));
const extraPinned = opts.isOriginalPinned;
cache = new ContentCache({
pools,
source,
@@ -319,9 +376,12 @@ export class Library {
getFile: (fileID) => store.getFileByID(fileID),
cacheOriginalsMaxBytes: opts.cacheOriginalsMaxBytes,
freeBelowBytes: opts.freeBelowBytes,
isPinned: opts.isOriginalPinned,
isPinned: (fileID) =>
precache!.isPinned(fileID) ||
(extraPinned?.(fileID) ?? false),
});
await cache.open();
precache.bind(cache);
}
const lib = new Library({
@@ -335,8 +395,15 @@ export class Library {
pools,
mldata,
cache,
precache,
});
// Start filling from whatever the loaded store already holds; each
// refresh below re-kicks with the new files (and retries any that
// failed). An empty store starts empty here and fills after its first
// refresh.
precache?.start();
if (store.loadedFromDisk) {
// An existing copy already answers reads; refresh in the background
// and start the interval once that first cycle settles.
@@ -396,6 +463,7 @@ export class Library {
}
const ml = this.mlStore?.stats();
const originals = this.cache?.originalsStatus();
const pre = this.precache?.status();
return {
userID: this.store.userID,
collections: collections.length,
@@ -408,6 +476,10 @@ export class Library {
mlIndexed: ml?.indexed,
originalsUsedBytes: originals?.usedBytes,
originalsLimitBytes: originals?.limitBytes,
thumbnailsCached: pre?.thumbnailsCached,
thumbnailsTotal: pre?.thumbnailsTotal,
originalsCached: pre?.originalsCached,
originalsPinned: pre?.originalsPinned,
closed: this.closed,
};
}
@@ -455,6 +527,7 @@ export class Library {
// finish; it will not schedule another cycle once closed.
close(): void {
this.closed = true;
this.precache?.close();
if (this.timer !== undefined) {
clearTimeout(this.timer);
this.timer = undefined;
@@ -572,7 +645,12 @@ export class Library {
if (change) this.notify(change);
}
this.lastRecords = next;
// Recompute the fill orders and pinned set against the new library.
this.precache?.update(next);
}
// Re-kick the fills every cycle: a finished sweep starts afresh to pick
// up new files and retry any that failed, and a running one is left be.
this.precache?.start();
// Persist whenever RAM holds changes disk has not accepted — including
// changes an earlier cycle staged whose save failed. `unsaved` clears
@@ -668,15 +746,7 @@ export class Library {
// Gather every file membership and project the store into by-id records,
// filling each record's cache paths from the content cache when present.
private deriveNow(): DerivedRecords {
const collections = this.store.listCollections();
const files: EnteFile[] = [];
for (const c of collections) files.push(...this.store.listFiles(c.id));
const cache = this.cache;
return deriveRecords(
collections,
files,
cache ? (fileID) => cache.pathsFor(fileID) : undefined,
);
return deriveRecordsFromStore(this.store, this.cache);
}
private notify(change: LibraryChange): void {