Skip to content

Commit dd26859

Browse files
beinanclaude
andauthored
fix(master): stop MergeWal starving Compact in the scheduler (#239)
## Problem `Compact` tasks could sit queued indefinitely whenever `MergeWal` load was present. In production the 600s auto-sweep kept ~50–80 MergeWal tasks queued/running, fragments grew to **1,085,065** (1–10 rows each), and because every worker's open `Dataset` handle keeps the full manifest resident, **18 of 20 workers were OOMKilled**. Scaling the master 3→8 replicas did not help — every added slot was consumed the same way. ## Root cause: admission, not concurrency `MERGE_WAL_CONCURRENCY` already gave merge its own semaphore, but a single dispatch loop called one **unfiltered** `claim_next()` and only chose a pool *after* the claim. Claiming is destructive — it deletes the queue key, grants a lease, and takes the per-experiment target lock. So a claim made because the *general* pool had permits could return a `MergeWal`, then park it on the saturated merge semaphore **while holding its target lock**. With MergeWal numerically dominant, nearly every claim returned one, and the loop's `while` guard stayed true only because the general pool was idle. The dispatcher spun claiming work it could not run while a lone `Compact` sat queued behind it. ### One correction to the issue's diagnosis The issue lists "single shared queue, interleaved only by queue key order" as root cause #1. That part is not accurate: task ids are **UUIDv7** (`lance-context-core/src/id.rs`), so the queue is genuinely time-ordered FIFO — ordering was never arbitrary. This matters, because plain FIFO turns out to be sufficient once admission is fixed. **No priority, fairness policy, or reserved capacity was added** — those would solve a problem that isn't there. ## Fix Add `TaskKinds`, a small kind set threaded into `claim_next`, and run **one poller per pool** — `GENERAL` (Compact + IndexId) and `MERGE_WAL` — each claiming only the kinds it can actually run. The filter is applied *before* the dependency probe, so skipped kinds cost nothing. FIFO order within a kind is unchanged, and **no new config knob** is introduced. One caveat, now documented in the config docs rather than left implicit: `MERGE_WAL_CONCURRENCY=0` shares a single pool, which necessarily reinstates single-poller behavior. Prefer a non-zero value when both kinds are in play. ## Tests Both etcd-backed, and **both verified to fail with the fix reverted**: - `filtered_claim_reaches_compact_behind_merge_wal_backlog` — 30 MergeWal enqueued *ahead* of one Compact (worst case for FIFO). Asserts an unfiltered claim still returns MergeWal, so the test cannot silently stop exercising the scenario; that `GENERAL` reaches the trailing Compact; that `GENERAL` never returns MergeWal; and that the merge pool still drains its own backlog. - `compact_runs_while_merge_wal_pool_is_saturated` — end-to-end through the dispatcher against a stub worker that accepts and never responds. Asserts the Compact reaches `Done` **and** that MergeWal work is still outstanding, so it cannot pass merely because the backlog drained. **Negative verification:** with the kind filter removed, the first fails on the kind assertion and the second hangs to timeout — the production symptom exactly. Full suite green: 22/22 etcd-backed master tests, 222 core, 68 server, `fmt` + `clippy -D warnings` clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 7e39d92 commit dd26859

3 files changed

Lines changed: 324 additions & 23 deletions

File tree

crates/lance-context-master/src/config.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,17 @@ pub struct MasterConfig {
152152
/// both backlogs are growing and both need to drain. Giving merge its own
153153
/// budget decouples them.
154154
///
155+
/// The budget alone was not sufficient: it bounds concurrency, not
156+
/// admission order. Because `MergeWal` is numerically dominant (the
157+
/// `merge_wal_interval_secs` sweep enqueues one per over-threshold
158+
/// experiment), a single unfiltered poller kept claiming MergeWal tasks and
159+
/// a queued `Compact` was never reached. The scheduler therefore runs one
160+
/// poller per pool, each claiming only the kinds it can run, so this budget
161+
/// now also determines admission.
162+
///
155163
/// `0` disables the separate budget and falls back to sharing the general
156-
/// `task_concurrency` pool.
164+
/// `task_concurrency` pool -- which also reinstates the single-poller
165+
/// behavior, so prefer a non-zero value when both kinds are in play.
157166
#[arg(long, env = "MERGE_WAL_CONCURRENCY", default_value_t = 4)]
158167
pub merge_wal_concurrency: usize,
159168

crates/lance-context-master/src/scheduler.rs

Lines changed: 165 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ use tokio::task::JoinHandle;
3434

3535
use crate::state::MasterState;
3636
use crate::stats_store::StatRow;
37-
use crate::task_store::TaskClaim;
37+
use crate::task_store::{TaskClaim, TaskKinds};
3838

3939
/// Maximum tasks a single auto-sweep may enqueue.
4040
///
@@ -447,7 +447,14 @@ async fn sweep_merge_wal_inner(state: &Arc<MasterState>) -> lance::Result<usize>
447447
Ok(queued)
448448
}
449449

450-
/// Spawn the scheduler poller plus the optional periodic auto-sweep.
450+
/// Spawn the scheduler pollers plus the optional periodic auto-sweep.
451+
///
452+
/// Returns the handle of the *general* poller only. When a separate WAL-merge
453+
/// budget is configured there is also a second poller (and the auto-sweep task)
454+
/// whose handles are dropped: like the sweep, they are meant to live as long as
455+
/// the process. Aborting the returned handle therefore stops general dispatch,
456+
/// not every scheduler task -- adequate for tests, which drop the whole
457+
/// `MasterState` immediately after.
451458
pub fn spawn_scheduler(state: &Arc<MasterState>) -> JoinHandle<()> {
452459
// Optional periodic compaction auto-sweep feeds the same queue.
453460
let interval_secs = state.config.compaction_interval_secs;
@@ -494,38 +501,84 @@ pub fn spawn_scheduler(state: &Arc<MasterState>) -> JoinHandle<()> {
494501
// into the shared pool.
495502
let merge_sem = (state.config.merge_wal_concurrency > 0)
496503
.then(|| Arc::new(Semaphore::new(state.config.merge_wal_concurrency)));
497-
let dispatch_state = state.clone();
504+
505+
// One poller per pool, each claiming only the kinds its pool runs.
506+
//
507+
// A single poller drawing from one unfiltered `claim_next` could not keep
508+
// the pools independent, because claiming is destructive: it deletes the
509+
// queue key, grants a lease, and takes the per-experiment target lock. The
510+
// claimed task's kind then decided which pool it drew from, so a claim made
511+
// on behalf of an idle pool could return a task belonging to a saturated
512+
// one and park it on `acquire_owned()` -- holding its target lock while
513+
// idle. With `MergeWal` numerically dominant (the 600s sweep enqueues one
514+
// per over-threshold experiment), nearly every claim returned a MergeWal,
515+
// and the loop's own `while` guard stayed true only because the *general*
516+
// pool had permits. The result was a dispatcher that spun claiming MergeWal
517+
// tasks it could not run while a lone `Compact` sat queued behind them --
518+
// starvation that adding replicas or raising `task_concurrency` could not
519+
// fix, since every added slot was filled the same way.
520+
//
521+
// Splitting the pollers makes each one claim only what it can run, so a
522+
// free compaction slot reaches past any number of queued MergeWal tasks.
523+
// FIFO order within a kind is unchanged.
524+
let general = spawn_pool_poller(
525+
state.clone(),
526+
sem,
527+
if merge_sem.is_some() {
528+
TaskKinds::GENERAL
529+
} else {
530+
// No separate merge budget: the general pool runs everything, so it
531+
// must still be allowed to claim MergeWal.
532+
TaskKinds::ANY
533+
},
534+
true,
535+
);
536+
if let Some(merge) = merge_sem {
537+
spawn_pool_poller(state.clone(), merge, TaskKinds::MERGE_WAL, false);
538+
}
539+
general
540+
}
541+
542+
/// Spawn one dispatch loop bound to a single execution pool.
543+
///
544+
/// `kinds` restricts what this loop will claim; `report_depth` designates the
545+
/// one loop that publishes the shared queue-depth gauge, so running several
546+
/// pollers does not multiply that metric.
547+
fn spawn_pool_poller(
548+
state: Arc<MasterState>,
549+
pool: Arc<Semaphore>,
550+
kinds: TaskKinds,
551+
report_depth: bool,
552+
) -> tokio::task::JoinHandle<()> {
498553
tokio::spawn(async move {
499554
loop {
500-
if let Ok(queued) = dispatch_state.task_store.queue_depth().await {
501-
metrics::gauge!("master_task_queue_depth").set(queued as f64);
555+
if report_depth {
556+
if let Ok(queued) = state.task_store.queue_depth().await {
557+
metrics::gauge!("master_task_queue_depth").set(queued as f64);
558+
}
502559
}
503-
// Poll while *either* pool can accept work; the claimed task's kind
504-
// decides which one it draws from below.
505-
while sem.available_permits() > 0
506-
|| merge_sem
507-
.as_ref()
508-
.is_some_and(|s| s.available_permits() > 0)
509-
{
560+
while pool.available_permits() > 0 {
510561
let claim_start = std::time::Instant::now();
511-
match dispatch_state.task_store.claim_next().await {
562+
match state.task_store.claim_next_of_kinds(kinds).await {
512563
Ok(Some(claim)) => {
513564
let claim_elapsed = claim_start.elapsed();
514565
// The task is already claimed at this point (queue key
515566
// deleted, lease granted, target lock held), so time
516567
// spent here is a claimed-but-idle task holding its
517-
// per-experiment lock — worth seeing separately.
568+
// per-experiment lock — worth seeing separately. This
569+
// loop only claims kinds its own pool runs, so the wait
570+
// is now bounded by that pool's own occupancy.
518571
let permit_start = std::time::Instant::now();
519-
let pool = match (claim.task.kind, merge_sem.as_ref()) {
520-
(TaskKind::MergeWal, Some(merge)) => merge.clone(),
521-
_ => sem.clone(),
522-
};
523-
let permit = pool.acquire_owned().await.expect("semaphore never closed");
572+
let permit = pool
573+
.clone()
574+
.acquire_owned()
575+
.await
576+
.expect("semaphore never closed");
524577
let timing = TaskClaimTiming {
525578
claim: claim_elapsed,
526579
permit_wait: permit_start.elapsed(),
527580
};
528-
let st = dispatch_state.clone();
581+
let st = state.clone();
529582
tokio::spawn(async move {
530583
run_task(&st, claim, timing).await;
531584
drop(permit);
@@ -756,6 +809,98 @@ mod tests {
756809
worker.abort();
757810
}
758811

812+
/// A `Compact` runs even while every `MergeWal` slot is occupied by slow
813+
/// fan-outs and the queue is dominated by MergeWal tasks.
814+
///
815+
/// This is the end-to-end form of the starvation bug: the merge pool is
816+
/// saturated by stub workers that never return, and 20 MergeWal tasks are
817+
/// enqueued *ahead* of the Compact. Before the split-poller fix the single
818+
/// dispatch loop kept claiming MergeWal tasks (parking them on the merge
819+
/// semaphore while they held their locks) and the trailing Compact was
820+
/// never reached, so this test would hang to its timeout.
821+
#[tokio::test]
822+
#[ignore = "requires ETCD_TEST_ENDPOINTS"]
823+
async fn compact_runs_while_merge_wal_pool_is_saturated() {
824+
use axum::{routing::post, Router};
825+
826+
// A worker that accepts the merge call and then never responds, so the
827+
// MergeWal task occupies its slot for the duration of the test.
828+
let hang = Router::new().route(
829+
"/api/v1/internal/merge-wal/{name}",
830+
post(|| async {
831+
std::future::pending::<()>().await;
832+
String::new()
833+
}),
834+
);
835+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
836+
let addr = listener.local_addr().unwrap();
837+
tokio::spawn(async move { axum::serve(listener, hang).await.unwrap() });
838+
839+
let dir = TempDir::new().unwrap();
840+
let mut cfg = config(&dir);
841+
cfg.worker_endpoints = vec![format!("http://{addr}")];
842+
cfg.merge_wal_concurrency = 2;
843+
cfg.task_concurrency = 2;
844+
let state = MasterState::new(cfg).await.unwrap();
845+
846+
// Build a compactable store before the dispatcher starts.
847+
let name = "starved";
848+
let uri = state.rollout_uri(name);
849+
{
850+
let mut store = RolloutStore::open(&uri).await.unwrap();
851+
for i in 0..4 {
852+
let rec = rollout_record(&format!("r{i}"));
853+
store.add(&[rec]).await.unwrap();
854+
store.cleanup_own_shard().await.unwrap();
855+
}
856+
}
857+
state
858+
.registry
859+
.write()
860+
.await
861+
.upsert(name, &uri)
862+
.await
863+
.unwrap();
864+
crate::scanner::scan_once(&state).await.unwrap();
865+
866+
// Saturate and over-fill the merge queue *before* the Compact.
867+
for i in 0..20 {
868+
enqueue(&state, TaskKind::MergeWal, &format!("exp-{i}"))
869+
.await
870+
.unwrap();
871+
}
872+
let compact = enqueue(&state, TaskKind::Compact, name).await.unwrap();
873+
874+
let worker = spawn_scheduler(&state);
875+
876+
let status = await_terminal(&state, &compact.id).await;
877+
assert_eq!(
878+
status.state,
879+
TaskState::Done,
880+
"Compact must drain while MergeWal saturates its own pool: {status:?}"
881+
);
882+
883+
// Sanity: the merge backlog really is still stuck, i.e. the Compact did
884+
// not simply win because the MergeWal tasks all completed.
885+
let stuck = state
886+
.task_store
887+
.list()
888+
.await
889+
.unwrap()
890+
.into_iter()
891+
.filter(|t| {
892+
t.kind == TaskKind::MergeWal
893+
&& matches!(t.state, TaskState::Queued | TaskState::Running)
894+
})
895+
.count();
896+
assert!(
897+
stuck > 0,
898+
"test must leave MergeWal work outstanding, else it proves nothing"
899+
);
900+
901+
worker.abort();
902+
}
903+
759904
/// A dependent task runs only after its dependency reaches `Done`: an
760905
/// `index_id` depending on a `compact` must start after compaction finishes,
761906
/// so the two never contend for the shared per-experiment base-table gate.

0 commit comments

Comments
 (0)