Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d09a7db01 |
+24
-61
@@ -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,32 +441,23 @@ 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;
|
||||
if (size === 0) {
|
||||
throw new Error(
|
||||
`content cache: ${kind} ${fileID} stored empty`,
|
||||
);
|
||||
}
|
||||
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);
|
||||
return { path: dest, bytes: size, cached: false };
|
||||
} finally {
|
||||
if (write) this.inFlightOriginals.delete(write);
|
||||
await this.download(file, dest, kind, opts?.onByte);
|
||||
await chmod(dest, FILE_MODE);
|
||||
const size = (await stat(dest)).size;
|
||||
if (size === 0) {
|
||||
throw new Error(
|
||||
`content cache: ${kind} ${fileID} stored empty`,
|
||||
);
|
||||
}
|
||||
known.set(fileID, dest);
|
||||
// A fresh original may have crossed the limit; make room by
|
||||
// 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 },
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user