ADR-0113: Memory-Footprint Budget & Search-Index Windowing
Status: Accepted
Date: 2026-07-07
Author: @johalputt
Relates to: ADR-0001 (SQLite-first), ADR-0101 (VayuFind built-in search), ADR-0110 (incremental index persistence)
Context
After a run of feature work the resident memory (RSS) of the single binary grew well beyond its historical ~190 MB baseline on the reference deployment (234k+ posts). A profiling audit found the growth was not a leak — it was two design assumptions that stopped holding at scale, plus one config knob that multiplied:
- SQLite page cache is per-connection.
cache_sizewas-65536(64 MB) on both the writer and every reader connection, and the read pool opens one connection per CPU. On a many-core host that multiplied to 256–512 MB of private page cache — largely redundant with the 512 MBmmap_size, which already keeps hot database pages resident in the shared OS page cache. - The in-memory search index scales O(posts). Each indexed document kept a
title + tags + excerptlowercase haystack in addition to the separate lowercase title and tags — a redundant third copy of the largest field. - The client search index shipped every post. VayuFind's downloadable instant-search index (ADR-0101) included the entire corpus, so both the browser download and the memoised server-side snapshot were tens of MB and grew without bound — even though a reader almost always wants recent content.
The design goal was to cut RSS without regressing any of: features, reliability, search results/ranking, UI, or measured latency.
Decision
Adopt an explicit memory-footprint budget for the binary and implement four bounded, reversible measures (shipped across v3.8.2–v3.8.3):
- Right-size the SQLite page cache. Writer
cache_size→ 32 MB, each reader → 16 MB;mmap_size(512 MB) unchanged. Hot pages stay resident via mmap, so query latency is unaffected while the per-connection caches no longer multiply. - Trim the search index per-document. Store only a lowercase excerpt
instead of a
title+tags+excerptconcatenation. The excerpt fallback in the scorer runs only after title and tags have already been checked, so ranking is byte-for-byte identical. - Single-pass client snapshot. Compute the snapshot's content version with a streaming hash while assembling the payload, instead of serialising the whole index to JSON a second time purely to hash it — removing a large short-lived allocation from every rebuild. The version stays fully content-sensitive.
- Window the client search index. Ship only the most-recent
clientSnapshotMax(5000) posts to the browser. Instant, zero-latency, in-browser search covers that window; when the corpus is larger the modal offers a one-click "search the full archive" row that jumps to the existing server-rendered/searchpage, which searches every post. Server-sideSearch/DocCountremain uncapped. - Bound the runtime. Set a Go soft memory limit at 90% of the detected
cgroup memory limit (honouring an explicit
GOMEMLIMIT; a no-op on bare hosts), and hand the boot-time index-scan working set back to the OS once the live index is in place.
Consequences
- Positive: steady RSS on a large store drops by roughly 200–400 MB (core-count dependent) with no change to features, ranking, UI, or measured latency; the client search download shrinks from tens of MB to a few MB; and the runtime now targets the container's real memory instead of overshooting toward an OOM-kill.
- No lost coverage: the full archive stays searchable server-side, so no post becomes unreachable; the windowed client index is a pure client-side optimisation surfaced by an explicit escalation affordance.
- Trade-off: the in-browser instant modal auto-completes only the recent window;
older posts are found via the server
/searchpage (one round-trip, only when the visitor chooses it). The server-side index remains O(posts) in memory — a full move off the in-memory engine (e.g. SQLite FTS5) is deliberately not taken here and is left for a future ADR should corpus growth demand it. - Every measure is reversible via a single constant/PRAGMA and is covered by tests (identical-ranking, version content-sensitivity, client cap + retained server coverage, cgroup-limit parsing).