fix: harden the stall defect class found auditing the 1.2.2 fix - #392
Open
gloryfromca wants to merge 4 commits into
Open
fix: harden the stall defect class found auditing the 1.2.2 fix#392gloryfromca wants to merge 4 commits into
gloryfromca wants to merge 4 commits into
Conversation
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>
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.
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
processing;/healthgreentrycreate_taskon a closing loopoptimize_failure_streak >= 5unreachable (~1% observable)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_threadand a deadline cancels the future, not the thread — so an orphan sweep outlives any critical section, andPath.iterdiris a lazyos.scandirthat 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
flockin 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. ShortLOCK_NBattempts 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
ruffclean;import-linter3/3 contracts kept.Deferred, with reasons
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.awatchoutside itstry) — only the optional OME subsystem's hot reload dies; running config is unaffected and a restart clears it./health— five of the seven are invisible because the verdict is assembled from failure counters, which a hang leaves untouched.last_prune_atis 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, sincemax_lsn/last_processed_lsnare already computed and then dropped when buildingCascadeHealth.🤖 Generated with Claude Code