Skip to content

fix: harden the stall defect class found auditing the 1.2.2 fix - #392

Open
gloryfromca wants to merge 4 commits into
mainfrom
fix/stall-class-hardening
Open

fix: harden the stall defect class found auditing the 1.2.2 fix#392
gloryfromca wants to merge 4 commits into
mainfrom
fix/stall-class-hardening

Conversation

@gloryfromca

Copy link
Copy Markdown
Collaborator

Audit follow-up to #384 / #386. The version-cleanup stall those fixed had a shape worth generalising: an unbounded await in a path whose scheduler treats "not finished" as "skip" — so one hang stops that work forever, and because nothing failed, every health signal built from failure counters reads as healthy.

Seven instances were found. Five are fixed here; the reasoning for what is deferred is at the bottom.

What each fix addresses

Defect Consequence if hit Evidence grade
5 read ops had no deadline whole md → LanceDB projection stops; rows stuck in processing; /health green never observed in ~120h of soak
heartbeat / rebuild loops had no outer try that loop dies permanently, zero log output not observed; reachable only via create_task on a closing loop
fallback rebuild reset the alert counter optimize_failure_streak >= 5 unreachable (~1% observable) logic certain; the triggering condition never observed
husk sweep relied on a lock it cannot hold table left with no FTS index → that kind's searches 500 three-way race, very low
memory-root lock wait unbounded + silent startup looks like a hang with no explanation not observed

None is high-probability. What they share with the fixed bug is the shape, and one of them (reads) has a strictly larger blast radius than the original — it stops all projection, not one table's cleanup.

Notable reasoning

Reads were deliberately skipped last round on the grounds that a read takes no lock and so blocks no writer. That is true and was the wrong conclusion: the drain loop reads on every batch and advances one batch at a time, so a hung read parks the pipeline even though it parks no lock.

The husk sweep's docstring contract was unsatisfiable. It required the write lock, but the sweep runs via asyncio.to_thread and a deadline cancels the future, not the thread — so an orphan sweep outlives any critical section, and Path.iterdir is a lazy os.scandir that can yield a dir created after the scan began. Holding the lock harder does not fix it; an age filter (skip dirs younger than 300s) holds regardless of lock ownership, so that is the guarantee now.

The lock wait was made pollable rather than merely bounded. Wrapping a blocking flock in a timeout is worse than no timeout: cancelling the coroutine leaves the worker thread to acquire the lock later with nobody left to release it. Short LOCK_NB attempts on an interval give the same semantics while being bounded and cancellable.

Loop supervision escalates to process exit (SIGTERM, so the ASGI server shuts down gracefully) after 5s/15s/45s restarts. This assumes a restarting supervisor; without one the process stops, which still beats serving searches from a silently frozen index.

Verification

  • 1925 unit + 182 integration passing; ruff clean; import-linter 3/3 contracts kept.
  • Mutation-verified — each fix reverted individually, confirming the new test turns red rather than merely passing alongside it:
