/** * The aggressive local precache (issue #48), driven from `Library.open`. * * Two background fills start with no caller input: every thumbnail in the * account newest first, and the originals of the pinned set (the favorites * album then the latest `precacheOriginalsDays` window ending at the newest * file). Both run through the shared pools at background priority, so on-demand * work always preempts them; both report through `status()`. The pinned set is * the eviction predicate (#47), so a pinned original is never evicted and a * file that leaves the set becomes an ordinary, evictable original. * * The unit tests drive `Precache` against a fake cache that records what it was * asked to fetch (order and kind) with no pool or network; the integration * tests drive the real wiring through `Library.open` with a stub content * source, and the eviction test drives the real `ContentCache`. */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtempSync, rmSync, existsSync, utimesSync } from "node:fs"; import { writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Precache, type PrecacheCache } from "../../src/library/precache.js"; import { deriveRecords, type DerivedRecords, } from "../../src/library/records.js"; import { ContentCache, type ContentSource, type EnsureResult, type StatFsFn, } from "../../src/library/content.js"; import { RequestPools } from "../../src/library/pools.js"; import { Library } from "../../src/library/index.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js"; const DAY_MICROS = 24 * 60 * 60 * 1000 * 1000; const collection = ( id: number, type: Collection["type"] = "album", ): Collection => ({ id, ownerID: 1, key: new Uint8Array([id & 0xff]), name: `album-${id}`, type, updationTime: 1, isShared: false, }); // A file whose creationTime (microseconds) places it `daysAgo` days before a // fixed reference instant, so the latest-week window is deterministic. const REFERENCE_MICROS = 1_000 * DAY_MICROS; const file = (id: number, collectionID: number, daysAgo: number): EnteFile => ({ id, collectionID, ownerID: 1, key: new Uint8Array([id & 0xff]), metadata: { title: `file-${id}.jpg`, fileType: "image", creationTime: REFERENCE_MICROS - daysAgo * DAY_MICROS, modificationTime: 0, }, file: { decryptionHeader: "aGVhZGVy" }, thumbnail: { decryptionHeader: "dGh1bWI=" }, updationTime: 1, }); const records = ( collections: Collection[], files: EnteFile[], ): DerivedRecords => deriveRecords(collections, files); // A fake cache: records every fetch (kind + order) and reports the files it has // stored via `pathsFor`. `presentThumbs`/`presentOriginals` seed already-cached // files so the precache skips them with a single lookup. class FakeCache implements PrecacheCache { readonly thumbFetched: number[] = []; readonly originalFetched: number[] = []; readonly presentThumbs = new Set(); readonly presentOriginals = new Set(); pathsFor(fileID: number): { originalPath?: string; thumbnailPath?: string; } { const out: { originalPath?: string; thumbnailPath?: string } = {}; if (this.presentThumbs.has(fileID)) out.thumbnailPath = `/thumbs/${fileID}`; if (this.presentOriginals.has(fileID)) out.originalPath = `/originals/${fileID}`; return out; } async ensureThumbnails(args: { fileIDs: number[]; priority: "background"; }): Promise { return args.fileIDs.map((fileID) => { this.thumbFetched.push(fileID); this.presentThumbs.add(fileID); return { fileID, path: `/thumbs/${fileID}` }; }); } async ensureOriginals(args: { fileIDs: number[]; }): Promise { return args.fileIDs.map((fileID) => { this.originalFetched.push(fileID); this.presentOriginals.add(fileID); return { fileID, path: `/originals/${fileID}` }; }); } } // Resolve once a predicate holds, polling the microtask queue; fails fast // rather than hanging the suite. const until = async (predicate: () => boolean): Promise => { for (let i = 0; i < 1000; i++) { if (predicate()) return; await new Promise((r) => setTimeout(r, 1)); } throw new Error("condition not met in time"); }; describe("Precache unit", () => { it("precaches every thumbnail newest first, skipping present ones", async () => { const cols = [collection(1)]; const files = [ file(1, 1, 0), file(2, 1, 1), file(3, 1, 2), file(4, 1, 3), ]; const cache = new FakeCache(); cache.presentThumbs.add(3); // already on disk: skipped const pre = new Precache({ originals: false }); pre.bind(cache); pre.update(records(cols, files)); pre.start(); await until(() => cache.thumbFetched.length === 3); // Newest first (file 1 is newest), file 3 skipped by a lookup. expect(cache.thumbFetched).toEqual([1, 2, 4]); expect(cache.originalFetched).toEqual([]); }); it("pins favorites then the latest-week window and precaches their originals in that order", async () => { const cols = [collection(1), collection(2, "favorites")]; // File 10 is an old favorite (30 days old); files 1..3 are within the // 7-day window; file 4 is outside it. const files = [ file(1, 1, 0), file(2, 1, 2), file(3, 1, 6), file(4, 1, 20), file(10, 2, 30), // favorite, old ]; const cache = new FakeCache(); const pre = new Precache({ originalsDays: 7 }); pre.bind(cache); pre.update(records(cols, files)); pre.start(); await until(() => cache.originalFetched.length === 4); // Favorite (10) first, then the window newest-first (1, 2, 3). File 4 // is outside the window and never pinned. expect(cache.originalFetched).toEqual([10, 1, 2, 3]); expect(pre.isPinned(10)).toBe(true); expect(pre.isPinned(1)).toBe(true); expect(pre.isPinned(4)).toBe(false); }); it("drops a file from the pinned set when the window moves past it", () => { const cols = [collection(1)]; const cache = new FakeCache(); const pre = new Precache({ originalsDays: 7 }); pre.bind(cache); pre.update(records(cols, [file(1, 1, 0), file(2, 1, 3)])); expect(pre.isPinned(2)).toBe(true); // A newer file arrives; the window's newest end moves forward so the // 3-day-old file 2 (now 13 days behind the newest) falls out. pre.update( records(cols, [file(3, 1, -10), file(1, 1, 0), file(2, 1, 3)]), ); expect(pre.isPinned(3)).toBe(true); expect(pre.isPinned(2)).toBe(false); }); it("reports progress through status()", async () => { const cols = [collection(1), collection(2, "favorites")]; const files = [file(1, 1, 0), file(2, 1, 1), file(10, 2, 0)]; const cache = new FakeCache(); const pre = new Precache({ originalsDays: 7 }); pre.bind(cache); pre.update(records(cols, files)); const before = pre.status(); expect(before.thumbnailsTotal).toBe(3); expect(before.thumbnailsCached).toBe(0); expect(before.originalsPinned).toBe(3); // files 1, 2, 10 all in window expect(before.originalsCached).toBe(0); pre.start(); await until( () => cache.thumbFetched.length === 3 && cache.originalFetched.length === 3, ); const after = pre.status(); expect(after.thumbnailsCached).toBe(3); expect(after.originalsCached).toBe(3); }); it("honours the disable flags", async () => { const cols = [collection(1)]; const files = [file(1, 1, 0)]; const cache = new FakeCache(); const pre = new Precache({ thumbnails: false, originals: false }); pre.bind(cache); pre.update(records(cols, files)); pre.start(); await new Promise((r) => setTimeout(r, 20)); expect(cache.thumbFetched).toEqual([]); expect(cache.originalFetched).toEqual([]); expect(pre.isPinned(1)).toBe(false); expect(pre.status().thumbnailsTotal).toBe(0); }); }); // ---- Integration through the real ContentCache and Library ---- const enteFile = (id: number, collectionID: number): EnteFile => file(id, collectionID, 0); describe("Precache eviction integration", () => { let root: string; beforeEach(() => { root = mkdtempSync(join(tmpdir(), "quak-precache-evict-")); }); afterEach(() => { if (root && existsSync(root)) rmSync(root, { recursive: true, force: true }); }); it("never evicts a pinned original the precache put in place", async () => { const cacheDir = join(root, "cache"); // File 1 is the favorites album's only file (pinned regardless of age); // files 2 and 3 sit outside the latest-week window, so only file 1 is // pinned. The by-id map serves the bytes for each fetch. const cols = [collection(1), collection(2, "favorites")]; const files = [file(1, 2, 30), file(2, 1, 40), file(3, 1, 50)]; const byID = new Map(files.map((f) => [f.id, f])); const source: ContentSource = { original: async ({ destination }) => { await writeFile(destination, Buffer.alloc(10, 1)); return { bytesWritten: 10 }; }, thumbnail: async ({ destination }) => { await writeFile(destination, Buffer.alloc(10, 1)); return { bytesWritten: 10 }; }, }; const statfs: StatFsFn = async () => ({ bsize: 1, bavail: 1_000_000_000, }); const pre = new Precache({ originalsDays: 7 }); const cache = new ContentCache({ pools: new RequestPools(), source, cacheDirectory: cacheDir, getFile: (id) => byID.get(id), statfs, cacheOriginalsMaxBytes: 25, // holds two 10-byte originals freeBelowBytes: 0, isPinned: (id) => pre.isPinned(id), }); pre.bind(cache); pre.update(records(cols, files)); expect(pre.isPinned(1)).toBe(true); expect(pre.isPinned(2)).toBe(false); await cache.open(); // Fill three originals; the 25-byte cap forces an eviction on the // third, and the pinned file 1 must survive it even though it is the // least-recently-used. await cache.original(1); utimesSync(join(cacheDir, "originals", "1.jpg"), 1000, 1000); // oldest await cache.original(2); utimesSync(join(cacheDir, "originals", "2.jpg"), 2000, 2000); await cache.original(3); expect(existsSync(join(cacheDir, "originals", "1.jpg"))).toBe(true); expect(existsSync(join(cacheDir, "originals", "2.jpg"))).toBe(false); expect(existsSync(join(cacheDir, "originals", "3.jpg"))).toBe(true); }); }); describe("Precache preemption", () => { let root: string; beforeEach(() => { root = mkdtempSync(join(tmpdir(), "quak-precache-preempt-")); }); afterEach(() => { if (root && existsSync(root)) rmSync(root, { recursive: true, force: true }); }); it("lets an on-demand original preempt the background originals fill", async () => { const byID = new Map([ [1, enteFile(1, 1)], [2, enteFile(2, 1)], [3, enteFile(3, 1)], ]); const finished: number[] = []; let openGate!: () => void; const gate = new Promise((r) => (openGate = r)); let sawFirst!: () => void; const firstStarted = new Promise((r) => (sawFirst = r)); let started = 0; const source: ContentSource = { original: async ({ file: f, destination }) => { if (++started === 1) sawFirst(); await gate; await writeFile(destination, Buffer.alloc(10, 1)); finished.push(f.id); return { bytesWritten: 10 }; }, thumbnail: async ({ destination }) => { await writeFile(destination, Buffer.alloc(10, 1)); return { bytesWritten: 10 }; }, }; // One content slot, so file 1 holds it while 2 and 3 wait. const cache = new ContentCache({ pools: new RequestPools({ contentConcurrency: 1 }), source, cacheDirectory: join(root, "cache"), getFile: (id) => byID.get(id), statfs: async () => ({ bsize: 1, bavail: 1_000_000_000 }), freeBelowBytes: 0, }); await cache.open(); const pA = cache.ensureOriginals({ fileIDs: [1] }); // background await firstStarted; // file 1 now holds the only slot const pB = cache.original(2); // on-demand, queued behind file 1 const pC = cache.ensureOriginals({ fileIDs: [3] }); // background, queued await new Promise((r) => setTimeout(r, 5)); // let both enqueue openGate(); await Promise.all([pA, pB, pC]); // On-demand file 2 was served before the background file 3. expect(finished).toEqual([1, 2, 3]); }); }); describe("Precache through Library.open", () => { let root: string; beforeEach(() => { root = mkdtempSync(join(tmpdir(), "quak-precache-lib-")); }); afterEach(() => { if (root && existsSync(root)) rmSync(root, { recursive: true, force: true }); }); class MockClient { served = false; whoami(): { email: string; userID: number } { return { email: "u@example.com", userID: 7 }; } async collectionsSince(): Promise { if (this.served) return { collections: [], deleted: [], cursor: 1 }; this.served = true; return { collections: [collection(1), collection(2, "favorites")], deleted: [], cursor: 1, }; } async filesSince(args: { collectionID: number }): Promise { const files = args.collectionID === 1 ? [enteFile(1, 1), enteFile(2, 1)] : [enteFile(3, 2)]; return { files, deleted: [], cursor: 1 }; } } it("starts both precaches from open() and reports them in status()", async () => { const source: ContentSource = { original: async ({ destination }) => { await writeFile(destination, Buffer.alloc(10, 1)); return { bytesWritten: 10 }; }, thumbnail: async ({ destination }) => { await writeFile(destination, Buffer.alloc(10, 1)); return { bytesWritten: 10 }; }, }; // Each fill reports "done" once the cache has recorded its files. The // source returning is not enough: the cache records a file only after // it has checked it on disk. const finished = new Set(); let bothFinished!: () => void; const precached = new Promise((r) => (bothFinished = r)); const lib = await Library.open({ client: new MockClient(), cacheDirectory: join(root, "cache"), contentSource: source, refreshIntervalSeconds: 3600, onProgress: (e) => { if ( e.status === "done" && (e.operation === "precacheThumbnails" || e.operation === "precacheOriginals") ) { finished.add(e.operation); if (finished.size === 2) bothFinished(); } }, }); // Every file's thumbnail is precached; the favorite (file 3) and the // week's files (1, 2) all have their originals precached. await precached; const status = lib.status(); expect(status.thumbnailsTotal).toBe(3); expect(status.thumbnailsCached).toBe(3); expect(status.originalsPinned).toBe(3); expect(status.originalsCached).toBe(3); await lib.close(); }); it.each(["thumbnail", "original"] as const)( "close() resolves only after a running %s precache fetch has written its file", async (kind) => { // Only the fill under test runs, and each of its fetches waits // until the test releases it. let release!: () => void; const held = new Promise((r) => (release = r)); let fetchStarted!: (destination: string) => void; const started = new Promise((r) => (fetchStarted = r)); const fetch = async ({ destination }: { destination: string }) => { fetchStarted(destination); await held; await writeFile(destination, Buffer.alloc(10, 1)); return { bytesWritten: 10 }; }; const unused = async () => { throw new Error("this fill is turned off"); }; const source: ContentSource = kind === "thumbnail" ? { original: unused, thumbnail: fetch } : { original: fetch, thumbnail: unused }; const lib = await Library.open({ client: new MockClient(), cacheDirectory: join(root, "cache"), contentSource: source, refreshIntervalSeconds: 3600, precacheThumbnails: kind === "thumbnail", precacheOriginals: kind === "original", }); try { const destination = await started; let closed = false; const closing = lib.close().then(() => { closed = true; }); await new Promise((r) => setTimeout(r, 20)); expect(closed).toBe(false); release(); await closing; expect(existsSync(destination)).toBe(true); } finally { release(); await lib.close(); } }, ); });