fix(search): cap the encrypted-search IDB cache per room (bound disk growth)
The on-device search index grew unbounded over a long session. putRows now prunes each touched room to MAX_ROWS_PER_ROOM (5000) — deleting the oldest rows by [roomId, ts] via a self-chaining IDB cursor within the same write tx (never awaits a non-IDB promise mid-tx, so the transaction can't auto-commit and truncate the prune). Exposed a pure, unit-tested evictCount() for the decision; the cursor path itself is browser-only (node --test has no IndexedDB). Deliberate tradeoff (documented in code): the coverage window keeps claiming the evicted tail so the search doesn't re-fetch → re-evict it forever. Net effect — in a room past 5000 cached rows, an evicted old message is silently unsearchable rather than churning. Clear cached index / logout still wipe everything. Two review agents verified the IndexedDB-spec correctness (cursor delete+continue semantics, put-then-count ordering, roomRange bracketing with no prefix bleed, tx liveness, abort→cache-miss) since CI can't. Gate-green (tsc, eslint, prettier, 925 tests, build). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
computeCoverage,
|
||||
evictCount,
|
||||
mergeSearchResults,
|
||||
putRows,
|
||||
queryRoom,
|
||||
@@ -57,6 +58,18 @@ test('mergeSearchResults: missing ts sorts as 0 (last)', () => {
|
||||
|
||||
const row = (ts: number): Pick<SearchCacheRow, 'ts'> => ({ ts });
|
||||
|
||||
test('evictCount: 0 when under or at the cap, else the excess', () => {
|
||||
assert.equal(evictCount(0, 100), 0);
|
||||
assert.equal(evictCount(100, 100), 0); // exactly at cap → nothing evicted
|
||||
assert.equal(evictCount(101, 100), 1);
|
||||
assert.equal(evictCount(250, 100), 150);
|
||||
});
|
||||
|
||||
test('evictCount: uses the default per-room cap (5000)', () => {
|
||||
assert.equal(evictCount(5000), 0);
|
||||
assert.equal(evictCount(5001), 1);
|
||||
});
|
||||
|
||||
test('computeCoverage: derives oldest/newest from rows', () => {
|
||||
const cov = computeCoverage('!r', [row(30), row(10), row(20)], 3);
|
||||
assert.deepEqual(cov, { roomId: '!r', oldestTs: 10, newestTs: 30, count: 3 });
|
||||
|
||||
@@ -16,6 +16,17 @@ const DB_NAME = 'lotus-search-cache';
|
||||
const DB_VERSION = 1;
|
||||
const MESSAGES_STORE = 'messages';
|
||||
const COVERAGE_STORE = 'coverage';
|
||||
|
||||
// Cap cached rows per room so the on-disk index can't grow unbounded over a
|
||||
// long-lived session. When a room exceeds this, the oldest rows (by ts) are
|
||||
// evicted on write. ~5k small rows/room is generous search history; the coverage
|
||||
// window is intentionally left claiming the evicted tail so we don't re-fetch +
|
||||
// re-evict it forever (Clear cached index / logout still wipe everything).
|
||||
const MAX_ROWS_PER_ROOM = 5000;
|
||||
|
||||
/** How many of a room's rows to evict to bring it back to the cap (0 if under). */
|
||||
export const evictCount = (currentCount: number, max = MAX_ROWS_PER_ROOM): number =>
|
||||
Math.max(0, currentCount - max);
|
||||
const ROOM_TS_INDEX = 'roomTs';
|
||||
|
||||
/** A single cached, decrypted message row. Keyed on `[roomId, eventId]`. */
|
||||
@@ -90,6 +101,29 @@ const awaitTx = (tx: IDBTransaction): Promise<void> =>
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
|
||||
/**
|
||||
* Within an open readwrite tx, delete the oldest rows of `roomId` (ascending
|
||||
* `[roomId, ts]` index) until it's back under the cap. Self-chains IDB requests
|
||||
* so the transaction stays alive — never awaits a non-IDB promise mid-tx (which
|
||||
* would let the transaction auto-commit early).
|
||||
*/
|
||||
const pruneRoom = (store: IDBObjectStore, roomId: string): void => {
|
||||
const index = store.index(ROOM_TS_INDEX);
|
||||
const countReq = index.count(roomRange(roomId));
|
||||
countReq.onsuccess = () => {
|
||||
let remaining = evictCount(countReq.result);
|
||||
if (remaining <= 0) return;
|
||||
const cursorReq = index.openCursor(roomRange(roomId), 'next'); // oldest first
|
||||
cursorReq.onsuccess = () => {
|
||||
const cursor = cursorReq.result;
|
||||
if (!cursor || remaining <= 0) return;
|
||||
cursor.delete();
|
||||
remaining -= 1;
|
||||
cursor.continue();
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/** Upsert message rows. No-op on empty input or when IDB is unavailable. */
|
||||
export const putRows = async (rows: SearchCacheRow[]): Promise<void> => {
|
||||
if (rows.length === 0) return;
|
||||
@@ -99,6 +133,8 @@ export const putRows = async (rows: SearchCacheRow[]): Promise<void> => {
|
||||
const tx = db.transaction(MESSAGES_STORE, 'readwrite');
|
||||
const store = tx.objectStore(MESSAGES_STORE);
|
||||
rows.forEach((row) => store.put(row));
|
||||
// Bound growth: prune each room this batch touched back to the cap.
|
||||
new Set(rows.map((row) => row.roomId)).forEach((roomId) => pruneRoom(store, roomId));
|
||||
await awaitTx(tx);
|
||||
} catch {
|
||||
// Cache write failures must never surface to the UI.
|
||||
|
||||
Reference in New Issue
Block a user