Skip to content

feat(mem_wal): make write backpressure injectable and account index memory - #7831

Merged
hamersaw merged 18 commits into
lance-format:mainfrom
hamersaw:feat/wal-backpressure-seam
Aug 26, 2026
Merged

hamersaw merged 18 commits into
lance-format:mainfrom
hamersaw:feat/wal-backpressure-seam

Conversation

@hamersaw

@hamersaw hamersaw commented Jul 17, 2026 •

Copy link
Copy Markdown
Contributor

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:

  1. An embedder cannot express its own budget. A process running many shards has limits lance cannot know — a process-wide memtable total, a page-cache working set. Nothing could represent them, and nothing could refuse a write: the valve only ever blocked, unboundedly.
  2. The bytes it measures are not the bytes that OOM you. MemTable::estimated_size counts buffered batches + the PK bloom filter. Every in-memory index is invisible to it.

Gap 2 is not a rounding error. HnswGraph::try_new pre-allocates one node per unit of capacity (= max_memtable_rows), so a vector memtable commits its entire graph on the first insert:

max_memtable_rows HNSW on row #1
125k 64.7 MiB
500k 258 MiB
1M 517 MiB
2M 1033 MiB

Measured, 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_size reads ~zero.

What

Accounting (resident_bytes at each layer, all 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 the loop try_new already runs, plus an atomic for the rebuilt packed_level0.
  • BTree — the skiplist arena counts chunks in its cold grow path: free per insert, 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. 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 MemTableStats onto ShardMemory::row_bytes(), alongside index_bytes(), frozen_bytes(), grace_bytes() and retained_bytes(); InMemoryMemTableRef::resident_bytes is the per-memtable total the ceiling is built on.

The seam:

async fn maybe_apply_backpressure(&self, 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 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. ShardMemory is 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, LocalBackpressureController keeps today's per-shard behaviour, its two write modes covered by a LocalSource enum instead of the closure.

Counters. unflushed_memtable_bytes read through a try_read() that yields 0 whenever the write lock is held — and tokio's RwLock is write-preferring, so also whenever a writer is merely queued. It zeroed exactly the shards taking writes, reporting lowest under the heaviest load. Replaced with active_bytes() / frozen_bytes(): relaxed loads of counters maintained under the write lock, index memory included.

Error::Backpressure (with is_backpressure()) makes a rejection distinguishable from a real failure without matching on the message.

Reservations are validated at open(). 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 would therefore put a shard over budget at zero rows — nothing to seal, so nothing to flush, so every put stalls and then fails as Backpressure, which is supposed to mean "retry later" and never comes true. open() now rejects that configuration outright, requiring index_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, an Arc<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() is shutdown_all() and nothing else, and there is no Drop on MemoryCounters — 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 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 its own gauge by construction. So the counter is deleted rather than patched, and the seam is smaller for it — net −41 lines.

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.

Contract changes

  • "Never errors due to backpressure" → an injected controller may reject. The built-in valve still never does.
  • "Never drops data" → never drops acked data. A rejected write was never accepted.
  • active/frozen now count index memory, so the built-in valve is meaningfully tighter on vector tables at a given max_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 (ranked with distances, published, packed_level0), and it is ~2-3 allocations per node. The reference implementation for the fix already lives next door in mem_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, and ShardMemory liveness across polls (it hangs if the view ever regresses to a copy).

cargo fmt --all + cargo clippy -p lance --lib --tests --benches clean in touched files.

🤖 Generated with Claude Code

hamersaw and others added 2 commits July 16, 2026 09:26
`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>
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7678afb9-dcbe-4cf8-8cb0-c80593802c01

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the enhancement New feature or request label Jul 17, 2026
@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.68407% with 51 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance/src/dataset/mem_wal/write.rs 80.16% 44 Missing and 3 partials ⚠️
rust/lance/src/dataset/mem_wal/index/btree.rs 86.66% 2 Missing ⚠️
rust/lance-core/src/error.rs 90.00% 0 Missing and 1 partial ⚠️
rust/lance/src/dataset/mem_wal/index.rs 88.88% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

hamersaw and others added 8 commits August 18, 2026 12:31
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>
`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>
`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>
@hamersaw
hamersaw marked this pull request as ready for review August 24, 2026 21:58
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 24, 2026
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>
@github-actions github-actions Bot added A-python Python bindings A-java Java bindings + JNI and removed A-python Python bindings A-java Java bindings + JNI labels Aug 24, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 24, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 24, 2026
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>
@github-actions github-actions Bot added A-python Python bindings A-java Java bindings + JNI labels Aug 25, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 25, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 25, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 25, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 25, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 25, 2026
@wjones127
wjones127 self-requested a review August 25, 2026 17:37
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 25, 2026
if since.elapsed() < STALL_GRACE {
tokio::time::sleep(DRAIN_POLL_INTERVAL).await;
continue;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the PR body to be correct with the code.

Comment thread rust/lance/src/dataset/mem_wal/write.rs Outdated
}
}

/// Row-data bytes of the active memtable: [`Self::active_bytes`] minus

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@hamersaw hamersaw Aug 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated this to use an atomic instead to mirror the existing approaches.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds like the right call to me.

Comment thread java/lance-jni/src/mem_wal.rs Outdated
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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 25, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added K-changes Latest Gatekeeper recommendation requests changes. and removed K-changes Latest Gatekeeper recommendation requests changes. labels Aug 25, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 25, 2026
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>
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 25, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added K-changes Latest Gatekeeper recommendation requests changes. and removed K-changes Latest Gatekeeper recommendation requests changes. labels Aug 25, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 25, 2026
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>
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 25, 2026
`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>

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 25, 2026

@wjones127 wjones127 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good now. Thanks for addressing my comments.

@hamersaw
hamersaw merged commit 361f0b6 into lance-format:main Aug 26, 2026
55 of 59 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-java Java bindings + JNI A-python Python bindings enhancement New feature or request K-approved Latest Gatekeeper recommendation permits acceptance.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants