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 assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
computeCoverage,
|
computeCoverage,
|
||||||
|
evictCount,
|
||||||
mergeSearchResults,
|
mergeSearchResults,
|
||||||
putRows,
|
putRows,
|
||||||
queryRoom,
|
queryRoom,
|
||||||
@@ -57,6 +58,18 @@ test('mergeSearchResults: missing ts sorts as 0 (last)', () => {
|
|||||||
|
|
||||||
const row = (ts: number): Pick<SearchCacheRow, 'ts'> => ({ ts });
|
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', () => {
|
test('computeCoverage: derives oldest/newest from rows', () => {
|
||||||
const cov = computeCoverage('!r', [row(30), row(10), row(20)], 3);
|
const cov = computeCoverage('!r', [row(30), row(10), row(20)], 3);
|
||||||
assert.deepEqual(cov, { roomId: '!r', oldestTs: 10, newestTs: 30, count: 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 DB_VERSION = 1;
|
||||||
const MESSAGES_STORE = 'messages';
|
const MESSAGES_STORE = 'messages';
|
||||||
const COVERAGE_STORE = 'coverage';
|
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';
|
const ROOM_TS_INDEX = 'roomTs';
|
||||||
|
|
||||||
/** A single cached, decrypted message row. Keyed on `[roomId, eventId]`. */
|
/** 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);
|
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. */
|
/** Upsert message rows. No-op on empty input or when IDB is unavailable. */
|
||||||
export const putRows = async (rows: SearchCacheRow[]): Promise<void> => {
|
export const putRows = async (rows: SearchCacheRow[]): Promise<void> => {
|
||||||
if (rows.length === 0) return;
|
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 tx = db.transaction(MESSAGES_STORE, 'readwrite');
|
||||||
const store = tx.objectStore(MESSAGES_STORE);
|
const store = tx.objectStore(MESSAGES_STORE);
|
||||||
rows.forEach((row) => store.put(row));
|
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);
|
await awaitTx(tx);
|
||||||
} catch {
|
} catch {
|
||||||
// Cache write failures must never surface to the UI.
|
// Cache write failures must never surface to the UI.
|
||||||
|
|||||||
Reference in New Issue
Block a user