Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 9d09a7db01 Bound the originals cache with adaptive LRU eviction (closes #47)
check / check (push) Successful in 26s
Bounds cacheDirectory/originals to an adaptive limit:
min(cacheOriginalsMaxBytes, bytesUsed + bytesFree - freeBelowBytes), with
bytesFree from fs.statfs, so it falls as the disk fills and rises as space
returns (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. Pinned
originals and the file just written are never evicted — so an on-demand fetch
larger than the limit keeps the path it returns and re-downloads nothing; the
skipped file becomes eligible on a later write. When only such files remain the
cache stays over-limit. Defaults: 100 GiB limit, 50 GiB reserve; thumbnails and
the backup directory are never counted.

Model: opus-4-8
2026-09-22 17:18:42 +00:00
2 changed files with 44 additions and 7 deletions
+17 -7
View File
@@ -451,9 +451,12 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
}
known.set(fileID, dest);
// A fresh original may have crossed the limit; make room by
// evicting least-recently-used originals (never this one, just
// touched). Thumbnails are never bounded or evicted.
if (kind === "original") await this.enforceOriginalsLimit();
// evicting least-recently-used originals. The file just written
// is excluded from the candidates so an on-demand fetch that is
// itself over budget proceeds over-limit and returns a path that
// exists on disk. Thumbnails are never bounded or evicted.
if (kind === "original")
await this.enforceOriginalsLimit(fileID);
return { path: dest, bytes: size, cached: false };
},
{ priority, key: fileID },
@@ -540,16 +543,23 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
}
// Evict least-recently-used originals until usage fits the limit. Pinned
// originals are skipped; when only pinned originals remain the cache stays
// over-limit until the pinned set shrinks.
private enforceOriginalsLimit(): Promise<void> {
// originals are skipped, as is `justWrittenID` (the original this write just
// stored, so an over-budget on-demand fetch never evicts the file it is
// about to return). When only such originals remain the cache stays
// over-limit until the protected set shrinks; the skipped file becomes
// eligible on a later write.
private enforceOriginalsLimit(justWrittenID: number): Promise<void> {
return this.serializeEnforce(async () => {
const { entries, used } = await this.measureOriginals();
const limit = await this.originalsLimit(used);
let remaining = used;
if (remaining > limit) {
const evictable = entries
.filter((e) => !this.isPinned(e.fileID))
.filter(
(e) =>
e.fileID !== justWrittenID &&
!this.isPinned(e.fileID),
)
.sort((a, b) => a.mtimeMs - b.mtimeMs);
for (const e of evictable) {
if (remaining <= limit) break;
+27
View File
@@ -187,6 +187,33 @@ describe("originals eviction", () => {
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.