Bound the originals cache with adaptive LRU eviction (closes #47)
check / check (push) Successful in 35s

Bounds cacheDirectory/originals to an adaptive limit: min(configured max,
bytesUsed + bytesFree - reserve), bytesFree from statfs, so it tracks disk
pressure (status().originalsLimitBytes exposes it). Each original write evicts
least-recently-used originals until usage fits; last-use is the file mtime,
bumped on every read that returns a path, so order survives restarts.

Never evicted: pinned originals and every original whose write overlaps the
evicting one — its own and any concurrent sibling's, since content writes run in
parallel. So an over-budget fetch keeps the path it returns, and no write can
delete a sibling's file before that fetch returns it; such files become eligible
on a later, non-overlapping write. Defaults: 100 GiB limit, 50 GiB reserve.

Model: opus-4-8
This commit is contained in:
2026-09-22 17:57:20 +00:00
parent 5db59a6e2b
commit e7f77b4b27
3 changed files with 619 additions and 12 deletions
+358
View File
@@ -0,0 +1,358 @@
/**
* 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<number, number>();
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 };
}
}
// A source whose original() writes 10 bytes but blocks on a gate before
// returning, so two fetches can be held in flight together. `bothStarted`
// resolves once both originals have entered, and `release()` lets them finish.
class GatedSource implements ContentSource {
private openGate!: () => void;
private readonly gate = new Promise<void>((r) => (this.openGate = r));
private inFlight = 0;
private reachedTwo!: () => void;
readonly bothStarted = new Promise<void>((r) => (this.reachedTwo = r));
async original(args: {
file: EnteFile;
destination: string;
}): Promise<{ bytesWritten: number }> {
this.inFlight += 1;
if (this.inFlight === 2) this.reachedTwo();
await this.gate;
await writeFile(args.destination, Buffer.alloc(10, 1));
return { bytesWritten: 10 };
}
async thumbnail(args: {
file: EnteFile;
destination: string;
}): Promise<{ bytesWritten: number }> {
await writeFile(args.destination, Buffer.alloc(10, 1));
return { bytesWritten: 10 };
}
release(): void {
this.openGate();
}
}
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<number, EnteFile>();
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("keeps a just-written original larger than the limit, evicting it only on a later write", async () => {
// A 5-byte cap cannot hold even one 10-byte original, but a fetch must
// never evict the file it just wrote and is about to return.
const { cache } = buildCache({
statfs: abundantFree,
cacheOriginalsMaxBytes: 5,
freeBelowBytes: 0,
});
await cache.open();
const result = await cache.original(1);
// The just-written original survives its own over-budget write and the
// returned path exists on disk.
expect(existsSync(result.path)).toBe(true);
expect(existsSync(originalPath(1))).toBe(true);
expect(cache.originalsStatus().usedBytes).toBe(10);
setMtime(1, 1000);
// A later write finds file 1 eligible and evicts it to make room, while
// the newly written file 2 is itself kept over-limit.
await cache.original(2);
expect(existsSync(originalPath(1))).toBe(false);
expect(existsSync(originalPath(2))).toBe(true);
expect(cache.originalsStatus().usedBytes).toBe(10);
});
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("keeps both originals when two over-budget fetches race", async () => {
// A 15-byte cap holds one 10-byte original but not two. Two fetches are
// held in flight together; when both stored files cross the limit,
// neither eviction pass may delete the sibling whose path has not yet
// been returned, so both survive over-limit.
const source = new GatedSource();
const { cache } = buildCache({
source,
statfs: abundantFree,
cacheOriginalsMaxBytes: 15,
freeBelowBytes: 0,
});
await cache.open();
const p1 = cache.original(1);
const p2 = cache.original(2);
await source.bothStarted; // both downloads are in flight before either stores
source.release();
const [r1, r2] = await Promise.all([p1, p2]);
// Both returned paths exist on disk even though together they exceed the
// cap; nothing was evicted out from under a fetch still in progress.
expect(existsSync(r1.path)).toBe(true);
expect(existsSync(r2.path)).toBe(true);
expect(existsSync(originalPath(1))).toBe(true);
expect(existsSync(originalPath(2))).toBe(true);
expect(cache.originalsStatus().usedBytes).toBe(20);
});
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);
});
});