Skip to content

feat(mem-wal): let a backpressure controller hold back memtable freezes - #8994

Open
hamersaw wants to merge 2 commits into
lance-format:mainfrom
hamersaw:refactor/wal-backpressure-application
Open

hamersaw wants to merge 2 commits into
lance-format:mainfrom
hamersaw:refactor/wal-backpressure-application

Conversation

@hamersaw

@hamersaw hamersaw commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Problem

The memtable seal trigger is blind to whatever the flush lands in. All four arms of memtable_reached_flush_threshold are memtable-local, so an embedder that bounds a downstream tier — a shared page cache over L0, in our case — has no way to stop that tier growing.

Refusing writes does not help: a refused write never runs its seal, and the memtables already frozen flush regardless. The tier grows at (flush rate − compaction rate) × time with nothing to bound it.

Change

BackpressureController gains may_seal(), defaulted to true so lance's own behaviour is unchanged — only an embedder bounding a tier lance cannot see has a reason to say no. When a controller says no, maybe_trigger_memtable_flush returns SealOutcome::Blocked instead of freezing, and the active memtable pins at its cap.

A blocked freeze has to refuse the put that needed it. Growing the memtable instead is not an option: the in-memory indexes are pre-allocated to exactly max_memtable_rows, so an overshoot fails the index apply. So:

  • the two pre-insert call sites turn Blocked into Error::backpressure — retryable by construction, since the controller admits freezes again once the tier drains;
  • the post-insert rotation ignores it, because those rows already landed; the memtable is left full for the next put's pre-insert check to refuse on.

ShardMemory now carries seal_required, which lets a controller tell a write that still fits in the active memtable from one that can only land after a freeze. The first costs the tier below nothing and there is no reason to slow it; the second is what puts the next generation in it. Gating on that distinction bounds the tier without throttling writes that would not have grown it.

It is answered off the published ArcSwap snapshot rather than under the write lock, so admission can decide before the lock the seal is taken under. To keep one predicate, memtable_reached_flush_threshold now delegates to fill_reached_flush_threshold, which both the writer (under the lock) and the snapshot path call — the check the controller decides on and the one the writer acts on cannot drift.

What is deliberately not gated

force_seal_active. It is how drain and drop-table get bytes out of memory and into storage, and they have to be able to seal whatever the tier below looks like.

Testing

test_a_blocked_seal_pins_the_memtable_and_refuses_the_put drives a real ShardWriter with a controller that blocks freezes and asserts the put is refused with a backpressure error, the generation does not rotate, no rows land, and the seal happens once the block lifts.

cargo test -p lance --lib mem_wal::write::tests — 103 passed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RY5TX2epNsh5VY6KtbrqyU

The seal trigger is blind to whatever the flush lands in. All four arms of
`memtable_reached_flush_threshold` are memtable-local, so an embedder that
bounds a downstream tier (a shared page cache over L0, say) has no way to stop
that tier growing: it can refuse writes, but a refused write never runs its
seal, and the frozen memtables already queued flush regardless.

Add `BackpressureController::may_seal`, defaulted to `true` so lance's own
behaviour is unchanged. When a controller says no, `maybe_trigger_memtable_flush`
returns `SealOutcome::Blocked` instead of freezing: the active memtable pins at
its cap and nothing further enters the tier below.

A blocked freeze has to refuse the put that needed it. Growing the memtable
past `max_memtable_rows` is not an option -- the in-memory indexes are
pre-allocated to exactly that many rows, so an overshoot fails the index apply.
The two pre-insert call sites therefore turn `Blocked` into
`Error::backpressure`, which is retryable by construction since the controller
admits freezes again as soon as the tier drains. The post-insert rotation
ignores it: those rows already landed, and the memtable is simply left full for
the next put's pre-insert check to refuse on.

`ShardMemory` now carries `seal_required`, so a controller can tell a write
that still fits in the active memtable from one that can only land after a
freeze. The first costs the tier below nothing and there is no reason to slow
it; the second is what puts the next generation in it. Answered off the
published snapshot rather than under the write lock, so admission can decide
before the lock the seal is taken under -- `memtable_reached_flush_threshold`
delegates to a new `fill_reached_flush_threshold` that both paths call, so the
predicate the controller decides on and the one the writer acts on cannot
drift.

`force_seal_active` is deliberately not gated. It is how drain and drop get
bytes out of memory and into storage, and they have to be able to seal whatever
the tier below looks like.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RY5TX2epNsh5VY6KtbrqyU
@github-actions github-actions Bot added the enhancement New feature or request label Sep 4, 2026
Renames `BackpressureController::may_seal` to `may_seal_memtable`, so the
call site says which thing is being held back, and trims the comments
added with it to what the code does now.

Also fixes a run of stray spaces mid-sentence in the `seal_blocked_error`
message, left by a botched line join.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0161eG2XSjrHZWcvKdvpJPAG
@hamersaw
hamersaw marked this pull request as ready for review September 9, 2026 17:30

@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 with a non-blocking risk.

Automatic seal attempts now honor the controller at the writer-lock boundary, and the retry/no-mutation path is covered. The remaining operational risk is that this boolean hook is a soft observation rather than a cross-shard reservation: queued or concurrent seals, plus the deliberate force/close bypass, can still add downstream work after capacity appears full. Embedders using this as an OOM boundary should coordinate reservations and leave shutdown headroom; a permit-bearing seal contract would be the stronger option if a hard cap is required.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Sep 9, 2026
///
/// Racy against a concurrent seal in both directions; the writer re-checks
/// under the lock and a parked controller re-reads on its next poll.
fn seal_required(&self, incoming_batches: usize, incoming_rows: usize) -> bool {

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.

I like this idea, when write can still land without trigger a flush, we should just admit it

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.

Though just calling out here that for wide rows which likely the ones triggering more frequent freeze, this may always return true unless whoever build on this also scale up the memtable size

/// `Blocked` into a refusal; the post-insert caller leaves the memtable full
/// for the next put to be refused on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SealOutcome {

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.

nit: maybe add some seals_blocked_total or puts_refused counters

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants