use crate::{storage, text, types::*, Error, Result};
use parking_lot::Mutex;
use rusqlite::{params, Connection};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
#[cfg(test)]
use std::sync::atomic::AtomicUsize;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::Path;
use std::sync::Arc;
use tantivy::{collector::{Collector, DocSetCollector, SegmentCollector, TopDocs, sort_key::{SortBySimilarityScore, SortByStaticFastValue}}, columnar::Column, directory::MmapDirectory, doc, DocId, Order, Score, SegmentOrdinal, SegmentReader,
query::{BoostQuery, BooleanQuery, ConstScoreQuery, Occur, Query, TermQuery},
schema::{Field, IndexRecordOption, Schema, TextFieldIndexing, TextOptions, Value as TantivyValue, FAST, INDEXED, STORED},
tokenizer::WhitespaceTokenizer, Index, IndexReader, IndexWriter, ReloadPolicy, Term};
pub(crate) const FORMAT: &str = "p-memory-text-v13";
struct Fields { key: Field, namespace: Field, scope: Field, kind: Field, tags: Field, text: Field, name: Field, path: Field, note: Field, body: Field }
const NAME_FIELD_BOOST: f32 = 3.0;
const WRITER_MEMORY_BUDGET: usize = 1_000_000_000;
const REBUILD_BATCH: usize = 2_000;
fn rebuild_marker() -> String { format!("{FORMAT}:rebuild") }
pub(crate) struct RebuildProgress {
active: AtomicBool,
processed: AtomicU64,
total: AtomicU64,
}
impl RebuildProgress {
fn new() -> Self { Self { active: AtomicBool::new(false), processed: AtomicU64::new(0), total: AtomicU64::new(0) } }
}
pub(crate) struct IndexDocument {
pub id: i64,
pub namespace_id: i64,
pub scope_id: i64,
pub kind: RecordKind,
pub text: String,
pub name: String,
pub path: String,
pub note_id: i64,
pub tags_prefix: String,
pub tag_ids: Vec<i64>,
}
pub(crate) struct IndexFilter { pub namespace: i64, pub scopes: Vec<i64>, pub kinds: Vec<i64>, pub tags: Vec<i64>, pub note_ids: Vec<i64> }
pub(crate) struct TextIndex {
index: Index, reader: IndexReader, writer: Mutex<IndexWriter>, fields: Fields,
#[cfg(test)] pub(crate) fail_search: AtomicBool,
#[cfg(test)] pub(crate) rebuilds: AtomicUsize,
#[cfg(test)] pub(crate) abort_rebuild_after: AtomicUsize,
progress: RebuildProgress,
}
impl TextIndex {
pub fn open(root: &Path) -> Result<Self> {
let directory = root.join("text-v2");
std::fs::create_dir_all(&directory)?;
let mut builder = Schema::builder();
let tokenized = || TextOptions::default().set_indexing_options(TextFieldIndexing::default()
.set_tokenizer("pretokenized").set_index_option(IndexRecordOption::WithFreqsAndPositions));
let fields = Fields {
key: builder.add_u64_field("key", INDEXED | STORED | FAST),
namespace: builder.add_u64_field("namespace", INDEXED),
scope: builder.add_u64_field("scope", INDEXED),
kind: builder.add_u64_field("kind", INDEXED),
tags: builder.add_u64_field("tags", INDEXED),
text: builder.add_text_field("text", tokenized()),
name: builder.add_text_field("name", tokenized()),
path: builder.add_text_field("path", tokenized()),
note: builder.add_u64_field("note", INDEXED | FAST),
body: builder.add_text_field("body", STORED),
};
let schema = builder.build();
let open = || -> Result<Index> {
let dir = MmapDirectory::open(&directory).map_err(|e| Error::Index(e.to_string()))?;
Ok(Index::open_or_create(dir, schema.clone())?)
};
let index = match open() {
Ok(index) => index,
Err(_) => {
std::fs::rename(&directory, root.join(format!("text-v2.corrupt-{}", uuid::Uuid::new_v4())))?;
std::fs::create_dir(&directory)?;
open()?
}
};
index.tokenizers().register("pretokenized", WhitespaceTokenizer::default());
let writer = index.writer_with_num_threads(1, WRITER_MEMORY_BUDGET)?;
let reader = index.reader_builder().reload_policy(ReloadPolicy::Manual).try_into()?;
Ok(Self { index, reader, writer: Mutex::new(writer), fields,
#[cfg(test)] fail_search: AtomicBool::new(false),
#[cfg(test)] rebuilds: AtomicUsize::new(0),
#[cfg(test)] abort_rebuild_after: AtomicUsize::new(0),
progress: RebuildProgress::new() })
}
pub fn document_count(&self) -> usize { self.reader.searcher().num_docs() as usize }
pub fn recover(&self, conn: &Connection) -> Result<()> {
let expected = format!("{FORMAT}:{}", storage::meta(conn, "indexed_revision")?);
let pending: i64 = conn.query_row("SELECT COUNT(*) FROM index_updates", [], |r| r.get(0))?;
if pending == 0 && self.index.load_metas()?.payload.as_deref() == Some(&expected) {
if storage::meta_opt(conn, "rebuild_cursor")?.is_some() { storage::clear_meta(conn, "rebuild_cursor")?; }
if storage::meta_opt(conn, "rebuild_processed")?.is_some() { storage::clear_meta(conn, "rebuild_processed")?; }
return Ok(());
}
self.rebuild(conn)
}
pub fn stage(&self, docs: &[IndexDocument]) -> Result<()> {
if docs.is_empty() { return Ok(()); }
let mut writer = self.writer.lock();
for item in docs { self.add(&mut writer, item)?; }
Ok(())
}
fn add(&self, writer: &mut IndexWriter, item: &IndexDocument) -> Result<()> {
writer.delete_term(Term::from_field_u64(self.fields.key, item.id as u64));
let mut document = doc!(self.fields.body => item.text.clone());
document.add_u64(self.fields.key, item.id as u64);
document.add_u64(self.fields.namespace, item.namespace_id as u64);
document.add_u64(self.fields.scope, item.scope_id as u64);
document.add_u64(self.fields.kind, item.kind.code() as u64);
for tag_id in &item.tag_ids { document.add_u64(self.fields.tags, *tag_id as u64); }
let searchable = if item.tags_prefix.is_empty() { item.text.clone() }
else { format!("{}\n{}", item.tags_prefix, item.text) };
let cleaned = text::clean_markdown(&searchable);
if !cleaned.is_empty() { document.add_text(self.fields.text, text::tokenize(&cleaned).join(" ")); }
if !item.name.is_empty() { document.add_text(self.fields.name, text::tokenize(&item.name).join(" ")); }
if !item.path.is_empty() { document.add_text(self.fields.path, text::tokenize(&item.path).join(" ")); }
if item.note_id != 0 { document.add_u64(self.fields.note, item.note_id as u64); }
writer.add_document(document)?;
Ok(())
}
fn finish(&self, writer: &mut IndexWriter, conn: &Connection, revision: i64) -> Result<()> {
let mut prepared = writer.prepare_commit()?;
prepared.set_payload(&format!("{FORMAT}:{revision}"));
prepared.commit()?;
self.reader.reload()?;
conn.execute("UPDATE meta SET value=?1 WHERE key='indexed_revision' AND value<?1", [revision])?;
conn.execute("DELETE FROM index_updates WHERE revision<=?1", [revision])?;
Ok(())
}
pub fn sync(&self, conn: &Connection) -> Result<()> {
let pending: i64 = conn.query_row("SELECT COUNT(*) FROM index_updates", [], |r| r.get(0))?;
if pending == 0 { return Ok(()); }
let covered: i64 = conn.query_row("SELECT COALESCE(MAX(revision),0) FROM index_updates", [], |r| r.get(0))?;
let mut writer = self.writer.lock();
let orphans: Vec<i64> = {
let mut stmt = conn.prepare("SELECT u.record_id FROM index_updates u \
LEFT JOIN records r ON r.id=u.record_id WHERE r.id IS NULL")?;
let ids = stmt.query_map([], |r| r.get(0))?.collect::<std::result::Result<Vec<i64>, _>>()?;
ids
};
for id in orphans { writer.delete_term(Term::from_field_u64(self.fields.key, id as u64)); }
self.finish(&mut writer, conn, covered)
}
pub fn rebuild(&self, conn: &Connection) -> Result<()> {
#[cfg(test)]
self.rebuilds.fetch_add(1, Ordering::SeqCst);
let resumable = self.index.load_metas()?.payload.as_deref() == Some(&rebuild_marker());
let (start_cursor, already) = if resumable {
(storage::meta_opt(conn, "rebuild_cursor")?.unwrap_or(0),
storage::meta_opt(conn, "rebuild_processed")?.unwrap_or(0).max(0) as u64)
} else { (0, 0) };
let total = conn.query_row("SELECT COUNT(*) FROM records WHERE kind<>?1", [RecordKind::Note.code()],
|r| r.get::<_, i64>(0))? as u64;
self.progress.total.store(total, Ordering::SeqCst);
self.progress.processed.store(already, Ordering::SeqCst);
self.progress.active.store(true, Ordering::SeqCst);
let result = self.rebuild_pages(conn, resumable, start_cursor, already);
self.progress.active.store(false, Ordering::SeqCst);
result
}
fn rebuild_pages(&self, conn: &Connection, resumable: bool, start_cursor: i64, mut processed: u64) -> Result<()> {
let mut writer = self.writer.lock();
if !resumable {
writer.delete_all_documents()?;
storage::set_meta(conn, "rebuild_cursor", 0)?;
storage::set_meta(conn, "rebuild_processed", 0)?;
let mut prepared = writer.prepare_commit()?;
prepared.set_payload(&rebuild_marker());
prepared.commit()?;
}
let mut cursor = start_cursor;
#[cfg(test)]
let mut pages = 0usize;
loop {
let (documents, last_scanned) = self.rebuild_page(conn, cursor)?;
if last_scanned <= cursor { break; }
for item in &documents { self.add(&mut writer, item)?; }
cursor = last_scanned;
let mut prepared = writer.prepare_commit()?;
prepared.set_payload(&rebuild_marker());
prepared.commit()?;
storage::set_meta(conn, "rebuild_cursor", cursor)?;
processed += documents.len() as u64;
storage::set_meta(conn, "rebuild_processed", processed as i64)?;
self.progress.processed.store(processed, Ordering::SeqCst);
#[cfg(test)]
{
pages += 1;
let cap = self.abort_rebuild_after.load(Ordering::SeqCst);
if cap != 0 && pages >= cap { return Err(Error::Index("injected rebuild abort".into())); }
}
}
self.finish(&mut writer, conn, storage::current_revision(conn)?)?;
storage::clear_meta(conn, "rebuild_cursor")?;
storage::clear_meta(conn, "rebuild_processed")?;
Ok(())
}
fn rebuild_page(&self, conn: &Connection, after: i64) -> Result<(Vec<IndexDocument>, i64)> {
let mut documents = Vec::new();
let mut files: HashMap<i64, Vec<String>> = HashMap::new();
let mut paths: HashMap<i64, (Vec<String>, String)> = HashMap::new();
let mut last = after;
let mut stmt = conn.prepare("SELECT r.id,r.namespace_id,r.kind,r.scope_id,r.payload_json FROM records r WHERE r.id>?1 ORDER BY r.id LIMIT ?2")?;
let rows = stmt.query_map(params![after, REBUILD_BATCH as i64], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?,
r.get::<_, i64>(2)?, r.get::<_, i64>(3)?, r.get::<_, String>(4)?)))?;
for row in rows {
let (id, namespace_id, kind_code, scope_id, payload_json) = row?;
last = id;
let Some(kind) = RecordKind::from_code(kind_code) else { continue };
if kind == RecordKind::Note { continue; }
let Ok(payload) = serde_json::from_str::<serde_json::Value>(&payload_json) else { continue };
let text = match kind {
RecordKind::Chunk => {
let note_id = payload.get("note_id").and_then(|v| v.as_i64()).unwrap_or(0);
let ordinal = payload.get("ordinal").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
if !files.contains_key(¬e_id) { files.insert(note_id, self.note_file_chunks(conn, note_id)); }
files.get(¬e_id).and_then(|chunks| chunks.get(ordinal)).cloned().unwrap_or_default()
}
_ => storage::record_text(kind, &payload),
};
let pairs = storage::record_tag_pairs(conn, id).unwrap_or_default();
let tags: Vec<String> = pairs.iter().map(|(_, tag)| tag.clone()).collect();
let (name, path, exclude) = match kind {
RecordKind::Chunk if payload.get("ordinal").and_then(|v| v.as_u64()).unwrap_or(0) == 0 => {
let note_id = payload.get("note_id").and_then(|v| v.as_i64()).unwrap_or(0);
let (dirs, stem) = paths.entry(note_id).or_insert_with(|| storage::note_path_parts(conn, note_id)).clone();
let mut exclude = dirs.clone();
if !stem.is_empty() { exclude.push(stem.clone()); }
(stem, dirs.join(" "), exclude)
}
RecordKind::Chunk => (String::new(), String::new(), Vec::new()),
_ => (storage::record_name(kind, &payload), String::new(), Vec::new()),
};
documents.push(IndexDocument { id, namespace_id, scope_id, kind, text, name, path,
note_id: if kind == RecordKind::Chunk { payload.get("note_id").and_then(|v| v.as_i64()).unwrap_or(0) } else { 0 },
tags_prefix: storage::tags_prefix(kind, &tags, &exclude, &payload),
tag_ids: pairs.into_iter().map(|(tag_id, _)| tag_id).collect() });
}
Ok((documents, last))
}
pub(crate) fn rebuild_progress(&self) -> RebuildProgressReport {
RebuildProgressReport {
active: self.progress.active.load(Ordering::SeqCst),
processed: self.progress.processed.load(Ordering::SeqCst),
total: self.progress.total.load(Ordering::SeqCst),
}
}
fn note_file_chunks(&self, conn: &Connection, note_id: i64) -> Vec<String> {
let Ok((path, namespace_id)) = conn.query_row("SELECT n.path,n.namespace_id FROM notes n WHERE n.record_id=?1",
[note_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))) else { return Vec::new() };
let Ok(content) = std::fs::read_to_string(storage::absolute_note_path(conn, namespace_id, &path)) else { return Vec::new() };
let chunk_chars = conn.query_row("SELECT payload_json FROM records WHERE id=?1", [note_id], |r| r.get::<_, String>(0))
.ok().and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
.and_then(|value| value.get("chunk_chars").and_then(|v| v.as_u64()))
.unwrap_or(220) as usize;
crate::notes::chunk_text(&content, chunk_chars).map(|chunks| chunks.into_iter().map(|chunk| chunk.content).collect()).unwrap_or_default()
}
fn exact_u64(field: Field, value: i64) -> Box<dyn Query> {
Box::new(TermQuery::new(Term::from_field_u64(field, value as u64), IndexRecordOption::Basic))
}
fn term(field: Field, value: &str, occurrence: Occur) -> (Occur, Box<dyn Query>) {
(occurrence, Box::new(TermQuery::new(Term::from_field_text(field, value), IndexRecordOption::WithFreqs)) as Box<dyn Query>)
}
fn filtered_query(&self, tokens: &[String], strict: bool, filter: &IndexFilter, field: MatchField) -> Box<dyn Query> {
let occurrence = if strict { Occur::Must } else { Occur::Should };
let hit = BooleanQuery::new(tokens.iter().map(|token| {
let column: Box<dyn Query> = match field {
MatchField::All => Box::new(BooleanQuery::new(vec![
Self::term(self.fields.text, token, Occur::Should),
Self::term(self.fields.name, token, Occur::Should),
])),
MatchField::Text => Box::new(TermQuery::new(Term::from_field_text(self.fields.text, token), IndexRecordOption::WithFreqs)),
MatchField::Name => Box::new(TermQuery::new(Term::from_field_text(self.fields.name, token), IndexRecordOption::WithFreqs)),
MatchField::Path => Box::new(TermQuery::new(Term::from_field_text(self.fields.path, token), IndexRecordOption::WithFreqs)),
};
(occurrence, column)
}).collect());
let relevance: Box<dyn Query> = if field == MatchField::All {
let name_relevance = BooleanQuery::new(tokens.iter().map(|token| Self::term(self.fields.name, token, Occur::Should)).collect());
Box::new(BooleanQuery::new(vec![
(Occur::Must, Box::new(hit) as Box<dyn Query>),
(Occur::Should, Box::new(BoostQuery::new(Box::new(name_relevance), NAME_FIELD_BOOST)) as Box<dyn Query>),
]))
} else {
Box::new(hit)
};
let mut clauses: Vec<(Occur, Box<dyn Query>)> = vec![(Occur::Must, Box::new(relevance))];
let scopes = BooleanQuery::new(filter.scopes.iter().map(|scope| (Occur::Should, Self::exact_u64(self.fields.scope, *scope))).collect());
clauses.push((Occur::Must, Box::new(ConstScoreQuery::new(Self::exact_u64(self.fields.namespace, filter.namespace), 0.0))));
clauses.push((Occur::Must, Box::new(ConstScoreQuery::new(Box::new(scopes), 0.0))));
if !filter.kinds.is_empty() {
let kinds = BooleanQuery::new(filter.kinds.iter().map(|kind| (Occur::Should, Self::exact_u64(self.fields.kind, *kind))).collect());
clauses.push((Occur::Must, Box::new(ConstScoreQuery::new(Box::new(kinds), 0.0))));
}
for tag_id in &filter.tags {
clauses.push((Occur::Must, Box::new(ConstScoreQuery::new(Self::exact_u64(self.fields.tags, *tag_id), 0.0))));
}
if !filter.note_ids.is_empty() {
let notes = BooleanQuery::new(filter.note_ids.iter().map(|id| (Occur::Should, Self::exact_u64(self.fields.note, *id))).collect());
clauses.push((Occur::Must, Box::new(ConstScoreQuery::new(Box::new(notes), 0.0))));
}
Box::new(BooleanQuery::new(clauses))
}
pub fn search_in(&self, query: &str, filter: &IndexFilter, limit: usize, field: MatchField) -> Result<Vec<(RecordKey, f64)>> {
#[cfg(test)]
if self.fail_search.load(Ordering::SeqCst) { return Err(Error::Index("injected index failure".into())); }
let mut result = Vec::new();
let mut seen = HashSet::new();
let searcher = self.reader.searcher();
let mut strict_rank = HashMap::new();
let mut key_columns: HashMap<u32, tantivy::columnar::Column<u64>> = HashMap::new();
for strict in [true, false] {
if !strict && result.len() >= limit { break; }
let tokens = text::query_terms(query, strict);
if tokens.is_empty() { continue; }
let query = self.filtered_query(&tokens, strict, filter, field);
let collector = TopDocs::with_limit(limit).order_by(((SortBySimilarityScore, Order::Desc), (SortByStaticFastValue::<u64>::for_field("key"), Order::Asc)));
let hits = searcher.search(&*query, &collector)?;
for ((score, _), address) in hits {
if !key_columns.contains_key(&address.segment_ord) {
let column = searcher.segment_reader(address.segment_ord).fast_fields().u64("key")?;
key_columns.insert(address.segment_ord, column);
}
let Some(value) = key_columns[&address.segment_ord].first(address.doc_id) else { continue };
let key = RecordKey { id: value as i64 };
if !seen.insert(key) { continue; }
strict_rank.insert(key, strict);
result.push((key, score as f64));
}
}
result.sort_by(|a, b| strict_rank[&b.0].cmp(&strict_rank[&a.0]).then_with(|| b.1.total_cmp(&a.1)).then_with(|| a.0.cmp(&b.0)));
result.truncate(limit);
Ok(result)
}
pub fn count_in_many(&self, query: &str, filter: &IndexFilter, field: MatchField, note_ids: &[i64]) -> Result<HashMap<i64, usize>> {
let mut out: HashMap<i64, usize> = note_ids.iter().map(|id| (*id, 0)).collect();
if out.is_empty() { return Ok(out); }
let tokens = text::query_terms(query, false);
if tokens.is_empty() { return Ok(out); }
let base = self.filtered_query(&tokens, false, filter, field);
let notes = BooleanQuery::new(note_ids.iter().map(|id| (Occur::Should, Self::exact_u64(self.fields.note, *id))).collect());
let scoped = BooleanQuery::new(vec![(Occur::Must, base), (Occur::Must, Box::new(notes))]);
let collector = NoteCountCollector { targets: Arc::new(note_ids.iter().copied().collect()) };
for (note, count) in self.reader.searcher().search(&scoped, &collector)? {
if let Some(slot) = out.get_mut(¬e) { *slot = count; }
}
Ok(out)
}
pub fn bodies(&self, ids: &[i64]) -> Result<BTreeMap<i64, String>> {
let mut out = BTreeMap::new();
if ids.is_empty() { return Ok(out); }
let searcher = self.reader.searcher();
let query = BooleanQuery::new(ids.iter().map(|id| (Occur::Should, Self::exact_u64(self.fields.key, *id))).collect());
for address in searcher.search(&query, &DocSetCollector)? {
let document: tantivy::TantivyDocument = searcher.doc(address)?;
let key = document.get_first(self.fields.key).and_then(|value| value.as_u64());
let body = document.get_first(self.fields.body).and_then(|value| value.as_str());
if let (Some(key), Some(body)) = (key, body) { out.insert(key as i64, body.to_string()); }
}
Ok(out)
}
}
struct NoteCountCollector {
targets: Arc<HashSet<i64>>,
}
struct NoteCountChild {
column: Column<u64>,
targets: Arc<HashSet<i64>>,
counts: HashMap<i64, usize>,
}
impl Collector for NoteCountCollector {
type Fruit = HashMap<i64, usize>;
type Child = NoteCountChild;
fn for_segment(&self, _segment_ord: SegmentOrdinal, segment: &SegmentReader) -> tantivy::Result<Self::Child> {
Ok(NoteCountChild {
column: segment.fast_fields().u64("note")?,
targets: self.targets.clone(),
counts: HashMap::new(),
})
}
fn requires_scoring(&self) -> bool { false }
fn merge_fruits(&self, segment_fruits: Vec<HashMap<i64, usize>>) -> tantivy::Result<HashMap<i64, usize>> {
let mut out = HashMap::new();
for fruit in segment_fruits {
for (note, count) in fruit { *out.entry(note).or_insert(0) += count; }
}
Ok(out)
}
}
impl SegmentCollector for NoteCountChild {
type Fruit = HashMap<i64, usize>;
fn collect(&mut self, doc: DocId, _score: Score) {
if let Some(value) = self.column.first(doc) {
let note = value as i64;
if self.targets.contains(¬e) { *self.counts.entry(note).or_insert(0) += 1; }
}
}
fn harvest(self) -> HashMap<i64, usize> { self.counts }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{KnowledgeBase, MemoryInput, SearchRequest};
fn seed(kb: &KnowledgeBase, n: usize) {
for i in 0..n { kb.memories().upsert(MemoryInput::new(&format!("重建分页测试条目{i}"))).unwrap(); }
kb.update_index().unwrap();
}
fn cursor_of(kb: &KnowledgeBase) -> Option<i64> {
let guard = kb.engine.writer.lock();
storage::meta_opt(&guard.as_ref().unwrap().conn, "rebuild_cursor").unwrap()
}
#[test]
fn rebuild_resumes_from_persisted_cursor() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let n = REBUILD_BATCH * 2 + 37;
seed(&kb, n);
let total = kb.health().unwrap().index_document_count;
assert_eq!(total, n);
let index = kb.index().unwrap();
index.abort_rebuild_after.store(1, Ordering::SeqCst);
assert!(kb.rebuild_indexes().is_err(), "注入的中断必须冒泡成错误");
assert_eq!(cursor_of(&kb), Some(REBUILD_BATCH as i64), "中断后必须留下停在该页末尾的持久游标");
let progress = kb.rebuild_progress().unwrap();
assert!(!progress.active, "中断后不应仍处于进行中态");
assert_eq!(progress.total as usize, n);
assert_eq!(progress.processed as usize, REBUILD_BATCH);
assert!(kb.rebuild_indexes().is_err());
assert_eq!(cursor_of(&kb), Some((REBUILD_BATCH * 2) as i64), "续跑必须从持久游标继续推进");
assert_eq!(kb.rebuild_progress().unwrap().processed as usize, REBUILD_BATCH * 2);
index.abort_rebuild_after.store(0, Ordering::SeqCst);
kb.rebuild_indexes().unwrap();
assert_eq!(kb.health().unwrap().index_document_count, total);
assert_eq!(cursor_of(&kb), None, "收尾后应清掉重建游标");
let done = kb.rebuild_progress().unwrap();
assert!(!done.active);
assert_eq!(done.processed, done.total);
drop(index);
drop(kb);
let kb = KnowledgeBase::open(dir.path()).unwrap();
assert_eq!(kb.health().unwrap().index_document_count, total);
}
#[test]
fn open_resumes_interrupted_rebuild() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let n = REBUILD_BATCH + 11;
seed(&kb, n);
let total = kb.health().unwrap().index_document_count;
let index = kb.index().unwrap();
index.abort_rebuild_after.store(1, Ordering::SeqCst);
assert!(kb.rebuild_indexes().is_err());
assert!(cursor_of(&kb).is_some());
drop(index);
drop(kb);
let kb = KnowledgeBase::open(dir.path()).unwrap();
assert_eq!(kb.health().unwrap().index_document_count, total, "recover 应自动补齐中断的重建");
assert_eq!(cursor_of(&kb), None);
}
#[test]
fn streamed_rebuild_keeps_every_record_searchable() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let n = REBUILD_BATCH * 2 + 17;
seed(&kb, n);
kb.rebuild_indexes().unwrap();
let index = kb.index().unwrap();
for i in [0usize, REBUILD_BATCH - 1, REBUILD_BATCH, REBUILD_BATCH * 2, n - 1] {
let query = format!("重建分页测试条目{i}");
let request = SearchRequest { query: query.clone(), kinds: vec![RecordKind::Memory],
vector: false, rerank: false, ..Default::default() };
let result = kb.search(&request).unwrap();
assert!(!result.hits.is_empty(), "重建后第 {i} 条应仍可检索到");
let hit = &result.hits[0];
let body = index.bodies(&[hit.key.id]).unwrap();
assert!(body.get(&hit.key.id).map(|t| t.contains(&query)).unwrap_or(false),
"重建后第 {i} 条的正文应能按 id 取回");
}
}
#[test]
fn rebuild_progress_reports_completion() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let n = REBUILD_BATCH + 5;
seed(&kb, n);
assert!(!kb.rebuild_progress().unwrap().active, "尚未重建时不应处于进行中态");
kb.rebuild_indexes().unwrap();
let p = kb.rebuild_progress().unwrap();
assert!(!p.active);
assert_eq!(p.total, n as u64);
assert_eq!(p.processed, n as u64, "完成后 processed 应等于总量");
}
}