Skip to content

Commit dbe27c3

Browse files
committed
fix(mem_wal): run grace eviction on its own task, not the flush handler's ticker
A frozen memtable's bytes are reclaimed only by the grace sweep. It ran as a ticker on the memtable-flush handler, and `TaskDispatcher` biases a handler's mailbox ahead of its ticker — deliberately, because a tick that only duplicates work a message would have done anyway must never starve a freeze completion or `close()`'s final append. That reasoning does not hold for this tick. While `handle()` is awaiting, the `select!` is not evaluated at all, so the ticker's only opportunity is the instant after a flush returns — and at any write rate where sealing outpaces flushing the mailbox is never empty at that instant. The sweep then never runs for as long as the load lasts, and nothing else reclaims the memory. Observed on a 112 GiB host with 5 MiB rows, 4 shards and a 256 MiB memtable (seal ~3.8 s, flush ~9.8 s): frozen memtables climbed 0 -> 222 and resident memory 0 -> 101 GiB before the process was killed, while the memory the backpressure view reports sat at ~6 GiB against a 19 GiB delay threshold and no writer ever parked. Backpressure was not wrong to stay quiet: grace-retained bytes are outside `ShardMemory::unflushed_bytes` by design, because throttling on memory that a flush cannot reclaim would park the writer on a sweeper tick. The starvable sweeper is the defect, not the accounting. Eviction now runs through a new `TaskExecutor::add_periodic`, which spawns work on its own task with the same cancellation and shutdown semantics as `add_handler`. `sweep_expired_frozen` becomes a free function over the shared state and memory view so a task that does not own the handler can run it. `TriggerMemTableFlush::SweepExpired` goes with it: the ticker was its only sender, so the variant, its `Debug` arm and the handler arm had no way to be reached. Doc references to it now name `sweep_expired_frozen`. The test asserts both halves — that a saturated handler starves its own ticker, and that a periodic task keeps its cadence beside it — so the distinction holds. Without the second assertion the test passes on the old code: a local-store flush is fast enough that the mailbox drains and the ticker runs.
1 parent 36bd4e2 commit dbe27c3

2 files changed

Lines changed: 195 additions & 40 deletions

File tree

rust/lance/src/dataset/mem_wal/memtable/flush.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1199,8 +1199,6 @@ pub enum TriggerMemTableFlush {
11991199
/// Optional channel to notify when flush completes.
12001200
done: Option<tokio::sync::oneshot::Sender<Result<FlushResult>>>,
12011201
},
1202-
/// Periodic tick: evict frozen memtables whose post-flush grace has elapsed.
1203-
SweepExpired,
12041202
}
12051203

12061204
impl std::fmt::Debug for TriggerMemTableFlush {
@@ -1212,7 +1210,6 @@ impl std::fmt::Debug for TriggerMemTableFlush {
12121210
.field("memtable_rows", &memtable.row_count())
12131211
.field("has_done", &done.is_some())
12141212
.finish(),
1215-
Self::SweepExpired => f.write_str("TriggerMemTableFlush::SweepExpired"),
12161213
}
12171214
}
12181215
}

rust/lance/src/dataset/mem_wal/write.rs

Lines changed: 195 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,43 @@ impl TaskExecutor {
567567
Ok(())
568568
}
569569

