Decide and take a search under one guard; a refill is a rebuild; a first build is lag

S1. serve_search absorbed the inbox and judged pending under one lock and
popped the search under a second, so an admission acknowledged before the
search was asked could be missed. The absorb, the judgement and the take are
now one critical section, and take_search never takes a search while the inbox
is non-empty or anything ahead of it is owed.

S2. The projector carries Build (Unbuilt, Built, Rebuilding). A collection made
again (label mismatch, or lost after it was once built) is Rebuilding until the
entries it owes are written; settle() is the one place that clears it, so a
failure mid-refill does not end the rebuild. Rebuilding reports Reconciling.
unsearchable stays the only refusal predicate.

S3. A mind whose index was never built is Unbuilt: its first build reports
Behind, so the search waits and answers whichever side looked first. Only a
rebuild of an index that exists refuses.

S5. ServeOptions::search_timeout is deleted. The sink owns the deadline
(Backoff::search_deadline, stamped on every search) and the serve loop asks
the daemon for it (IndexSink::search_deadline), so the loop's wait and the
worker's bound are one value. A mind with no identity and documents owed is
Reconciling, so a search is taken and refused rather than left for a spinning
worker.

S4. A test pins that a later call's bound shrinks as the search spends time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 20:28:22 +02:00
commit e99dc4404e
6 changed files with 429 additions and 60 deletions

View file

