use std::io::Read;
use std::sync::{Arc, Mutex};
use laurus::Document;
use laurus::lexical::{LexicalIndexConfig, LexicalSearchRequest, LexicalStore, TermQuery};
use laurus::storage::memory::{MemoryStorage, MemoryStorageConfig};
use laurus::storage::{FileMetadata, Storage, StorageInput, StorageOutput};
fn doc(title: &str) -> Document {
Document::builder()
.add_text("title", title)
.add_text("body", "lorem ipsum")
.build()
}
fn hits(store: &LexicalStore, term: &str) -> usize {
let query = Box::new(TermQuery::new("title", term));
store
.search(LexicalSearchRequest::new(query))
.unwrap()
.hits
.len()
}
fn read_file(storage: &Arc<dyn Storage>, name: &str) -> String {
let mut input = storage.open_input(name).unwrap();
let mut content = String::new();
input.read_to_string(&mut content).unwrap();
content
}
fn delmap_files(storage: &Arc<dyn Storage>) -> Vec<String> {
storage
.list_files()
.unwrap()
.into_iter()
.filter(|f| f.ends_with(".delmap"))
.collect()
}
#[test]
fn deletions_persist_only_at_commit() {
let storage: Arc<dyn Storage> = Arc::new(MemoryStorage::new(MemoryStorageConfig::default()));
let store = LexicalStore::new(storage.clone(), LexicalIndexConfig::default()).unwrap();
store.upsert_document(1, doc("alpha")).unwrap();
store.commit().unwrap();
assert_eq!(hits(&store, "alpha"), 1);
assert!(delmap_files(&storage).is_empty());
store.upsert_document(1, doc("beta")).unwrap();
assert!(
delmap_files(&storage).is_empty(),
"deletion bitmap must stay buffered until commit (#875)"
);
for file in storage.list_files().unwrap() {
if file.ends_with(".meta") && file != "index.meta" {
assert!(
read_file(&storage, &file).contains("\"has_deletions\": false"),
"{file}: has_deletions must not flip before commit (#875)"
);
}
}
store.commit().unwrap();
assert!(
!delmap_files(&storage).is_empty(),
"commit must persist the deletion bitmap"
);
assert!(
storage
.list_files()
.unwrap()
.iter()
.any(|f| f.ends_with(".meta")
&& f != "index.meta"
&& read_file(&storage, f).contains("\"has_deletions\": true")),
"commit must flip has_deletions on the affected segment"
);
assert_eq!(hits(&store, "beta"), 1);
assert_eq!(hits(&store, "alpha"), 0, "old version must stay deleted");
}
#[derive(Debug)]
struct RecordingStorage {
inner: Arc<dyn Storage>,
created: Mutex<Vec<String>>,
fail_contains: Mutex<Option<String>>,
}
impl RecordingStorage {
fn new(inner: Arc<dyn Storage>) -> Self {
RecordingStorage {
inner,
created: Mutex::new(Vec::new()),
fail_contains: Mutex::new(None),
}
}
fn set_fail_contains(&self, pattern: Option<&str>) {
*self.fail_contains.lock().unwrap() = pattern.map(str::to_string);
}
fn maybe_fail(&self, name: &str) -> laurus::Result<()> {
if let Some(pattern) = self.fail_contains.lock().unwrap().as_deref()
&& name.contains(pattern)
{
return Err(laurus::LaurusError::Storage(format!(
"injected write failure for '{name}'"
)));
}
Ok(())
}
}
impl Storage for RecordingStorage {
fn open_input(&self, name: &str) -> laurus::Result<Box<dyn StorageInput>> {
self.inner.open_input(name)
}
fn create_output(&self, name: &str) -> laurus::Result<Box<dyn StorageOutput>> {
self.maybe_fail(name)?;
self.created.lock().unwrap().push(name.to_string());
self.inner.create_output(name)
}
fn create_output_append(&self, name: &str) -> laurus::Result<Box<dyn StorageOutput>> {
self.maybe_fail(name)?;
self.created.lock().unwrap().push(name.to_string());
self.inner.create_output_append(name)
}
fn file_exists(&self, name: &str) -> bool {
self.inner.file_exists(name)
}
fn delete_file(&self, name: &str) -> laurus::Result<()> {
self.inner.delete_file(name)
}
fn list_files(&self) -> laurus::Result<Vec<String>> {
self.inner.list_files()
}
fn file_size(&self, name: &str) -> laurus::Result<u64> {
self.inner.file_size(name)
}
fn metadata(&self, name: &str) -> laurus::Result<FileMetadata> {
self.inner.metadata(name)
}
fn rename_file(&self, old_name: &str, new_name: &str) -> laurus::Result<()> {
self.inner.rename_file(old_name, new_name)
}
fn create_temp_output(&self, prefix: &str) -> laurus::Result<(String, Box<dyn StorageOutput>)> {
self.inner.create_temp_output(prefix)
}
fn sync(&self) -> laurus::Result<()> {
self.inner.sync()
}
fn close(&mut self) -> laurus::Result<()> {
Ok(())
}
}
#[test]
fn commit_writes_delmap_before_metadata_checkpoint() {
let inner: Arc<dyn Storage> = Arc::new(MemoryStorage::new(MemoryStorageConfig::default()));
let recording = Arc::new(RecordingStorage::new(inner));
let storage: Arc<dyn Storage> = recording.clone();
let store = LexicalStore::new(storage, LexicalIndexConfig::default()).unwrap();
store.upsert_document(1, doc("alpha")).unwrap();
store.commit().unwrap();
store.upsert_document(1, doc("beta")).unwrap();
recording.created.lock().unwrap().clear();
store.commit().unwrap();
let created = recording.created.lock().unwrap().clone();
let delmap_pos = created.iter().position(|f| f.ends_with(".delmap"));
let checkpoint_pos = created.iter().position(|f| f == "metadata.json");
assert!(
delmap_pos.is_some(),
"the deletion-flush commit must write a .delmap, got: {created:?}"
);
assert!(
checkpoint_pos.is_some(),
"the commit must write metadata.json, got: {created:?}"
);
assert!(
delmap_pos.unwrap() < checkpoint_pos.unwrap(),
".delmap must land before the metadata.json last_wal_seq checkpoint \
(#875), got: {created:?}"
);
assert!(
!created.iter().any(|f| f == "deletions.log"),
"the deletion log is disabled (#875) — nothing replays it, got: {created:?}"
);
}
#[test]
fn optimize_does_not_resurrect_buffered_deletions() {
let storage: Arc<dyn Storage> = Arc::new(MemoryStorage::new(MemoryStorageConfig::default()));
let store = LexicalStore::new(storage.clone(), LexicalIndexConfig::default()).unwrap();
store.upsert_document(1, doc("alpha")).unwrap();
store.commit().unwrap();
store.upsert_document(2, doc("bravo")).unwrap();
store.commit().unwrap();
store.upsert_document(1, doc("gamma")).unwrap();
store.optimize().unwrap();
assert_eq!(
hits(&store, "alpha"),
0,
"optimize must not resurrect a buffered deletion (#875)"
);
assert_eq!(hits(&store, "bravo"), 1, "unrelated doc survives the merge");
store.commit().unwrap();
assert_eq!(hits(&store, "gamma"), 1);
assert_eq!(hits(&store, "alpha"), 0);
}
#[test]
fn failed_commit_preserves_buffered_deletions_for_retry() {
let inner: Arc<dyn Storage> = Arc::new(MemoryStorage::new(MemoryStorageConfig::default()));
let recording = Arc::new(RecordingStorage::new(inner));
let storage: Arc<dyn Storage> = recording.clone();
let store = LexicalStore::new(storage, LexicalIndexConfig::default()).unwrap();
store.upsert_document(1, doc("alpha")).unwrap();
store.commit().unwrap();
store.upsert_document(1, doc("beta")).unwrap();
recording.set_fail_contains(Some(".delmap"));
store
.commit()
.expect_err("the injected .delmap write failure must propagate");
recording.set_fail_contains(None);
store.commit().unwrap();
assert_eq!(hits(&store, "beta"), 1, "the new version must be live");
assert_eq!(
hits(&store, "alpha"),
0,
"the deferred deletion must survive a failed commit and persist on \
the retry (#875)"
);
}
#[test]
fn add_field_propagates_writer_commit_failure_and_preserves_deletions() {
let inner: Arc<dyn Storage> = Arc::new(MemoryStorage::new(MemoryStorageConfig::default()));
let recording = Arc::new(RecordingStorage::new(inner));
let storage: Arc<dyn Storage> = recording.clone();
let store = LexicalStore::new(storage, LexicalIndexConfig::default()).unwrap();
store.upsert_document(1, doc("alpha")).unwrap();
store.commit().unwrap();
store.upsert_document(1, doc("beta")).unwrap();
recording.set_fail_contains(Some(".delmap"));
let text_option = laurus::lexical::core::field::FieldOption::Text(Default::default());
store
.add_field("extra", text_option, None)
.expect_err("add_field must propagate the writer-commit failure");
recording.set_fail_contains(None);
store.commit().unwrap();
assert_eq!(hits(&store, "beta"), 1, "the new version must be live");
assert_eq!(
hits(&store, "alpha"),
0,
"the deferred deletion must survive a failed add_field and persist (#875)"
);
}
#[test]
fn rollback_keeps_buffered_deletions() {
use laurus::lexical::{InvertedIndexWriter, InvertedIndexWriterConfig};
let storage: Arc<dyn Storage> = Arc::new(MemoryStorage::new(MemoryStorageConfig::default()));
let mut writer =
InvertedIndexWriter::new(storage.clone(), InvertedIndexWriterConfig::default()).unwrap();
writer.upsert_document(1, doc("alpha")).unwrap();
writer.commit().unwrap();
assert!(delmap_files(&storage).is_empty());
writer.upsert_document(1, doc("beta")).unwrap();
writer.rollback().unwrap();
writer.commit().unwrap();
drop(writer);
assert!(
!delmap_files(&storage).is_empty(),
"the commit after rollback must persist the buffered deletion (#875)"
);
let store = LexicalStore::new(storage, LexicalIndexConfig::default()).unwrap();
assert_eq!(
hits(&store, "alpha"),
0,
"the deletion must survive rollback and persist at commit (#875)"
);
assert_eq!(hits(&store, "beta"), 0, "the rolled-back version is gone");
}