/** * Tests for the originals cache size limit and LRU eviction (issue #47), * layered on the on-disk content cache (#46). * * Only `cacheDirectory/originals` is bounded and evicted. Before each original * write the effective limit is * min(cacheOriginalsMaxBytes, bytesUsedByOriginals + bytesFree - freeBelowBytes) * with `bytesFree` read from `statfs` on the volume holding `cacheDirectory`. * The limit therefore falls as the disk fills and rises as space returns. When * a write would cross the limit, least-recently-used originals are removed until * it fits; pinned files are skipped, and if only pinned files remain the write * proceeds over-limit. Last-use is the file `mtime`, bumped whenever a read * returns an original's path, so ordering survives a restart with no ledger. * * `statfs` is injected so the adaptive limit is exercised deterministically: * `bsize` is 1, so `bavail` is the free byte count the formula sees. The source * writes a controllable number of bytes per file, and tests set each stored * file's `mtime` explicitly so LRU order does not depend on wall-clock timing. */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtempSync, rmSync, existsSync, statSync, utimesSync } from "node:fs"; import { writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { ContentCache, type ContentSource, type StatFsFn, } from "../../src/library/content.js"; import { RequestPools } from "../../src/library/pools.js"; import type { EnteFile } from "../../src/model/types.js"; const file = (id: number): EnteFile => ({ id, collectionID: 1, ownerID: 1, key: new Uint8Array([id & 0xff]), metadata: { title: `file-${id}.jpg`, fileType: "image", creationTime: 0, modificationTime: 0, }, file: { decryptionHeader: "aGVhZGVy" }, thumbnail: { decryptionHeader: "dGh1bWI=" }, updationTime: 0, }); // A source that writes a controllable number of bytes per file (default 10). class SizedSource implements ContentSource { sizeFor = new Map(); async original(args: { file: EnteFile; destination: string; }): Promise<{ bytesWritten: number }> { const n = this.sizeFor.get(args.file.id) ?? 10; await writeFile(args.destination, Buffer.alloc(n, 1)); return { bytesWritten: n }; } async thumbnail(args: { file: EnteFile; destination: string; }): Promise<{ bytesWritten: number }> { await writeFile(args.destination, Buffer.alloc(10, 1)); return { bytesWritten: 10 }; } } let root: string; let cacheDir: string; beforeEach(() => { root = mkdtempSync(join(tmpdir(), "quak-eviction-")); cacheDir = join(root, "cache"); }); afterEach(() => { if (root && existsSync(root)) rmSync(root, { recursive: true, force: true }); }); const originalPath = (id: number): string => join(cacheDir, "originals", `${id}.jpg`); // Pin an original's mtime to a fixed second-resolution instant so LRU order is // deterministic. Lower `seconds` = older = evicted first. const setMtime = (id: number, seconds: number): void => { utimesSync(originalPath(id), seconds, seconds); }; const buildCache = (args: { source?: ContentSource; files?: EnteFile[]; statfs: StatFsFn; cacheOriginalsMaxBytes?: number; freeBelowBytes?: number; isPinned?: (fileID: number) => boolean; }): { cache: ContentCache; source: SizedSource } => { const source = (args.source as SizedSource) ?? new SizedSource(); const byID = new Map(); for (const f of args.files ?? [file(1), file(2), file(3), file(4), file(5)]) byID.set(f.id, f); const cache = new ContentCache({ pools: new RequestPools(), source, cacheDirectory: cacheDir, getFile: (id) => byID.get(id), statfs: args.statfs, cacheOriginalsMaxBytes: args.cacheOriginalsMaxBytes, freeBelowBytes: args.freeBelowBytes, isPinned: args.isPinned, }); return { cache, source }; }; // Plenty of free space, so the configured max governs the limit unless a test // dials it down. `bsize` of 1 makes `bavail` the free byte count. const abundantFree: StatFsFn = async () => ({ bsize: 1, bavail: 1_000_000_000, }); describe("originals eviction", () => { it("removes least-recently-used originals to fit the limit", async () => { // Each original is 10 bytes; a 25-byte cap holds two. const { cache } = buildCache({ statfs: abundantFree, cacheOriginalsMaxBytes: 25, freeBelowBytes: 0, }); await cache.open(); await cache.original(1); setMtime(1, 1000); await cache.original(2); setMtime(2, 2000); // Writing the third (total 30 > 25) evicts the oldest, file 1. await cache.original(3); expect(existsSync(originalPath(1))).toBe(false); expect(existsSync(originalPath(2))).toBe(true); expect(existsSync(originalPath(3))).toBe(true); expect(cache.pathsFor(1).originalPath).toBeUndefined(); expect(cache.originalsStatus().usedBytes).toBe(20); }); it("skips pinned files, evicting the oldest unpinned instead", async () => { const { cache } = buildCache({ statfs: abundantFree, cacheOriginalsMaxBytes: 25, freeBelowBytes: 0, isPinned: (id) => id === 1, }); await cache.open(); await cache.original(1); setMtime(1, 1000); // oldest, but pinned await cache.original(2); setMtime(2, 2000); await cache.original(3); // File 1 is oldest but pinned, so file 2 is evicted instead. expect(existsSync(originalPath(1))).toBe(true); expect(existsSync(originalPath(2))).toBe(false); expect(existsSync(originalPath(3))).toBe(true); }); it("proceeds over-limit when only pinned files remain", async () => { // A 5-byte cap cannot hold even one 10-byte original. const { cache } = buildCache({ statfs: abundantFree, cacheOriginalsMaxBytes: 5, freeBelowBytes: 0, isPinned: () => true, }); await cache.open(); const result = await cache.original(1); expect(existsSync(result.path)).toBe(true); const status = cache.originalsStatus(); expect(status.usedBytes).toBe(10); expect(status.usedBytes).toBeGreaterThan(status.limitBytes ?? 0); }); it("adapts the limit down as the disk fills and up as space returns", async () => { // The configured max is generous; free space drives the limit. Eviction // only kicks in once free space falls below the protected reserve. let free = 1_000_000; const statfs: StatFsFn = async () => ({ bsize: 1, bavail: free }); const { cache } = buildCache({ statfs, cacheOriginalsMaxBytes: 1000, freeBelowBytes: 100, }); await cache.open(); // Ample free space: the limit is the configured max and nothing is // evicted as three 10-byte originals accumulate. await cache.original(1); setMtime(1, 1000); await cache.original(2); setMtime(2, 2000); await cache.original(3); setMtime(3, 3000); expect(cache.originalsStatus().limitBytes).toBe(1000); expect(cache.originalsStatus().usedBytes).toBe(30); // The disk fills: only 95 bytes free, below the 100-byte reserve. The // next write (used 40) sees limit = min(1000, 40 + 95 - 100) = 35 and // evicts the oldest original (file 1) to fit. free = 95; await cache.original(4); expect(cache.originalsStatus().limitBytes).toBe(35); expect(existsSync(originalPath(1))).toBe(false); expect(cache.originalsStatus().usedBytes).toBe(30); setMtime(4, 4000); // Space returns: the limit rises back to the configured max and the // next write is kept without eviction. free = 1_000_000; await cache.original(5); expect(cache.originalsStatus().limitBytes).toBe(1000); expect(existsSync(originalPath(4))).toBe(true); expect(existsSync(originalPath(5))).toBe(true); }); it("bumps an original's mtime when a read returns its path", async () => { const { cache } = buildCache({ statfs: abundantFree, cacheOriginalsMaxBytes: 1000, freeBelowBytes: 0, }); await cache.open(); await cache.original(1); // Age the file well into the past. setMtime(1, 1000); expect(statSync(originalPath(1)).mtimeMs).toBeLessThan(2_000_000); // A second read is a cache hit that must touch the file. const before = Date.now(); await cache.original(1); expect(statSync(originalPath(1)).mtimeMs).toBeGreaterThanOrEqual( before - 2000, ); }); it("never counts or evicts thumbnails", async () => { const { cache } = buildCache({ statfs: abundantFree, cacheOriginalsMaxBytes: 5, freeBelowBytes: 0, }); await cache.open(); await cache.thumbnail(1); await cache.thumbnail(2); expect(cache.pathsFor(1).thumbnailPath).toBeDefined(); expect(cache.pathsFor(2).thumbnailPath).toBeDefined(); expect(cache.originalsStatus().usedBytes).toBe(0); }); });