Skip to content

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

Draft
hamersaw wants to merge 1 commit into
lance-format:mainfrom
hamersaw:refactor/wal-backpressure-application
Draft

feat(mem-wal): let a backpressure controller hold back memtable freezes#8994
hamersaw wants to merge 1 commit 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant