fix(cascade): per-kind prune staleness + rebuild safety - #384
Merged
Conversation
gloryfromca
force-pushed
the
fix/cascade-prune-health-followups
branch
from
August 3, 2026 09:07
1bf2e17 to
9db26d4
Compare
Adversarial review of the review-response fixes found five real defects, all in code this PR introduced. Health signal (P1): prune staleness reported the time since the NEWEST successful prune across kinds, so on a multi-kind deployment (every real one) a single kind whose cleanup died was masked by the others pruning on schedule — /health stayed green while that table's index dir grew unbounded, the exact incident the signal exists to catch. Report the WORST kind instead and name it in the reason. The failure streak could not cover this either: an intervening light beat resets it, so it never reaches the threshold for a prune-only failure. Documented that split of duties. Spurious fallback rebuilds (P1): the benign-conflict carve-out excluded the heavy beat, justified by "runs under the write lock, so it can't hit this benignly" — but that lock is in-process only, so a second process (a long `cascade backfill`, a `cascade sync`) preempts prune's Rewrite commit. Those counted as real failures, and ~25min of cross-process churn reached the threshold and fired a fallback rebuild, which drops every index before recreating it; a rebuild that also lost the race was swallowed as a warning, leaving the table with no FTS index (every /search on that kind 500s) until the next 12h sweep. Treat commit conflicts as benign on both beats and let prune-staleness detect a prune that genuinely stops succeeding. cascade rebuild (P1 ×2 + P2): it drops and recreates tables with no guard while --help/docstrings advertised it as safe, so `rebuild --yes` against a live daemon corrupted the rebuild (the daemon keeps writing through cached handles). Refuse when the OME jobstore lock is held, reusing backfill's detection and its exit code 3. It also ran the pre-drop migration pass (`ensure_business_indexes`) against the damaged table, so on the corruption classes it exists to repair (missing column, un-alterable type) the recovery path died on the damage itself — skip it via `_runtime(ensure=False)`. Reset the queue BEFORE dropping so every crash window converges on "queue pending → re-index" instead of empty tables with a fully-done queue (a silently empty deployment), and handle Ctrl-C with exit 130 plus a resume hint. Recovery guidance (P1): the nullable-vector migration error still told users to wipe the index directory — which this PR's own runbook documents as the wrong recovery (queue stays done, index comes back empty). Point it at `everos cascade rebuild`. Dropped the schema-drift error's "restart first" step too: the startup migrations only alter nullability, never a name or type, so a name/type drift never self-heals. Also: backfill's post-write prune passed a zero retention window from a separate process, able to delete files under a daemon /search still holding that version — pass the daemon's window instead. Runbook gains the /health cascade block (thresholds, what flips healthy, why failed_permanent does not) and its quoted schema-drift error now matches the code. Tests: the three safety mechanisms this PR adds were unpinned — a one-line revert of any of them passed the suite. Added per-kind staleness, heavy-beat benign conflict, benign-filter negative case (an error whose message merely contains "retryable" must still count), prune recurrence across light beats (mutation-verified: hoisting the attempt-clock advance out of the heavy branch turns it red), the prune timeout releasing the write lock, timeout-below-cadence, the rebuild server guard, and a tier3 assertion that the /health cascade block is actually wired. Froze the last fabricated-monotonic test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Also: treat ``last_prune_attempt_at == 0.0`` as "never attempted" instead of comparing clocks. ``monotonic()`` is boot-relative, so ``now - 0 >= cadence`` is false for the first ~cadence of container uptime — the catch-up prune was skipped exactly when a fresh process most needs it (and made a test depend on the runner uptime, which CI caught).
gloryfromca
force-pushed
the
fix/cascade-prune-health-followups
branch
from
August 3, 2026 09:16
9db26d4 to
f0e14c7
Compare
run7 (1h at 2.5x rate, concurrent CLI maintenance, doubled fuzz) reproduced a table whose version cleanup stopped permanently: 150 versions retained, disk 11x live size, while the other two tables sat at 1 version each — and with no error logged anywhere, because nothing failed. It simply never returned. Three things combined. The maintenance scheduler allows one task per table (a LanceDB table takes one writer), so it skips a kind whose task is still in flight. The prune timeout sat *inside* the lock and covered only the cleanup call. And the other six critical sections on that lock — add, upsert, update, delete, delete_by_md_path, rebuild_indexes — had no deadline at all. So one operation stuck anywhere outside that narrow window wedged the table for good: every writer blocked on acquire, and every later heartbeat was turned away because the stuck task never finished. Make it structurally impossible instead of patching prune: all seven sections now go through `LanceRepoBase._locked(budget, op)`, where the deadline covers **acquisition and the body**. No path can wait for this lock, or hold it, indefinitely. Budgets are hang-catchers, not throughput limits: 120s for row writes, 600s for an index rebuild, the existing 60s for prune. Expiry raises `VectorStoreBusyError`, deliberately under `ExternalServiceError` so the cascade worker retries the row; under `VectorStoreError` a transient lock contention would be marked permanently failed and need a manual `cascade fix`. Tests: a stuck holder now makes a waiter fail its deadline and release (the lock is reusable afterwards), and the prune timeout is pinned as retryable. Verified by mutation — moving the timeout back inside the lock makes a waiter block until the enclosing observation window expires (1001ms vs 51ms), i.e. wait forever in production. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
120s for a row write was a guess, and a bad one: the budget doubles as the detection latency for a wedged table, so an over-slack value means minutes of blocked writers before anything surfaces — the failure this change exists to prevent. Measured the four locked write ops on a local SSD across table sizes and batch sizes (10k-100k rows, 50-500 rows per call): add 3-22ms, upsert (merge_insert, the read-modify-write one) 6-25ms, update 2-4ms, delete 2-3ms; worst observation 63ms, and flat in both dimensions since these are append-and-commit, not scans. So: writes 120s -> 15s (~240x the worst observation, enough for a contended disk and several waiters queued ahead — the deadline includes acquisition and asyncio.Lock is FIFO), rebuild 600s -> 300s (still the one genuinely slow section at ~0.3s per 50k rows per indexed column). Prune stays 60s. Test pins the sizing intent: writes stay in the tens of seconds, and rebuild > prune > write so the slowest section is not the most eagerly killed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A soak run stalled one table's writes for ~16s and the logs could not say why: maintenance beats only log at `debug`, so a section that is slow but still inside its deadline is invisible, and the timeout warning did not distinguish "never acquired the lock" from "acquired it and overran". `_locked` now carries that apart. The deadline warning gains `acquired`, `waited_seconds` and `held_seconds` — `acquired` alone answers whether a holder was slow or this operation was — and a completed section that held the lock for at least a second logs `lancedb_write_lock_slow_hold` at info, so a stall that never reaches a deadline still leaves a trace. Uses `time.monotonic` (elapsed measurement, not wall clock — the datetime discipline bans `time.time`). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A soak run showed every slow search was a failing search: `search:vector` p50 251ms / p99 1.6s, but its 8 requests over 10s were exactly its 8 failures (13-14s each). The cause was a query vector whose width disagreed with the index. LanceDB only notices after the query is built and reports it as an opaque `ValueError: Invalid input, No vector column found to match…`, which escaped as an unhandled 500. Validate at `_embed_query` — the single point every query vector passes through — against the provider's declared `dim`. Microseconds instead of 13s, and a named `ConfigurationError` (500 + CONFIGURATION_ERROR) instead of an unhandled crash. Deliberately not `InvalidInputError`/422: callers only send query *text*, so a bad width is our provider's fault, not the caller's. Also cap traceback rendering. structlog's default is `RichTracebackFormatter(show_locals=True, max_frames=100, extra_lines=3)`, which on an async stack rendered 82 frames into 6423 log lines per exception — 85MB of server.log across 11 of them — at ~290ms of synchronous CPU each, and risks printing request payloads into logs. With locals off and 15 frames the same traceback is 103 lines and 10ms. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#379 merged without changelog entries, so this covers both it and the follow-up work in this branch: the maintenance split (compaction vs reclamation) that fixes unbounded index growth, bounded write-lock critical sections, the /health cascade readiness block and its alert contract, `cascade rebuild`, schema type-drift detection, the query-vector width check, and the traceback-rendering cap. Each entry states the operator-visible consequence, not just the change — `cascade rebuild` now refusing to run against a live server, benign-conflict warnings dropping in volume, and `/health` being able to report a stalled kind that was previously invisible are all behaviour changes someone will notice. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cyfyifanchen
approved these changes
Aug 4, 2026
This was referenced Aug 4, 2026
Merged
gloryfromca
added a commit
that referenced
this pull request
Aug 4, 2026
* fix(lancedb): put table-handle resolution inside the deadline run9 reproduced the stall the previous commits were supposed to close, on a different table: episode went 13 minutes without a prune — versions climbing 63→66 while foresight and atomic_fact both collapsed to 1 — with **zero** failure, timeout, or conflict logs. Its last successful prune was logged at 11:41:59 and the staleness clock matched to the second. The deadline covered the critical section but not the await ahead of it: `table = await self._table()` sat outside `_locked`, so a hang while resolving the table handle never returned. The scheduler runs one maintenance task per kind and skips a kind whose task is still in flight, so that kind stops being maintained permanently, silently, because nothing failed. Move the handle resolution inside the deadline for all seven locked operations, and give the lock-free compaction beat its own `_deadline` (it takes no lock so it cannot block writers, but it can still park a kind by never returning). Belt and braces in the scheduler: both beats now run under `_MAINTENANCE_TASK_TIMEOUT_SECONDS`, a last-resort bound on the whole call, so any await I have not thought of costs one cadence rather than forever. Regression test: a repo whose `_table_lookup` never resolves must make prune, optimize and add all raise `VectorStoreBusyError` and leave the lock free. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(release): v1.2.2 Bump to 1.2.2 and cut the changelog. This release is the storage-layer reliability work: LanceDB maintenance split into lock-free compaction and write-locked reclamation (fixing unbounded index growth), every write-lock critical section bounded by a deadline that covers acquisition, a per-kind prune-staleness signal on GET /health, `everos cascade rebuild` for a drifted or corrupt index, startup detection of column type drift, and a query-vector width check that fails fast instead of 13s deep inside LanceDB. Carries the table-handle deadline fix (previously #385) rather than shipping 1.2.2 with a known stall: the deadline covered the critical section but not the await ahead of it, so a hang while resolving a table handle parked that kind's maintenance permanently and silently. Found by a 1h high-rate soak run after #384 merged. No migration, no config change, no API change: `docs/openapi.json` differs only in the version string. The only operator-visible requirement is that `everos cascade rebuild` now refuses to run while a server holds the OME lock. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: zhanghui <zhanghui@shanda.com> Co-authored-by: Claude Opus 4.8 <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.
Follow-up to #379. An adversarial review of that PR's own review-response fixes found five real defects, all in code #379 introduced. Each was verified against the code before fixing; the negative results are noted at the end.
P1
Prune-staleness health signal was masked across kinds.
_prune_stalenessreported time since the newest successful prune across all kinds, so on a multi-kind deployment (every real one) a single kind whose cleanup died was hidden by the ~5 others pruning on schedule —/healthstayed green while that table's index dir grew unbounded, the exact incident the signal exists to catch. Now reports the worst kind and names it in the reason. The failure streak could not cover this either (an intervening light beat resets it), so the docstrings now state that split of duties explicitly.Heavy-beat commit conflicts fired spurious fallback rebuilds. The benign-conflict carve-out excluded the heavy beat, justified by "runs under the write lock, so it can't hit this benignly" — but that lock is in-process only, so a second process (a long
cascade backfill, acascade sync) preempts prune's Rewrite commit. Counting those as real failures let sustained cross-process churn reach the threshold and trigger a fallback rebuild, which drops every index before recreating it; a rebuild that also lost the race was swallowed as a warning, leaving the table with no FTS index (every/searchon that kind 500s) until the next 12h sweep. Commit conflicts are now benign on both beats; a prune that genuinely stops succeeding is detected by prune-staleness, which doesn't destroy indexes to "fix" a lost race.cascade rebuildhad no live-daemon guard while--helpand docstrings advertised it as safe —rebuild --yesagainst a running server dropped the tables under the daemon's cached handles, corrupting the rebuild and leaving a permanent-failure backlog. It now refuses when the OME jobstore lock is held, reusing backfill's detection and its exit code3.cascade rebuilddied on the damage it repairs. It ran the pre-drop migration pass (ensure_business_indexes) against the broken table, so on the corruption classes it exists to fix (missing column, un-alterable type) the recovery path raised before the drop — now skipped via_runtime(ensure=False).The nullable-vector migration error still told users to wipe the index directory — which #379's own runbook documents as the wrong recovery (queue stays
done, index comes back empty). Now points ateveros cascade rebuild.P2
rebuildresets the queue before dropping, so every crash window converges on "queue pending → re-index" instead of empty tables with a fully-donequeue (a silently empty deployment);KeyboardInterruptexits130with a resume hint.prunepassed a zero retention window from a separate process, able to delete files under a daemon/searchstill holding that version — now passes the daemon's window./healthcascade block (thresholds, what flipshealthy, whyfailed_permanentdoesn't), and its quoted schema-drift error now matches the code. Pin comment no longer references a harness that isn't in this repo.Tests
#379's three safety mechanisms were unpinned — a one-line revert of any passed the suite. Added: per-kind staleness (worst kind + named), heavy-beat benign conflict, benign-filter negative case (an error whose message merely contains
retryablemust still count), prune recurrence across light beats, the prune timeout releasing the write lock, timeout-below-cadence, the rebuild server guard, and a tier3 assertion that the/healthcascade block is actually wired (its unit tests hand-buildlifespan_data, so a rename would ship silently). Froze the last fabricated-monotonic()test.The prune-recurrence test is mutation-verified: hoisting the attempt-clock advance out of the heavy branch (which would make prune run once per process lifetime) turns it red.
Verification
make lintclean (ruff, import-linter contracts 3/3, datetime, openapi--check), 1891 unit + 182 integration passing, zero skips introduced.Not changed (deliberate)
Decoupling the alert thresholds / retention from the instance cadence and exposing them through
CascadeConfigis a real gap (the docstrings say "tune via the constructor" but the orchestrator passes none of them) — it's a config-plumbing change, kept out of a fix PR. Same for making the/healthfailure-block counters nullable instead of zeroed (schema change) andrebuild's missing--root/--verbose.🤖 Generated with Claude Code
Rollout / deployment notes
TL;DR — no migration, no config change, no API change. Pull,
uv sync, restart.What changes at runtime
reasons)/healthcan now reportcascade.healthy: falsein a case that previously stayed green — a single kind whose version cleanup died. That is the fix working, not a new fault. The reason string names the table.cascade_lancedb_optimize_failedwarnings drop under cross-process churn (they were mostly lost races), and spuriouscascade_lancedb_optimize_fallback_rebuildevents stop. If you alert on either event name, expect the rate to fall.cascade rebuildrefuses to run while a server is upeveros cascade rebuild --yesthat used to (silently corrupt and) proceed now exits 3 with an explanation. Any automation calling it must stop the server first.cascade rebuildresets the queue before droppingbackfillpasses the daemon's retention window to prunecascade rebuildUpgrade steps
last_prune_attempt_at); it is rebuilt on every start. Nothing is written to SQLite, LanceDB, or markdown.docs/openapi.jsonis unchanged by this PR (dump_openapi.py --checkpasses) — thecascadeblock's fields are the same; only the text insidereasonschanged.What to watch after deploying
cascade.healthyonGET /health— if it flips false with aversion cleanup stalled for kind '<x>'reason, that kind's cleanup genuinely stopped (previously invisible). Check disk on that table.cascade_lancedb_optimize_failed— should go down, not up.cascade rebuild— must now run with the server stopped (exit code 3 otherwise).Validation on real hardware
Beyond the unit/integration suites, the pre-fix code in this line was soaked for ~110h+ (runs 1–6) including a 2h run with cross-process maintenance concurrency and malformed-input injection applied simultaneously: integrity CLEAN (no schema drift, md↔index counts consistent), disk bounded and fully reclaimed (4.36 GB peak → 205 MB floor, then flat), 0 crashes / 0 OOM. A 1h high-rate run (run7: 2.5x write rate, concurrent CLI maintenance for the
full hour, doubled fuzz) against this PR's code came back CLEAN on integrity
(no schema drift, md↔index counts consistent), 0 crashes / 0 OOM / 0 restarts,
/addand write error rates 0%.It also found a real defect, which the last commit fixes. One table stopped
reclaiming versions permanently — 150→200 versions retained, disk 11x live size
— while the other two sat at 1 version each, and nothing was logged, because
nothing failed: the maintenance task simply never returned. The pre-fix
staleness signal (newest prune across kinds) would have hidden this completely;
the per-kind signal in this PR reported it and named the table, which is exactly
what it was changed for.
Root cause was structural: the maintenance scheduler allows one task per table,
so it skips a kind whose task is still in flight; the prune deadline sat inside
the lock covering only the cleanup call; and the other six critical sections on
that lock had no deadline at all. One operation stuck anywhere outside that
narrow window wedged the table forever.
LanceRepoBase._locked(budget, op)nowbounds acquisition and body for all seven, so no path can wait for or hold
the lock indefinitely; expiry raises the retryable
VectorStoreBusyError.Verified both ways: mutation test (moving the deadline back inside the lock makes
a waiter block until the enclosing window expires — 1001ms vs 51ms) and on real
hardware (restarting into the fix collapsed the stuck table from 200 versions /
490MB to 4 versions / 192MB,
/healthback to healthy with staleness cyclingnormally, and zero deadline-exceeded warnings — no false kills).
Follow-up findings from the soak runs on this branch
The 1h high-rate run (run7) and the 2h re-run (run8) turned up three more real
defects, each fixed in a later commit here.
A table could stop reclaiming versions permanently. run7: one table held 150
versions and 11x its live size on disk while the other two sat at 1 version
each — and nothing was logged, because nothing failed; the maintenance task
simply never returned. The scheduler allows one task per table (a LanceDB table
takes one writer), so it kept skipping that kind. The prune deadline sat inside
the lock covering only the cleanup call, and the other six critical sections had
no deadline at all. Fixed by bounding all seven through
_locked(budget, op)with the deadline covering acquisition. Mutation-verified (moving the deadline
back inside the lock makes a waiter block until the enclosing window expires,
1001ms vs 51ms) and confirmed on hardware: run8 ended with all three tables at
versions=1, FTS indices complete with zero unindexed tail, and lock-deadlinewarnings at 2 per 9416 writes, both auto-retried.
Worth noting the per-kind staleness fix in this PR is what surfaced it: the
previous signal took the newest prune across kinds, so the two healthy tables
hid the dead one and
/healthstayed green.Write-lock budgets were guessed. 120s for a row write was arbitrary, and the
budget doubles as the detection latency for a wedged table. Measured the four
operations across table and batch sizes (10k–100k rows, 50–500 rows per call):
2–25ms, worst 63ms, flat in both dimensions. Writes are now 15s, rebuild 300s.
A mismatched query vector cost 13–14s and an unhandled 500. Every slow search
in run8 was a failing search —
search:vectorp50 251ms / p99 1.6s, but its 8requests over 10s were exactly its 8 failures. LanceDB only notices a width
mismatch after building the query and reports it as an opaque
ValueError.Now validated at the single point every query vector passes through.
Also capped traceback rendering: structlog's default (
show_locals=True, 100frames) rendered one exception into 6423 log lines — 85MB across 11 exceptions
in one run — at ~290ms of synchronous CPU each. Locals off and 15 frames brings
the same traceback to 103 lines / 10ms.
Run 8 verdict (2h, 15 writes/s, concurrent
cascade syncevery second forthe full run, doubled fuzz, 56.5k entries): integrity CLEAN, disk bounded and
reclaimed (3489MB peak → 644MB floor), 0 crashes / 0 OOM / 0 restarts. The 25
permanently-failed files match the 23 malformed markdown files fuzz injected —
that is the intended path for bad input, not a defect.
One thing I could not close:
LanceError(IO): Spill has sent an errorappears inruns 6–8 and scales with the fuzz rate. It comes from the cascade write path
(
merge_insert) alongsidecascade_worker_unrecoverable, and the counts line upwith the injected malformed files, so it is bad input being rejected — but the
message is undiagnosable, and seven hypotheses had to be tested and discarded
before that was clear (schema drift, NaN/inf/zero query vectors, poisoned stored
vectors, spill-directory space, the index-rebuild window, and dual FTS indices —
each disproved by a reproduction attempt). Translating LanceDB write errors into
something that names the field and type would be worth doing separately.
CHANGELOG
[Unreleased]was empty (#379 merged without entries), so this branchnow records both PRs' user-visible changes.