feat(mem_wal): make write backpressure injectable and account index memory#7831
Draft
hamersaw wants to merge 2 commits into
Draft
feat(mem_wal): make write backpressure injectable and account index memory#7831hamersaw wants to merge 2 commits into
hamersaw wants to merge 2 commits into
Conversation
`MemTable::estimated_size` counts row data and the PK bloom filter only, so every in-memory index is invisible to callers sizing memtable memory. That gap is not a rounding error: a configured HNSW index pre-allocates its whole graph from `capacity` on the first insert, so at the WAL's defaults (125k rows, m=16) a vector memtable costs 64.7 MiB the moment row lance-format#1 lands — while `estimated_size` still reads near zero. Many small vector tables therefore sail past any budget built on `estimated_size` and OOM the process. Add `memory_size` at each layer, cheap enough for the write path: - HNSW: the node arena and lookup slabs are sized from `capacity` at construction, so the total is computed once in `HnswGraph::try_new` (which already walks the nodes) plus an atomic for the rebuilt `packed_level0`. Vectors are held by reference, so this is independent of `dim`. - BTree: the skiplist arena counts its chunks in the cold `grow` path — free per insert, and exact for the nodes. - FTS: partitions are capped at `MAX_PARTITIONS` and size themselves in O(1); only the mutable tail needed a running counter, kept in step with the existing walk at its single growth point (`append_batch`). `estimated_size` keeps its data-only meaning — it sizes the flush unit, so a generation stays a function of the rows in it. `MemTable::memory_size` is the new resident-bytes accessor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…bytes
Backpressure was a concrete struct whose only seam was a per-call closure, and
it could see one shard's row bytes. An embedder running many shards in one
process has budgets lance cannot: a process-wide memtable total, a page-cache
working set. Nothing could express them, and nothing could refuse a write —
the valve only ever blocked, unboundedly.
Turn it into a trait an embedder can implement:
async fn maybe_apply_backpressure(&self, incoming_bytes: usize,
shard: ShardMemory) -> Result<()>
`ShardWriterConfig::backpressure` **replaces** the built-in valve rather than
layering on it, so one implementation owns the whole policy. That is why the
gate is handed both the size of the batch about to be admitted and what the
calling shard already holds: a replacement can enforce a per-shard ceiling in
the same place as its process-wide one, with no back-reference into the writer.
With nothing injected, `LocalBackpressureController` keeps today's per-shard
behaviour, its two write modes covered by a `LocalSource` enum rather than the
closure.
Fix what the counters measure. `unflushed_memtable_bytes` read through a
`try_read()` that yielded 0 whenever the write lock was held — and tokio's
`RwLock` is write-preferring, so it also yielded 0 whenever a writer was merely
queued. It zeroed exactly the shards taking writes, reporting lowest under the
heaviest load. Replace it with `active_bytes()`/`frozen_bytes()`: relaxed loads
of counters maintained under the write lock, counting index memory too (a
vector memtable's HNSW graph is ~65 MiB that `estimated_size` cannot see).
`ShardWriterConfig::pod_memory_bytes` optionally sums them across every shard
in the process, so a global gate reads one atomic instead of scanning shards.
`Error::Backpressure` (with `is_backpressure()`) lets a rejection be told from
a real failure without matching on the message. This relaxes the contract:
"never errors due to backpressure" becomes "an injected controller may reject",
and "never drops data" becomes "never drops *acked* data" — a rejected write
was never accepted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
MemWAL's backpressure is a concrete struct whose only seam is a per-call closure, and it can see one shard's row bytes. That leaves two gaps:
MemTable::estimated_sizecounts buffered batches + the PK bloom filter. Every in-memory index is invisible to it.Gap 2 is not a rounding error.
HnswGraph::try_newpre-allocates one node per unit ofcapacity(=max_memtable_rows), so a vector memtable commits its entire graph on the first insert:max_memtable_rowsMeasured, not estimated, and identical at dim=768 and dim=8 — vectors are held by reference, so the cost is ~542 B per row of capacity regardless of dimension. A memtable holding one row costs the same as a full one, while
estimated_sizereads ~zero.What
Accounting (
memory_sizeat each layer, all cheap enough for the write path):capacityat construction, so the total is computed once in the looptry_newalready runs, plus an atomic for the rebuiltpacked_level0.growpath: free per insert, exact for the nodes.MAX_PARTITIONSand size themselves in O(1); only the mutable tail needed a running counter, kept in step with the existing walk at its single growth point. A test asserts the two agree exactly, including across a freeze.estimated_sizekeeps its data-only meaning — it sizes the flush unit, so a generation stays a function of the rows in it.MemTable::memory_sizeis the new resident-bytes accessor.The seam:
ShardWriterConfig::backpressurereplaces the built-in valve rather than layering on it, so one implementation owns the whole policy. That is why the gate receives both the size of the batch about to be admitted and a live view of what the calling shard holds: a replacement can enforce a per-shard ceiling in the same place as its process-wide one, without a back-reference into the writer calling it.ShardMemoryis deliberately live rather than a snapshot — a controller that delays is waiting for those bytes to fall, so a captured copy would never observe the drain.With nothing injected,
LocalBackpressureControllerkeeps today's per-shard behaviour, its two write modes covered by aLocalSourceenum instead of the closure.Counters.
unflushed_memtable_bytesread through atry_read()that yields0whenever the write lock is held — and tokio'sRwLockis write-preferring, so also whenever a writer is merely queued. It zeroed exactly the shards taking writes, reporting lowest under the heaviest load. Replaced withactive_bytes()/frozen_bytes(): relaxed loads of counters maintained under the write lock, index memory included.pod_memory_bytesoptionally sums them across every shard in the process, so a global gate reads one atomic instead of scanning.Error::Backpressure(withis_backpressure()) makes a rejection distinguishable from a real failure without matching on the message.Contract changes
active/frozennow count index memory, so the built-in valve is meaningfully tighter on vector tables at a givenmax_unflushed_memtable_bytes. That is the correct direction for a memory valve, but it is a behaviour change worth knowing about.Follow-up (not this PR)
The graph costs ~5x the adjacency it stores: level-0 links are held three times (
rankedwith distances,published,packed_level0), and it is ~2-3 allocations per node. The reference implementation for the fix already lives next door inmem_wal/index/arena_skiplist.rs, whose BTree gets 4 MiB where HNSW spends 64.7 MiB. Filing separately — this PR only makes the cost visible.Test
cargo test -p lance --lib -- dataset::mem_wal— 507 pass. New: HNSW pre-allocation behaviour, FTS counter-vs-walk equality, injected-replaces-default, default-when-none-injected, reject-surfaces-as-Backpressure, andShardMemoryliveness across polls (it hangs if the view ever regresses to a copy).cargo fmt --all+cargo clippy -p lance --lib --tests --benchesclean in touched files.🤖 Generated with Claude Code