use std::{
io::Write,
path::{Path, PathBuf},
};
use eyre::Result;
use opendal::{Operator, services::Memory};
use sqlx::PgPool;
use tantivy::{
DocAddress, Index, IndexSettings, ReloadPolicy, Score, TantivyDocument,
collector::TopDocs,
directory::{Directory, RamDirectory, TerminatingWrite},
doc,
query::QueryParser,
schema::{STORED, SchemaBuilder, TEXT},
};
use tokio::task;
use uuid::Uuid;
use crate::{FullDirectory, LightDirectory, empty::Empty};
async fn pool() -> Result<PgPool> {
let pool = PgPool::connect("postgresql://postgres:postgres@localhost:15432/postgres").await?;
Ok(pool)
}
fn operator() -> Result<Operator> {
let operator = Operator::new(Memory::default())?.finish();
Ok(operator)
}
async fn byte_range(pool: &PgPool, index: Uuid, path: &str) -> Result<Option<(i64, Option<i64>)>> {
let row = sqlx::query_as(
r#"
SELECT byte_offset, byte_length
FROM tantivy.files
WHERE index = $1
AND path = $2
AND deleted_at IS NULL
"#,
)
.bind(index)
.bind(path)
.fetch_optional(pool)
.await?;
Ok(row)
}
fn write_file(dir: &impl Directory, path: &Path, data: &[u8]) -> Result<()> {
let mut writer = dir.open_write(path)?;
writer.write_all(data)?;
writer.terminate()?;
Ok(())
}
fn read_file(dir: &impl Directory, path: &Path) -> Result<Vec<u8>> {
let handle = dir.get_file_handle(path)?;
let len = handle.len();
Ok(handle.read_bytes(0..len)?.to_vec())
}
#[tokio::test(flavor = "multi_thread")]
async fn full_bundles_segment_components() -> Result<()> {
let index = Uuid::new_v4();
let pool = pool().await?;
let operator = operator()?;
let dir = FullDirectory::open(index, operator.clone(), pool.clone())
.await?
.with_bundling();
let idx = (PathBuf::from("seg.idx"), b"postings bytes".to_vec());
let term = (
PathBuf::from("seg.term"),
b"term dictionary bytes!!".to_vec(),
);
let del = (PathBuf::from("seg.7.del"), b"deletes".to_vec());
let dir_ = dir.clone();
let files = [idx.clone(), term.clone(), del.clone()];
task::spawn_blocking(move || -> Result<()> {
for (path, data) in &files {
write_file(&dir_, path, data)?;
}
dir_.sync_directory()?;
Ok(())
})
.await??;
assert!(
operator.exists(&format!("idx-{index}/seg.bundle")).await?,
"the segment bundle object must exist",
);
assert!(!operator.exists(&format!("idx-{index}/seg.idx")).await?);
assert!(!operator.exists(&format!("idx-{index}/seg.term")).await?);
assert!(operator.exists(&format!("idx-{index}/seg.7.del")).await?);
assert!(
!operator
.exists(&format!("idx-{index}/seg.bundle.del"))
.await?
);
assert_eq!(
byte_range(&pool, index, "seg.idx").await?,
Some((0, Some(idx.1.len() as i64))),
);
assert_eq!(
byte_range(&pool, index, "seg.term").await?,
Some((idx.1.len() as i64, Some(term.1.len() as i64))),
);
assert_eq!(
byte_range(&pool, index, "seg.7.del").await?,
Some((0, None))
);
let dir_ = dir.clone();
let read = task::spawn_blocking(move || -> Result<Vec<Vec<u8>>> {
Ok(vec![
read_file(&dir_, &idx.0)?,
read_file(&dir_, &term.0)?,
read_file(&dir_, &del.0)?,
])
})
.await??;
assert_eq!(read, vec![idx.1, term.1, del.1]);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn light_bundles_segment_components() -> Result<()> {
let index = Uuid::new_v4();
let pool = pool().await?;
let operator = operator()?;
let inner = RamDirectory::create();
let dir = LightDirectory::open(inner.clone(), index, operator, pool.clone())
.await?
.with_bundling();
let idx = (PathBuf::from("seg.idx"), b"postings".to_vec());
let store = (PathBuf::from("seg.store"), b"doc store payload".to_vec());
let dir_ = dir.clone();
let files = [idx.clone(), store.clone()];
task::spawn_blocking(move || -> Result<()> {
for (path, data) in &files {
write_file(&dir_, path, data)?;
}
dir_.sync_directory()?;
Ok(())
})
.await??;
assert!(
inner.exists(Path::new("seg.bundle"))?,
"the bundle file must exist in the inner directory",
);
assert!(!inner.exists(&idx.0)?);
assert!(!inner.exists(&store.0)?);
assert_eq!(
byte_range(&pool, index, "seg.idx").await?,
Some((0, Some(idx.1.len() as i64))),
);
let dir_ = dir.clone();
let read = task::spawn_blocking(move || -> Result<(Vec<u8>, Vec<u8>)> {
Ok((read_file(&dir_, &idx.0)?, read_file(&dir_, &store.0)?))
})
.await??;
assert_eq!(read, (idx.1, store.1));
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn bundling_with_tiny_cap_still_detects_empty() -> Result<()> {
let index = Uuid::new_v4();
let pool = pool().await?;
let operator = operator()?;
let dir = FullDirectory::open(index, operator.clone(), pool.clone())
.await?
.with_bundling()
.with_bundle_max_file_bytes(1);
let path = PathBuf::from("seg.fast");
let empty = Empty::Fast.bytes().to_vec();
let dir_ = dir.clone();
let empty_ = empty.clone();
let path_ = path.clone();
task::spawn_blocking(move || -> Result<()> {
write_file(&dir_, &path_, &empty_)?;
dir_.sync_directory()?;
Ok(())
})
.await??;
assert!(!operator.exists(&format!("idx-{index}/seg.fast")).await?);
assert!(!operator.exists(&format!("idx-{index}/seg.bundle")).await?);
let dir_ = dir.clone();
let read = task::spawn_blocking(move || read_file(&dir_, &path)).await??;
assert_eq!(read, empty);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn full_bundling_round_trips_a_real_index() -> Result<()> {
let index_id = Uuid::new_v4();
let pool = pool().await?;
let operator = operator()?;
let directory = FullDirectory::open(index_id, operator, pool)
.await?
.with_bundling();
search_one(directory).await
}
#[tokio::test(flavor = "multi_thread")]
async fn light_bundling_round_trips_a_real_index() -> Result<()> {
let index_id = Uuid::new_v4();
let pool = pool().await?;
let operator = operator()?;
let inner = RamDirectory::create();
let directory = LightDirectory::open(inner, index_id, operator, pool)
.await?
.with_bundling();
search_one(directory).await
}
async fn search_one(directory: impl Directory) -> Result<()> {
let mut schema = SchemaBuilder::new();
let title = schema.add_text_field("title", TEXT | STORED);
let body = schema.add_text_field("body", TEXT);
let schema = schema.build();
let init = task::spawn_blocking(move || -> Result<(Index, _)> {
let index = Index::create(directory, schema, IndexSettings::default())?;
let writer = index.writer(50_000_000)?;
Ok((index, writer))
});
let (index, mut writer) = init.await??;
let write = task::spawn_blocking(move || -> Result<()> {
writer.add_document(doc!(
title => "The Old Man and the Sea",
body => "He was an old man who fished alone in a skiff in the Gulf Stream.",
))?;
writer.commit()?;
Ok(())
});
write.await??;
let parser = QueryParser::for_index(&index, vec![title, body]);
let query = parser.parse_query("sea")?;
let search = task::spawn_blocking(move || -> Result<Vec<(Score, DocAddress)>> {
let reader = index
.reader_builder()
.reload_policy(ReloadPolicy::Manual)
.try_into()?;
let top = reader
.searcher()
.search(&query, &TopDocs::with_limit(10).order_by_score())?;
for (_score, addr) in &top {
let _doc: TantivyDocument = reader.searcher().doc(*addr)?;
}
Ok(top)
});
let top = search.await??;
assert_eq!(top.len(), 1, "the indexed document should be found");
Ok(())
}