Skip to content

Bound the RO reader pool's page caches - #177

Open
prismatic7 wants to merge 1 commit into
CodeAbra:mainfrom
prismatic7:fix/ro-pool-page-cache-bound
Open

prismatic7 wants to merge 1 commit into
CodeAbra:mainfrom
prismatic7:fix/ro-pool-page-cache-bound

Conversation

@prismatic7

Copy link
Copy Markdown

Summary

The read-only reader pool opens its slots via get_lilli_raw_conn(), which bypasses HippoDB.__init__ and therefore its PRAGMA cache_size writer bound. Every slot was constructed with the pager's max-cached-pages unset, i.e. unbounded. This applies a per-slot page-cache bound at _open_slot().

Type of change

  • Bug fix
  • New feature
  • Refactor (no behaviour change)
  • Documentation
  • Build / tooling

Affected areas

  • Capture path
  • Recall / retrieval
  • Consolidation / sleep cycles
  • Daemon lifecycle / FSM
  • Storage / encryption at rest
  • MCP wrapper (TypeScript)
  • Bench harness
  • CLI / doctor
  • Other: ___

Testing

  • pytest passes locally
  • ruff check src/ tests/ clean
  • New tests added for changed behaviour, or rationale below for why not

tests/test_ro_pool_page_cache_bound.py — 7 tests:

Test What it pins
test_bound_issues_the_cache_size_pragma the bound is the engine's own PRAGMA cache_size
test_bound_respects_an_override IAI_MCP_RO_POOL_CACHE_KIB is honoured
test_bound_is_a_no_op_when_disabled 0 restores upstream behaviour exactly
test_a_failed_bound_never_prevents_the_slot_opening the bound is advisory, never fatal
test_slot_cache_kib_default_override_and_malformed knob parsing incl. a malformed value
test_pool_open_slot_applies_the_bound_end_to_end the call site in _open_slot, not just the helper
test_scan_retention_is_capped_at_the_budget retention across a full-store scan

Regression surface — 46 passed, 0 failed:
test_recall_ro_pool.py, test_ro_pool_refresh.py, test_recall_ro_routing.py, test_ro_pool_checkpoint_invalidation.py, test_lilli_page_cache_bound.py, test_col_index_reader_pool_activation.py

The end-to-end test was checked against a revert: removing only the _bound_slot_page_cache(conn) call from _open_slot makes it fail with saw [], so it guards the wiring rather than restating the helper.

Note on ruff: src/iai_mcp/hippo/_ro_pool.py has 13 pre-existing ruff check errors on pristine main (mostly UP037 on quoted self-referential annotations). This change adds zero new ones — the count is 13 before and after. The new test file is clean. I did not opportunistically fix the pre-existing 13, since that would widen the diff; happy to do it as a separate PR if wanted.

Benchmarks

No bench.* re-run: this changes footprint, not retrieval. rows_scanned and returned rows are identical with and without the bound, so there is no retrieval-result delta to report.

The measured effect is on retained pages, which is where the cost lives — the pager keeps every clean page it reads, and the pool holds several slots. Reference pager, page_size=8192, 4,000 pages written then fully scanned:

unbounded   resident 4,003 pages   32.8 MB retained
16 MiB      resident 2,048 pages   16.8 MB retained   <- exactly the budget

I want to be explicit about the limits of my evidence, because the fork this was distilled from claims "+368 MB → +6 MB" and I could not reproduce that. Process-level RSS cannot isolate this change: at 20,000 records I measured +443.5 MB unbounded vs +435.3 MB bounded, and a 16 MiB budget cannot produce an 8 MB delta. Page-cache retention is held engine-side in Rust and is invisible to both tracemalloc and whole-process RSS. So the quoted number is the pager's own resident-page count — the mechanism, measured directly — not a process-level figure I could not attribute. Effect scales with page count and with IAI_MCP_RO_POOL_SIZE, which is why the default is a parameter rather than a constant.

Notes for reviewers

  • Hook choice. _refresh_or_reopen_slot() prefers the engine's in-place snapshot refresh, which does not recreate the connection, and falls back to _open_slot(). Bounding in _open_slot() survives both paths; bounding in _refresh_or_reopen_slot() would not.
  • Default is 16384 KiB (16 MiB) per slot, so the pool's ceiling is RO_POOL_SIZE * 16 MiB = 128 MiB at the default pool size of 8. IAI_MCP_RO_POOL_CACHE_KIB=0 restores unbounded. If you'd prefer a different default or a pool-wide budget rather than per-slot, that's a one-line change and I'd rather take your preference than guess.
  • Implementation reuses your existing idiom. maintenance.py already does exactly this shape with _MAINTENANCE_SCAN_CACHE_KIB and its _bounded_scan_cache context manager; this is the same PRAGMA cache_size=-{kib}, applied at slot open rather than around a scan. The negative-KiB → page-count conversion stays in the engine (crates/lilliengine/src/conn.rs, ~2104) so there is one place that knows the page size.
  • PRAGMA cache_size has no read-back on the engine, so assertions are made against the issued PRAGMA and the pager's retention rather than by querying the pragma back.
  • Test-seeding gotcha worth knowing if you write similar tests: seeding via the Python reference pager and then reading through the native engine fails with storage corruption: checksum mismatch on page 1 — the two page formats differ, including a per-page checksum. The retention test therefore uses the reference pager on its own, and the slot test uses a recording stand-in.

HippoDB.__init__ caps its writer connection's page cache with
PRAGMA cache_size, but the read-only pool does not go through HippoDB: it
opens its slots via get_lilli_raw_conn(), so every slot was constructed with
the pager's max page count unset -- unbounded.

That is a retention problem, not a budget one. The pager keeps every clean
page it reads, so a full-store scan through a pooled reader leaves the whole
working set resident for the life of the daemon, and the pool holds several
slots.

Measured on the reference pager (8 KiB pages, 4,000 pages written then
scanned):

  unbounded   resident 4,003 pages   32.8 MB retained
  16 MiB      resident 2,048 pages   16.8 MB retained

with identical rows returned either way.

Apply the bound where the slot is opened, through the engine's own
PRAGMA cache_size handler so the conversion from the configured budget to a
page count stays in one place. IAI_MCP_RO_POOL_CACHE_KIB overrides the budget
(16384 default); 0 restores unbounded behaviour.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant