Skip to content

fix(server): bound native heap retention in the shared daemon - #48

Merged
JRedeker merged 1 commit into
mainfrom
work/work-8665df17cfb9d83bf0a36825
Oct 24, 2026
Merged

JRedeker merged 1 commit into
mainfrom
work/work-8665df17cfb9d83bf0a36825

Conversation

@JRedeker

Copy link
Copy Markdown
Contributor

Problem

After 58 hours with no active jobs, the shared lgrep daemon (Vision child) held 33 GiB RSS plus 27 GiB swap, almost all of it anonymous private dirty memory. lgrep_diagnostics could not explain it: the symbol cache reported 0 bytes and all 8 owner stores were idle. This takes most of the host's swap away from concurrent agent work. Tracked in LGREP-56.

Cause (allocator evidence)

  • Live process: about 1,500 anonymous 64 MiB glibc heap regions hold 22.9 GiB RSS plus 22.7 GiB swap. Most of the rest sits in merged ~128 MiB heap regions.
  • Read-only reproduction: a probe running lgrep's read paths over three live caches showed RSS climbing every cycle. Over the same run, the Lance session cache stayed flat, the Arrow pool held 0 bytes, and tracemalloc showed 0 bytes. After every table and session was dropped, RSS stayed high until malloc_trim ran.
  • Conclusion: memory freed after LanceDB scans stays in fragmented per-thread glibc arenas.
  • Secondary cause: each owner connection got its own default Lance session, capped at 6 GiB index + 1 GiB metadata.

Change

  • run_server caps glibc malloc arenas at 4 before any worker or LanceDB thread starts.
  • RuntimeSupervisor trims free heap on a timer thread after the last active job finishes, at most once every 30 seconds. The trim never runs on the event loop or a worker thread.
  • All chunk-store connections share one Lance session, bounded at 256 MiB index + 256 MiB metadata cache.
  • lgrep_diagnostics.process_memory reports RSS, swap, Lance cache bytes and limits, the arena cap, and the trim count, separately from symbol_cache. docs/operations.md describes both.
  • All of these hooks do nothing when the C library is not glibc.

Evidence

  • pytest: 1237 passed, 1 xfailed. ruff check and ruff format --check are clean.
  • Bounded probe at this head, using ChunkStore and RuntimeSupervisor over three live caches, 10 cycles, sampled while idle. With the fix, RSS stayed at 697–792 MiB with no upward trend. The baseline (default sessions, no cap, no trim) climbed from 1778 to 2482 MiB. Searches kept returning results throughout.

Not in scope

  • Compaction and version cleanup of uncompacted tables: LGREP-36.
  • Persistent runtime logs: LGREP-37.
  • The already-grown live process shrinks only after a restart on the new release. That restart needs a separate operator decision.

The shared daemon reached 33 GiB RSS plus 27 GiB swap after 58 hours with
no active jobs and an empty symbol cache. Allocator evidence places the
memory in glibc malloc arenas: freed LanceDB scan memory stays in
fragmented per-thread arenas, while the Python heap, the Arrow pool, and
the Lance caches stay small. Each owner connection also got its own
default Lance session (6 GiB index + 1 GiB metadata cap).

- Cap glibc malloc arenas at 4 in run_server before threads start.
- RuntimeSupervisor trims free heap (malloc_trim) on a timer thread after
  the last active job finishes, at most every 30 seconds.
- Every chunk-store connection shares one Lance session bounded at
  256 MiB index + 256 MiB metadata cache.
- lgrep_diagnostics adds process_memory (RSS, swap, Lance cache bytes and
  limits, arena cap, trim count), separate from symbol_cache.

A bounded probe over three live caches through ChunkStore and
RuntimeSupervisor held idle RSS at 697-792 MiB over 10 cycles; the
baseline climbed from 1778 to 2482 MiB. All hooks are no-ops off glibc.

Refs LGREP-56
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