Files
cairnobs/search/src/index.rs
T
jcoffey-dev a53c309bad Upgrade tantivy to 0.26 and clear the lru advisory
Dependabot #10: lru's IterMut violates Stacked Borrows, fixed in 0.16.3.
lru was transitive through tantivy 0.22.1, which pins lru ^0.12.0, so
there was no in-range fix -- cargo update -p lru locks nothing. The
advisory was also not reachable: tantivy calls only get, put, len,
peek_lru and new on its LruCache, never iter_mut. Upgrading rather than
dismissing because it is early enough that carrying four versions of
drift costs more than paying it now, and the alert then closes on its
own evidence rather than on an argument.

lru resolves to 0.16.4, past the patch line.

One API change across the four releases. TopDocs no longer implements
Collector on its own -- an ordering has to be chosen rather than
defaulted into. order_by_score() is exactly what bare TopDocs did in
0.22, so result order is preserved rather than quietly changed, which
matters for a search endpoint whose contract is "most relevant first".

Index compatibility checked rather than assumed, since a format change
would have meant a reindex for every existing deployment. Against a live
index of 1757 documents written by 0.22: the service opened it without
error, a document indexed hours earlier by 0.22 is still findable, new
documents written by 0.26 are findable, and a phrase query spans both.
No migration needed.

The compliance inventory is regenerated for the new graph: 17 crates
added, 5 gone, 20 bumped, and three duplicate-version entries collapsed
where the graph no longer needs two. Nothing newly flagged -- every
addition is permissive -- and cargo-deny check licenses, which is the
gate CI actually runs, passes. Rows for crates whose name and version
are unchanged are left byte-for-byte alone, so the diff shows the real
change rather than 200 rows of SPDX term reordering.

Signed-off-by: John Coffey <[email protected]>
2026-09-04 22:17:28 -07:00

198 lines
7.6 KiB
Rust