@ -25,6 +25,9 @@ pub trait IndexSink<S: MindStore> {
/// Starts a search for the documents nearest `text`, at most `top_k` of
/// them wanted. `Err` says why the index cannot be asked now.
fn search(&mut self, text: &str, top_k: u32) -> Result<SearchTicket, String>;
/// How long after it was asked a search is worth answering: the index's
/// own bound on it, which the loop waiting for the answer must share.
fn search_deadline(&self) -> std::time::Duration;
/// Every search that finished since the last call, by ticket: its
/// candidates and scores, or why it failed.
fn searched(&mut self) -> Vec<(SearchTicket, Result<Hits, String>)>;
@ -174,6 +177,11 @@ impl<S: MindStore, I: IndexSink<S>> Daemon<S, I> {
}
}
/// How long a search is worth waiting for.
pub fn search_deadline(&self) -> std::time::Duration {
self.index.search_deadline()
}
/// Searches the index has finished since this was last called.
pub fn searched(&mut self) -> Vec<(SearchTicket, Result<Hits, String>)> {
self.index.searched()
@ -466,6 +474,10 @@ pub(crate) mod tests {
Err("this index cannot search".into())
}
fn search_deadline(&self) -> std::time::Duration {
std::time::Duration::ZERO
}
fn searched(&mut self) -> Vec<(SearchTicket, Result<Hits, String>)> {
Vec::new()
}
@ -502,6 +514,10 @@ pub(crate) mod tests {
Err("this index cannot search".into())
}
fn search_deadline(&self) -> std::time::Duration {
std::time::Duration::ZERO
}
fn searched(&mut self) -> Vec<(SearchTicket, Result<Hits, String>)> {
Vec::new()
}
@ -524,6 +540,10 @@ pub(crate) mod tests {
Err("this index cannot search".into())
}
fn search_deadline(&self) -> std::time::Duration {
std::time::Duration::ZERO
}
fn searched(&mut self) -> Vec<(SearchTicket, Result<Hits, String>)> {
Vec::new()
}

View file

@ -71,6 +71,8 @@ pub(crate) struct EmbedderState {
/// Whether an identity read is never answered: it fails when its bound
/// has passed, as an adapter that honours its bound does.
pub(crate) hang_identity: bool,
/// How long an identity read takes to answer, when it answers.
pub(crate) identity_delay: Duration,
}
#[derive(Clone)]
@ -81,7 +83,7 @@ pub(crate) struct FakeEmbedder {
impl FakeEmbedder {
pub(crate) fn new(digest: &str) -> Self {
let state = EmbedderState { identity: identity(digest), down: false, embedded: Vec::new(), bounds: Vec::new(), vector_length: None, identity_bounds: Vec::new(), hang_identity: false };
let state = EmbedderState { identity: identity(digest), down: false, embedded: Vec::new(), bounds: Vec::new(), vector_length: None, identity_bounds: Vec::new(), hang_identity: false, identity_delay: Duration::ZERO };
Self { state: Arc::new(Mutex::new(state)), gate: None }
}
@ -107,7 +109,10 @@ impl Embedder for FakeEmbedder {
if state.down {
bail!("the embedder is down");
}
Ok(state.identity.clone())
let (identity, delay) = (state.identity.clone(), state.identity_delay);
drop(state);
std::thread::sleep(delay);
Ok(identity)
}
fn embed(&mut self, texts: &[String], within: Duration) -> Result<Vec<Vec<f32>>> {

View file

@ -62,23 +62,27 @@
//! - A search runs on the worker between batches, so it waits for the batch in
//! flight, and at most one runs between two steps of the writes. The serve
//! loop never waits: it holds the reply and answers when the worker has
//! (`ServeOptions::search_timeout` bounds the wait), and takes back a search
//! (`IndexSink::search_deadline` bounds the wait), and takes back a search
//! it has stopped waiting for. At most `SEARCHES_QUEUED_MAX` wait; one more is
//! refused. A search whose embed is already running cannot be recalled, so it
//! can hold the writes off for as long as it has left: the ticket carries the
//! moment `SEARCH_DEADLINE` after it was asked, and every call the search
//! moment `Backoff::search_deadline` after it was asked, and every call the search
//! makes (the model's identity, the collection's label, the embed, the
//! query) is bounded by what remains of it.
//! - A search reads its own writes. Admissions and searches meet under one
//! lock, admissions first: a search waits in the queue while the worker
//! absorbs and writes what was admitted before it, and is answered once the
//! index has caught up. Lag is not a rebuild and refuses nothing. Under a
//! steady stream of admissions a search may never see the index caught up;
//! its deadline then answers it.
//! lock, and the worker judges a search and takes it in one critical
//! section, admissions first: a search waits in the queue while anything
//! ahead of it is owed (documents in the inbox, documents to write, a first
//! reconciliation to make), and is answered once the index has caught up. Lag
//! is not a rebuild and refuses nothing, and a mind's first build is lag:
//! there is no earlier index to protect. Under a steady stream of admissions
//! a search may never see the index caught up; its deadline then answers it.
//! - A search is refused only while the index is being rebuilt or cannot be
//! asked: `Reconciling` (a re-pulled model, a lost collection, a mind's first
//! admission), `Failing` or `Refused`. The refusal is an `Unavailable` that
//! names the state and how many documents are pending; it is never answered
//! asked: `Reconciling` (a collection made again because the model, its
//! digest or the text version changed or because it was lost, until every
//! document it owes is written again), `Failing` or `Refused`. The refusal
//! is an `Unavailable` that names the state and how many documents are
//! pending; it is never answered
//! from a collection being rebuilt. A search that fails for its own reasons
//! (a timeout, a store error) fails that search alone and the
//! reconciliation stands. Only what the check-then-embed finds changed (the
@ -109,10 +113,10 @@ pub const MANAGED_BY: &str = "huginn";
/// Documents embedded and written per call.
pub const BATCH: usize = 32;
/// How long a search may take, queue wait included: the serve loop's default
/// `ServeOptions::search_timeout`. Every bound on the search path derives from
/// the moment this long after the search was asked, so the two cannot drift
/// apart.
/// How long a search may take, queue wait included, by default
/// (`Backoff::search_deadline`). The sink stamps it on every search when it is
/// asked, and the serve loop asks the sink for it (`IndexSink::search_deadline`),
/// so the loop's wait and the worker's bound are one value.
pub const SEARCH_DEADLINE: Duration = Duration::from_secs(30);
/// The bound on a bulk index-flush embed, the identity and label reads around
@ -277,6 +281,22 @@ impl Budget {
}
}
/// Whether the collection holds an index worth protecting, and whether it is
/// being made again. The state that marks a rebuild lasts until the entries it
/// owes have been written; nothing else clears it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Build {
/// No reconciliation has yet found or made this mind's collection. Its
/// first build has nothing stale to protect, so it is lag.
Unbuilt,
/// The collection was built by this model and only lags the mind.
Built,
/// The collection was made again (another model, digest or text version,
/// or lost while the worker ran) and is refilled from what the projector
/// knows: until every entry it owes is written, it answers nothing.
Rebuilding,
}
/// The projection's state: the entries not yet known to be in the collection,
/// and, once reconciled, the length of vector it accepts.
pub struct Projector<E: Embedder, V: VectorIndex> {
@ -294,6 +314,7 @@ pub struct Projector<E: Embedder, V: VectorIndex> {
/// The model the collection was reconciled under; `None` until it is, and
/// again whenever that reconciliation is forgotten.
reconciled: Option<ModelIdentity>,
build: Build,
}
impl<E: Embedder, V: VectorIndex> Projector<E, V> {
@ -307,6 +328,7 @@ impl<E: Embedder, V: VectorIndex> Projector<E, V> {
known: BTreeMap::new(),
wanted: BTreeSet::new(),
reconciled: None,
build: Build::Unbuilt,
}
}
@ -398,7 +420,12 @@ impl<E: Embedder, V: VectorIndex> Projector<E, V> {
self.embedder.model_identity(BULK_EMBED_TIMEOUT).context("reading the embedding model's identity")?;
let wanted = self.collection_meta(&mind, &identity);
match self.index.describe(&self.collection, INDEX_CALL_TIMEOUT).context("describing the collection")? {
Described::Absent => self.recreate(&wanted)?,
Described::Absent => {
// A collection lost while the worker ran was an index; one that
// was never made is a first build.
let build = if self.build == Build::Unbuilt { Build::Built } else { Build::Rebuilding };
self.recreate(&wanted, build)?;
}
Described::Unlabelled => {
return Err(StepError::Refused(format!(
"collection {} exists and carries no Huginn metadata; it is not this organ's to rewrite",
@ -413,12 +440,14 @@ impl<E: Embedder, V: VectorIndex> Projector<E, V> {
self.collection, found.managed_by, found.instance, found.mind, self.instance, mind
)));
}
Described::Labelled(found) if found != wanted => self.recreate(&wanted)?,
Described::Labelled(found) if found != wanted => self.recreate(&wanted, Build::Rebuilding)?,
Described::Labelled(_) if self.build == Build::Unbuilt => self.build = Build::Built,
Described::Labelled(_) => {}
}
let present = self.index.ids(&self.collection).context("listing the collection's points")?;
self.wanted = self.known.keys().filter(|document_id| !present.contains(&point_id(document_id))).cloned().collect();
self.reconciled = Some(identity);
self.settle();
Ok(())
}
@ -436,8 +465,20 @@ impl<E: Embedder, V: VectorIndex> Projector<E, V> {
}
}
fn recreate(&mut self, meta: &CollectionMeta) -> Result<()> {
self.index.recreate(&self.collection, meta).context("creating the collection")
/// Makes the collection again. `build` is what it is once made, recorded
/// with the collection so a failure before the refill leaves it recorded.
fn recreate(&mut self, meta: &CollectionMeta, build: Build) -> Result<()> {
self.index.recreate(&self.collection, meta).context("creating the collection")?;
self.build = build;
Ok(())
}
/// A rebuild ends when the entries it owes are all written: the one place
/// it is cleared.
fn settle(&mut self) {
if self.build == Build::Rebuilding && self.wanted.is_empty() {
self.build = Build::Built;
}
}
/// The one embed-and-upsert routine, for startup and live entries alike:
@ -483,6 +524,7 @@ impl<E: Embedder, V: VectorIndex> Projector<E, V> {
for entry in &batch {
self.wanted.remove(&entry.id.id.0);
}
self.settle();
Ok(())
}
@ -537,7 +579,7 @@ impl<E: Embedder, V: VectorIndex> Projector<E, V> {
}
let budget = Budget::new(within);
let Some(reconciled) = self.reconciled.clone() else {
bail!("the mind has no identity yet, so it has no index");
bail!("the index has not been built yet");
};
let dimensions = reconciled.dimensions as usize;
let query = format!("Instruct: {QUERY_INSTRUCTION}\nQuery: {text}");
@ -566,8 +608,12 @@ impl<E: Embedder, V: VectorIndex> Projector<E, V> {
pub fn progress_status(&self) -> IndexStatus {
match (&self.reconciled, self.pending()) {
(None, 0) if self.mind.is_none() => IndexStatus::Current,
// The first build of an identified mind has nothing stale to
// protect: the wait for it is lag.
(None, pending) if self.mind.is_some() && self.build == Build::Unbuilt => IndexStatus::Behind { pending },
(None, pending) => IndexStatus::Reconciling { pending },
(Some(_), 0) => IndexStatus::Current,
(Some(_), pending) if self.build == Build::Rebuilding => IndexStatus::Reconciling { pending },
(Some(_), pending) => IndexStatus::Behind { pending },
}
}
@ -585,17 +631,19 @@ impl<E: Embedder, V: VectorIndex> Projector<E, V> {
/// The worker's clock: the retry delay starts at `initial`, doubles per
/// consecutive failure and stops at `max`; an idle worker verifies the model
/// and the collection again every `recheck`.
/// and the collection again every `recheck`; a search is worth waiting for
/// `search_deadline` after it was asked.
#[derive(Clone, Copy, Debug)]
pub struct Backoff {
pub initial: Duration,
pub max: Duration,
pub recheck: Duration,
pub search_deadline: Duration,
}
impl Default for Backoff {
fn default() -> Self {
Self { initial: Duration::from_secs(30), max: Duration::from_secs(600), recheck: Duration::from_secs(60) }
Self { initial: Duration::from_secs(30), max: Duration::from_secs(600), recheck: Duration::from_secs(60), search_deadline: SEARCH_DEADLINE }
}
}
@ -719,20 +767,41 @@ fn unsearchable(status: &IndexStatus) -> Option<String> {
/// the worker returns to its writes after each, so a queue of searches cannot
/// hold the index's writes off for the sum of their embeddings.
///
/// Admissions come first: the inbox is absorbed before the search is taken, so
/// every document committed before the search was asked is wanted, and a search
/// behind lag stays queued until the worker's writes have caught up. An index
/// being rebuilt does not wait: the search is taken and refused. The search's
/// calls are bounded by what remains of the time it was given when it was asked.
/// Admissions come first, and the judgement and the take are one critical
/// section: the inbox is absorbed and the search taken under one guard, so a
/// document acknowledged before the search was asked is wanted when the search
/// is judged, and none can slip in between. A search is taken only with nothing
/// ahead of it (`take_search`). The search's calls are bounded by what remains
/// of the time it was given when it was asked.
fn serve_search<E: Embedder, V: VectorIndex>(projector: &mut Projector<E, V>, shared: &Lock) {
if !absorb(projector, shared, None) || (projector.pending() > 0 && !projector.stale()) {
let mut guard = lock(shared);
if !absorb_into(projector, &mut guard, None) {
return;
}
let Some(Queued { ticket, text, top_k, due }) = lock(shared).searches.pop_front() else { return };
#[cfg(test)]
tests::after_absorb(&mut guard);
let Some(Queued { ticket, text, top_k, due }) = take_search(projector, &mut guard) else { return };
drop(guard);
let found = projector.search(&text, top_k, due.saturating_duration_since(Instant::now())).map_err(|error| format!("{error:#}"));
lock(shared).found.push((ticket, found));
}
/// The search at the front of the queue, if it may be served now. Never while
/// a document sits in the inbox, and never while anything ahead of it is
/// owed (documents to write, a reconciliation to make) unless the index
/// cannot be asked at all, in which case the search is taken to be refused:
/// waiting would not change the answer.
fn take_search<E: Embedder, V: VectorIndex>(projector: &Projector<E, V>, guard: &mut Shared) -> Option<Queued> {
if !guard.inbox.is_empty() {
return None;
}
let caught_up = projector.pending() == 0 && !projector.stale();
if !caught_up && unsearchable(&projector.progress_status()).is_none() {
return None;
}
guard.searches.pop_front()
}
/// Answers every queued search with why the index cannot: they were accepted
/// while it was healthy and it has failed since.
fn refuse_searches(shared: &Lock, why: &str) {
@ -756,7 +825,16 @@ fn absorb<E: Embedder, V: VectorIndex>(
shared: &Lock,
failing: Option<(&StepError, u32)>,
) -> bool {
let mut guard = lock(shared);
absorb_into(projector, &mut lock(shared), failing)
}
/// `absorb` under a guard the caller already holds, for the caller that must
/// decide something in the same critical section.
fn absorb_into<E: Embedder, V: VectorIndex>(
projector: &mut Projector<E, V>,
guard: &mut Shared,
failing: Option<(&StepError, u32)>,
) -> bool {
if guard.closed {
return false;
}
@ -830,6 +908,8 @@ pub struct WorkerSink {
shared: Lock,
/// Whether the worker has been told the mind's identity.
identified: bool,
/// How long after it was asked a search is worth answering.
deadline: Duration,
}
impl WorkerSink {
@ -866,7 +946,7 @@ impl WorkerSink {
));
let worker = Arc::clone(&shared);
std::thread::spawn(move || work(projector, worker, backoff));
Self { shared, identified }
Self { shared, identified, deadline: backoff.search_deadline }
}
/// Blocks until the health satisfies `predicate`, and returns it. For
@ -944,12 +1024,16 @@ impl<S: MindStore> IndexSink<S> for WorkerSink {
}
let ticket = SearchTicket(guard.next_ticket);
guard.next_ticket += 1;
guard.searches.push_back(Queued { ticket, text: text.to_owned(), top_k, due: Instant::now() + SEARCH_DEADLINE });
guard.searches.push_back(Queued { ticket, text: text.to_owned(), top_k, due: Instant::now() + self.deadline });
drop(guard);
self.shared.1.notify_all();
Ok(ticket)
}
fn search_deadline(&self) -> Duration {
self.deadline
}
fn searched(&mut self) -> Vec<(SearchTicket, Result<Hits, String>)> {
std::mem::take(&mut lock(&self.shared).found)
}

View file

@ -19,6 +19,20 @@ use super::*;
use crate::daemon::Daemon;
use crate::daemon::tests::{INSTANCE, OTHER, batch, campaign_seed, now, question_and_ruling, slug};
thread_local! {
/// Runs once, in the worker's own turn, between the inbox being absorbed
/// and a search being taken, holding the guard `serve_search` holds.
static AFTER_ABSORB: std::cell::RefCell<Option<Box<dyn FnOnce(&mut Shared)>>> = const { std::cell::RefCell::new(None) };
}
/// The hook `serve_search` calls at the point where a second lock once left a
/// gap. It is the test's only way in: nothing else can reach that point.
pub(super) fn after_absorb(guard: &mut Shared) {
if let Some(hook) = AFTER_ABSORB.with(|hook| hook.borrow_mut().take()) {
hook(guard);
}
}
fn collection() -> String {
collection_name(&slug(INSTANCE))
}
@ -84,6 +98,17 @@ fn projected(embedder: &FakeEmbedder, index: &FakeIndex, entries: &[IndexEntry])
projector
}
/// A projector whose collection was lost while it ran and has been made again:
/// it owes every entry, and is rebuilding.
fn rebuilding(embedder: &FakeEmbedder, index: &FakeIndex, entries: &[IndexEntry]) -> Projector<FakeEmbedder, FakeIndex> {
let mut projector = projected(embedder, index, &entries[..1]);
index.state.lock().unwrap().collections.remove(&collection());
projector.want(entries[1..].iter().cloned());
projector.advance().expect_err("the write meets no collection");
assert_eq!(projector.advance().unwrap(), Advance::Progressed, "the reconciliation makes the collection again");
projector
}
/// P4's function, as voidbot's `toQdrantPointId` computes it: the first sixteen
/// bytes of the SHA-256, version and variant bits set. The fixed vectors were
/// computed independently of this crate.
@ -347,8 +372,7 @@ fn every_call_on_the_search_path_is_bounded_by_the_searchs_time_and_the_flush_by
.chain(index.bounds.iter().map(|(_, bound)| *bound))
.collect();
assert!(bounds.iter().all(|bound| !bound.is_zero() && *bound <= within), "{bounds:?}");
let served = crate::serve::ServeOptions::default().search_timeout;
assert_eq!(served, SEARCH_DEADLINE);
assert_eq!(Backoff::default().search_deadline, SEARCH_DEADLINE);
assert!(SEARCH_DEADLINE < BULK_EMBED_TIMEOUT);
}
@ -435,7 +459,7 @@ fn committed(response: HuginnMindResponse) {
}
fn quick() -> Backoff {
Backoff { initial: Duration::from_millis(1), max: Duration::from_millis(2), recheck: Duration::from_secs(3600) }
Backoff { initial: Duration::from_millis(1), max: Duration::from_millis(2), recheck: Duration::from_secs(3600), search_deadline: Duration::from_secs(30) }
}
/// The index never delays or refuses admission: the embedder is shut, the
@ -811,7 +835,7 @@ fn the_backoff_doubles_from_thirty_seconds_to_a_ten_minute_cap() {
seen.push(delay.as_secs());
}
assert_eq!(seen, [30, 60, 120, 240, 480, 600, 600, 600]);
let small = Backoff { initial: Duration::from_millis(1), max: Duration::from_millis(3), recheck: Duration::from_secs(3600) };
let small = Backoff { initial: Duration::from_millis(1), max: Duration::from_millis(3), recheck: Duration::from_secs(3600), search_deadline: Duration::from_secs(30) };
assert_eq!(small.after(Duration::from_millis(1)), Duration::from_millis(2));
assert_eq!(small.after(Duration::from_millis(2)), Duration::from_millis(3));
}
@ -910,7 +934,7 @@ fn a_mind_without_an_identity_makes_no_collection_until_its_first_admission() {
assert_eq!(projector.progress_status(), IndexStatus::Current);
assert_eq!(projector.advance().unwrap(), Advance::Idle);
let identified = Projector::new(embedder.clone(), index.clone(), &slug(INSTANCE), Some(MIND.into()));
assert_eq!(identified.progress_status(), IndexStatus::Reconciling { pending: 0 }, "an identified mind has yet to reconcile");
assert_eq!(identified.progress_status(), IndexStatus::Behind { pending: 0 }, "an identified mind has yet to build its first index: lag, not a rebuild");
assert!(index.state.lock().unwrap().collections.is_empty());
let sink = WorkerSink::spawn(embedder, index.clone(), &slug(INSTANCE), None, Vec::new(), quick());
@ -1037,7 +1061,7 @@ fn a_search_refuses_when_the_index_is_not_reconciled_with_the_model() {
let entries = mind.index_entries(None).unwrap();
let (embedder, index) = pair();
let mut fresh = Projector::new(embedder.clone(), index.clone(), &slug(INSTANCE), Some(MIND.into()));
assert!(format!("{:#}", fresh.search("anything", 3, SEARCH_DEADLINE).unwrap_err()).contains("being rebuilt"));
assert!(format!("{:#}", fresh.search("anything", 3, SEARCH_DEADLINE).unwrap_err()).contains("not been built"));
assert!(embedder.texts().is_empty() && index.state.lock().unwrap().searches.is_empty());
let mut projector = projected(&embedder, &index, &entries);
@ -1238,7 +1262,7 @@ fn a_search_is_refused_only_for_a_rebuild_or_a_failure() {
let sink_in = |status: S, inbox: usize| {
let shared = shared_with(entries_of(inbox), None, Vec::new(), false);
lock(&shared).status = status;
WorkerSink { shared, identified: true }
WorkerSink { shared, identified: true, deadline: SEARCH_DEADLINE }
};
assert!(ask_sink(&mut sink_in(S::Current, 2), "anything", 3).is_ok(), "admitted a moment ago and not yet absorbed");
assert!(ask_sink(&mut sink_in(S::Behind { pending: 4 }, 0), "anything", 3).is_ok(), "the worker is writing");
@ -1251,10 +1275,12 @@ fn a_search_is_refused_only_for_a_rebuild_or_a_failure() {
let (_root, mind) = mind_with_documents();
let entries = mind.index_entries(None).unwrap();
let (embedder, index) = pair();
let mut fresh = Projector::new(embedder.clone(), index.clone(), &slug(INSTANCE), Some(MIND.into()));
fresh.want(entries.iter().cloned());
let refusal = format!("{:#}", fresh.search("anything", 3, SEARCH_DEADLINE).unwrap_err());
let (lost_embedder, lost_index) = pair();
let mut rebuilding_now = rebuilding(&lost_embedder, &lost_index, &entries);
let searches = lost_index.state.lock().unwrap().searches.len();
let refusal = format!("{:#}", rebuilding_now.search("anything", 3, SEARCH_DEADLINE).unwrap_err());
assert!(refusal.contains("being rebuilt") && refusal.contains("pending: 4"), "{refusal}");
assert_eq!(lost_index.state.lock().unwrap().searches.len(), searches, "the rebuilt collection was not asked");
assert!(embedder.texts().is_empty() && index.state.lock().unwrap().searches.is_empty());
// Lag it lets through: catching up is the worker's, before the search is taken.
@ -1524,9 +1550,9 @@ fn the_worker_absorbs_admissions_before_a_search_and_waits_for_lag_but_not_for_a
assert_eq!(index.state.lock().unwrap().held_when_searched, [4]);
drop(guard);
// A rebuild: the projector has forgotten its reconciliation with the entries owed.
let mut rebuilding = Projector::new(embedder.clone(), index.clone(), &slug(INSTANCE), Some(MIND.into()));
rebuilding.want(entries.iter().cloned());
// A rebuild: the collection was lost and made again, and every entry is owed.
let (rebuild_embedder, rebuild_index) = pair();
let mut rebuilding = rebuilding(&rebuild_embedder, &rebuild_index, &entries);
let shared = shared_with(Vec::new(), None, vec![queued(2, "b")], false);
serve_search(&mut rebuilding, &shared);
let guard = lock(&shared);
@ -1571,7 +1597,7 @@ fn a_failure_report_carries_the_pending_count_it_has_now() {
assert_eq!(guard.status, IndexStatus::Failing { pending: 4, attempts: 3, error: "down".into() });
}
assert!(absorb(&mut projector, &shared, None));
assert_eq!(lock(&shared).status, IndexStatus::Reconciling { pending: 4 });
assert_eq!(lock(&shared).status, IndexStatus::Behind { pending: 4 }, "a first build is lag");
lock(&shared).closed = true;
assert!(!absorb(&mut projector, &shared, None), "a closed sink ends the worker");
}
@ -1640,3 +1666,241 @@ fn an_idle_worker_rechecks_once_per_interval() {
let listed = index.state.lock().unwrap().listed;
assert!((2..=40).contains(&listed), "the startup reconciliation and about five rechecks listed the collection {listed} times");
}
// The search's rulings: what may be waiting ahead of it, when a rebuild
// refuses, what a first build is, how time is spent, and who owns the deadline.
/// A search is never taken while a document sits in the inbox. An admission
/// that lands after the inbox was absorbed and before the search was taken (the
/// hook puts one there, where a second lock once left the gap) leaves the
/// search queued; the next turn absorbs it, and the search is answered only
/// once it is written.
#[test]
fn a_search_is_never_taken_while_an_admission_sits_in_the_inbox() {
let (_root, mind) = mind_with_documents();
let entries = mind.index_entries(None).unwrap();
let (embedder, index) = pair();
let mut projector = projected(&embedder, &index, &entries[..3]);
let shared = shared_with(Vec::new(), None, vec![queued(1, "a")], false);
let late = entries[3].clone();
AFTER_ABSORB.with(|hook| *hook.borrow_mut() = Some(Box::new(move |guard| guard.inbox.push(late))));
serve_search(&mut projector, &shared);
{
let guard = lock(&shared);
assert_eq!(guard.searches.len(), 1, "the search was not taken past the admission");
assert!(guard.found.is_empty());
assert_eq!(guard.inbox.len(), 1);
}
serve_search(&mut projector, &shared);
assert_eq!(lock(&shared).searches.len(), 1, "absorbed, but not yet written");
drain(&mut projector);
serve_search(&mut projector, &shared);
let guard = lock(&shared);
assert!(matches!(guard.found.as_slice(), [(SearchTicket(1), Ok(_))]), "{:?}", guard.found);
assert_eq!(index.state.lock().unwrap().held_when_searched, [4], "the search saw the admission");
}
/// Through the sink, an admission acknowledged before the search was asked is
/// in the inbox while the worker is inside a batch; the search reads it.
#[test]
fn a_search_reads_an_admission_acknowledged_before_it_was_asked() {
let gate = Gate::shut();
let (_, index) = pair();
let embedder = FakeEmbedder::new("d1").behind(&gate);
let mut sink = WorkerSink::spawn(embedder, index.clone(), &slug(INSTANCE), Some(MIND.into()), entries_of(2), quick());
gate.wait_arrived(1);
let admitted = entries_of(5).split_off(2);
lock(&sink.shared).inbox.extend(admitted);
let ticket = ask_sink(&mut sink, "anything", 3).expect("lag is not a rebuild");
gate.open();
let answered = answers(&mut sink, 1);
assert_eq!(answered[0].0, ticket);
assert!(answered[0].1.is_ok(), "{answered:?}");
assert_eq!(index.state.lock().unwrap().held_when_searched, [5]);
}
/// A collection made again is a rebuild until every document it owes is
/// written again: searches are refused as rebuilding, however the collection
/// came to be made again, and however many steps or failures the refill takes.
#[test]
fn a_collection_made_again_refuses_searches_until_it_is_refilled() {
let refused = |projector: &mut Projector<FakeEmbedder, FakeIndex>, pending: u32| {
let why = format!("{:#}", projector.search("anything", 3, SEARCH_DEADLINE).unwrap_err());
assert!(why.contains("being rebuilt") && why.contains(&format!("pending: {pending}")), "{why}");
};
let entries = entries_of(BATCH + 8);
// Lost while the worker ran.
let (embedder, index) = pair();
let mut projector = rebuilding(&embedder, &index, &entries);
assert_eq!(projector.progress_status(), IndexStatus::Reconciling { pending: 40 });
refused(&mut projector, 40);
index.state.lock().unwrap().down = true;
projector.advance().expect_err("the store is down mid-refill");
index.state.lock().unwrap().down = false;
assert_eq!(projector.advance().unwrap(), Advance::Progressed, "reconciled again, under a label that already fits");
assert_eq!(projector.progress_status(), IndexStatus::Reconciling { pending: 40 }, "a failure does not end the rebuild");
assert_eq!(projector.advance().unwrap(), Advance::Progressed);
assert_eq!(projector.progress_status(), IndexStatus::Reconciling { pending: 8 }, "half refilled is still a rebuild");
refused(&mut projector, 8);
assert_eq!(projector.advance().unwrap(), Advance::Progressed);
assert_eq!(projector.progress_status(), IndexStatus::Current);
assert!(projector.search("anything", 3, SEARCH_DEADLINE).is_ok(), "answered once refilled");
assert_eq!(index.state.lock().unwrap().held_when_searched, [40]);
projector.want(entries_of(1).into_iter().map(|mut entry| {
entry.id.id.0.push_str("-later");
entry
}));
assert_eq!(projector.progress_status(), IndexStatus::Behind { pending: 1 }, "a later admission is lag, not a rebuild");
// Made again because the model changed.
let (embedder, index) = pair();
let mut projector = projected(&embedder, &index, &entries);
embedder.state.lock().unwrap().identity.digest = "d2".into();
assert_eq!(projector.recheck().unwrap(), Advance::Progressed, "the change is seen and the collection made again");
assert_eq!(projector.progress_status(), IndexStatus::Reconciling { pending: 40 });
projector.advance().unwrap();
assert_eq!(projector.progress_status(), IndexStatus::Reconciling { pending: 8 });
refused(&mut projector, 8);
drain(&mut projector);
assert!(projector.search("anything", 3, SEARCH_DEADLINE).is_ok());
}
/// Through the worker: a collection lost while the worker runs is made again,
/// and a search asked during the refill is refused as rebuilding, naming how
/// many documents are pending; one asked after it is answered.
#[test]
fn a_search_asked_during_a_refill_is_refused_and_one_after_it_is_answered() {
let gate = Gate::shut();
gate.open();
let (_, index) = pair();
let embedder = FakeEmbedder::new("d1").behind(&gate);
let backoff = Backoff { recheck: Duration::from_millis(50), ..quick() };
let mut sink = WorkerSink::spawn(embedder, index.clone(), &slug(INSTANCE), Some(MIND.into()), entries_of(BATCH + 8), backoff);
sink.wait_status(|status| *status == IndexStatus::Current);
gate.shut_again();
index.state.lock().unwrap().collections.remove(&collection());
gate.wait_arrived(1);
let refusal = ask_sink(&mut sink, "anything", 3).unwrap_err();
assert!(refusal.contains("being rebuilt") && refusal.contains("pending: 40"), "{refusal}");
gate.open();
sink.wait_status(|status| *status == IndexStatus::Current);
let ticket = ask_sink(&mut sink, "anything", 3).expect("refilled");
let answered = answers(&mut sink, 1);
assert_eq!((answered[0].0, answered[0].1.is_ok()), (ticket, true));
assert_eq!(index.state.lock().unwrap().held_when_searched.last(), Some(&40));
}
/// A mind whose index has never been built has nothing stale to protect: its
/// first build is lag, whether the search was asked before the worker had
/// looked at the first admission or after. Only a rebuild of an index that
/// exists refuses.
#[test]
fn a_minds_first_build_is_lag_whoever_looks_first() {
for asked_before_the_worker_looked in [true, false] {
let what = if asked_before_the_worker_looked { "asked first" } else { "asked after the worker looked" };
let (embedder, index) = pair();
let entries = entries_of(3);
let (mut projector, shared) = if asked_before_the_worker_looked {
let projector = Projector::new(embedder.clone(), index.clone(), &slug(INSTANCE), None);
(projector, shared_with(entries, Some(MIND.into()), Vec::new(), false))
} else {
let mut projector = Projector::new(embedder.clone(), index.clone(), &slug(INSTANCE), Some(MIND.into()));
projector.want(entries);
let shared = shared_with(Vec::new(), None, Vec::new(), false);
lock(&shared).status = projector.progress_status();
(projector, shared)
};
let mut sink = WorkerSink { shared: Arc::clone(&shared), identified: true, deadline: SEARCH_DEADLINE };
let ticket = ask_sink(&mut sink, "anything", 3).unwrap_or_else(|why| panic!("{what}: {why}"));
serve_search(&mut projector, &shared);
assert_eq!(projector.progress_status(), IndexStatus::Behind { pending: 3 }, "{what}: not yet reconciled, and lag");
assert_eq!(lock(&shared).searches.len(), 1, "{what}: the search waits for the first reconciliation");
assert_eq!(projector.advance().unwrap(), Advance::Progressed);
serve_search(&mut projector, &shared);
assert_eq!(lock(&shared).searches.len(), 1, "{what}: and for the first writes");
drain(&mut projector);
serve_search(&mut projector, &shared);
let guard = lock(&shared);
assert!(matches!(guard.found.as_slice(), [(found, Ok(_))] if *found == ticket), "{what}: {:?}", guard.found);
assert_eq!(index.state.lock().unwrap().held_when_searched, [3], "{what}");
}
let entries = entries_of(4);
let (embedder, index) = pair();
projected(&embedder, &index, &entries[..3]);
let mut partial = Projector::new(embedder.clone(), index.clone(), &slug(INSTANCE), Some(MIND.into()));
partial.want(entries.iter().cloned());
partial.advance().unwrap();
assert_eq!(partial.progress_status(), IndexStatus::Behind { pending: 1 }, "an index found whole at start-up only lags");
embedder.state.lock().unwrap().identity.digest = "d2".into();
let mut replaced = Projector::new(embedder, index, &slug(INSTANCE), Some(MIND.into()));
replaced.want(entries);
replaced.advance().unwrap();
assert_eq!(replaced.progress_status(), IndexStatus::Reconciling { pending: 4 }, "an index that exists and is made again is a rebuild");
}
/// A mind with no identity and documents owed has nothing to wait for: the
/// search is taken and refused, not left queued for a worker that would spin
/// on it.
#[test]
fn a_search_is_refused_when_documents_are_owed_to_a_mind_with_no_identity() {
let (embedder, index) = pair();
let mut projector = Projector::new(embedder.clone(), index.clone(), &slug(INSTANCE), None);
projector.want(entries_of(2));
let shared = shared_with(Vec::new(), None, vec![queued(1, "a")], false);
serve_search(&mut projector, &shared);
let guard = lock(&shared);
assert!(guard.searches.is_empty());
let [(SearchTicket(1), Err(why))] = guard.found.as_slice() else { panic!("{:?}", guard.found) };
assert!(why.contains("being rebuilt") && why.contains("pending: 2"), "{why}");
assert!(embedder.texts().is_empty() && index.state.lock().unwrap().searches.is_empty());
}
/// Every call a search makes is bounded by what its time has left, not by the
/// time it was given: after a slow identity read the embed's bound is smaller
/// by what the read took, and the query's by both reads.
#[test]
fn a_later_calls_bound_shrinks_as_the_search_spends_its_time() {
let entries = entries_of(3);
let (embedder, index) = pair();
let mut projector = projected(&embedder, &index, &entries);
let delay = Duration::from_millis(150);
let within = Duration::from_secs(10);
{
let mut state = embedder.state.lock().unwrap();
state.identity_delay = delay;
state.bounds.clear();
state.identity_bounds.clear();
}
index.state.lock().unwrap().bounds.clear();
projector.search("anything", 3, within).unwrap();
let embedder = embedder.state.lock().unwrap();
let index = index.state.lock().unwrap();
assert!(embedder.identity_bounds[0] <= within);
assert!(embedder.bounds[0] <= within - delay, "the embed came after one slow read: {:?}", embedder.bounds);
assert!(embedder.identity_bounds[1] <= within - delay, "{:?}", embedder.identity_bounds);
let queried = index.bounds.iter().find(|(call, _)| *call == "search").unwrap().1;
assert!(queried <= within - delay * 2, "the query came after two slow reads: {queried:?}");
}
/// The deadline has one owner: the sink. It stamps every search with it, and
/// it is what the serve loop is told (`Daemon::search_deadline`), so the loop's
/// wait and the worker's bound are one value.
#[test]
fn the_sink_owns_the_searchs_deadline_and_tells_the_loop() {
let (_root, mind) = mind_with_documents();
let deadline = Duration::from_secs(7);
let shared = shared_with(Vec::new(), None, Vec::new(), false);
let mut sink = WorkerSink { shared: Arc::clone(&shared), identified: true, deadline };
let asked = Instant::now();
ask_sink(&mut sink, "anything", 3).unwrap();
let due = lock(&shared).searches.back().expect("no worker took it").due;
assert!(due >= asked + deadline && due <= Instant::now() + deadline, "stamped with the sink's deadline");
assert_eq!(<WorkerSink as IndexSink<OwnedRedbMessagePackBackingStore>>::search_deadline(&sink), deadline);
assert_eq!(Daemon::new(mind, sink).search_deadline(), deadline);
}

View file

@ -140,7 +140,7 @@ fn ids(response: &HuginnMindResponse) -> Vec<String> {
}
fn quick() -> Backoff {
Backoff { initial: Duration::from_millis(1), max: Duration::from_millis(2), recheck: Duration::from_secs(3600) }
Backoff { initial: Duration::from_millis(1), max: Duration::from_millis(2), recheck: Duration::from_secs(3600), search_deadline: Duration::from_secs(30) }
}
/// A daemon serving a mind over a real socket, with the ports the test gave it.
@ -167,7 +167,7 @@ impl Harness {
&slug(INSTANCE),
mind.genesis_receipt_id().unwrap(),
mind.index_entries(None).unwrap(),
quick(),
Backoff { search_deadline: search_timeout, ..quick() },
);
let mut daemon: Daemon<OwnedRedbMessagePackBackingStore, WorkerSink> = Daemon::new(mind, sink);
let mut hub = bind("127.0.0.1:0".parse().unwrap(), &daemon.runtime_id()).unwrap();
@ -175,7 +175,7 @@ impl Harness {
let registry = schema_registry().unwrap();
let stopping = Arc::new(AtomicBool::new(false));
let loop_stopping = Arc::clone(&stopping);
let options = ServeOptions { search_timeout, ..ServeOptions::default() };
let options = ServeOptions::default();
let serving = std::thread::spawn(move || run(&mut daemon, &mut hub, &registry, &loop_stopping, &options));
Self { endpoint, stopping, serving: Some(serving), embedder, index, _root: root }
}

View file

@ -88,10 +88,6 @@ pub struct ServeOptions {
pub session_timeout: Duration,
pub idle_sleep: Duration,
pub deferred_ttl: Duration,
/// How long a semantic query may wait for the index before it is answered
/// `Unavailable`. The worker's own calls time out on their own; this is
/// what the client is promised.
pub search_timeout: Duration,
}
impl Default for ServeOptions {
@ -100,7 +96,6 @@ impl Default for ServeOptions {
session_timeout: Duration::from_secs(30),
idle_sleep: Duration::from_millis(2),
deferred_ttl: Duration::from_secs(60),
search_timeout: crate::index::SEARCH_DEADLINE,
}
}
}
@ -449,7 +444,8 @@ struct Waiting {
/// Until `stopping`: expire what timed out, resend what was not acknowledged,
/// answer every frame waiting on the socket, then answer every semantic query
/// the index has finished or that has waited out `search_timeout`. A semantic
/// the index has finished or that has waited out the index's own
/// `search_deadline`. A semantic
/// query is never answered inline: `answer` hands back a `Search`, the loop
/// holds it beside its session and goes on serving, and the embedder and the
/// vector store are the worker's to wait on. A hostile datagram and a departed
@ -497,13 +493,13 @@ pub fn run<S: MindStore, I: IndexSink<S>>(
match answer(daemon, registry, &mut bodies, message, Utc::now()) {
Routed::Reply(reply) => send(hub, &session, &reply),
Routed::Search { message_id, operation, search } => {
let until = Instant::now() + options.search_timeout;
let until = Instant::now() + daemon.search_deadline();
waiting.insert(search.ticket, Waiting { session, message_id, operation, search, until });
}
}
served += 1;
}
let finished = collect_searches(daemon, &waiting, options);
let finished = collect_searches(daemon, &waiting);
let answered = !finished.is_empty();
for (ticket, found) in finished {
let Some(asked) = waiting.remove(&ticket) else { continue };
@ -525,14 +521,14 @@ pub fn run<S: MindStore, I: IndexSink<S>>(
fn collect_searches<S: MindStore, I: IndexSink<S>>(
daemon: &mut Daemon<S, I>,
waiting: &BTreeMap<SearchTicket, Waiting>,
options: &ServeOptions,
) -> Vec<(SearchTicket, Result<crate::daemon::Hits, String>)> {
let deadline = daemon.search_deadline();
let mut collected = daemon.searched();
let now = Instant::now();
for (ticket, asked) in waiting {
if asked.until <= now {
daemon.abandon(*ticket);
collected.push((*ticket, Err(format!("the semantic search did not finish within {:?}", options.search_timeout))));
collected.push((*ticket, Err(format!("the semantic search did not finish within {deadline:?}"))));
}
}
collected