Reverted to Test outcome
table handle outside the read deadline hangs (the defect's actual signature)
husk sweep back inside the write lock fail
age floor set to 0 fail
restart budget removed fail
process-exit request removed fail
alert counter reset restored fail
waiting log removed fail

Deferred, with reasons

  • Rebuild starvation — the optimize runner's loop condition is "while there is unindexed data", which under sustained writes is never, so the 12h rebuild sweep waits it out. The 180s bound here makes that visible (cascade_lancedb_rebuild_skipped_optimize_unfinished) but not absent. The functional fix needs the runner to yield when a rebuild is pending, which changes the optimize↔rebuild mutual-exclusion contract — both commit on the same manifest version, so getting it wrong is worse than the bug. It also cannot be validated today: the cadence is 12h and no soak run has exceeded 2h, so the periodic sweep has only ever run at boot (where there is no write load). Making the cadence configurable is a prerequisite.
  • OME config hot-reload (awatch outside its try) — only the optional OME subsystem's hot reload dies; running config is unaffected and a restart clears it.
  • Progress clocks in /health — five of the seven are invisible because the verdict is assembled from failure counters, which a hang leaves untouched. last_prune_at is the one exception and is the only reason the original bug was ever found. Giving each long-lived loop a last-success clock is the structural fix; the drain half is nearly free, since max_lsn / last_processed_lsn are already computed and then dropped when building CascadeHealth.

🤖 Generated with Claude Code

zhanghui and others added 4 commits August 5, 2026 13:55
Two instances of the same defect class the write-lock deadline work left
behind: an await in a scheduler-gated path with nothing bounding it.

Reads (count / get_by_id / find_where / find_where_paginated / search) were
skipped last round on the reasoning that a read takes no lock and so blocks
no writer. True, but incomplete: the cascade drain loop reads on every batch
and advances strictly one batch at a time, so a read that never returns stops
the whole md -> LanceDB projection. Claimed rows stay `processing` forever
(claim_pending_batch only takes `pending`, orphan recovery runs once at
startup), and /health keeps reporting healthy because a hang raises nothing.
Budget 60s, ~1000x the measured 62ms flat scan over 117k rows.

The empty-index-dir sweep ran inside the prune critical section under a
docstring contract requiring the write lock. That contract could not hold: the
sweep runs via asyncio.to_thread, and a deadline cancels the future, not the
thread, so an orphan sweep outlives the lock -- and Path.iterdir is a lazy
os.scandir, so it can yield a dir created after the scan began. It could
therefore rmdir a directory a concurrent create_index had just made, leaving
the table with no FTS index and every search on that kind 500ing. Safety now
comes from an age filter (skip dirs younger than 300s), which holds regardless
of lock ownership; the sweep moved out of the critical section so a slow
filesystem walk can no longer overrun the prune budget.

Mutation-verified: moving the table handle back outside the read deadline
hangs the new test; moving the sweep back inside the lock fails it; setting
the age floor to 0 fails the fresh-dir test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The drain / heartbeat / rebuild loops were plain create_task coroutines. One
uncaught exception ended that loop permanently: nothing restarted it, and
because the worker holds a strong reference to the task the interpreter never
printed "Task exception was never retrieved" either (that fires on GC). The
loop's job just stopped happening with zero output. _run_loop had an inner
try; its two siblings did not.

Each loop now runs under _supervise: log, wait, restart with escalating
backoff (5s / 15s / 45s), then request process exit via SIGTERM so a
restarting supervisor (systemd Restart=always, Docker restart:
unless-stopped, a k8s Deployment) can recover it. SIGTERM rather than
os._exit so the ASGI server runs its graceful-shutdown path. A done-callback
is the last-resort observer for the supervisor itself ending unexpectedly.

Separately, the fallback rebuild reset the same counter the health verdict
reads, so the optimize-failure threshold was effectively unreachable: a table
failing 100% of the time cycled 1..5 -> 0 -> 1.. and the threshold value
existed only during the sub-second rebuild, ~1% observable against a 30s
scrape. cascade.healthy stayed green while the table never reclaimed a
version. The rate limiter moves to failures_since_fallback; only a successful
optimize clears the alert streak. Same shape as the run7 cross-kind max()
masking bug -- a remediation path refreshing the signal meant to report it.

Mutation-verified: dropping the restart budget, removing the exit request,
and restoring the counter reset each turn the corresponding test red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Acquisition polls with LOCK_NB instead of blocking inside a worker thread. A
blocking flock could be neither bounded nor cancelled: cancelling the awaiting
coroutine leaves the thread to acquire the lock later with nobody left to
release it, which is strictly worse than waiting.

The wait itself is correct by design -- the second process is supposed to wait,
then find the migration already done -- and flock is released by the kernel on
process exit, so a dead holder never wedges it. What was wrong is that it had
no upper bound and emitted nothing: a server startup landing on a held lock
looked like a hang whose last log line was lifespan_provider_startup
name=lancedb. It now logs memory_root_lock_waiting on first contention,
reports how long it waited on success, and gives up after timeout_seconds
(default 300s, generous because the legitimate holder is an O(rows) migration).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test_lap_append_during_handler_no_loss asserted no loss after
_wait_path_done, whose settle window is 0.1s. That is a bet that the
filesystem event for the appends which landed *during* a handler
invocation has already been delivered — a terminal row does not mean the
file is fully projected, because the handler read the md at whatever
length it had then, marked the row done, and the rest arrive on a later
event. The bet holds on macOS/fsevents and lost on a loaded Linux runner
(md=30 lance=17), failing the assertion for a reason unrelated to the
behaviour under test.

Waits for quiescence instead: terminal row + empty pending queue + a
projected count unchanged across three consecutive polls. Strictly
stronger than the old condition, and real loss still fails — the count
just converges below the md entry count and stays there.

Co-Authored-By: Claude Opus 5 (1M context) <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.

1 participant