use anyhow::{Context, Result};
use std::path::Path;
use tantivy::collector::TopDocs;
use tantivy::query::QueryParser;
use tantivy::schema::{Schema, Value, STORED, STRING, TEXT};
use tantivy::{doc, Index, IndexReader, IndexWriter, ReloadPolicy, TantivyDocument, Term};
use tokio::sync::Mutex;
/// Minimal Tantivy index: a stable `record_id` (stored, exact-match) and
/// tokenized `message` text. Everything else (timestamp, host, service,
/// severity) is fetched by joining `record_id` back against ClickHouse in
/// `/api`'s search handler, not duplicated in here — this stays a pure
/// text index, not a second copy of the row.
pub struct SearchIndex {
index: Index,
writer: Mutex<IndexWriter>,
reader: IndexReader,
record_id_field: tantivy::schema::Field,
message_field: tantivy::schema::Field,
}
/// 50MB is Tantivy's own suggested minimum writer heap budget; Phase 1
/// has no real sizing data yet to tune this against.
const WRITER_HEAP_BYTES: usize = 50_000_000;
impl SearchIndex {
pub fn open_or_create(path: &Path) -> Result<Self> {
std::fs::create_dir_all(path).context("creating tantivy index directory")?;
let mut schema_builder = Schema::builder();
let record_id_field = schema_builder.add_text_field("record_id", STRING | STORED);
let message_field = schema_builder.add_text_field("message", TEXT);
let schema = schema_builder.build();
let dir = tantivy::directory::MmapDirectory::open(path)
.context("opening tantivy mmap directory")?;
let index =
Index::open_or_create(dir, schema).context("opening/creating tantivy index")?;
let writer = index
.writer(WRITER_HEAP_BYTES)
.context("creating tantivy index writer")?;
let reader = index
.reader_builder()
.reload_policy(ReloadPolicy::OnCommitWithDelay)
.try_into()
.context("building tantivy index reader")?;
Ok(Self {
index,
writer: Mutex::new(writer),
reader,
record_id_field,
message_field,
})
}
/// Upserts one record: delete-then-add on record_id. Tantivy segments
/// are immutable, so this delete-then-add is the standard idiom for
/// updates, not a workaround -- and it matters here specifically
/// because /search's offset tracking is best-effort (see
/// consumer.rs's OffsetStore), so the same record can genuinely be
/// reprocessed after an unclean shutdown. Without this, that would
/// silently duplicate documents instead of just re-writing the same
/// one.
pub async fn upsert(&self, record_id: &str, message: &str) -> Result<()> {
let writer = self.writer.lock().await;
let term = Term::from_field_text(self.record_id_field, record_id);
writer.delete_term(term);
writer
.add_document(doc!(
self.record_id_field => record_id,
self.message_field => message,
))
.context("adding document to tantivy index")?;
Ok(())
}
pub async fn commit(&self) -> Result<()> {
let mut writer = self.writer.lock().await;
writer.commit().context("committing tantivy index")?;
// Explicit reload rather than relying solely on ReloadPolicy::
// OnCommitWithDelay's background timing: callers of `commit()`
// (the periodic ticker in consumer.rs, and tests) expect a
// committed document to be immediately searchable, not visible
// after some undocumented delay.
self.reader.reload().context("reloading tantivy reader after commit")?;
Ok(())
}
/// Runs a Tantivy query-parser query against the `message` field,
/// returning matching record_ids, most-relevant first. Phase 1: no
/// pagination, no score exposed to the caller — just IDs for `/api`
/// to join against ClickHouse.
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<String>> {
let searcher = self.reader.searcher();
let query_parser = QueryParser::for_index(&self.index, vec![self.message_field]);
let parsed_query = query_parser
.parse_query(query)
.context("parsing search query")?;
// tantivy 0.26 made the ordering explicit: TopDocs on its own no
// longer implements Collector, so a sort has to be chosen rather
// than defaulted into. order_by_score() is what 0.22's bare
// TopDocs did, so this preserves "most-relevant first" exactly
// rather than quietly changing result order.
let top_docs = searcher
.search(&parsed_query, &TopDocs::with_limit(limit).order_by_score())
.context("executing search")?;
let mut ids = Vec::with_capacity(top_docs.len());
for (_score, doc_address) in top_docs {
let retrieved: TantivyDocument = searcher
.doc(doc_address)
.context("retrieving matched document")?;
if let Some(value) = retrieved.get_first(self.record_id_field) {
if let Some(s) = value.as_str() {
ids.push(s.to_string());
}
}
}
Ok(ids)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn new_test_index() -> (SearchIndex, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("creating temp dir");
let index = SearchIndex::open_or_create(dir.path()).expect("opening tantivy index");
(index, dir)
}
#[tokio::test]
async fn upsert_and_search_finds_matching_message() {
let (index, _dir) = new_test_index();
index.upsert("id-1", "hello world").await.unwrap();
index.upsert("id-2", "goodbye moon").await.unwrap();
index.commit().await.unwrap();
let results = index.search("hello", 10).unwrap();
assert_eq!(results, vec!["id-1".to_string()]);
}
#[tokio::test]
async fn search_before_commit_finds_nothing() {
let (index, _dir) = new_test_index();
index.upsert("id-1", "hello world").await.unwrap();
// no commit yet
let results = index.search("hello", 10).unwrap();
assert!(results.is_empty(), "expected no results before commit, got {results:?}");
}
#[tokio::test]
async fn upsert_same_id_twice_does_not_duplicate() {
let (index, _dir) = new_test_index();
index.upsert("id-1", "hello world").await.unwrap();
index.commit().await.unwrap();
index.upsert("id-1", "hello world again").await.unwrap();
index.commit().await.unwrap();
let results = index.search("hello", 10).unwrap();
assert_eq!(
results.len(),
1,
"expected exactly one result after re-upserting the same record_id, got {results:?}"
);
}
#[tokio::test]
async fn search_respects_limit() {
let (index, _dir) = new_test_index();
for i in 0..5 {
index
.upsert(&format!("id-{i}"), "shared term")
.await
.unwrap();
}
index.commit().await.unwrap();
let results = index.search("shared", 2).unwrap();
assert_eq!(results.len(), 2);
}
#[tokio::test]
async fn search_supports_phrase_queries() {
let (index, _dir) = new_test_index();
index.upsert("id-1", "the quick brown fox").await.unwrap();
index.upsert("id-2", "quick and brown but not adjacent fox").await.unwrap();
index.commit().await.unwrap();
let results = index.search("\"quick brown\"", 10).unwrap();
assert_eq!(results, vec!["id-1".to_string()]);
}
}