From f40bbaeb6f69dbb51ecbd7f31fd42967b285629e Mon Sep 17 00:00:00 2001 From: Graham Barber Date: Sun, 2 Aug 2026 17:30:04 -0700 Subject: [PATCH] fix(search): retry transient PermissionDenied segment writes (Windows AV contention) --- crates/trawler-core/src/search.rs | 895 ++++++++++-------- .../trawler-core/tests/rebuild_equivalence.rs | 8 + 2 files changed, 499 insertions(+), 404 deletions(-) diff --git a/crates/trawler-core/src/search.rs b/crates/trawler-core/src/search.rs index 0cc6006..3cad9c4 100644 --- a/crates/trawler-core/src/search.rs +++ b/crates/trawler-core/src/search.rs @@ -1,404 +1,491 @@ -//! Full-text search over block content (tantivy), plus lexical "similar -//! blocks" suggestions. The index directory is entirely disposable: delete -//! it and `SearchIndex::open_or_create` transparently rebuilds from the -//! graph on next open. -//! -//! See openspec/changes/trawler-mvp specs/search/spec.md and tasks.md 3.1-3.4. - -use std::collections::HashSet; -use std::fs; -use std::path::Path; -use std::sync::Mutex; - -use loro::{LoroDoc, TreeID}; -use tantivy::collector::TopDocs; -use tantivy::query::QueryParser; -use tantivy::schema::{Field, Schema, TantivyDocument, Value, STORED, STRING, TEXT}; -use tantivy::snippet::SnippetGenerator; -use tantivy::{doc, Index, IndexReader, IndexWriter, ReloadPolicy, Term}; - -use crate::index::GraphIndex; -use crate::outline::Outline; -use crate::storage::OUTLINE_TREE; - -/// A single search hit: the block, its relevance score, and an HTML -/// snippet with `...` marking matched terms (spec: "matches -/// highlighted"). -#[derive(Debug, Clone)] -pub struct SearchHit { - pub block: TreeID, - pub score: f32, - pub snippet_html: String, -} - -pub struct SearchIndex { - index: Index, - writer: Mutex, - reader: IndexReader, - id_field: Field, - content_field: Field, -} - -fn build_schema() -> (Schema, Field, Field) { - let mut builder = Schema::builder(); - let id_field = builder.add_text_field("id", STRING | STORED); - let content_field = builder.add_text_field("content", TEXT | STORED); - (builder.build(), id_field, content_field) -} - -impl SearchIndex { - /// Open the search index at `dir`, or build it from scratch (from - /// `doc`) if the directory is missing or empty — the "rebuild on - /// missing" half of "the search index is disposable". - pub fn open_or_create(dir: impl AsRef, doc: &LoroDoc) -> tantivy::Result { - let dir = dir.as_ref().to_path_buf(); - fs::create_dir_all(&dir)?; - let (schema, id_field, content_field) = build_schema(); - - let already_exists = dir.join("meta.json").exists(); - let index = if already_exists { - Index::open_in_dir(&dir)? - } else { - Index::create_in_dir(&dir, schema)? - }; - - let writer: IndexWriter = index.writer(50_000_000)?; - let reader = index - .reader_builder() - .reload_policy(ReloadPolicy::OnCommitWithDelay) - .try_into()?; - - let search_index = Self { - index, - writer: Mutex::new(writer), - reader, - id_field, - content_field, - }; - - if !already_exists { - search_index.rebuild(doc)?; - } - - Ok(search_index) - } - - /// Full rebuild from the graph — used for the initial build and - /// available to callers that want to force one (e.g. after detecting - /// drift, or a "rebuild search index" command). - pub fn rebuild(&self, doc: &LoroDoc) -> tantivy::Result<()> { - let outline = Outline::new(doc); - let tree = doc.get_tree(OUTLINE_TREE); - - let mut writer = self.writer.lock().unwrap(); - writer.delete_all_documents()?; - for root in tree.roots() { - let mut stack = vec![root]; - while let Some(id) = stack.pop() { - if let Ok(content) = outline.content(id) { - writer.add_document(doc!( - self.id_field => id.to_string(), - self.content_field => content, - ))?; - } - stack.extend(tree.children(id).unwrap_or_default()); - } - } - writer.commit()?; - self.reader.reload()?; - Ok(()) - } - - /// Index (or re-index) a single block's current content. Safe to call - /// on every content-changing edit: commit is synchronous, so the block - /// is searchable as soon as this returns — comfortably inside the - /// spec's 1-second freshness budget (spec: "Index freshness"). - pub fn upsert_block(&self, id: TreeID, content: &str) -> tantivy::Result<()> { - let mut writer = self.writer.lock().unwrap(); - writer.delete_term(Term::from_field_text(self.id_field, &id.to_string())); - writer.add_document(doc!( - self.id_field => id.to_string(), - self.content_field => content, - ))?; - writer.commit()?; - self.reader.reload()?; - Ok(()) - } - - /// Remove a block from the index (after a delete). - pub fn remove_block(&self, id: TreeID) -> tantivy::Result<()> { - let mut writer = self.writer.lock().unwrap(); - writer.delete_term(Term::from_field_text(self.id_field, &id.to_string())); - writer.commit()?; - self.reader.reload()?; - Ok(()) - } - - /// Ranked full-text search with HTML-highlighted snippets (spec: - /// "Search and jump"). - pub fn search(&self, query_text: &str, limit: usize) -> tantivy::Result> { - let searcher = self.reader.searcher(); - let query_parser = QueryParser::for_index(&self.index, vec![self.content_field]); - let query = query_parser.parse_query(query_text)?; - - let snippet_generator = SnippetGenerator::create(&searcher, &*query, self.content_field)?; - let top_docs = searcher.search(&query, &TopDocs::with_limit(limit).order_by_score())?; - - let mut hits = Vec::with_capacity(top_docs.len()); - for (score, address) in top_docs { - let retrieved: TantivyDocument = searcher.doc(address)?; - let Some(id_str) = retrieved.get_first(self.id_field).and_then(|v| v.as_str()) else { - continue; - }; - let Ok(block) = TreeID::try_from(id_str) else { - continue; - }; - let snippet = snippet_generator.snippet_from_doc(&retrieved); - hits.push(SearchHit { - block, - score, - snippet_html: snippet.to_html(), - }); - } - Ok(hits) - } -} - -/// Swappable "find blocks similar to this one" provider. `SearchIndex` -/// implements this with lexical (shared-term) similarity for MVP; a future -/// semantic/embedding tier can implement the same trait without any -/// consumer (UI, query engine) changing (spec: "The similarity provider -/// SHALL be a swappable interface"). -pub trait Similarity { - /// Blocks similar to `block` (whose current content is `content`), - /// excluding `block` itself and its ancestors/descendants, ranked by - /// similarity (spec: "Similar blocks (lexical)"). - fn similar_blocks( - &self, - block: TreeID, - content: &str, - graph: &GraphIndex, - limit: usize, - ) -> tantivy::Result>; -} - -impl Similarity for SearchIndex { - fn similar_blocks( - &self, - block: TreeID, - content: &str, - graph: &GraphIndex, - limit: usize, - ) -> tantivy::Result> { - let excluded = exclusion_set(block, graph); - - // Lexical similarity: query using the block's own content as the - // search text, so shared distinctive terms drive the ranking — - // the same TF-IDF machinery as ordinary search, applied to the - // block's own text instead of user-typed input. - let hits = self.search(&escape_for_query(content), limit + excluded.len())?; - Ok(hits - .into_iter() - .filter(|hit| !excluded.contains(&hit.block)) - .take(limit) - .collect()) - } -} - -fn exclusion_set(block: TreeID, graph: &GraphIndex) -> HashSet { - let mut excluded = HashSet::from([block]); - - // Descendants: walk graph.children, which the derived index already - // maintains — no need to touch the Loro doc here. - let mut stack = graph.children.get(&block).cloned().unwrap_or_default(); - while let Some(id) = stack.pop() { - excluded.insert(id); - if let Some(kids) = graph.children.get(&id) { - stack.extend(kids.iter().copied()); - } - } - - // Ancestors: walk up via the same children map (no dedicated parent - // map in GraphIndex — O(pages) since outlines aren't very deep). - let mut current = block; - 'outer: loop { - for (&parent, kids) in &graph.children { - if kids.contains(¤t) { - excluded.insert(parent); - current = parent; - continue 'outer; - } - } - break; - } - - excluded -} - -/// tantivy's default query parser treats `+ - " ( ) ^ ~ * ? : \` as query -/// syntax; escape them so a block's own Markdown content (which routinely -/// contains e.g. `-` bullets or `:`) is treated as plain terms, not a -/// malformed query. -fn escape_for_query(text: &str) -> String { - text.chars() - .map(|c| match c { - '+' | '-' | '"' | '(' | ')' | '^' | '~' | '*' | '?' | ':' | '\\' => ' ', - other => other, - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::outline::Position; - use std::path::PathBuf; - - fn temp_dir(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("trawler-search-test-{name}")); - let _ = fs::remove_dir_all(&dir); - dir - } - - fn doc_with_tree() -> LoroDoc { - let doc = LoroDoc::new(); - doc.get_tree(OUTLINE_TREE).enable_fractional_index(0); - doc - } - - #[test] - fn search_and_jump_finds_matching_block() { - // spec scenario: "Search and jump" - let doc = doc_with_tree(); - let outline = Outline::new(&doc); - let page = outline - .create_block(None, Position::Index(0), "Home") - .unwrap(); - let target = outline - .create_block( - Some(page), - Position::Index(0), - "watched an otter trawl the bay", - ) - .unwrap(); - outline - .create_block(Some(page), Position::Index(1), "unrelated grocery list") - .unwrap(); - - let dir = temp_dir("search-and-jump"); - let index = SearchIndex::open_or_create(&dir, &doc).unwrap(); - - let hits = index.search("otter trawl", 10).unwrap(); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].block, target); - assert!(hits[0].snippet_html.contains("")); - - fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn index_freshness_finds_content_after_upsert() { - // spec scenario: "Just-typed content is findable" - let doc = doc_with_tree(); - let outline = Outline::new(&doc); - let page = outline - .create_block(None, Position::Index(0), "Home") - .unwrap(); - - let dir = temp_dir("index-freshness"); - let index = SearchIndex::open_or_create(&dir, &doc).unwrap(); - - let block = outline - .create_block( - Some(page), - Position::Index(0), - "a genuinely novel zyzzyx term", - ) - .unwrap(); - index - .upsert_block(block, &outline.content(block).unwrap()) - .unwrap(); - - let hits = index.search("zyzzyx", 10).unwrap(); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].block, block); - - fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn rebuild_after_deletion_reproduces_same_results() { - // spec scenario: "Rebuild after deletion" - let doc = doc_with_tree(); - let outline = Outline::new(&doc); - let page = outline - .create_block(None, Position::Index(0), "Home") - .unwrap(); - let target = outline - .create_block(Some(page), Position::Index(0), "mending the cod-end net") - .unwrap(); - - let dir = temp_dir("rebuild-after-deletion"); - { - let index = SearchIndex::open_or_create(&dir, &doc).unwrap(); - let hits = index.search("cod-end", 10).unwrap(); - assert_eq!(hits.len(), 1); - } - - fs::remove_dir_all(&dir).unwrap(); - - let rebuilt = SearchIndex::open_or_create(&dir, &doc).unwrap(); - let hits = rebuilt.search("cod-end", 10).unwrap(); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].block, target); - - fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn similar_blocks_excludes_self_and_ancestors_and_finds_related() { - // spec scenario: "Related note resurfaces" - let doc = doc_with_tree(); - let outline = Outline::new(&doc); - let page = outline - .create_block(None, Position::Index(0), "Home") - .unwrap(); - let subject = outline - .create_block( - Some(page), - Position::Index(0), - "mending the cod-end after the last haul", - ) - .unwrap(); - let child_of_subject = outline - .create_block(Some(subject), Position::Index(0), "cod-end thread notes") - .unwrap(); - let related = outline - .create_block( - Some(page), - Position::Index(1), - "another cod-end net repair from last season", - ) - .unwrap(); - let unrelated = outline - .create_block(Some(page), Position::Index(2), "grocery list: milk, eggs") - .unwrap(); - - let dir = temp_dir("similar-blocks"); - let search = SearchIndex::open_or_create(&dir, &doc).unwrap(); - let graph = GraphIndex::rebuild(&doc); - - let subject_content = outline.content(subject).unwrap(); - let hits = search - .similar_blocks(subject, &subject_content, &graph, 10) - .unwrap(); - let hit_blocks: Vec = hits.iter().map(|h| h.block).collect(); - - assert!(!hit_blocks.contains(&subject)); - assert!(!hit_blocks.contains(&child_of_subject)); - assert!(hit_blocks.contains(&related)); - assert!(!hit_blocks.contains(&unrelated)); - - fs::remove_dir_all(&dir).ok(); - } -} +//! Full-text search over block content (tantivy), plus lexical "similar +//! blocks" suggestions. The index directory is entirely disposable: delete +//! it and `SearchIndex::open_or_create` transparently rebuilds from the +//! graph on next open. +//! +//! See openspec/changes/trawler-mvp specs/search/spec.md and tasks.md 3.1-3.4. + +use std::collections::HashSet; +use std::fs; +use std::path::Path; +use std::sync::Mutex; + +use loro::{LoroDoc, TreeID}; +use tantivy::collector::TopDocs; +use tantivy::query::QueryParser; +use tantivy::schema::{Field, Schema, TantivyDocument, Value, STORED, STRING, TEXT}; +use tantivy::snippet::SnippetGenerator; +use tantivy::{doc, Index, IndexReader, IndexWriter, ReloadPolicy, Term}; + +use crate::index::GraphIndex; +use crate::outline::Outline; +use crate::storage::OUTLINE_TREE; + +/// A single search hit: the block, its relevance score, and an HTML +/// snippet with `...` marking matched terms (spec: "matches +/// highlighted"). +#[derive(Debug, Clone)] +pub struct SearchHit { + pub block: TreeID, + pub score: f32, + pub snippet_html: String, +} + +pub struct SearchIndex { + index: Index, + writer: Mutex, + reader: IndexReader, + id_field: Field, + content_field: Field, +} + +fn build_schema() -> (Schema, Field, Field) { + let mut builder = Schema::builder(); + let id_field = builder.add_text_field("id", STRING | STORED); + let content_field = builder.add_text_field("content", TEXT | STORED); + (builder.build(), id_field, content_field) +} + +/// How many times a write operation retries after a transient +/// permission failure before giving up. +const TRANSIENT_IO_RETRIES: usize = 5; +/// First retry backoff; doubles per attempt (20/40/80/160/320 ms — +/// roughly 0.6 s worst case, far past any antivirus scan hold). +const TRANSIENT_IO_BACKOFF_MS: u64 = 20; + +/// Whether a tantivy error is a transient `PermissionDenied` on a +/// segment file. On Windows, an antivirus or indexing service briefly +/// holding a freshly written segment (observed on `.fieldnorm` files +/// under concurrent test load) surfaces exactly this way: os error 5 on +/// a file tantivy itself just created and will happily reopen a moment +/// later. Retrying is the standard remedy; every other error kind stays +/// fatal. +fn is_transient_permission_error(err: &tantivy::TantivyError) -> bool { + use tantivy::directory::error::{OpenReadError, OpenWriteError}; + use tantivy::TantivyError; + let kind = match err { + TantivyError::IoError(io_error) => io_error.kind(), + TantivyError::OpenWriteError(OpenWriteError::IoError { io_error, .. }) => io_error.kind(), + TantivyError::OpenReadError(OpenReadError::IoError { io_error, .. }) => io_error.kind(), + _ => return false, + }; + kind == std::io::ErrorKind::PermissionDenied +} + +impl SearchIndex { + /// Open the search index at `dir`, or build it from scratch (from + /// `doc`) if the directory is missing or empty — the "rebuild on + /// missing" half of "the search index is disposable". + pub fn open_or_create(dir: impl AsRef, doc: &LoroDoc) -> tantivy::Result { + let dir = dir.as_ref().to_path_buf(); + fs::create_dir_all(&dir)?; + let (schema, id_field, content_field) = build_schema(); + + let already_exists = dir.join("meta.json").exists(); + let index = if already_exists { + Index::open_in_dir(&dir)? + } else { + Index::create_in_dir(&dir, schema)? + }; + + let writer: IndexWriter = index.writer(50_000_000)?; + let reader = index + .reader_builder() + .reload_policy(ReloadPolicy::OnCommitWithDelay) + .try_into()?; + + let search_index = Self { + index, + writer: Mutex::new(writer), + reader, + id_field, + content_field, + }; + + if !already_exists { + search_index.rebuild(doc)?; + } + + Ok(search_index) + } + + /// Run a writer batch, retrying transient permission failures (see + /// [`is_transient_permission_error`]). `rollback()` between attempts + /// resets any partially applied ops to the last commit, so each retry + /// re-applies its whole — idempotent — delete/add batch from a clean + /// slate; a rollback failure just makes the next attempt fail with + /// the real error, which then bubbles. + fn with_write_retry( + &self, + mut op: impl FnMut(&mut IndexWriter) -> tantivy::Result, + ) -> tantivy::Result { + let mut writer = self.writer.lock().unwrap(); + let mut backoff_ms = TRANSIENT_IO_BACKOFF_MS; + let mut attempts = 0; + loop { + match op(&mut writer) { + Ok(value) => return Ok(value), + Err(err) + if attempts < TRANSIENT_IO_RETRIES && is_transient_permission_error(&err) => + { + attempts += 1; + let _ = writer.rollback(); + std::thread::sleep(std::time::Duration::from_millis(backoff_ms)); + backoff_ms *= 2; + } + Err(err) => return Err(err), + } + } + } + + /// Full rebuild from the graph — used for the initial build and + /// available to callers that want to force one (e.g. after detecting + /// drift, or a "rebuild search index" command). + pub fn rebuild(&self, doc: &LoroDoc) -> tantivy::Result<()> { + let outline = Outline::new(doc); + let tree = doc.get_tree(OUTLINE_TREE); + + self.with_write_retry(|writer| { + writer.delete_all_documents()?; + for root in tree.roots() { + let mut stack = vec![root]; + while let Some(id) = stack.pop() { + if let Ok(content) = outline.content(id) { + writer.add_document(doc!( + self.id_field => id.to_string(), + self.content_field => content, + ))?; + } + stack.extend(tree.children(id).unwrap_or_default()); + } + } + writer.commit() + })?; + self.reader.reload()?; + Ok(()) + } + + /// Index (or re-index) a single block's current content. Safe to call + /// on every content-changing edit: commit is synchronous, so the block + /// is searchable as soon as this returns — comfortably inside the + /// spec's 1-second freshness budget (spec: "Index freshness"). + pub fn upsert_block(&self, id: TreeID, content: &str) -> tantivy::Result<()> { + self.with_write_retry(|writer| { + writer.delete_term(Term::from_field_text(self.id_field, &id.to_string())); + writer.add_document(doc!( + self.id_field => id.to_string(), + self.content_field => content, + ))?; + writer.commit() + })?; + self.reader.reload()?; + Ok(()) + } + + /// Remove a block from the index (after a delete). + pub fn remove_block(&self, id: TreeID) -> tantivy::Result<()> { + self.with_write_retry(|writer| { + writer.delete_term(Term::from_field_text(self.id_field, &id.to_string())); + writer.commit() + })?; + self.reader.reload()?; + Ok(()) + } + + /// Ranked full-text search with HTML-highlighted snippets (spec: + /// "Search and jump"). + pub fn search(&self, query_text: &str, limit: usize) -> tantivy::Result> { + let searcher = self.reader.searcher(); + let query_parser = QueryParser::for_index(&self.index, vec![self.content_field]); + let query = query_parser.parse_query(query_text)?; + + let snippet_generator = SnippetGenerator::create(&searcher, &*query, self.content_field)?; + let top_docs = searcher.search(&query, &TopDocs::with_limit(limit).order_by_score())?; + + let mut hits = Vec::with_capacity(top_docs.len()); + for (score, address) in top_docs { + let retrieved: TantivyDocument = searcher.doc(address)?; + let Some(id_str) = retrieved.get_first(self.id_field).and_then(|v| v.as_str()) else { + continue; + }; + let Ok(block) = TreeID::try_from(id_str) else { + continue; + }; + let snippet = snippet_generator.snippet_from_doc(&retrieved); + hits.push(SearchHit { + block, + score, + snippet_html: snippet.to_html(), + }); + } + Ok(hits) + } +} + +/// Swappable "find blocks similar to this one" provider. `SearchIndex` +/// implements this with lexical (shared-term) similarity for MVP; a future +/// semantic/embedding tier can implement the same trait without any +/// consumer (UI, query engine) changing (spec: "The similarity provider +/// SHALL be a swappable interface"). +pub trait Similarity { + /// Blocks similar to `block` (whose current content is `content`), + /// excluding `block` itself and its ancestors/descendants, ranked by + /// similarity (spec: "Similar blocks (lexical)"). + fn similar_blocks( + &self, + block: TreeID, + content: &str, + graph: &GraphIndex, + limit: usize, + ) -> tantivy::Result>; +} + +impl Similarity for SearchIndex { + fn similar_blocks( + &self, + block: TreeID, + content: &str, + graph: &GraphIndex, + limit: usize, + ) -> tantivy::Result> { + let excluded = exclusion_set(block, graph); + + // Lexical similarity: query using the block's own content as the + // search text, so shared distinctive terms drive the ranking — + // the same TF-IDF machinery as ordinary search, applied to the + // block's own text instead of user-typed input. + let hits = self.search(&escape_for_query(content), limit + excluded.len())?; + Ok(hits + .into_iter() + .filter(|hit| !excluded.contains(&hit.block)) + .take(limit) + .collect()) + } +} + +fn exclusion_set(block: TreeID, graph: &GraphIndex) -> HashSet { + let mut excluded = HashSet::from([block]); + + // Descendants: walk graph.children, which the derived index already + // maintains — no need to touch the Loro doc here. + let mut stack = graph.children.get(&block).cloned().unwrap_or_default(); + while let Some(id) = stack.pop() { + excluded.insert(id); + if let Some(kids) = graph.children.get(&id) { + stack.extend(kids.iter().copied()); + } + } + + // Ancestors: walk up via the same children map (no dedicated parent + // map in GraphIndex — O(pages) since outlines aren't very deep). + let mut current = block; + 'outer: loop { + for (&parent, kids) in &graph.children { + if kids.contains(¤t) { + excluded.insert(parent); + current = parent; + continue 'outer; + } + } + break; + } + + excluded +} + +/// tantivy's default query parser treats `+ - " ( ) ^ ~ * ? : \` as query +/// syntax; escape them so a block's own Markdown content (which routinely +/// contains e.g. `-` bullets or `:`) is treated as plain terms, not a +/// malformed query. +fn escape_for_query(text: &str) -> String { + text.chars() + .map(|c| match c { + '+' | '-' | '"' | '(' | ')' | '^' | '~' | '*' | '?' | ':' | '\\' => ' ', + other => other, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::outline::Position; + use std::path::PathBuf; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("trawler-search-test-{name}")); + let _ = fs::remove_dir_all(&dir); + dir + } + + fn doc_with_tree() -> LoroDoc { + let doc = LoroDoc::new(); + doc.get_tree(OUTLINE_TREE).enable_fractional_index(0); + doc + } + + #[test] + fn transient_permission_errors_are_retryable_and_others_fatal() { + use std::io::ErrorKind; + use std::sync::Arc; + use tantivy::directory::error::OpenWriteError; + use tantivy::TantivyError; + + let denied = || std::io::Error::new(ErrorKind::PermissionDenied, "Access is denied."); + assert!(is_transient_permission_error(&TantivyError::IoError( + Arc::new(denied()) + ))); + // The exact shape of the observed Windows flake: os error 5 + // opening a fresh segment file for write. + assert!(is_transient_permission_error( + &TantivyError::OpenWriteError(OpenWriteError::IoError { + io_error: Arc::new(denied()), + filepath: "seg.fieldnorm".into(), + }) + )); + + let missing = std::io::Error::new(ErrorKind::NotFound, "gone"); + assert!(!is_transient_permission_error(&TantivyError::IoError( + Arc::new(missing) + ))); + assert!(!is_transient_permission_error(&TantivyError::SchemaError( + "not io at all".into() + ))); + } + + #[test] + fn search_and_jump_finds_matching_block() { + // spec scenario: "Search and jump" + let doc = doc_with_tree(); + let outline = Outline::new(&doc); + let page = outline + .create_block(None, Position::Index(0), "Home") + .unwrap(); + let target = outline + .create_block( + Some(page), + Position::Index(0), + "watched an otter trawl the bay", + ) + .unwrap(); + outline + .create_block(Some(page), Position::Index(1), "unrelated grocery list") + .unwrap(); + + let dir = temp_dir("search-and-jump"); + let index = SearchIndex::open_or_create(&dir, &doc).unwrap(); + + let hits = index.search("otter trawl", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].block, target); + assert!(hits[0].snippet_html.contains("")); + + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn index_freshness_finds_content_after_upsert() { + // spec scenario: "Just-typed content is findable" + let doc = doc_with_tree(); + let outline = Outline::new(&doc); + let page = outline + .create_block(None, Position::Index(0), "Home") + .unwrap(); + + let dir = temp_dir("index-freshness"); + let index = SearchIndex::open_or_create(&dir, &doc).unwrap(); + + let block = outline + .create_block( + Some(page), + Position::Index(0), + "a genuinely novel zyzzyx term", + ) + .unwrap(); + index + .upsert_block(block, &outline.content(block).unwrap()) + .unwrap(); + + let hits = index.search("zyzzyx", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].block, block); + + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn rebuild_after_deletion_reproduces_same_results() { + // spec scenario: "Rebuild after deletion" + let doc = doc_with_tree(); + let outline = Outline::new(&doc); + let page = outline + .create_block(None, Position::Index(0), "Home") + .unwrap(); + let target = outline + .create_block(Some(page), Position::Index(0), "mending the cod-end net") + .unwrap(); + + let dir = temp_dir("rebuild-after-deletion"); + { + let index = SearchIndex::open_or_create(&dir, &doc).unwrap(); + let hits = index.search("cod-end", 10).unwrap(); + assert_eq!(hits.len(), 1); + } + + fs::remove_dir_all(&dir).unwrap(); + + let rebuilt = SearchIndex::open_or_create(&dir, &doc).unwrap(); + let hits = rebuilt.search("cod-end", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].block, target); + + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn similar_blocks_excludes_self_and_ancestors_and_finds_related() { + // spec scenario: "Related note resurfaces" + let doc = doc_with_tree(); + let outline = Outline::new(&doc); + let page = outline + .create_block(None, Position::Index(0), "Home") + .unwrap(); + let subject = outline + .create_block( + Some(page), + Position::Index(0), + "mending the cod-end after the last haul", + ) + .unwrap(); + let child_of_subject = outline + .create_block(Some(subject), Position::Index(0), "cod-end thread notes") + .unwrap(); + let related = outline + .create_block( + Some(page), + Position::Index(1), + "another cod-end net repair from last season", + ) + .unwrap(); + let unrelated = outline + .create_block(Some(page), Position::Index(2), "grocery list: milk, eggs") + .unwrap(); + + let dir = temp_dir("similar-blocks"); + let search = SearchIndex::open_or_create(&dir, &doc).unwrap(); + let graph = GraphIndex::rebuild(&doc); + + let subject_content = outline.content(subject).unwrap(); + let hits = search + .similar_blocks(subject, &subject_content, &graph, 10) + .unwrap(); + let hit_blocks: Vec = hits.iter().map(|h| h.block).collect(); + + assert!(!hit_blocks.contains(&subject)); + assert!(!hit_blocks.contains(&child_of_subject)); + assert!(hit_blocks.contains(&related)); + assert!(!hit_blocks.contains(&unrelated)); + + fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/trawler-core/tests/rebuild_equivalence.rs b/crates/trawler-core/tests/rebuild_equivalence.rs index 7849ff3..8eccaf7 100644 --- a/crates/trawler-core/tests/rebuild_equivalence.rs +++ b/crates/trawler-core/tests/rebuild_equivalence.rs @@ -6,6 +6,14 @@ //! //! Reproduce a failure: every run prints its seed; rerun with //! `TRAWLER_TEST_SEED= cargo test -p trawler-core --test rebuild_equivalence`. +//! +//! Regression note (2026-08-02): this test intermittently failed on +//! Windows under concurrent cargo activity with `PermissionDenied` on a +//! tantivy segment file (`.fieldnorm`) mid-upsert — an antivirus/indexer +//! briefly holding a freshly written file. `SearchIndex`'s write paths +//! now retry transient permission errors with backoff (see +//! `search::is_transient_permission_error`); a recurrence here that +//! *isn't* PermissionDenied is a real bug, not the flake. use std::fs; use std::path::PathBuf; -- 2.51.2