Skip to content

fix(rollout): share one bounded Lance cache session across resident stores - #182

Merged
beinan merged 1 commit into
mainfrom
fix/rollout-shared-bounded-session
Jul 25, 2026
Merged

fix(rollout): share one bounded Lance cache session across resident stores#182
beinan merged 1 commit into
mainfrom
fix/rollout-shared-bounded-session

Conversation

@beinan

@beinan beinan commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes the rollout worker RSS leak: memory grew linearly with cumulative appends (~350–490 KB/append) and was never released across merge_wal/compact cycles, with the busiest workers all converging on ~5.95 GiB regardless of which stores they owned. Extrapolated, a busy worker OOMs.

Root cause. Each RolloutStore opened its base dataset via Dataset::open, which builds a fresh per-store Lance Session. That session's index/metadata caches default to 6 GiB + 1 GiB and are byte-weighted LRUs keyed by dataset URI. Every flushed MemWAL generation is a distinct URI (_mem_wal/{shard}/{generation}), and the read paths (observe, list, stats) open each generation with the base dataset's session. So a busy store fed an ever-growing key set into its own 6 GiB cache — RSS tracked cumulative appends, and the ~5.95 GiB plateau is simply DEFAULT_INDEX_CACHE_SIZE (6 GiB) filling up. merge/compact never released it because the Session outlives the merged generations and every reload reuses the same session.

This matches every data point in the issue: RSS tracks rollout_appends_total not uptime; the three busiest workers converge on the same value despite owning different stores; the marginal KB/append declines (byte-weighted eviction) but never goes negative. The lance ShardWriter was ruled out — its frozen memtables drain correctly (pop_front + retain on flush).

Fix. Build one shared, capacity-bounded Session and attach it to every resident store, so the process's total Lance cache is bounded by a single budget instead of 6 GiB per store.

Changes

  • RolloutStoreOptions gains a session: Option<Arc<Session>> field; RolloutStore threads it through every open/reload path (load_with_options, create_with_options, compact, zonemap rebuild, generation reads, schema evolution).
  • New RolloutStore::build_session(index_bytes, metadata_bytes) constructor; Session re-exported from lance-context-core.
  • Server builds one shared session from a configurable total byte budget: --rollout-cache-bytes / ROLLOUT_CACHE_BYTES, default 2 GiB, 0 disables sharing (restores the pre-fix per-store default). Split 6:1 index:metadata to match Lance's own default ratio, and injected into every rollout store the server opens.

Semantic change

None to correctness or durability. Only the Lance cache is now bounded process-wide and shared, so cross-store cache hit rate improves and total cache memory is capped by config rather than 6 GiB × resident stores.

Test plan

  • cargo build + cargo clippy --all-targets clean on lance-context-core and lance-context-server
  • cargo fmt --check clean
  • cargo test -p lance-context-core (161 passed) and -p lance-context-server (49 passed), plus integration tests
  • Manual: high-append ingest against a busy worker; confirm RSS plateaus near the configured budget instead of climbing toward 6 GiB

🤖 Generated with Claude Code

…tores

Worker RSS grew linearly with cumulative appends (~350-490 KB/append) and
never released across merge/compact cycles, converging on ~6 GiB regardless
of which stores a worker owned.

Root cause: each RolloutStore opened its base dataset via Dataset::open,
which builds a fresh per-store Lance Session whose index/metadata caches
default to 6 GiB + 1 GiB and are keyed by dataset URI. Every flushed MemWAL
generation is a distinct URI, so a busy store's read path (observe/list/stats)
fed an ever-growing key set into that per-store cache until it approached the
6 GiB cap. The session outlives the merged generations, so merge/compact never
released it.

Fix: build one shared, capacity-bounded Session and attach it to every
resident store, so the process's total Lance cache is bounded by a single
budget rather than 6 GiB per store.

- RolloutStoreOptions gains a `session` field; RolloutStore threads it through
  every open/reload path (load, create, compact, zonemap, generation reads).
- New RolloutStore::build_session constructor; re-export Session from core.
- Server builds one shared session from a configurable total byte budget
  (--rollout-cache-bytes / ROLLOUT_CACHE_BYTES, default 2 GiB, 0 disables),
  split 6:1 index:metadata to match Lance's own default ratio, and injects it
  into every rollout store it opens.

Co-Authored-By: Claude <noreply@anthropic.com>
@beinan
beinan merged commit 4ea6d7d into main Jul 25, 2026
9 checks passed
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