mirror of
https://github.com/usips/trufflepig.git
synced 2026-10-25 20:48:06 -04:00
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:
1 parent
28b6205d2a
commit
ee503ec971
4 files changed
+147
-20
No files matched your search
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user