570+
/// Register periodic work that is **not** driven by a message.
571+
///
572+
/// Runs `work` on its own task every `every`. Use it for work only the tick
573+
/// performs: a ticker registered through [`MessageHandler::tickers`] shares
574+
/// the handler's message loop, where a mailbox that never empties can keep
575+
/// it from running.
576+
///
577+
/// Cancellation and shutdown match [`Self::add_handler`]: the task observes
578+
/// the same token and is joined by [`Self::shutdown_all`].
579+
pub fn add_periodic<F, Fut>(&self, name: String, every: Duration, mut work: F) -> Result<()>
580+
where
581+
F: FnMut() -> Fut + Send + 'static,
582+
Fut: std::future::Future<Output = ()> + Send,
583+
{
584+
let cancellation_token = self.cancellation_token.clone();
585+
let task_name = name.clone();
586+
let handle = tokio::spawn(async move {
587+
let mut interval = interval_at(tokio::time::Instant::now() + every, every);
588+
// Same reason as the dispatcher: `Burst` would replay every tick
589+
// missed while `work` ran, which for a slow `work` means the timer
590+
// is permanently ready and the cancellation arm never wins.
591+
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
592+
loop {
593+
tokio::select! {
594+
biased;
595+
_ = cancellation_token.cancelled() => {
596+
debug!("Periodic task '{}' received cancellation", task_name);
597+
return Ok(());
598+
}
599+
_ = interval.tick() => work().await,
600+
}
601+
}
602+
});
603+
self.tasks.write().unwrap().push((name, handle));
604+
Ok(())
605+
}
606+
570607
/// Cancel and join every handler registered by [`Self::add_handler`].
571608
///
572609
/// Cancellation causes each handler's dispatcher to stop accepting messages and call
@@ -1204,7 +1241,7 @@ struct ResidentMemTables {
12041241
/// resident by a *failed* flush, which are the ones most worth metering.
12051242
frozen: Vec<InMemoryMemTableRef>,
12061243
/// Sealed memtables whose flush *did* commit, lingering out
1207-
/// `frozen_memtable_grace` before `SweepExpired` drops them.
1244+
/// `frozen_memtable_grace` before [`sweep_expired_frozen`] drops them.
12081245
///
12091246
/// Held apart from `frozen` rather than dropped from the view: no flush can
12101247
/// reclaim these, so metering the flush valve on them would throttle against
@@ -1226,7 +1263,7 @@ struct ResidentMemTables {
12261263
/// Re-derive a shard's resident-memtable set from its writer state.
12271264
///
12281265
/// Call under the write lock after any change to that set: `open`,
1229-
/// `freeze_memtable`, a flush commit, and `SweepExpired` — which changes it by
1266+
/// `freeze_memtable`, a flush commit, and [`sweep_expired_frozen`] — which changes it by
12301267
/// evicting grace-expired generations that are still counted until it runs.
12311268
///
12321269
/// Cheap: two `Arc` clones per live memtable, no byte walk — the totals are
@@ -1264,7 +1301,8 @@ struct WriterState {
12641301
/// `frozen_memtable_grace` beyond it so as-of reads stay batch-resolved.
12651302
/// Pushed in `freeze_memtable`; stamped `flushed_at_ms` by `flush_memtable`
12661303
/// on commit success only (retained un-stamped on failure until a later
1267-
/// flush or WAL replay on reopen); swept after the grace by `SweepExpired`.
1304+
/// flush or WAL replay on reopen); swept after the grace by
1305+
/// [`sweep_expired_frozen`].
12681306
frozen_memtables: VecDeque<FrozenMemTable>,
12691307
/// Flag to prevent duplicate memtable flush requests.
12701308
flush_requested: bool,
@@ -1782,7 +1820,8 @@ impl SharedWriterState {
17821820
// dispatches below. `state.memtable` was already replaced, so a failed
17831821
// send that returned here without this push would drop the table and its
17841822
// accepted rows would silently vanish from every scan. Keep it queryable
1785-
// past its manifest commit too (swept after the grace by `SweepExpired`);
1823+
// past its manifest commit too (swept after the grace by
1824+
// `sweep_expired_frozen`);
17861825
// Arc refcount, not a copy — the flush task holds it alive anyway.
17871826
state.frozen_memtables.push_back(FrozenMemTable {
17881827
memtable: frozen_memtable.clone(),
@@ -2455,6 +2494,25 @@ impl ShardWriter {
24552494
memtable_flush_rx,
24562495
)?;
24572496

2497+
// On its own task: only this sweep reclaims a frozen memtable's bytes,
2498+
// and a ticker on the flush handler can be starved by its mailbox. See
2499+
// `TaskExecutor::add_periodic`.
2500+
//
2501+
// Zero grace evicts on flush commit, so there is nothing to sweep.
2502+
if !config.frozen_memtable_grace.is_zero() {
2503+
// Sweep often enough that eviction lags the grace by at most ~1/3,
2504+
// so a generation lives no more than ~grace * 4/3 past its commit.
2505+
let tick = (config.frozen_memtable_grace / 3).max(Duration::from_millis(100));
2506+
let sweep_state = state.clone();
2507+
let sweep_memory = memory.clone();
2508+
let grace = config.frozen_memtable_grace;
2509+
task_executor.add_periodic("memtable_grace_sweeper".to_string(), tick, move || {
2510+
let state = sweep_state.clone();
2511+
let memory = sweep_memory.clone();
2512+
async move { sweep_expired_frozen(&state, &memory, grace).await }
2513+
})?;
2514+
}
2515+
24582516
// The index-apply task. Its own channel and its own dispatcher: the
24592517
// dispatcher awaits `handle()` inline, so sharing the WAL flusher's
24602518
// channel would queue every index apply behind a ~100ms S3 PUT.
@@ -3947,7 +4005,8 @@ struct MemTableFlushHandler {
39474005
stats: SharedWriteStats,
39484006
observer: Option<Arc<dyn WalObserver>>,
39494007
/// How long a frozen memtable lingers in memory after its flush commits
3950-
/// before `SweepExpired` evicts it. See `ShardWriterConfig::frozen_memtable_grace`.
4008+
/// before [`sweep_expired_frozen`] evicts it. See
4009+
/// `ShardWriterConfig::frozen_memtable_grace`.
39514010
grace: Duration,
39524011
}
39534012

@@ -3976,41 +4035,37 @@ impl MemTableFlushHandler {
39764035
grace,
39774036
}
39784037
}
4038+
}
39794039

3980-
/// Evict frozen memtables whose post-flush grace has elapsed. Un-stamped
3981-
/// (not-yet-flushed) entries are always kept.
3982-
async fn sweep_expired_frozen(&self) {
3983-
let now = now_millis();
3984-
let grace_ms = self.grace.as_millis() as u64;
3985-
let mut state = self.state.write().await;
3986-
let before = state.frozen_memtables.len();
3987-
state
3988-
.frozen_memtables
3989-
.retain(|frozen| match frozen.flushed_at_ms {
3990-
Some(flushed_at) => now.saturating_sub(flushed_at) < grace_ms,
3991-
None => true,
3992-
});
3993-
// Eviction is the only thing that reclaims a grace-retained generation,
3994-
// so this is where its bytes leave the memory view.
3995-
if state.frozen_memtables.len() != before {
3996-
publish_memory(&self.memory, &state);
3997-
}
4040+
/// Evict frozen memtables whose post-flush grace has elapsed. Un-stamped
4041+
/// (not-yet-flushed) entries are always kept.
4042+
///
4043+
/// A free function so the sweeper task can call it without owning the flush
4044+
/// handler — see [`TaskExecutor::add_periodic`] for why it must not run there.
4045+
async fn sweep_expired_frozen(
4046+
state: &Arc<RwLock<WriterState>>,
4047+
memory: &Arc<ArcSwap<ResidentMemTables>>,
4048+
grace: Duration,
4049+
) {
4050+
let now = now_millis();
4051+
let grace_ms = grace.as_millis() as u64;
4052+
let mut state = state.write().await;
4053+
let before = state.frozen_memtables.len();
4054+
state
4055+
.frozen_memtables
4056+
.retain(|frozen| match frozen.flushed_at_ms {
4057+
Some(flushed_at) => now.saturating_sub(flushed_at) < grace_ms,
4058+
None => true,
4059+
});
4060+
// Eviction is the only thing that reclaims a grace-retained generation,
4061+
// so this is where its bytes leave the memory view.
4062+
if state.frozen_memtables.len() != before {
4063+
publish_memory(memory, &state);
39984064
}
39994065
}
40004066

40014067
#[async_trait]
40024068
impl MessageHandler<TriggerMemTableFlush> for MemTableFlushHandler {
4003-
fn tickers(&mut self) -> Vec<(Duration, MessageFactory<TriggerMemTableFlush>)> {
4004-
// Zero grace evicts on commit, so no sweeper is needed.
4005-
if self.grace.is_zero() {
4006-
return vec![];
4007-
}
4008-
// Sweep often enough that eviction lags the grace by at most ~1/3, so a
4009-
// generation lives no more than ~grace * 4/3 past its flush commit.
4010-
let tick = (self.grace / 3).max(Duration::from_millis(100));
4011-
vec![(tick, Box::new(|| TriggerMemTableFlush::SweepExpired))]
4012-
}
4013-
40144069
async fn handle(&mut self, message: TriggerMemTableFlush) -> Result<()> {
40154070
match message {
40164071
TriggerMemTableFlush::Flush { memtable, done } => {
@@ -4023,7 +4078,6 @@ impl MessageHandler<TriggerMemTableFlush> for MemTableFlushHandler {
40234078
result?;
40244079
}
40254080
}
4026-
TriggerMemTableFlush::SweepExpired => self.sweep_expired_frozen().await,
40274081
}
40284082
Ok(())
40294083
}
@@ -4150,7 +4204,7 @@ impl MemTableFlushHandler {
41504204
// Retire the frozen handle on commit success, keyed by generation
41514205
// (non-FIFO completion is fine). Zero grace evicts here; otherwise
41524206
// stamp the grace clock so it lingers for multi-part as-of reads
4153-
// until `SweepExpired`. On failure leave it un-stamped: rows stay in
4207+
// until the grace sweep. On failure leave it un-stamped: rows stay in
41544208
// the read union until a later flush or WAL replay, else a transient
41554209
// error reopens the hole.
41564210
if flush_result.is_ok() {
@@ -5767,6 +5821,110 @@ mod tests {
57675821
/// lingers for `frozen_memtable_grace`. A long grace therefore leaves count
57685822
/// non-zero with bytes back at zero — and pins that the two surfaces are
57695823
/// answering different questions, not disagreeing about one.
5824+
/// A saturated handler starves its own ticker; `add_periodic` does not.
5825+
///
5826+
/// `TaskDispatcher` biases a handler's mailbox ahead of its ticker, and the
5827+
/// `select!` is not evaluated while `handle()` awaits, so a mailbox that
5828+
/// never empties leaves the ticker no instant to win. Both halves are
5829+
/// asserted so the distinction holds.
5830+
#[tokio::test(start_paused = true)]
5831+
async fn test_saturated_handler_starves_its_ticker_but_not_a_periodic_task() {
5832+
#[derive(Debug)]
5833+
enum Msg {
5834+
Work,
5835+
Tick,
5836+
}
5837+
5838+
#[derive(Debug)]
5839+
struct SlowHandler {
5840+
worked: Arc<AtomicUsize>,
5841+
ticked: Arc<AtomicUsize>,
5842+
work: Duration,
5843+
tick: Duration,
5844+
}
5845+
5846+
#[async_trait]
5847+
impl MessageHandler<Msg> for SlowHandler {
5848+
fn tickers(&mut self) -> Vec<(Duration, MessageFactory<Msg>)> {
5849+
vec![(self.tick, Box::new(|| Msg::Tick))]
5850+
}
5851+
5852+
async fn handle(&mut self, message: Msg) -> Result<()> {
5853+
match message {
5854+
Msg::Work => {
5855+
self.worked.fetch_add(1, Ordering::Relaxed);
5856+
tokio::time::sleep(self.work).await;
5857+
}
5858+
Msg::Tick => {
5859+
self.ticked.fetch_add(1, Ordering::Relaxed);
5860+
}
5861+
}
5862+
Ok(())
5863+
}
5864+
}
5865+
5866+
// One unit of work outlasts the tick interval: that is what starves a
5867+
// ticker sharing the handler's loop.
5868+
let work = Duration::from_millis(100);
5869+
let tick = Duration::from_millis(10);
5870+
let run = Duration::from_millis(1000);
5871+
let expected_ticks = (run.as_millis() / tick.as_millis()) as usize;
5872+
5873+
let executor = TaskExecutor::new();
5874+
let worked = Arc::new(AtomicUsize::new(0));
5875+
let ticked = Arc::new(AtomicUsize::new(0));
5876+
let swept = Arc::new(AtomicUsize::new(0));
5877+
5878+
let (tx, rx) = mpsc::unbounded_channel();
5879+
executor
5880+
.add_handler(
5881+
"slow".to_string(),
5882+
Box::new(SlowHandler {
5883+
worked: worked.clone(),
5884+
ticked: ticked.clone(),
5885+
work,
5886+
tick,
5887+
}),
5888+
rx,
5889+
)
5890+
.unwrap();
5891+
5892+
// The same cadence, on its own task rather than the handler's loop.
5893+
let swept_by_task = swept.clone();
5894+
executor
5895+
.add_periodic("sweeper".to_string(), tick, move || {
5896+
let swept = swept_by_task.clone();
5897+
async move {
5898+
swept.fetch_add(1, Ordering::Relaxed);
5899+
}
5900+
})
5901+
.unwrap();
5902+
5903+
// Enough queued work that the mailbox never empties during the run.
5904+
for _ in 0..(run.as_millis() / work.as_millis()) + 2 {
5905+
tx.send(Msg::Work).unwrap();
5906+
}
5907+
tokio::time::sleep(run).await;
5908+
5909+
let (worked, ticked, swept) = (
5910+
worked.load(Ordering::Relaxed),
5911+
ticked.load(Ordering::Relaxed),
5912+
swept.load(Ordering::Relaxed),
5913+
);
5914+
assert!(worked > 0, "the handler must have been busy");
5915+
assert!(
5916+
ticked * 10 < expected_ticks,
5917+
"a ticker sharing a saturated handler's loop should be starved, but \
5918+
it ran {ticked} of ~{expected_ticks}"
5919+
);
5920+
assert!(
5921+
swept * 2 >= expected_ticks,
5922+
"periodic task ran {swept} of ~{expected_ticks}"
5923+
);
5924+
5925+
executor.shutdown_all().await.ok();
5926+
}
5927+
57705928
#[tokio::test]
57715929
async fn test_memtable_stats_frozen_count_outlives_frozen_bytes() {
57725930
let (store, base_path, base_uri, _temp_dir) = create_local_store().await;
@@ -8870,7 +9028,7 @@ mod tests {
88709028
/// On a successful flush commit the sealed generation's rows land in the
88719029
/// manifest immediately, but the in-memory handle is NOT dropped — it
88729030
/// lingers for `frozen_memtable_grace` (so in-flight as-of reads keep
8873-
/// batch-resolved membership), then is swept by the `SweepExpired` ticker.
9031+
/// batch-resolved membership), then is swept by the grace sweep.
88749032
#[tokio::test]
88759033
async fn test_frozen_retained_during_grace_then_swept() {
88769034
let (store, base_path, base_uri, _temp_dir) = create_local_store().await;

0 commit comments

Comments
 (0)