feat(mem-wal): let a backpressure controller hold back memtable freezes - #8994
Draft
hamersaw wants to merge 1 commit into
Draft
feat(mem-wal): let a backpressure controller hold back memtable freezes#8994hamersaw wants to merge 1 commit into
hamersaw wants to merge 1 commit into
Conversation
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
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.
Problem
The memtable seal trigger is blind to whatever the flush lands in. All four arms of
memtable_reached_flush_thresholdare 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) × timewith nothing to bound it.Change
BackpressureControllergainsmay_seal(), defaulted totrueso 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_flushreturnsSealOutcome::Blockedinstead 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:BlockedintoError::backpressure— retryable by construction, since the controller admits freezes again once the tier drains;ShardMemorynow carriesseal_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
ArcSwapsnapshot 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_thresholdnow delegates tofill_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_putdrives a realShardWriterwith 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