feat(mem_wal): make write backpressure injectable and account index memory - #7831
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 #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! |
Resolves three mem_wal conflicts against upstream: - memtable.rs: keep `memory_size`; drop `wal_batch_mapping` and `last_flushed_wal_entry_position`, which upstream deleted along with their fields. - index.rs: keep `IndexStore::memory_size`; take upstream's rewritten `indexed_count` doc. - write.rs: take upstream's `track_batch_for_wal` dual-cursor signature and drop its `BackpressureController::new` in `open_memtable_mode` — the injectable `resolve_backpressure` this branch adds supersedes it and must stay after `writer_state`, whose counters the default valve reads. Upstream's new `backpressure_stats()` called `.stats()` on what is now a trait object, so `stats_snapshot()` joins the trait with a zeroed default for injected controllers and a real implementation on `LocalBackpressureController`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two tests arriving with main still built the controller as a concrete type and passed the pool reading as a closure at call time. Both moved: the concrete type is `LocalBackpressureController`, and the synthetic reading is installed at construction via `LocalSource::Fake`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-backpressure-seam
`ShardWriterConfig::pod_memory_bytes` let an embedder accumulate a process-wide `Σ(active + frozen)` incrementally, adding on insert and subtracting on flush-commit. An incremental counter has no way back. Nothing releases a writer's residual bytes when it goes away without a final flush -- `abort()` is `shutdown_all()` and nothing else, and there is no `Drop` on `MemoryCounters` -- so every eviction that skips a flush leaks its memtable into the sum permanently. LanceDB's WAL evicts that way on four paths (poison heal, drop rollback, drop finalize, drop-in-doubt reconcile), and enough of them leave a pod refusing every write against a memtable that reads empty. A `Drop` impl would close that particular hole, but the embedder is better served recomputing the total from `memtable_stats()`, which is also what it exports: derived, self-healing within one interval, and identical to the gauge by construction. So this removes the counter rather than patching it, and the seam shrinks with it. `ShardMemory` and the per-shard `active`/`frozen` counters are untouched -- they are the live per-shard view a controller needs to enforce a per-shard ceiling, and they have no cross-shard lifetime problem. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-backpressure-seam
`WriterMode::WalOnly` built a second `WalOnlyState` after handing the first to `WalFlushHandler`, shadowing it. Writes queued on the one in `WriterMode`; the background append drained the one the handler held, which nothing ever pushed to. WAL-only mode never appended. Introduced by a6f19a5, which inserted the binding to feed the new `LocalSource::WalOnly` instead of reusing the existing one. Clippy found it as a `redundant_clone`: the first `state` was dropped without further use, which is exactly the symptom. Also in this commit, both from the same seam work: - The bench still set `ShardWriterConfig::pod_memory_bytes`, removed along with the field. `cargo check --tests` does not build benches, so it only surfaced in the MSRV job. - `MemTableIndex::memory_size`'s doc linked `Self::ensure_state`, which is private, failing `RUSTDOCFLAGS="-D warnings" cargo doc`. Merges lance main (11.0.0-beta.22) so the branch is current. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three types were each computing a shard's active/frozen/unflushed bytes, and two of them differed only by a `_bytes` suffix. Collapse to one. `ShardMemory` is now the single place a shard's byte totals are computed. `ShardWriter::active_bytes`/`frozen_bytes` become `ShardWriter::memory()`, which returns the same `ShardMemory` the admission controller is handed -- so an embedder ranking shards by size and the controller gating a write read one implementation rather than two that can drift. `ShardMemorySource` stays an enum over the two write modes, but every arm is now a field read; the arithmetic that combines them lives once. `ShardMemorySnapshot` becomes `ResidentMemTables`: data only, no methods. The two names were near-identical for different things, which was most of the cost of keeping them straight. `MemTable::row_bytes`/`resident_bytes` are deleted rather than kept as delegates -- they duplicated `MemTableBytes`'s arithmetic, and callers now go through `.bytes()`. That is ~4 relaxed atomics per put on the seal check, noise next to the write lock it precedes, and leaves no invariant to keep in sync. `MemTableMemory` is renamed `MemTableBytes` so the two `*Memory` types stop colliding. Two things fell out. The oldest flush watcher is published into the snapshot at freeze/flush/open instead of read through `SharedWriterState::oldest_memtable_watcher`, removing that method and its `try_read` -- the same write-preferring blind spot the byte counters had. `SharedWriterState.state` was then an orphan, so the struct no longer holds a back-reference to the writer lock at all. Naming, applied throughout: every byte quantity ends `_bytes`, and the prefix says which bytes. BatchStore::estimated_bytes -> row_bytes WalOnlyState::estimated_size -> queue_bytes IndexStore::memory_size (+6 index) -> resident_bytes FtsMemIndex::memory_usage -> resident_bytes_exact (the O(terms) oracle) FtsMemIndex::memory_size -> resident_bytes `Sbbf::estimated_memory_size` is left alone (outside mem_wal, public API), as is `MemTableStats::pending_wal_estimated_bytes` (bytes owed the WAL, a different quantity). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ants `ResidentMemTables` was a third way to hold the same set. `WriterState` owns it, `InMemoryMemTables` projects it per scan, and the memory view published a lock-free copy -- and the per-memtable projections were near-duplicates too: `InMemoryMemTableRef` is a strict superset of `MemTableBytes` except for the bloom filter's bytes. So `MemTableBytes` is deleted and `InMemoryMemTableRef` grows the byte accessors. `in_memory_ref` is now the single builder: the read path scans through it and `ShardMemory` sizes through it. The bloom filter needed no per-memtable field. `PK_BLOOM_FILTER_EXPECTED_ITEMS` and `_FPP` are constants, so every memtable's filter is the same size -- a property of the build, not a measurement. `pk_bloom_filter_bytes()` returns it from a `OnceLock`, and it is counted as index memory rather than row data, where a fixed term would make every memtable seal a constant early. That last part fixes a real disagreement: `MemTable::should_flush` compared batch bytes while `memtable_reached_flush_threshold` compared batch bytes plus the bloom, so the two seal predicates tripped at different bytes on every memtable. They now share one byte arm, pinned by a test. `MemTableStats` also loses its byte fields. `ShardWriter::memory()` is the one way to ask what a shard holds -- it answers without the writer lock -- and `memtable_stats` was re-implementing the `flushed_at_ms.is_none()` filter and its summation to produce numbers free to disagree with the gate. The struct is now about rows, generation and WAL-pending, which is what its name says. Tests, since none of this was covered: - `test_memory_snapshot_never_drifts_from_writer_state` walks open, 24 puts (sealing several times), a flush drain and 8 more puts, asserting after EVERY operation that the published snapshot equals a ground truth recomputed from `WriterState`. Parametrized over `frozen_memtable_grace` 600s and 0 so both flush-commit branches and the sweep path are covered. This is what catches a missed `publish_memory` call site. - `test_memory_is_readable_while_a_writer_holds_the_lock` is the deterministic form of the bug the design exists for: the old accounting read through `try_read()`, which fails while a writer holds the lock -- and, since tokio's `RwLock` is write-preferring, while one is merely queued -- so it reported zero on exactly the shards taking writes. - `test_both_seal_predicates_share_one_byte_arm` pins the fix above. - `test_memory_handle_tracks_the_live_memtable` pins that a handle taken before any rows still sees them, and that the bloom lands in the index term. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four failures, all from checking only `-p lance` rather than the gates CI actually runs. `cargo fmt` had never been run over the scripted edits, which alone fails `format`, `lint` and `Rust Clippy and Fmt Check` -- the latter two run `cargo fmt --all -- --check` before clippy. MSRV builds `--benches`, and a blind search-and-replace had put `writer.memory().active_bytes()` inside `memtable_stats_json`, which has no `writer` in scope. The resident bytes are threaded in as a parameter instead. rustdoc runs with `-D warnings`, so narrowing the per-index accessors to `pub(crate)` broke two *public* doc comments that linked to them (`FtsMemIndex::resident_bytes_exact` and `IndexStore::resident_bytes`). Both delinked. The real one: `python/` and `java/lance-jni/` are separate cargo workspaces outside `rust/`, and both consume `MemTableStats`. Dropping its byte fields broke them, and neither was ever built. The Python binding exposes `estimated_size_bytes` and `frozen_bytes` as public dict keys, so deletion was not an option -- both now read from `ShardWriter::memory()`, keeping the keys meaning what they meant, and `index_bytes` is added alongside. Java's JNI `MemTableStats` ctor keeps its shape, fed the same way. That forced one design addition. `ShardMemory::row_bytes()` is derivable as `active_bytes() - index_bytes()`, but only across two separate loads -- exactly the torn-read hazard documented against on `unflushed_bytes()`. Reading it off one load is the correct primitive. Verified against the gates rather than the crate: `cargo fmt --all -- --check` in all three workspaces, `cargo check --workspace --tests --benches`, `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps`, and both binding crates compiling. 353 mem_wal tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five gaps between `ShardMemory` and the shard's real footprint, all on the boundary where the injectable controller reads it. An HNSW index reported zero until its first insert, and that insert allocates the whole graph — so the admission check running just ahead of it could not see, or refuse, the largest allocation in a vector memtable. The graph and lookup slabs are sized from capacity alone and never from `dim`, so `reserved_bytes` now answers before either exists and the index is charged from configuration. `row_bytes` measures an Arrow slice's window, which is the right flush unit and the wrong ledger: one-row slices of distinct parents pin every parent in full. `BatchStore::retained_bytes` sums capacity over the distinct allocations the store keeps alive, deduplicated by address — sound because the store retains every batch it accepts, so no address is recycled under it. `resident_bytes` is built on that; `row_bytes` still drives both seal predicates. BTree keys over `INLINE_CAP` spill to a `Box<[u8]>` outside the skiplist arena, which the arena's chunk counter never sees, so a long-string column duplicated its payload uncharged. Memtables flushed but still inside `frozen_memtable_grace` were dropped from the published view entirely. They are resident for the whole window, so they are now published apart from the owed-to-flush set: `unflushed_bytes` keeps its meaning for the local valve, `retained_bytes` and `grace_bytes` give a process-wide budget the whole footprint, and `SweepExpired` republishes since eviction is what reclaims them. A failed flush leaves its generation charged with its watcher already popped and no retry queued. `oldest_flush` used to backfill the active memtable's watcher, which only a put can fire — and the valve is what holds puts out, so the wait had no event to end on. `ShardMemory::drain` now says whether a wait can end at all, and the valve refuses with `Error::Backpressure` and a byte breakdown rather than parking for good. The refusal waits out `STALL_GRACE` first, because a writer mid-freeze looks identical for the length of its locked section. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| if since.elapsed() < STALL_GRACE { | ||
| tokio::time::sleep(DRAIN_POLL_INTERVAL).await; | ||
| continue; | ||
| } |
There was a problem hiding this comment.
issue (blocking): The ceiling now counts index bytes but the seal trigger still counts row bytes (write.rs:1489, memtable.rs:511), so a shard configured with a large-capacity HNSW is over max_unflushed_memtable_bytes at open() with zero rows, can never seal, and — via this arm — refuses every put after STALL_GRACE; index.rs:1919 confirms the charge lands from configuration. Acceptance: either the seal trigger consults resident bytes so a stalled shard can actually drain, or open() validates that the configured indexes' reserved bytes leave headroom under the ceiling and fails fast there — plus a test that a writer with an HNSW index sized well above max_unflushed_memtable_bytes still accepts writes (or is rejected at open, not at put #1).
There was a problem hiding this comment.
Updated this so that open() validates headroom. This feels like the right call to make right now, but the failing if there is no room is probably not the right long-term call. We should then proactively create room by flushing + compacting table data.
| /// Unlike the built-in valve it may also reject: see | ||
| /// [`Error::Backpressure`]. | ||
| /// Default: `None` (use the built-in valve). | ||
| pub backpressure: Option<Arc<dyn BackpressureController>>, |
There was a problem hiding this comment.
question (blocking): The PR body advertises maybe_apply_backpressure(&self, incoming_bytes: usize, shard: ShardMemory) but the trait ships without incoming_bytes — is the no-size shape final? Adding a parameter later breaks every external implementor, so I'd like this settled now rather than discovered by an embedder; the "batches are already resident" rationale is reasonable, I just want it to be the decided one.
There was a problem hiding this comment.
Updated the PR body to be correct with the code.
| } | ||
| } | ||
|
|
||
| /// Row-data bytes of the active memtable: [`Self::active_bytes`] minus |
There was a problem hiding this comment.
issue (non-blocking): row_bytes is not active_bytes() - index_bytes(): active_bytes is built on retained_row_bytes (full pinned parent capacity) while this is the window sum, and the gap is unbounded for zero-copy slices — your own test_memory_handle_tracks_the_live_memtable asserts resident == retained_row_bytes + index_bytes, not row_bytes + index_bytes.
There was a problem hiding this comment.
I think this can be solved with just a doc update, the functionality seems correct to me.
| /// store holds every batch it has accepted until it is dropped. | ||
| /// | ||
| /// Call under the writer guard, before the batch is moved into its slot. | ||
| fn charge_retained(&self, batch: &RecordBatch) -> usize { |
There was a problem hiding this comment.
question (non-blocking): The "same parent charged once" claim holds because ArrayData::slice advances offset rather than the buffer pointer — but a batch whose buffers came from a kernel that re-slices (Buffer::slice_with_length, concat/take output) gets a distinct data_ptr for the same allocation and is charged again in full; worth softening the doc to "once per distinct buffer view" since it over-counts rather than under-counts. Separately: retained_buffers is never pruned and to_data() + a mutex + a hash insert per column now run on every append — fine at current batch rates, but worth a note that it grows with accepted batches.
There was a problem hiding this comment.
Both fair. Softened to "once per distinct buffer view", spelling out that ArrayData::slice advances the offset and leaves the pointer alone so ordinary slices dedupe, while a buffer that came back re-sliced from a kernel presents a different data_ptr for the same allocation and is charged again in full — over-counting, which is the safe direction for a ceiling.
Also documented the growth: retained_buffers is never pruned, bounded only by the store being dropped at flush, and the walk plus to_data, the mutex and a hash insert run per column per append — fine at current batch rates, and the first thing to look at if that changes.
| } | ||
| } | ||
|
|
||
| fn resident_bytes(&self) -> usize { |
There was a problem hiding this comment.
issue (non-blocking): null_bytes takes a blocking lock() on null_positions, and this path is now reached from ShardMemory::active_bytes() on every put and every 10ms DRAIN_POLL_INTERVAL tick — so a memory poll can block behind an in-progress insert holding the same mutex; a try_lock with a fallback (it's a size estimate) would keep the read path non-blocking.
There was a problem hiding this comment.
Updated this to use an atomic instead to mirror the existing approaches.
There was a problem hiding this comment.
That sounds like the right call to me.
| JValueGen::Long(stats.row_count as i64), | ||
| JValueGen::Long(stats.batch_count as i64), | ||
| JValueGen::Long(stats.estimated_size as i64), | ||
| JValueGen::Long(row_bytes as i64), |
There was a problem hiding this comment.
question (non-blocking): Python gained index_bytes, grace_bytes, and retained_bytes in this PR while Java still exposes only the one repurposed row_bytes slot — is the JNI surface intentionally left behind, or should it get the same breakdown so cross-language embedders can build the same budget?
There was a problem hiding this comment.
These should all be exposed through the Java SDK as well now.
…ling An HNSW graph is charged from `max_memtable_rows` before its first insert, while only row bytes seal a memtable. A reservation with no room left under `max_unflushed_memtable_bytes` therefore put a shard over budget at zero rows: nothing to seal, so nothing to flush, so every put stalled and then failed as `Error::Backpressure` — a signal that means "retry later" for a condition that never clears. `open()` now requires `index_reserved + max_memtable_size` to fit under the ceiling and names both figures when it does not. Only the built-in valve reads that ceiling, so the check is skipped when a controller is injected. Also from review: - `ShardMemory::row_bytes` claimed to be `active_bytes() - index_bytes()`. That difference is `retained_row_bytes` — what the batches pin, unbounded above the window sum for zero-copy slices. Corrected to say what it is. - `charge_retained` dedupes per distinct buffer view, not per allocation: a re-sliced buffer presents a fresh `data_ptr` and is charged again. Documented, along with `retained_buffers` growing with accepted batches. - The BTree null-position heap moves to an atomic charged before the positions are reachable, so a memory poll no longer blocks behind an in-flight insert. - Java gains `indexBytes`, `graceBytes` and `retainedBytes` alongside Python's, and `estimatedSizeBytes` documents that it excludes index memory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The seal trigger measured the row window while the backpressure ceiling measured resident bytes — the heap the batches pin plus the in-memory indexes. Neither bounds the other, so a memtable could carry a shard past its ceiling with no seal reachable by any arm of the predicate: the valve then found `Drain::Stalled` and refused every put, permanently. Three one-row slices of 4MB parents wedged a writer with 135 bytes of row window against a 1MB threshold. `memtable_reached_flush_threshold` gains a resident-bytes arm keyed to `max_unflushed_memtable_bytes`, so a memtable that fills the ceiling seals and gives the valve a flush to end on. Both callers — the live put path and replay — go through the one predicate, so the two stay aligned. `max_memtable_size` deliberately keeps measuring only the row window. It is the knob an operator sizes to reason about fragments in the base dataset, and charging pinned or index memory to it would make fragment size depend on Arrow's allocator and on index configuration instead. Measured: retained bytes run ~24% above the row window for ordinary owned batches, so sharing the threshold would have shrunk every fragment. The open-time reservation check stays: it is what keeps a fresh memtable from being at its seal threshold before the first row, which would otherwise turn into a storm of one-row generations rather than a stall. Also updates the stall error, which blamed index memory for a condition that can now only be a failed flush leaving its generation charged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The resident-bytes arm only runs where the seal check runs: inside the writer lock, immediately after an insert. The index apply that follows it runs outside that lock, so a put's index growth is invisible to the only check that put makes — and the next put is gated by the valve before it can insert and check again. A BTree over 2000 long keys reached 3.1MB against a 1.5MB ceiling with nothing sealed, and every subsequent write was refused. Replay reaches the same state from the other side: it builds its final memtable's indexes after its last threshold check and hands that memtable back as active. Re-run the seal check before parking on the valve, so a shard at its ceiling gets the flush the wait needs rather than a refusal. The guard is two relaxed loads; the lock is only taken when the shard is already at the ceiling, which is the path about to block anyway. Empty memtables are skipped — an injected controller bypasses the open-time reservation check, so a fresh memtable can sit above this ceiling on its indexes alone, and freezing it would spin. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`max_memtable_size` is both the seal threshold for row data and the headroom the open-time reservation check reserves for rows beneath `max_unflushed_memtable_bytes`. At zero it reserves none, so a fresh memtable's index reservation could equal the ceiling exactly and still pass the check: over budget before its first row, with an empty memtable offering nothing to seal, so the writer refused its first write and never recovered. Reject it at open. With a non-zero threshold the existing check (`index_reserved + max_memtable_size <= ceiling`) implies `index_reserved < ceiling`, which is what makes a fresh baseline strictly admissible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
✅ Gate recommendation: approve.
3 fixed / 0 remain. The built-in controller now rejects zero row headroom before claiming the epoch, and the pre-valve seal recheck lets both post-index growth and replay tails rotate before a subsequent write enters backpressure. The solution preserves row-window fragment sizing while closing every verified permanent no-drain path.
Please mark this PR with the breaking-change label.
wjones127
left a comment
There was a problem hiding this comment.
Looks good now. Thanks for addressing my comments.
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 (
resident_bytesat 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.Row bytes keep their data-only meaning — they size the flush unit, so a generation stays a function of the rows in it. They move off
MemTableStatsontoShardMemory::row_bytes(), alongsideindex_bytes(),frozen_bytes(),grace_bytes()andretained_bytes();InMemoryMemTableRef::resident_bytesis the per-memtable total the ceiling is built on.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 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.The gate is deliberately not told the incoming batch's size. Batches are decoded and resident by the time it runs, so there is nothing left to reserve against — refusing does not un-allocate them, and bounding a single write's memory belongs to the ingress. This is the settled shape rather than a first cut: adding a parameter later would break every external implementor.
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.Error::Backpressure(withis_backpressure()) makes a rejection distinguishable from a real failure without matching on the message.Reservations are validated at
open(). An HNSW graph is charged frommax_memtable_rowsbefore its first insert, while only row bytes seal a memtable. A reservation with no room left undermax_unflushed_memtable_byteswould therefore put a shard over budget at zero rows — nothing to seal, so nothing to flush, so every put stalls and then fails asBackpressure, which is supposed to mean "retry later" and never comes true.open()now rejects that configuration outright, requiringindex_reserved + max_memtable_size <= max_unflushed_memtable_bytes, with both figures named in the error. Only the built-in valve reads that ceiling, so the check is skipped when a controller is injected.Update: the process-wide counter is gone
Earlier revisions carried
ShardWriterConfig::pod_memory_bytes, anArc<AtomicUsize>every writer added to on insert and subtracted from on flush-commit, so an embedder could read a process-wide total with one load instead of scanning every shard.It has been removed, because an incremental counter of that shape has no way back. Nothing releases a writer's residual bytes when it goes away without a final flush —
abort()isshutdown_all()and nothing else, and there is noDroponMemoryCounters— so every teardown that skips a flush leaks its memtable into the sum permanently. The downstream embedder evicts that way on four separate paths, and enough of them leave a process refusing every write against a memtable that reads empty.A
Dropimpl would close that particular hole. But the embedder is better served recomputing the total frommemtable_stats(), which is also what it exports: derived, self-healing within one interval, and identical to its own gauge by construction. So the counter is deleted rather than patched, and the seam is smaller for it — net −41 lines.ShardMemoryand the per-shardactive/frozencounters are untouched. They are the live per-shard view a controller needs to enforce a per-shard ceiling, and they have no cross-shard lifetime problem.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. 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