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 24 additions and 124 deletions
+14 -51
View File
@@ -58,13 +58,6 @@ const THUMBNAIL_EXT = ".jpg";
type Kind = "original" | "thumbnail";
// An original write in progress, with the IDs of the concurrent original writes
// it overlaps (recorded both ways as writes begin, cleared when the write ends).
interface OriginalWrite {
fileID: number;
overlaps: Set<number>;
}
// The priority a caller attaches to a thumbnail prefetch. The pool has two
// tiers, so this three-value surface collapses onto them: only a currently
// visible thumbnail preempts (on-demand); "ahead" prefetch and speculative
@@ -255,13 +248,6 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
// Serializes limit enforcement so concurrent original writes never race on
// the map or delete each other's just-freed room.
private enforcing: Promise<void> = Promise.resolve();
// Original writes in progress. Writes for different files run concurrently
// (the content pool), so an eviction pass must never delete a file whose
// fetch has not yet returned. Each entry records the IDs of the concurrent
// original writes it overlaps — noted both ways as writes begin — and a
// write's eviction pass spares them all. Bounded by the pool's concurrency,
// so eviction is never deferred beyond the active working set.
private readonly inFlightOriginals = new Set<OriginalWrite>();
constructor(opts: ContentCacheOptions) {
this.pools = opts.pools;
@@ -455,14 +441,6 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
// to completion.
if (signal?.aborted) throw new AbortDrop();
// Register this original among those in flight, linking it with
// every sibling already writing so neither evicts the other's
// file. Non-null iff this is an original.
const write =
kind === "original"
? this.beginOriginalWrite(fileID)
: null;
try {
await this.download(file, dest, kind, opts?.onByte);
await chmod(dest, FILE_MODE);
const size = (await stat(dest)).size;
@@ -473,14 +451,13 @@ 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. An over-budget
// fetch keeps the file it returns, and no overlapping
// sibling is evicted. Thumbnails are never bounded.
if (write) await this.enforceOriginalsLimit(write);
// 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 };
} finally {
if (write) this.inFlightOriginals.delete(write);
}
},
{ priority, key: fileID },
);
@@ -565,26 +542,13 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
});
}
// Record a starting original write among those in flight, linking it with
// every sibling already writing so neither can evict the other's file.
private beginOriginalWrite(fileID: number): OriginalWrite {
const write: OriginalWrite = { fileID, overlaps: new Set() };
for (const other of this.inFlightOriginals) {
write.overlaps.add(other.fileID);
other.overlaps.add(fileID);
}
this.inFlightOriginals.add(write);
return write;
}
// Evict least-recently-used originals until usage fits the limit. Skipped:
// pinned originals, the file `write` just stored, and every original whose
// write overlaps it (`write.overlaps`). The last two spare any fetch whose
// lifetime overlaps this one, so concurrent over-budget fetches all keep the
// paths they return; when only such originals remain the cache stays
// over-limit until they settle, and a later, non-overlapping write finds
// them eligible again.
private enforceOriginalsLimit(write: OriginalWrite): Promise<void> {
// Evict least-recently-used originals until usage fits the limit. Pinned
// 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);
@@ -593,8 +557,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
const evictable = entries
.filter(
(e) =>
e.fileID !== write.fileID &&
!write.overlaps.has(e.fileID) &&
e.fileID !== justWrittenID &&
!this.isPinned(e.fileID),
)
.sort((a, b) => a.mtimeMs - b.mtimeMs);
-63
View File
@@ -70,40 +70,6 @@ class SizedSource implements ContentSource {
}
}
// 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;
@@ -311,35 +277,6 @@ describe("originals eviction", () => {
);
});
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,