Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/web/src/primitives/__tests__/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ describe('db.js - Unified Dexie Database', () => {
data: new ArrayBuffer(100),
size: 100,
cachedAt: Date.now(),
lastAccessedAt: Date.now(),
};

await db.pdfs.add(pdfEntry);
Expand All @@ -137,6 +138,7 @@ describe('db.js - Unified Dexie Database', () => {
data: new ArrayBuffer(10),
size: 10,
cachedAt: 1,
lastAccessedAt: 1,
},
{
id: 'proj-1:study-2:b.pdf',
Expand All @@ -146,6 +148,7 @@ describe('db.js - Unified Dexie Database', () => {
data: new ArrayBuffer(20),
size: 20,
cachedAt: 2,
lastAccessedAt: 2,
},
{
id: 'proj-2:study-1:c.pdf',
Expand All @@ -155,6 +158,7 @@ describe('db.js - Unified Dexie Database', () => {
data: new ArrayBuffer(30),
size: 30,
cachedAt: 3,
lastAccessedAt: 3,
},
]);

Expand All @@ -174,6 +178,7 @@ describe('db.js - Unified Dexie Database', () => {
data: new ArrayBuffer(10),
size: 10,
cachedAt: 3000,
lastAccessedAt: 3000,
},
{
id: 'p:s:oldest.pdf',
Expand All @@ -183,6 +188,7 @@ describe('db.js - Unified Dexie Database', () => {
data: new ArrayBuffer(10),
size: 10,
cachedAt: 1000,
lastAccessedAt: 1000,
},
{
id: 'p:s:middle.pdf',
Expand All @@ -192,6 +198,7 @@ describe('db.js - Unified Dexie Database', () => {
data: new ArrayBuffer(10),
size: 10,
cachedAt: 2000,
lastAccessedAt: 2000,
},
]);

Expand All @@ -215,6 +222,7 @@ describe('db.js - Unified Dexie Database', () => {
data: new ArrayBuffer(10),
size: 10,
cachedAt: 1,
lastAccessedAt: 1,
},
{
id: 'proj-to-delete:s2:b.pdf',
Expand All @@ -224,6 +232,7 @@ describe('db.js - Unified Dexie Database', () => {
data: new ArrayBuffer(10),
size: 10,
cachedAt: 2,
lastAccessedAt: 2,
},
{
id: 'proj-to-keep:s1:c.pdf',
Expand All @@ -233,6 +242,7 @@ describe('db.js - Unified Dexie Database', () => {
data: new ArrayBuffer(10),
size: 10,
cachedAt: 3,
lastAccessedAt: 3,
},
]);

Expand All @@ -259,6 +269,7 @@ describe('db.js - Unified Dexie Database', () => {
data: new ArrayBuffer(10),
size: 10,
cachedAt: 1,
lastAccessedAt: 1,
});

await clearAllData();
Expand Down
96 changes: 78 additions & 18 deletions packages/web/src/primitives/__tests__/pdfCache.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import 'fake-indexeddb/auto';
import { db } from '../db.js';
import {
Expand Down Expand Up @@ -131,23 +131,83 @@ describe('pdfCache', () => {
});

describe('LRU eviction', () => {
it('evicts oldest entries when cache limit exceeded', async () => {
// Cache limit is 200MB, so we test with smaller files but verify ordering
const data = new ArrayBuffer(1000);

// Cache multiple entries with different timestamps
await cachePdf('project-1', 'study-1', 'old.pdf', data);
await new Promise(r => setTimeout(r, 10)); // Small delay for cachedAt difference
await cachePdf('project-1', 'study-2', 'newer.pdf', data);

// Verify both are cached
expect(await getCachedPdf('project-1', 'study-1', 'old.pdf')).not.toBeNull();
expect(await getCachedPdf('project-1', 'study-2', 'newer.pdf')).not.toBeNull();

// Verify ordering by cachedAt
const entries = await db.pdfs.orderBy('cachedAt').toArray();
expect(entries[0].fileName).toBe('old.pdf');
expect(entries[1].fileName).toBe('newer.pdf');
const MB = 1024 * 1024;

/** Seed a row whose recorded `size` is large enough to drive eviction
* without allocating a matching buffer. */
async function seed(fileName: string, size: number, lastAccessedAt: number, cachedAt = 1000) {
await db.pdfs.put({
id: `project-1:study-1:${fileName}`,
projectId: 'project-1',
studyId: 'study-1',
fileName,
data: new ArrayBuffer(8),
size,
cachedAt,
lastAccessedAt,
});
}

it('updates recency when a cached PDF is opened', async () => {
await seed('opened.pdf', 8, 1000);

expect(await getCachedPdf('project-1', 'study-1', 'opened.pdf')).not.toBeNull();

await vi.waitFor(async () => {
const row = await db.pdfs.get('project-1:study-1:opened.pdf');
expect(row!.lastAccessedAt).toBeGreaterThan(1000);
});
});

it('leaves the download time alone when a PDF is opened', async () => {
await seed('opened.pdf', 8, 1000);

await getCachedPdf('project-1', 'study-1', 'opened.pdf');

await vi.waitFor(async () => {
const row = await db.pdfs.get('project-1:study-1:opened.pdf');
expect(row!.lastAccessedAt).toBeGreaterThan(1000);
expect(row!.cachedAt).toBe(1000);
});
});

it('evicts the least recently opened entry, not the oldest download', async () => {
// Downloaded first but opened recently: must survive.
await seed('old-download.pdf', 100 * MB, 9000, 1000);
// Downloaded later but never opened since: must be evicted first.
await seed('stale.pdf', 100 * MB, 2000, 5000);

await cachePdf('project-1', 'study-2', 'incoming.pdf', new ArrayBuffer(1000));

expect(await getCachedPdf('project-1', 'study-1', 'stale.pdf')).toBeNull();
expect(await getCachedPdf('project-1', 'study-1', 'old-download.pdf')).not.toBeNull();
expect(await getCachedPdf('project-1', 'study-2', 'incoming.pdf')).not.toBeNull();
});

it('promotes an entry out of the eviction path once it is opened', async () => {
await seed('a.pdf', 100 * MB, 2000, 1000);
await seed('b.pdf', 100 * MB, 3000, 2000);

// Opening the older entry makes the other one the eviction candidate.
await getCachedPdf('project-1', 'study-1', 'a.pdf');
await vi.waitFor(async () => {
const row = await db.pdfs.get('project-1:study-1:a.pdf');
expect(row!.lastAccessedAt).toBeGreaterThan(3000);
});

await cachePdf('project-1', 'study-2', 'incoming.pdf', new ArrayBuffer(1000));

expect(await getCachedPdf('project-1', 'study-1', 'b.pdf')).toBeNull();
expect(await getCachedPdf('project-1', 'study-1', 'a.pdf')).not.toBeNull();
});

it('keeps the cache under the limit when every entry is evictable', async () => {
await seed('a.pdf', 100 * MB, 2000);
await seed('b.pdf', 100 * MB, 3000);

await cachePdf('project-1', 'study-2', 'incoming.pdf', new ArrayBuffer(1000));

expect(await getCacheSize()).toBeLessThanOrEqual(200 * MB);
});
});
});
19 changes: 19 additions & 0 deletions packages/web/src/primitives/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ interface PdfCacheRow {
data: ArrayBuffer;
size: number;
cachedAt: number;
/** Kept distinct from `cachedAt` so eviction can order by last open while
* the download time stays available to show the user. */
lastAccessedAt: number;
}

interface AvatarRow {
Expand Down Expand Up @@ -141,6 +144,22 @@ class CoratesDB extends Dexie {
this.version(4).stores({
syncCaches: 'id, updatedAt',
});

// v5: PDF cache eviction orders by last open. Existing rows must be
// backfilled: Dexie's index skips records that lack the key, so an
// unmigrated row would be invisible to the eviction scan and never freed.
this.version(5)
.stores({
pdfs: 'id, projectId, studyId, cachedAt, lastAccessedAt',
})
.upgrade(async tx => {
await tx
.table<PdfCacheRow>('pdfs')
.toCollection()
.modify(row => {
row.lastAccessedAt = row.cachedAt;
});
});
}
}

Expand Down
27 changes: 23 additions & 4 deletions packages/web/src/primitives/pdfCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ function getCacheKey(projectId: string, studyId: string, fileName: string): stri
return `${projectId}:${studyId}:${fileName}`;
}

/**
* Record that a PDF was opened, for eviction ordering.
*
* Deliberately not awaited by callers: every read is on the path to rendering
* a PDF, and a failed bookkeeping write must not turn a cache hit into a miss.
*/
function touchLastAccessed(id: string): void {
db.pdfs.update(id, { lastAccessedAt: Date.now() }).catch(err => {
console.warn('Failed to update PDF cache recency:', err);
});
}

/**
* Get a PDF from the local cache
*/
Expand All @@ -33,22 +45,27 @@ export async function getCachedPdf(
try {
const id = getCacheKey(projectId, studyId, fileName);
const record = await db.pdfs.get(id);
return record?.data ?? null;
if (!record) {
return null;
}

touchLastAccessed(id);
return record.data;
} catch (err) {
console.warn('Failed to read from PDF cache:', err);
return null;
}
}

/**
* Evict oldest entries until cache size is under the limit.
* Evict least recently opened entries until cache size is under the limit.
* Uses a metadata-only query to avoid loading PDF binary data into memory.
*/
async function evictIfNeeded(requiredSpace: number): Promise<void> {
try {
const metadata: Array<{ id: string; size: number }> = [];
let totalSize = 0;
await db.pdfs.orderBy('cachedAt').each(entry => {
await db.pdfs.orderBy('lastAccessedAt').each(entry => {
metadata.push({ id: entry.id, size: entry.size || 0 });
totalSize += entry.size || 0;
});
Expand Down Expand Up @@ -92,14 +109,16 @@ export async function cachePdf(

await evictIfNeeded(fileSize);

const now = Date.now();
await db.pdfs.put({
id: getCacheKey(projectId, studyId, fileName),
projectId,
studyId,
fileName,
data,
size: fileSize,
cachedAt: Date.now(),
cachedAt: now,
lastAccessedAt: now,
});

return true;
Expand Down
Loading