use std::ops::Bound;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use tantivy::collector::DocSetCollector;
use tantivy::query::{BooleanQuery, Occur, Query, RangeQuery, TermQuery};
use tantivy::schema::{IndexRecordOption, Value};
use tantivy::{Index, IndexReader, IndexWriter, ReloadPolicy, TantivyDocument, Term};
use crate::events::{PendingChange, PendingOp};
use crate::indexer::PROGRESS_INTERVAL;
use crate::indexer::{
add_file_document, add_image_document_with_content, commit_index_tail,
is_transient_writer_killed, walk_index_files,
};
use crate::ocr::is_image;
use crate::ocr_queue::OcrQueue;
use crate::{Fields, IndexProgress, build_schema, register_tokenizers};
const WRITER_RETRIES: u32 = 6;
const WRITER_RETRY_BASE_DELAY: Duration = Duration::from_millis(300);
const WRITER_RETRY_MAX_DELAY: Duration = Duration::from_secs(3);
#[derive(Debug, Default, Clone, Copy)]
pub struct BatchOutcome {
pub upserted: usize,
pub removed: usize,
pub skipped: usize,
}
pub struct IndexUpdater {
index: Index,
writer: IndexWriter,
fields: Fields,
index_dir: PathBuf,
}
impl IndexUpdater {
pub fn open(index_dir: &Path) -> Result<Self> {
crate::meta::ensure_schema_version(index_dir)?;
let index = Index::open_in_dir(index_dir).context("打不开索引目录,先建一次索引再监听")?;
register_tokenizers(&index);
let (_, fields) = build_schema();
let writer = index.writer(50 * 1024 * 1024)?;
Ok(Self {
index,
writer,
fields,
index_dir: index_dir.to_path_buf(),
})
}
pub fn apply(&mut self, batch: &[PendingChange]) -> Result<BatchOutcome> {
self.apply_with_progress(batch, |_| {})
}
pub fn apply_with_progress(
&mut self,
batch: &[PendingChange],
mut on_progress: impl FnMut(IndexProgress),
) -> Result<BatchOutcome> {
self.commit_with_retry(|this| {
let mut outcome = BatchOutcome::default();
let mut touched_image = false;
let mut processed = 0usize;
for change in batch {
match change.op {
PendingOp::Upsert => {
this.upsert_one(&change.path, &mut outcome, &mut touched_image)?;
}
PendingOp::UpsertTree => {
for file in walk_index_files(&change.path) {
this.upsert_one(&file, &mut outcome, &mut touched_image)?;
processed += 1;
if processed % PROGRESS_INTERVAL == 0 {
on_progress(IndexProgress {
processed,
path: file.clone(),
});
}
}
}
PendingOp::Remove => {
this.delete_exact(&change.path);
outcome.removed += 1;
}
PendingOp::RemoveTree => {
outcome.removed += this.delete_tree(&change.path)?;
}
}
}
let index_dir = this.index_dir.clone();
commit_index_tail(
|| {
if touched_image {
OcrQueue::for_index_dir(&index_dir)
.save()
.context("保存 OCR 队列状态失败")
} else {
Ok(())
}
},
|| this.writer.commit().map(|_| ()).context("增量提交失败"),
)?;
Ok(outcome)
})
}
fn upsert_one(
&self,
path: &Path,
outcome: &mut BatchOutcome,
touched_image: &mut bool,
) -> Result<()> {
self.delete_exact(path);
if is_image(path) {
*touched_image = true;
}
if add_file_document(&self.writer, &self.fields, path, &self.index_dir)? {
outcome.upserted += 1;
} else {
outcome.skipped += 1;
}
Ok(())
}
pub(crate) fn stage_and_commit_ocr_batch(
&mut self,
items: &[(PathBuf, i64, u64, String)],
) -> Result<()> {
if items.is_empty() {
return Ok(());
}
self.commit_with_retry(|this| {
for (path, mtime, size, content) in items {
this.delete_exact(path);
add_image_document_with_content(
&this.writer,
&this.fields,
path,
*mtime,
*size,
content,
)?;
}
this.writer.commit().context("OCR 批量结果提交失败")?;
Ok(())
})
}
fn commit_with_retry<T>(&mut self, mut op: impl FnMut(&mut Self) -> Result<T>) -> Result<T> {
let mut delay = WRITER_RETRY_BASE_DELAY;
for attempt in 1..=WRITER_RETRIES {
match op(self) {
Ok(value) => return Ok(value),
Err(err) if attempt < WRITER_RETRIES && is_transient_writer_killed(&err) => {
eprintln!(
"索引写入端撞上瞬时的杀软扫描冲突(第 {attempt}/{WRITER_RETRIES} 次,\
等待 {delay:?} 后重开写入端重试): {err}"
);
std::thread::sleep(delay);
self.reopen_writer()?;
delay = (delay * 2).min(WRITER_RETRY_MAX_DELAY);
continue;
}
Err(err) => return Err(err),
}
}
unreachable!("循环要么在 Ok 分支返回,要么在最后一次尝试的 Err 分支返回")
}
fn reopen_writer(&mut self) -> Result<()> {
self.writer.rollback().context("重开索引写入端失败")?;
Ok(())
}
pub(crate) fn index(&self) -> &Index {
&self.index
}
pub(crate) fn fields(&self) -> &Fields {
&self.fields
}
fn delete_exact(&self, path: &Path) {
let term = Term::from_field_text(self.fields.path, &path.to_string_lossy());
self.writer.delete_term(term);
}
fn delete_tree(&self, path: &Path) -> Result<usize> {
let exact = path.to_string_lossy().into_owned();
let mut prefix = exact.clone();
if !prefix.ends_with(std::path::MAIN_SEPARATOR) {
prefix.push(std::path::MAIN_SEPARATOR);
}
let upper = format!("{prefix}\u{10FFFF}");
let exact_query = TermQuery::new(
Term::from_field_text(self.fields.path, &exact),
IndexRecordOption::Basic,
);
let subtree_query = RangeQuery::new(
Bound::Included(Term::from_field_text(self.fields.path, &prefix)),
Bound::Excluded(Term::from_field_text(self.fields.path, &upper)),
);
let query = BooleanQuery::new(vec![
(Occur::Should, Box::new(exact_query) as Box<dyn Query>),
(Occur::Should, Box::new(subtree_query) as Box<dyn Query>),
]);
let reader: IndexReader = self
.index
.reader_builder()
.reload_policy(ReloadPolicy::Manual)
.try_into()?;
let searcher = reader.searcher();
let hits = searcher.search(&query, &DocSetCollector)?;
let mut count = 0usize;
for addr in hits {
let doc: TantivyDocument = searcher.doc(addr)?;
if let Some(path) = doc.get_first(self.fields.path).and_then(|v| v.as_str()) {
let term = Term::from_field_text(self.fields.path, path);
self.writer.delete_term(term);
count += 1;
}
}
Ok(count)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::{PendingChange, PendingOp};
use crate::indexer::rebuild_index;
#[test]
fn reopen_writer_succeeds_and_stays_usable() -> Result<()> {
let index_dir = tempfile::tempdir()?;
let target = tempfile::tempdir()?;
std::fs::write(target.path().join("seed.md"), "种子内容 seedmarker")?;
rebuild_index(index_dir.path(), target.path())?;
let mut updater = IndexUpdater::open(index_dir.path())?;
updater.reopen_writer()?;
let added = target.path().join("added.md");
std::fs::write(&added, "新增内容 freshmarker")?;
let batch = vec![PendingChange {
path: added.clone(),
op: PendingOp::Upsert,
}];
let outcome = updater.apply(&batch)?;
assert_eq!(
outcome.upserted, 1,
"重开后的写入端应能正常完成一次增量提交"
);
Ok(())
}
}