Skip to content

πŸ› fix: retry a conflicted repo instead of replaying the cached failure forever - #195

Merged
flupkede merged 1 commit into
developfrom
fix/serve-conflicted-repo-never-retries
Aug 9, 2026
Merged

πŸ› fix: retry a conflicted repo instead of replaying the cached failure forever#195
flupkede merged 1 commit into
developfrom
fix/serve-conflicted-repo-never-retries

Conversation

@flupkede

@flupkede flupkede commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Summary

  • A repo whose database failed to open (e.g. a transient write lock held by an indexing run) was cached as RepoState::Conflicted and that cached failure was replayed on every subsequent call forever β€” never retried, even after the lock was released.
  • Root cause: both code paths that mark a repo Conflicted (warmup_repo and the get_or_open_stores slow path) return early via ? before reaching their touch_access call, so the repo never gets a last_access entry and is therefore never picked up by the idle reaper (evict_idle_repos), regardless of how long it sits idle.
  • Fix: get_or_open_stores now atomically drops a cached Conflicted entry (via DashMap::remove_if, matching the existing is_indexing pattern) and retries the open fresh on every call, instead of replaying the stale cached error.
  • Includes a regression test (conflicted_repo_recovers_after_lock_released) that holds a real file lock, confirms the first open fails and caches Conflicted, releases the lock, and confirms the next call recovers without a restart or idle wait.

Test plan

  • `cargo test --lib` β€” 570 passed, 0 failed (including the new regression test)
  • `cargo fmt --check`, `cargo clippy --all-targets -D warnings` β€” clean
  • Reviewed twice (reviewer agent): round 1 found a check-then-act race in the fix itself (non-atomic get+remove), fixed with `remove_if`; round 2 PASS.

…e forever

A repo whose database failed to open (typically a transient write lock - an
indexing run holding the DB when a query arrived) was cached as
RepoState::Conflicted, and the fast path in get_or_open_stores replayed that
error on every later call without ever retrying the open.

The state's only documented exit was idle eviction, and it was unreachable:
evict_idle_repos iterates last_access, but both paths that mark a repo
Conflicted (warmup_repo and the get_or_open_stores slow path) propagate the
failure with `?` before reaching their touch_access call. A repo that
conflicts on first open therefore never gets a last_access entry and is never
considered for eviction, however long it sits idle. Querying it did not help
either: the fast path replayed the cached error while calling touch_access on
the way, so the only queries that would have registered it for eviction were
also the ones resetting its idle timer.

Net effect: a momentary lock was indistinguishable from permanent corruption
and could only be cleared by restarting serve - while the error text promised
"the next query will retry automatically".

A cached conflict is now dropped on next access and the open genuinely
retried. Retrying is cheap when it still fails (a refused file lock), and this
mirrors the missing-DB path, which already refuses to cache Conflicted for the
same reason (missing_db_not_cached_as_conflicted).

Regression test asserts recovery without a restart and without an idle wait.
It carries two preconditions so it cannot pass vacuously (the first open must
genuinely fail, and that failure must actually be cached as Conflicted), and
was confirmed to FAIL with the fix neutralised - reproducing the exact
user-visible error string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Review-fixes:
- [Important] Non-atomic get()+remove() on the Conflicted cache entry could race with a concurrent insert (e.g. add_repo_handler or force-reindex installing a fresh RepoState::Write) and delete that live entry instead, dropping its cancel_token without cancelling it. Fixed by using DashMap::remove_if with a matches!(v, RepoState::Conflicted) predicate β€” same atomic check-and-remove primitive already used by is_indexing() in this file β€” so removal can only ever affect an entry still Conflicted at removal time.
@flupkede
flupkede merged commit af40482 into develop Aug 9, 2026
4 checks passed
@flupkede
flupkede deleted the fix/serve-conflicted-repo-never-retries branch August 9, 2026 10:51
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