fix(cli): never wait on the writer lease to read

A client that saw no daemon reconciled in the request with a blocking
writer lease, so a daemon that started meanwhile made it wait out the
whole initial scan. Without --no-daemon the in-request reconcile now
uses Store::index_unless_leased and leaves the index warming when the
lease is held.

Warming handle, more, ctx, and path reads no longer open the writer
(DDL, INSERT, 30 s busy timeout behind a publication): they use
Store::unpublished, an empty in-memory schema at generation 0 that
saves result sets in the cache and writes nothing to index.sqlite3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Joshua MoonandClaude Opus 5.5 committed 2026-09-30 10:06:26 -04:00
1 parent 28b6205d2a
commit ee503ec971
4 files changed
+147 -20

No files matched your search

+5 -2
View File
@@ -77,8 +77,11 @@ and answers `verified: false` with `freshness: unpublished`; handle reads keep
answering `stale_source`, as does a changed file that is now binary or cannot be
extracted (`src/source/acquisition/stale_symbol.rs`). Only a client that no
daemon serves (or `--no-daemon`) reconciles an unpublished index in the request
(`src/cli/dispatch.rs:read_store`); otherwise searches and `sym:` reads answer
`index_warming`, while `more`, `ctx`, handle, and path reads need no publication.
(`src/cli/dispatch.rs:read_store`); without `--no-daemon` it never waits for
the writer lease (`Store::index_unless_leased`), so a daemon scan that started
meanwhile leaves the index warming. Otherwise searches and `sym:` reads answer
`index_warming`, while `more`, `ctx`, handle, and path reads use an empty
in-memory view (`Store::unpublished`) that writes nothing to the index.
## Semantic preparation
+76 -7
View File
@@ -293,10 +293,11 @@ fn local_dispatch(
/// The query-only store for a read verb. Only a client that no daemon serves
/// (or `--no-daemon`) reconciles in the request: searches first, and any read
/// of an unpublished index. Reconciliation is paused off `deadline`, so only
/// the query spends its budget. Otherwise an unpublished index answers
/// `index_warming` for searches and `sym:` reads, while `more`, `ctx`, handles,
/// and path reads open it as it is: they need no published index.
/// of an unpublished index, paused off `deadline` so only the query spends its
/// budget. Without `--no-daemon` it never waits for the writer lease: a daemon
/// that started scanning meanwhile leaves the index warming. An unpublished
/// index answers `index_warming` for searches and `sym:` reads; `more`, `ctx`,
/// handle, and path reads use an empty unpublished view that writes nothing.
fn read_store(
root: &Path,
cache: &Path,
@@ -306,15 +307,26 @@ fn read_store(
) -> Result<Store> {
let verb = options.words.first().map_or("status", String::as_str);
let reconciles = !daemon_running && (options.no_daemon || !crate::daemon::running(cache));
let reconcile = || Store::open(root, cache)?.index().map(drop);
let reconcile = || -> Result<()> {
let mut store = Store::open(root, cache)?;
if options.no_daemon {
store.index()?;
} else {
store.index_unless_leased()?;
}
Ok(())
};
if reconciles && matches!(verb, "search" | "refs" | "map") {
deadline.pause_during(reconcile)?;
}
match Store::open_read(root, cache, *deadline) {
let opened = match Store::open_read(root, cache, *deadline) {
Err(error) if is_index_warming(&error) && reconciles => {
deadline.pause_during(reconcile)?;
Store::open_read(root, cache, *deadline)
}
opened => opened,
};
match opened {
Err(error)
if is_index_warming(&error)
&& !matches!(verb, "search" | "refs" | "map")
@@ -323,7 +335,8 @@ fn read_store(
.get(1)
.is_some_and(|target| target.starts_with("sym:")) =>
{
Store::open(root, cache)
std::fs::create_dir_all(cache)?;
Store::unpublished(root, cache)
}
opened => opened,
}
@@ -384,4 +397,60 @@ mod tests {
.unwrap();
assert!(!found.hits.is_empty());
}
fn words(words: &[&str]) -> Arguments {
super::super::parse(
&words
.iter()
.map(|word| (*word).to_owned())
.collect::<Vec<_>>(),
)
.unwrap()
}
/// A daemon that took the writer lease after the client's liveness check
/// leaves the client answering `index_warming`, never waiting on the lease.
#[test]
fn client_reconcile_never_waits_behind_a_daemon_scan() {
let root = tempfile::tempdir().unwrap();
let cache = tempfile::tempdir().unwrap();
std::fs::write(root.path().join("lib.rs"), "pub fn leased() {}\n").unwrap();
let lease = std::fs::File::create(cache.path().join("index.lock")).unwrap();
fs2::FileExt::lock_exclusive(&lease).unwrap();
let (root_path, cache_path) = (root.path().to_owned(), cache.path().to_owned());
let (sender, receiver) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut deadline = QueryDeadline::start();
let options = words(&["search", "sym:leased"]);
let opened = read_store(&root_path, &cache_path, &options, false, &mut deadline);
let _ = sender.send(opened.err().map(|error| is_index_warming(&error)));
});
let answered = receiver.recv_timeout(Duration::from_secs(10));
drop(lease);
assert_eq!(answered, Ok(Some(true)));
}
/// Inside a daemon, non-search reads of an unpublished index write nothing.
#[test]
fn warming_path_reads_leave_the_index_untouched() {
let root = tempfile::tempdir().unwrap();
let cache = tempfile::tempdir().unwrap();
std::fs::write(root.path().join("lib.rs"), "pub fn early() {}\n").unwrap();
let mut deadline = QueryDeadline::start();
let options = words(&["show", "lib.rs"]);
let store = read_store(root.path(), cache.path(), &options, true, &mut deadline).unwrap();
let shown = source::show(&store, "lib.rs", &OutputBudget::new(2_000).unwrap()).unwrap();
assert!(shown.contains("pub fn early()"), "{shown}");
assert!(!cache.path().join("index.sqlite3").exists());
let error = read_store(
root.path(),
cache.path(),
&words(&["show", "sym:early"]),
true,
&mut deadline,
)
.err()
.unwrap();
assert!(is_index_warming(&error), "{error:#}");
}
}
+42 -11
View File
@@ -265,24 +265,48 @@ impl Store {
self.access == StoreAccess::Writer,
"read_only_store: indexing requires Store::open"
);
self.index_with_resolver(|staged| {
resolve::references(staged)?;
php_resolver::resolve(staged)?;
module_resolver::resolve(staged)
})
self.index_with_resolver(resolve_all)
}
/// [`Store::index`], or `None` at once when another writer (a daemon's scan)
/// holds the writer lease, instead of waiting for it.
pub fn index_unless_leased(&mut self) -> Result<Option<Coverage>> {
anyhow::ensure!(
self.access == StoreAccess::Writer,
"read_only_store: indexing requires Store::open"
);
let writer_lock = self.writer_lease_file()?;
match fs2::FileExt::try_lock_exclusive(&writer_lock) {
Ok(()) => self.index_leased(writer_lock, resolve_all).map(Some),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
Err(error) => Err(error.into()),
}
}
fn writer_lease_file(&self) -> Result<std::fs::File> {
Ok(std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(self.cache.join("index.lock"))?)
}
fn index_with_resolver(
&mut self,
resolve_staged: impl FnOnce(&mut Connection) -> Result<()>,
) -> Result<Coverage> {
let writer_lock = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(self.cache.join("index.lock"))?;
let writer_lock = self.writer_lease_file()?;
fs2::FileExt::lock_exclusive(&writer_lock)?;
self.index_leased(writer_lock, resolve_staged)
}
/// Stages and publishes while `_writer_lock` holds the writer lease.
fn index_leased(
&mut self,
_writer_lock: std::fs::File,
resolve_staged: impl FnOnce(&mut Connection) -> Result<()>,
) -> Result<Coverage> {
// The writer lease makes any previous staging directory an interrupted scan.
for entry in std::fs::read_dir(&self.cache)? {
let entry = entry?;
@@ -348,4 +372,11 @@ impl Store {
}
}
/// The full resolver pass over a staged index.
fn resolve_all(staged: &mut Connection) -> Result<()> {
resolve::references(staged)?;
php_resolver::resolve(staged)?;
module_resolver::resolve(staged)
}
use rusqlite::OptionalExtension;
+24
View File
@@ -51,6 +51,30 @@ impl Store {
})
}
/// An empty view of an index that has no publication yet: an in-memory
/// schema at generation 0, so handle, `more`, and path reads work (saving
/// result sets in `cache`) without touching `index.sqlite3` or waiting on
/// its writer.
pub fn unpublished(root: &Path, cache: &Path) -> Result<Self> {
let root = root
.canonicalize()
.context("canonicalize repository root")?;
ensure!(root.is_dir(), "repository root is not a directory");
let cache = cache.canonicalize().context("canonicalize cache")?;
let conn = Connection::open_in_memory()?;
super::schema::create(&conn)?;
super::publish::create_journal(&conn)?;
Ok(Self {
conn,
index_root: root.clone(),
root,
results_cache: cache.clone(),
cache,
result_sets: std::cell::OnceCell::new(),
access: StoreAccess::Reader,
})
}
/// Reads current bytes from `read_root` (a linked worktree) through this
/// index; stored paths are root-relative, so they address the same files.
/// Result sets are saved in `results_cache`, the read root's own cache.