fix(master): unwedge /experiments and drain compaction backlogs faster - #235
Merged
beinan merged 1 commit intoAug 7, 2026
Merged
Conversation
Four production failures, all in the master, all reinforcing each other during a backlog. 1. `/experiments` hung behind the stats scan. `state.stats` is an exclusive `Mutex` (every `StatsStore` read takes `&mut self`), and `list_experiments` used it as its only data source with a bare `lock().await` -- no timeout, no fallback, and no `TimeoutLayer` on the router. Any slow scan round wedged the endpoint outright. The round was slow by construction: `scan_once_inner` took the lock and then called `retire_cold_experiments`, a serial loop that per cold experiment opens the store, runs `cleanup_own_shard` (600s bound), *waits on the process-wide compaction semaphore*, and compacts (another 600s). That put up to `N_cold x ~20min` of object-store IO inside the mutex, and blocked on a semaphore while holding it, so an unrelated slow compaction extended the hold further. Retirement needs nothing from the lock -- it reads a local and returns a name set -- so it now runs before the lock is taken. The critical section is one commit again. Independently, the handler now `try_lock`s and falls back to an in-memory snapshot published after each round, flagging the response `stale`. Slightly-old data beats an unbounded hang, and the staleness is visible rather than silent. 2. The sweeps ignored backlog size. `list_above` pushed `limit` into the scan *before* sorting, then sorted by name -- so which rows came back was scan order. An experiment with 15k fragments had no better chance of being swept than one with 17, and the per-sweep cap could be spent entirely on nearly-clean experiments while the worst offenders sat untouched. Now ordered by the threshold column descending, so each sweep spends its budget worst-first. The predicate is still pushed down; only the arbitrary truncation is gone. 3. WAL merge starved compaction. `MergeWal` is an HTTP fan-out to every worker: slow in wall-clock, near free in local resources. Drawing from the single `task_concurrency` pool let a backlog of fan-outs occupy every slot -- exactly when both backlogs are growing and both need to drain. It now has its own budget (`MERGE_WAL_CONCURRENCY`, default 4; `0` restores the shared pool). 4. `task_concurrency` was documented as if it were global. It is per-process, so N replicas run up to N*task_concurrency tasks. Correctness never depended on it (etcd claims and per-target locks already prevent two replicas touching one experiment), so this is a doc fix -- but the old wording invited tuning it as a cluster-wide bound. The `/experiments` test holds the stats lock for the duration of the call, which reproduces the production wedge exactly; it times out against the previous blocking read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Four production failures in the master, all reinforcing each other during a backlog. Observed live: 2/3 master replicas stuck on the stats lock,
/experimentstiming out, ~100k fragments accumulated (15k on a single experiment), and one experiment at 98+ pending WAL generations.1.
/experimentshung behind the stats scanstate.statsis an exclusiveMutex— everyStatsStoreread method takes&mut self, so reads cannot even run concurrently with each other.list_experimentsused it as its only data source via a barelock().await, with no timeout, no fallback, and noTimeoutLayeron the router. Any slow scan round wedged the endpoint outright.And the round was slow by construction.
scan_once_innertook the lock and then calledretire_cold_experiments, a serial loop that for each cold experiment:cleanup_own_shard—RETIRE_TIMEOUT, 600s,That is up to
N_cold × ~20minof object-store IO inside the mutex that the UI depends on — and a semaphore wait nested inside a mutex hold, so an unrelated slow compaction elsewhere in the process extended it further. Withstats_cold_retire_secsdefaulting to 7 days, this path is live in any deployment more than a week old.Two independent fixes:
snapshotand returns a set of names. It now runs before the lock is taken, leaving the critical section as the single commit it was meant to be.try_locks, and on contention serves an in-memory snapshot published after each round, flagging the responsestale. A list at most one scan interval old beats an unbounded hang, and the degradation is visible rather than silent. The search fallback — which takes the registry write lock and opens datasets on demand — is skipped while stale, since doing the most expensive work precisely when the process is busy is what turned contention into a second stall.2. The sweeps ignored backlog size
list_abovepushedlimitinto the scan before sorting, then sorted by name. Which rows came back was therefore scan order, effectively arbitrary — a 15k-fragment experiment had no better chance of being swept than a 17-fragment one, and the per-sweep cap could be spent entirely on nearly-clean experiments while the worst offenders sat untouched for many sweeps.Now ordered by the threshold column descending (
fragment_countfor compaction,pending_wal_generationsfor merge), so each sweep spends its budget worst-first. The predicate is still pushed into the scan — that was the point of the original change — only the arbitrary pre-sort truncation is gone.3. WAL merge starved compaction
MergeWalis an HTTP fan-out to every worker endpoint: slow in wall-clock terms, nearly free in local resources. It shared the singletask_concurrencypool with compaction, so a backlog of slow fan-outs could occupy every slot — exactly when both backlogs are growing and both need to drain.It now draws from its own budget:
MERGE_WAL_CONCURRENCY, default 4.0restores the previous shared-pool behavior.4.
task_concurrencywas documented as if it were globalIt is per-process, so N replicas run up to
N * task_concurrencytasks between them. Correctness never depended on the value — etcd claims and per-target locks already prevent two replicas touching one experiment — but the old wording invited operators to tune it as a cluster-wide bound. Doc-only.Note for operators
This PR does not change any default. The fragment backlog is primarily paced by
COMPACTION_MAX_SOURCE_FRAGMENTS(32) againstCOMPACTION_INTERVAL_SECS(600): withCompactdeduped per target, one experiment drains at ~32 fragments per 10 minutes, i.e. ~3 days for 15k. Raising those is the operational lever; this PR makes sure the sweep budget is spent on the right experiments and that the UI survives while it drains.Verification
cargo build --workspace,cargo fmt --all -- --check,cargo clippy --workspace --all-targets -- -D warnings, andcargo clippy --manifest-path crates/lance-context/Cargo.toml --all-targets -- -D warningsall clean.cargo test --workspace --all-targets— all green (217 core + 41 master + rest).cargo test -p lance-context-master --lib -- --ignoredagainst a local etcd 3.7.0 — 20/20 pass./experimentstest was confirmed non-vacuous: it holds the stats lock for the duration of the call, reproducing the production wedge, and fails by timeout when reverted to the previous blockinglock().await.threshold_query_returns_only_matching_rowsencoded the old ordering and was updated; its stated intent ("exactly the rows over the threshold") is membership, which is unchanged.list— the UI pagination path — keeps its name ordering and is untouched.🤖 Generated with Claude Code