Skip to content

fix(cascade): per-kind prune staleness + rebuild safety - #384

Merged
cyfyifanchen merged 6 commits into
mainfrom
fix/cascade-prune-health-followups
Aug 4, 2026
Merged

fix(cascade): per-kind prune staleness + rebuild safety#384
cyfyifanchen merged 6 commits into
mainfrom
fix/cascade-prune-health-followups

Conversation

@gloryfromca

@gloryfromca gloryfromca commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

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_staleness reported 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 — /health stayed 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, a cascade 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 /search on 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 rebuild had no live-daemon guard while --help and docstrings advertised it as safe — rebuild --yes against 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 code 3.

cascade rebuild died 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 at everos cascade rebuild.

P2

  • rebuild resets 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); KeyboardInterrupt exits 130 with a resume hint.
  • 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 — now passes the daemon's window.
  • Dropped the schema-drift error's "restart first" step: the startup migrations only alter nullability, never a name or type, so a name/type drift never self-heals.
  • Runbook: documents the /health cascade block (thresholds, what flips healthy, why failed_permanent doesn'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 retryable must 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 /health cascade block is actually wired (its unit tests hand-build lifespan_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 lint clean (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 CascadeConfig is 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 /health failure-block counters nullable instead of zeroed (schema change) and rebuild'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

Change Operator-visible effect
Prune-staleness alert is now per kind (worst kind, named in reasons) /health can now report cascade.healthy: false in 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.
Commit conflicts are benign on both maintenance beats cascade_lancedb_optimize_failed warnings drop under cross-process churn (they were mostly lost races), and spurious cascade_lancedb_optimize_fallback_rebuild events stop. If you alert on either event name, expect the rate to fall.
cascade rebuild refuses to run while a server is up A scripted everos cascade rebuild --yes that used to (silently corrupt and) proceed now exits 3 with an explanation. Any automation calling it must stop the server first.
cascade rebuild resets the queue before dropping No operator action; makes a mid-command crash recoverable (re-run, or just start the server) instead of leaving an empty index.
backfill passes the daemon's retention window to prune Slightly less reclaimed at the instant backfill finishes; the next daemon prune (≤5 min) picks it up.
Migration / schema-drift errors now point at cascade rebuild Recovery text no longer tells you to delete the index dir (which yields an empty index).

Upgrade steps

git pull && uv sync
# restart the server (in-memory scheduler state only; nothing persisted changes)
  • No data migration. The only new state is an in-memory field on the worker's per-kind scheduler (last_prune_attempt_at); it is rebuilt on every start. Nothing is written to SQLite, LanceDB, or markdown.
  • No config change. All thresholds keep their shipped defaults (prune cadence 300s, retention 60s, prune timeout 60s, alert at 3 missed cadences).
  • No API/schema change. docs/openapi.json is unchanged by this PR (dump_openapi.py --check passes) — the cascade block's fields are the same; only the text inside reasons changed.
  • Rollback is a plain revert: no persisted state to undo.

What to watch after deploying

  1. cascade.healthy on GET /health — if it flips false with a version cleanup stalled for kind '<x>' reason, that kind's cleanup genuinely stopped (previously invisible). Check disk on that table.
  2. Rate of cascade_lancedb_optimize_failed — should go down, not up.
  3. Any automation invoking 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,
/add and 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) now
bounds 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, /health back to healthy with staleness cycling
normally, 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-deadline
warnings 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 /health stayed 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:vector p50 251ms / p99 1.6s, but its 8
requests 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, 100
frames) 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 sync every second for
the 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 error appears in
runs 6–8 and scales with the fuzz rate. It comes from the cascade write path
(merge_insert) alongside cascade_worker_unrecoverable, and the counts line up
with 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 branch
now records both PRs' user-visible changes.

@gloryfromca
gloryfromca force-pushed the fix/cascade-prune-health-followups branch from 1bf2e17 to 9db26d4 Compare August 3, 2026 09:07
@gloryfromca gloryfromca changed the title fix(cascade): unmask per-kind prune staleness, stop spurious rebuilds, guard rebuild fix(cascade): per-kind prune staleness + rebuild safety Aug 3, 2026
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
gloryfromca force-pushed the fix/cascade-prune-health-followups branch from 9db26d4 to f0e14c7 Compare August 3, 2026 09:16
zhanghui and others added 5 commits August 4, 2026 13:47
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
cyfyifanchen merged commit d256048 into main Aug 4, 2026
9 checks passed
@cyfyifanchen
cyfyifanchen deleted the fix/cascade-prune-health-followups branch August 4, 2026 12:03
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants