use std::io::{Read, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::time::Duration;
use parking_lot::{Mutex, RwLock};
use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
use serde::{Deserialize, Serialize};
use crate::data::Document;
use crate::error::Result;
use crate::storage::Storage;
use crate::store::document::UnifiedDocumentStore;
pub type SeqNumber = u64;
#[derive(Debug, Clone, Serialize, Deserialize, Archive, RkyvSerialize, RkyvDeserialize)]
pub enum LogEntry {
Upsert {
doc_id: u64,
external_id: String,
document: Document,
},
Delete {
doc_id: u64,
#[serde(default)]
external_id: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, Archive, RkyvSerialize, RkyvDeserialize)]
pub struct LogRecord {
pub seq: SeqNumber,
pub entry: LogEntry,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum WalSyncPolicy {
PerRecord,
Group {
max_records: usize,
max_bytes: usize,
max_interval: Option<Duration>,
},
}
impl Default for WalSyncPolicy {
fn default() -> Self {
Self::PerRecord
}
}
pub const DEFAULT_GROUP_MAX_RECORDS: usize = 1024;
pub const DEFAULT_GROUP_MAX_BYTES: usize = 1024 * 1024;
impl WalSyncPolicy {
pub fn group_with_defaults() -> Self {
Self::Group {
max_records: DEFAULT_GROUP_MAX_RECORDS,
max_bytes: DEFAULT_GROUP_MAX_BYTES,
max_interval: None,
}
}
pub fn group_with_interval(interval: Duration) -> Self {
Self::Group {
max_records: DEFAULT_GROUP_MAX_RECORDS,
max_bytes: DEFAULT_GROUP_MAX_BYTES,
max_interval: Some(interval),
}
}
pub fn flush_interval(&self) -> Option<Duration> {
match self {
Self::Group { max_interval, .. } => *max_interval,
Self::PerRecord => None,
}
}
}
const WAL_MAGIC: &[u8; 4] = b"LWAL";
const WAL_VERSION: u8 = 3;
const WAL_VERSION_V2: u8 = 2;
const WAL_HEADER_LEN: u64 = 5;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum WalFormat {
Legacy,
V2,
V3,
}
impl WalFormat {
fn has_crc(self) -> bool {
matches!(self, WalFormat::V2 | WalFormat::V3)
}
}
#[derive(Debug)]
struct WalWriterState {
out: Box<dyn crate::storage::StorageOutput>,
format: WalFormat,
dirty: bool,
unsynced_records: usize,
unsynced_bytes: usize,
#[cfg(test)]
sync_count: usize,
}
#[derive(Debug)]
pub struct DocumentLog {
wal_storage: Arc<dyn Storage>,
wal_path: String,
next_doc_id: AtomicU64,
wal_writer: Mutex<Option<WalWriterState>>,
next_seq: AtomicU64,
doc_store: RwLock<UnifiedDocumentStore>,
sync_policy: WalSyncPolicy,
sync_deferral_depth: AtomicUsize,
}
impl DocumentLog {
pub fn new(
wal_storage: Arc<dyn Storage>,
wal_path: &str,
doc_store_storage: Arc<dyn Storage>,
) -> Result<Self> {
Self::with_sync_policy(
wal_storage,
wal_path,
doc_store_storage,
WalSyncPolicy::PerRecord,
)
}
pub fn with_sync_policy(
wal_storage: Arc<dyn Storage>,
wal_path: &str,
doc_store_storage: Arc<dyn Storage>,
sync_policy: WalSyncPolicy,
) -> Result<Self> {
let doc_store = UnifiedDocumentStore::open(doc_store_storage)?;
Ok(Self {
wal_storage,
wal_path: wal_path.to_string(),
next_doc_id: AtomicU64::new(1),
wal_writer: Mutex::new(None),
next_seq: AtomicU64::new(1),
doc_store: RwLock::new(doc_store),
sync_policy,
sync_deferral_depth: AtomicUsize::new(0),
})
}
fn detect_existing_format(&self, existing_size: u64) -> Result<WalFormat> {
if existing_size < WAL_HEADER_LEN {
return Ok(WalFormat::Legacy);
}
let mut header = [0u8; WAL_HEADER_LEN as usize];
let mut input = self.wal_storage.open_input(&self.wal_path)?;
if input.read_exact(&mut header).is_err() || &header[0..4] != WAL_MAGIC {
return Ok(WalFormat::Legacy);
}
Ok(match header[4] {
WAL_VERSION => WalFormat::V3,
WAL_VERSION_V2 => WalFormat::V2,
_ => WalFormat::V2,
})
}
fn ensure_writer(&self, writer_guard: &mut Option<WalWriterState>) -> Result<()> {
if writer_guard.is_some() {
return Ok(());
}
let existing_size = if self.wal_storage.file_exists(&self.wal_path) {
self.wal_storage.open_input(&self.wal_path)?.size()?
} else {
0
};
let mut out = self.wal_storage.create_output_append(&self.wal_path)?;
let format = if existing_size == 0 {
out.write_all(WAL_MAGIC)?;
out.write_all(&[WAL_VERSION])?;
WalFormat::V3
} else {
self.detect_existing_format(existing_size)?
};
*writer_guard = Some(WalWriterState {
out,
format,
dirty: false,
unsynced_records: 0,
unsynced_bytes: 0,
#[cfg(test)]
sync_count: 0,
});
Ok(())
}
pub fn append(&self, external_id: &str, doc: Document) -> Result<(u64, SeqNumber)> {
let mut writer_guard = self.wal_writer.lock();
self.ensure_writer(&mut writer_guard)?;
let doc_id = self.next_doc_id.fetch_add(1, Ordering::SeqCst);
let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
let record = LogRecord {
seq,
entry: LogEntry::Upsert {
doc_id,
external_id: external_id.to_string(),
document: doc,
},
};
self.write_record(&mut writer_guard, &record)?;
Ok((doc_id, seq))
}
pub fn append_delete(&self, doc_id: u64, external_id: &str) -> Result<SeqNumber> {
let mut writer_guard = self.wal_writer.lock();
self.ensure_writer(&mut writer_guard)?;
let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
let record = LogRecord {
seq,
entry: LogEntry::Delete {
doc_id,
external_id: external_id.to_string(),
},
};
self.write_record(&mut writer_guard, &record)?;
Ok(seq)
}
fn encode_payload(record: &LogRecord, format: WalFormat) -> Result<Vec<u8>> {
match format {
WalFormat::V3 => rkyv::to_bytes::<rkyv::rancor::Error>(record)
.map(|bytes| bytes.to_vec())
.map_err(|e| {
crate::error::LaurusError::SerializationError(format!(
"WAL rkyv encode failed: {e}"
))
}),
WalFormat::V2 | WalFormat::Legacy => Ok(serde_json::to_vec(record)?),
}
}
fn decode_payload(buffer: &[u8], format: WalFormat) -> Result<LogRecord> {
match format {
WalFormat::V3 => {
rkyv::from_bytes::<LogRecord, rkyv::rancor::Error>(buffer).map_err(|e| {
crate::error::LaurusError::SerializationError(format!(
"WAL rkyv decode failed: {e}"
))
})
}
WalFormat::V2 | WalFormat::Legacy => Ok(serde_json::from_slice(buffer)?),
}
}
fn append_record_bytes(state: &mut Option<WalWriterState>, record: &LogRecord) -> Result<()> {
let Some(state) = state.as_mut() else {
return Ok(());
};
let bytes = Self::encode_payload(record, state.format)?;
let len: u32 = bytes.len().try_into().map_err(|_| {
crate::error::LaurusError::InvalidOperation(format!(
"WAL record size {} exceeds u32::MAX",
bytes.len()
))
})?;
let len_bytes = len.to_le_bytes();
let mut frame_len = len_bytes.len() + bytes.len();
state.out.write_all(&len_bytes)?;
if state.format.has_crc() {
let mut hasher = crc32fast::Hasher::new();
hasher.update(&len_bytes);
hasher.update(&bytes);
let crc = hasher.finalize().to_le_bytes();
state.out.write_all(&crc)?;
frame_len += crc.len();
}
state.out.write_all(&bytes)?;
state.dirty = true;
state.unsynced_records += 1;
state.unsynced_bytes = state.unsynced_bytes.saturating_add(frame_len);
Ok(())
}
fn flush_writer(state: &mut Option<WalWriterState>) -> Result<()> {
if let Some(state) = state.as_mut()
&& state.dirty
{
state.out.flush_and_sync()?;
state.dirty = false;
state.unsynced_records = 0;
state.unsynced_bytes = 0;
#[cfg(test)]
{
state.sync_count += 1;
}
}
Ok(())
}
fn write_record(&self, state: &mut Option<WalWriterState>, record: &LogRecord) -> Result<()> {
Self::append_record_bytes(state, record)?;
let flush_now = match self.sync_policy {
WalSyncPolicy::PerRecord => self.sync_deferral_depth.load(Ordering::Acquire) == 0,
group @ WalSyncPolicy::Group { .. } => Self::batch_ready(state, group),
};
if flush_now {
Self::flush_writer(state)?;
}
Ok(())
}
fn batch_ready(state: &Option<WalWriterState>, policy: WalSyncPolicy) -> bool {
match policy {
WalSyncPolicy::PerRecord => true,
WalSyncPolicy::Group {
max_records,
max_bytes,
max_interval: _,
} => state.as_ref().is_some_and(|s| {
s.unsynced_records >= max_records.max(1) || s.unsynced_bytes >= max_bytes.max(1)
}),
}
}
pub fn flush_wal(&self) -> Result<()> {
let mut writer_guard = self.wal_writer.lock();
Self::flush_writer(&mut writer_guard)
}
pub fn ensure_per_record_durability(&self) -> Result<()> {
match self.sync_policy {
WalSyncPolicy::PerRecord => self.flush_wal(),
WalSyncPolicy::Group { .. } => Ok(()),
}
}
pub fn defer_sync(&self) -> WalSyncDeferral<'_> {
self.sync_deferral_depth.fetch_add(1, Ordering::AcqRel);
WalSyncDeferral { log: self }
}
#[cfg(test)]
pub(crate) fn wal_is_dirty(&self) -> bool {
self.wal_writer
.lock()
.as_ref()
.is_some_and(|state| state.dirty)
}
#[cfg(test)]
pub(crate) fn wal_sync_count(&self) -> usize {
self.wal_writer
.lock()
.as_ref()
.map_or(0, |state| state.sync_count)
}
pub fn read_all(&self) -> Result<Vec<LogRecord>> {
if !self.wal_storage.file_exists(&self.wal_path) {
let store_next = self.doc_store.read().next_doc_id();
self.set_next_doc_id(store_next);
return Ok(Vec::new());
}
let size = self.wal_storage.open_input(&self.wal_path)?.size()?;
let format = self.detect_existing_format(size)?;
let has_header = format != WalFormat::Legacy;
let mut reader = self.wal_storage.open_input(&self.wal_path)?;
let mut records = Vec::new();
let mut max_seq: u64 = 0;
let mut max_doc_id: u64 = 0;
let mut position = if has_header {
let mut header = [0u8; WAL_HEADER_LEN as usize];
reader.read_exact(&mut header)?;
WAL_HEADER_LEN
} else {
0
};
while position < size {
if position + 4 > size {
break;
}
let mut len_bytes = [0u8; 4];
reader.read_exact(&mut len_bytes)?;
let len = u32::from_le_bytes(len_bytes) as u64;
position += 4;
let crc_expected = if format.has_crc() {
if position + 4 > size {
break;
}
let mut crc_bytes = [0u8; 4];
reader.read_exact(&mut crc_bytes)?;
position += 4;
Some(u32::from_le_bytes(crc_bytes))
} else {
None
};
if position + len > size {
break;
}
let mut buffer = vec![0u8; len as usize];
reader.read_exact(&mut buffer)?;
position += len;
if let Some(expected) = crc_expected {
let mut hasher = crc32fast::Hasher::new();
hasher.update(&len_bytes);
hasher.update(&buffer);
if hasher.finalize() != expected {
::log::warn!(
"WAL recovery: CRC mismatch at byte offset {} ({len} body bytes); \
recovered {} valid record(s) before it",
position - len,
records.len()
);
break;
}
}
let record: LogRecord = match Self::decode_payload(&buffer, format) {
Ok(record) => record,
Err(e) => {
::log::warn!(
"WAL recovery: dropping corrupt trailing record at byte offset {} \
({len} body bytes): {e}; recovered {} valid record(s) before it",
position - len,
records.len()
);
break;
}
};
if record.seq > max_seq {
max_seq = record.seq;
}
if let LogEntry::Upsert { doc_id, .. } = &record.entry
&& *doc_id > max_doc_id
{
max_doc_id = *doc_id;
}
records.push(record);
}
let current_next_seq = self.next_seq.load(Ordering::SeqCst);
if max_seq >= current_next_seq {
self.next_seq.store(max_seq + 1, Ordering::SeqCst);
}
let current_next_doc = self.next_doc_id.load(Ordering::SeqCst);
if max_doc_id >= current_next_doc {
self.next_doc_id.store(max_doc_id + 1, Ordering::SeqCst);
}
let store_next = self.doc_store.read().next_doc_id();
self.set_next_doc_id(store_next);
Ok(records)
}
pub fn truncate(&self) -> Result<()> {
self.truncate_retaining_after(SeqNumber::MAX)
}
pub fn truncate_retaining_after(&self, retain_after_seq: SeqNumber) -> Result<()> {
let mut writer_guard = self.wal_writer.lock();
if let Some(mut state) = writer_guard.take() {
if state.dirty {
state.out.flush_and_sync()?;
}
state.out.close()?;
}
if self.last_seq() <= retain_after_seq {
let mut writer = self.wal_storage.create_output(&self.wal_path)?;
writer.flush_and_sync()?;
writer.close()?;
self.wal_storage.sync()?;
return Ok(());
}
let retained: Vec<LogRecord> = self
.read_all()?
.into_iter()
.filter(|record| record.seq > retain_after_seq)
.collect();
let (tmp_name, tmp_out) = self.wal_storage.create_temp_output(&self.wal_path)?;
let mut tmp_state = Some(WalWriterState {
out: tmp_out,
format: WalFormat::V3,
dirty: false,
unsynced_records: 0,
unsynced_bytes: 0,
#[cfg(test)]
sync_count: 0,
});
if let Some(state) = tmp_state.as_mut() {
state.out.write_all(WAL_MAGIC)?;
state.out.write_all(&[WAL_VERSION])?;
}
{
let _deferral = self.defer_sync();
for record in &retained {
self.write_record(&mut tmp_state, record)?;
}
}
if let Some(state) = tmp_state.as_mut() {
state.out.flush_and_sync()?;
state.out.close()?;
}
self.wal_storage.rename_file(&tmp_name, &self.wal_path)?;
self.wal_storage.sync()?;
Ok(())
}
pub fn last_seq(&self) -> SeqNumber {
self.next_seq.load(Ordering::SeqCst).saturating_sub(1)
}
pub fn next_doc_id(&self) -> u64 {
self.next_doc_id.load(Ordering::SeqCst)
}
pub fn set_next_doc_id(&self, id: u64) {
let current = self.next_doc_id.load(Ordering::SeqCst);
if id > current {
self.next_doc_id.store(id, Ordering::SeqCst);
}
}
pub fn store_document(&self, doc_id: u64, doc: Document) {
self.doc_store.write().put_document_with_id(doc_id, doc);
}
pub fn get_document(&self, doc_id: u64) -> Result<Option<Document>> {
self.doc_store.read().get_document(doc_id)
}
pub fn get_documents_batch(
&self,
doc_ids: &[u64],
) -> Result<std::collections::HashMap<u64, Document>> {
self.doc_store.read().get_documents_batch(doc_ids)
}
pub fn find_by_external_id(&self, external_id: &str) -> Result<Option<u64>> {
self.doc_store.read().find_by_external_id(external_id)
}
pub fn find_all_by_external_id(&self, external_id: &str) -> Result<Vec<u64>> {
self.doc_store.read().find_all_by_external_id(external_id)
}
pub fn commit_documents(&self) -> Result<()> {
self.doc_store.write().commit()
}
}
#[derive(Debug)]
pub struct WalSyncDeferral<'a> {
log: &'a DocumentLog,
}
impl Drop for WalSyncDeferral<'_> {
fn drop(&mut self) {
self.log.sync_deferral_depth.fetch_sub(1, Ordering::AcqRel);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::{DataValue, Document};
use crate::storage::memory::{MemoryStorage, MemoryStorageConfig};
fn make_storage() -> Arc<dyn Storage> {
Arc::new(MemoryStorage::new(MemoryStorageConfig::default()))
}
fn make_log() -> DocumentLog {
let wal_storage = make_storage();
let doc_storage = make_storage();
DocumentLog::new(wal_storage, "test.log", doc_storage).unwrap()
}
fn make_group_log(max_records: usize, max_bytes: usize) -> DocumentLog {
DocumentLog::with_sync_policy(
make_storage(),
"test.log",
make_storage(),
WalSyncPolicy::Group {
max_records,
max_bytes,
max_interval: None,
},
)
.unwrap()
}
fn small_doc() -> Document {
Document::builder()
.add_field("body", DataValue::Text("x".to_string()))
.build()
}
#[test]
fn test_append_and_read() {
let log = make_log();
let doc = Document::builder()
.add_field("body", DataValue::Text("hello".to_string()))
.build();
let (doc_id, seq1) = log.append("ext_1", doc.clone()).unwrap();
assert_eq!(doc_id, 1);
assert_eq!(seq1, 1);
let seq2 = log.append_delete(doc_id, "ext_1").unwrap();
assert_eq!(seq2, 2);
let records = log.read_all().unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0].seq, 1);
match &records[0].entry {
LogEntry::Upsert {
doc_id,
external_id,
..
} => {
assert_eq!(*doc_id, 1);
assert_eq!(external_id, "ext_1");
}
_ => panic!("Expected Upsert"),
}
assert_eq!(records[1].seq, 2);
match &records[1].entry {
LogEntry::Delete {
doc_id,
external_id,
} => {
assert_eq!(*doc_id, 1);
assert_eq!(external_id, "ext_1");
}
_ => panic!("Expected Delete"),
}
}
#[test]
fn test_truncate() {
let log = make_log();
let doc = Document::builder()
.add_field("body", DataValue::Text("hello".to_string()))
.build();
log.append("ext_1", doc).unwrap();
log.truncate().unwrap();
let records = log.read_all().unwrap();
assert!(records.is_empty());
let doc2 = Document::builder()
.add_field("body", DataValue::Text("world".to_string()))
.build();
let (doc_id, seq) = log.append("ext_2", doc2).unwrap();
assert_eq!(doc_id, 2);
assert_eq!(seq, 2);
}
#[test]
fn truncate_recreates_fresh_v3_wal() {
use std::io::Read as _;
let wal_storage = make_storage();
let doc_storage = make_storage();
let log = DocumentLog::new(wal_storage.clone(), "test.log", doc_storage).unwrap();
let doc = Document::builder()
.add_field("body", DataValue::Text("hello".to_string()))
.build();
log.append("ext_1", doc.clone()).unwrap();
log.append("ext_2", doc.clone()).unwrap();
log.truncate().unwrap();
assert_eq!(
wal_storage.open_input("test.log").unwrap().size().unwrap(),
0,
"truncate leaves an empty WAL file"
);
assert!(log.read_all().unwrap().is_empty());
log.append("ext_3", doc).unwrap();
let mut header = [0u8; WAL_HEADER_LEN as usize];
wal_storage
.open_input("test.log")
.unwrap()
.read_exact(&mut header)
.unwrap();
assert_eq!(&header[0..4], WAL_MAGIC);
assert_eq!(header[4], WAL_VERSION);
let records = log.read_all().unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].seq, 3);
}
#[test]
fn flush_wal_is_noop_without_writer() {
let log = make_log();
log.flush_wal().unwrap();
assert!(
log.wal_writer.lock().is_none(),
"flush_wal must not open a writer"
);
}
#[test]
fn append_leaves_writer_clean_so_flush_wal_is_noop() {
let log = make_log();
let doc = Document::builder()
.add_field("body", DataValue::Text("hello".to_string()))
.build();
log.append("ext_1", doc).unwrap();
assert!(
!log.wal_writer.lock().as_ref().unwrap().dirty,
"a per-record append leaves the writer synced/clean"
);
log.flush_wal().unwrap();
assert!(
!log.wal_writer.lock().as_ref().unwrap().dirty,
"flush_wal on a clean writer stays clean"
);
assert_eq!(log.read_all().unwrap().len(), 1);
}
#[test]
fn flush_wal_syncs_a_dirty_writer() {
let log = make_log();
let record = LogRecord {
seq: 1,
entry: LogEntry::Upsert {
doc_id: 1,
external_id: "ext_1".to_string(),
document: Document::builder()
.add_field("body", DataValue::Text("deferred".to_string()))
.build(),
},
};
{
let mut guard = log.wal_writer.lock();
log.ensure_writer(&mut guard).unwrap();
DocumentLog::append_record_bytes(&mut guard, &record).unwrap();
assert!(
guard.as_ref().unwrap().dirty,
"appended-but-unsynced bytes mark the writer dirty"
);
}
log.flush_wal().unwrap();
assert!(
!log.wal_writer.lock().as_ref().unwrap().dirty,
"flush_wal clears the dirty flag after syncing"
);
let records = log.read_all().unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].seq, 1);
}
#[test]
fn defer_sync_suppresses_per_record_fsync_until_flush() {
let log = make_log();
let deferral = log.defer_sync();
for i in 0..5 {
log.append(&format!("ext_{i}"), small_doc()).unwrap();
}
assert!(
log.wal_is_dirty(),
"deferred appends must stay unsynced until the batch-end flush"
);
assert_eq!(
log.wal_sync_count(),
0,
"no per-record fsync may run inside the deferral scope"
);
drop(deferral);
log.flush_wal().unwrap();
assert!(!log.wal_is_dirty());
assert_eq!(
log.wal_sync_count(),
1,
"the whole batch must amortize to exactly one fsync"
);
assert_eq!(log.read_all().unwrap().len(), 5);
}
#[test]
fn defer_sync_drop_restores_per_record_fsync() {
let log = make_log();
{
let _deferral = log.defer_sync();
log.append("ext_0", small_doc()).unwrap();
assert!(log.wal_is_dirty());
}
log.append("ext_1", small_doc()).unwrap();
assert!(
!log.wal_is_dirty(),
"the first append after the scope must sync itself and the leftover bytes"
);
assert_eq!(log.read_all().unwrap().len(), 2);
}
#[test]
fn defer_sync_keeps_group_thresholds_firing() {
let log = make_group_log(2, usize::MAX);
let _deferral = log.defer_sync();
log.append("ext_0", small_doc()).unwrap();
assert!(
log.wal_is_dirty(),
"below the record threshold the group batch stays unsynced"
);
log.append("ext_1", small_doc()).unwrap();
assert!(
!log.wal_is_dirty(),
"the group record threshold must fire despite the deferral scope"
);
assert_eq!(log.wal_sync_count(), 1);
}
#[test]
fn sync_policy_defaults() {
assert_eq!(WalSyncPolicy::default(), WalSyncPolicy::PerRecord);
assert_eq!(
WalSyncPolicy::group_with_defaults(),
WalSyncPolicy::Group {
max_records: DEFAULT_GROUP_MAX_RECORDS,
max_bytes: DEFAULT_GROUP_MAX_BYTES,
max_interval: None,
}
);
assert_eq!(WalSyncPolicy::group_with_defaults().flush_interval(), None);
assert_eq!(
WalSyncPolicy::group_with_interval(Duration::from_millis(50)).flush_interval(),
Some(Duration::from_millis(50))
);
}
#[test]
fn group_policy_defers_then_flushes_at_record_threshold() {
let log = make_group_log(3, usize::MAX);
log.append("e1", small_doc()).unwrap();
log.append("e2", small_doc()).unwrap();
{
let guard = log.wal_writer.lock();
let state = guard.as_ref().unwrap();
assert!(state.dirty, "appends under the threshold stay unsynced");
assert_eq!(state.unsynced_records, 2);
assert!(state.unsynced_bytes > 0);
}
log.append("e3", small_doc()).unwrap();
{
let guard = log.wal_writer.lock();
let state = guard.as_ref().unwrap();
assert!(
!state.dirty,
"reaching the record threshold flushes the batch"
);
assert_eq!(state.unsynced_records, 0);
assert_eq!(state.unsynced_bytes, 0);
}
assert_eq!(log.read_all().unwrap().len(), 3);
}
#[test]
fn group_policy_flushes_at_byte_threshold() {
let log = make_group_log(usize::MAX, 1);
log.append("e1", small_doc()).unwrap();
let guard = log.wal_writer.lock();
let state = guard.as_ref().unwrap();
assert!(
!state.dirty,
"a record past the byte threshold flushes immediately"
);
assert_eq!(state.unsynced_bytes, 0);
}
#[test]
fn group_partial_batch_is_made_durable_by_flush_wal() {
let log = make_group_log(1000, usize::MAX);
log.append("e1", small_doc()).unwrap();
log.append("e2", small_doc()).unwrap();
assert!(
log.wal_writer.lock().as_ref().unwrap().dirty,
"a partial batch stays unsynced"
);
log.flush_wal().unwrap();
{
let guard = log.wal_writer.lock();
let state = guard.as_ref().unwrap();
assert!(!state.dirty, "flush_wal forces the partial batch durable");
assert_eq!(state.unsynced_records, 0);
assert_eq!(state.unsynced_bytes, 0);
}
assert_eq!(log.read_all().unwrap().len(), 2);
}
#[test]
fn test_doc_id_recovery() {
let wal_storage = make_storage();
let doc_storage = make_storage();
{
let log =
DocumentLog::new(wal_storage.clone(), "test.log", doc_storage.clone()).unwrap();
let doc = Document::builder()
.add_field("body", DataValue::Text("hello".to_string()))
.build();
log.append("ext_1", doc.clone()).unwrap();
log.append("ext_2", doc).unwrap();
}
{
let log =
DocumentLog::new(wal_storage.clone(), "test.log", doc_storage.clone()).unwrap();
let records = log.read_all().unwrap();
assert_eq!(records.len(), 2);
assert_eq!(log.next_doc_id(), 3);
let doc = Document::builder()
.add_field("body", DataValue::Text("world".to_string()))
.build();
let (doc_id, seq) = log.append("ext_3", doc).unwrap();
assert_eq!(doc_id, 3);
assert_eq!(seq, 3);
}
}
fn frame(body: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(4 + body.len());
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(body);
out
}
#[test]
fn read_all_recovers_prefix_when_trailing_record_body_is_corrupt() {
use std::io::Write as _;
let wal_storage = make_storage();
let doc_storage = make_storage();
let valid = LogRecord {
seq: 1,
entry: LogEntry::Upsert {
doc_id: 1,
external_id: "ext_1".to_string(),
document: Document::builder()
.add_field("body", DataValue::Text("hello".to_string()))
.build(),
},
};
{
let mut out = wal_storage.create_output("test.log").unwrap();
out.write_all(&frame(&serde_json::to_vec(&valid).unwrap()))
.unwrap();
out.write_all(&frame(b"this is not valid json")).unwrap();
out.flush_and_sync().unwrap();
out.close().unwrap();
}
let log = DocumentLog::new(wal_storage, "test.log", doc_storage).unwrap();
let records = log.read_all().unwrap();
assert_eq!(records.len(), 1, "only the valid prefix is recovered");
assert_eq!(records[0].seq, 1);
assert_eq!(log.last_seq(), 1);
assert_eq!(log.next_doc_id(), 2);
let doc = Document::builder()
.add_field("body", DataValue::Text("next".to_string()))
.build();
let (doc_id, seq) = log.append("ext_2", doc).unwrap();
assert_eq!(doc_id, 2);
assert_eq!(seq, 2);
}
#[test]
fn read_all_recovers_nothing_when_first_record_is_corrupt() {
use std::io::Write as _;
let wal_storage = make_storage();
let doc_storage = make_storage();
{
let mut out = wal_storage.create_output("test.log").unwrap();
out.write_all(&frame(b"garbage")).unwrap();
out.flush_and_sync().unwrap();
out.close().unwrap();
}
let log = DocumentLog::new(wal_storage, "test.log", doc_storage).unwrap();
let records = log.read_all().unwrap();
assert!(records.is_empty(), "no valid prefix to recover");
}
#[test]
fn wal_v3_fresh_file_has_header_and_round_trips() {
use std::io::Read as _;
let wal_storage = make_storage();
let doc_storage = make_storage();
let log = DocumentLog::new(wal_storage.clone(), "test.log", doc_storage).unwrap();
let doc = Document::builder()
.add_field("body", DataValue::Text("hello".to_string()))
.build();
log.append("ext_1", doc).unwrap();
log.append_delete(1, "ext_1").unwrap();
let mut header = [0u8; WAL_HEADER_LEN as usize];
wal_storage
.open_input("test.log")
.unwrap()
.read_exact(&mut header)
.unwrap();
assert_eq!(&header[0..4], WAL_MAGIC);
assert_eq!(header[4], WAL_VERSION);
let records = log.read_all().unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0].seq, 1);
assert_eq!(records[1].seq, 2);
}
#[test]
fn wal_v3_crc_mismatch_recovers_prefix() {
use std::io::{Read as _, Write as _};
let wal_storage = make_storage();
let doc_storage = make_storage();
{
let log =
DocumentLog::new(wal_storage.clone(), "test.log", doc_storage.clone()).unwrap();
let doc = Document::builder()
.add_field("body", DataValue::Text("first".to_string()))
.build();
log.append("ext_1", doc.clone()).unwrap();
log.append("ext_2", doc).unwrap();
}
let mut bytes = Vec::new();
wal_storage
.open_input("test.log")
.unwrap()
.read_to_end(&mut bytes)
.unwrap();
let last = bytes.len() - 1;
bytes[last] ^= 0xFF;
{
let mut out = wal_storage.create_output("test.log").unwrap();
out.write_all(&bytes).unwrap();
out.flush_and_sync().unwrap();
out.close().unwrap();
}
let log = DocumentLog::new(wal_storage, "test.log", doc_storage).unwrap();
let records = log.read_all().unwrap();
assert_eq!(
records.len(),
1,
"CRC mismatch drops the corrupt 2nd record"
);
assert_eq!(records[0].seq, 1);
}
#[test]
fn legacy_wal_recovers_and_appends_stay_legacy() {
use std::io::{Read as _, Write as _};
let wal_storage = make_storage();
let doc_storage = make_storage();
let rec = LogRecord {
seq: 1,
entry: LogEntry::Upsert {
doc_id: 1,
external_id: "ext_1".to_string(),
document: Document::builder()
.add_field("body", DataValue::Text("legacy".to_string()))
.build(),
},
};
{
let mut out = wal_storage.create_output("test.log").unwrap();
out.write_all(&frame(&serde_json::to_vec(&rec).unwrap()))
.unwrap();
out.flush_and_sync().unwrap();
out.close().unwrap();
}
let log = DocumentLog::new(wal_storage.clone(), "test.log", doc_storage).unwrap();
let records = log.read_all().unwrap();
assert_eq!(records.len(), 1, "legacy record recovers");
let doc = Document::builder()
.add_field("body", DataValue::Text("more".to_string()))
.build();
log.append("ext_2", doc).unwrap();
let mut magic = [0u8; 4];
wal_storage
.open_input("test.log")
.unwrap()
.read_exact(&mut magic)
.unwrap();
assert_ne!(
&magic, WAL_MAGIC,
"appends must not switch a legacy file to v2 mid-file"
);
let records = log.read_all().unwrap();
assert_eq!(records.len(), 2);
}
fn frame_v2(body: &[u8]) -> Vec<u8> {
let len_bytes = (body.len() as u32).to_le_bytes();
let mut hasher = crc32fast::Hasher::new();
hasher.update(&len_bytes);
hasher.update(body);
let crc = hasher.finalize().to_le_bytes();
let mut out = Vec::with_capacity(4 + 4 + body.len());
out.extend_from_slice(&len_bytes);
out.extend_from_slice(&crc);
out.extend_from_slice(body);
out
}
fn write_v2_file(storage: &Arc<dyn Storage>, path: &str, records: &[LogRecord]) {
use std::io::Write as _;
let mut out = storage.create_output(path).unwrap();
out.write_all(WAL_MAGIC).unwrap();
out.write_all(&[WAL_VERSION_V2]).unwrap();
for rec in records {
out.write_all(&frame_v2(&serde_json::to_vec(rec).unwrap()))
.unwrap();
}
out.flush_and_sync().unwrap();
out.close().unwrap();
}
fn sample_record(seq: u64, doc_id: u64) -> LogRecord {
LogRecord {
seq,
entry: LogEntry::Upsert {
doc_id,
external_id: format!("ext_{doc_id}"),
document: Document::builder()
.add_field("title", DataValue::Text("hello world".to_string()))
.add_field("score", DataValue::Float64(1.5))
.add_field("embedding", DataValue::Vector(vec![0.25; 64]))
.build(),
},
}
}
#[test]
fn wal_v2_json_payload_recovers() {
let wal_storage = make_storage();
let doc_storage = make_storage();
let records = [sample_record(1, 1), sample_record(2, 2)];
write_v2_file(&wal_storage, "test.log", &records);
let log = DocumentLog::new(wal_storage, "test.log", doc_storage).unwrap();
let recovered = log.read_all().unwrap();
assert_eq!(recovered.len(), 2, "both v2 JSON records recover");
assert_eq!(recovered[0].seq, 1);
assert_eq!(recovered[1].seq, 2);
if let LogEntry::Upsert { document, .. } = &recovered[0].entry {
assert_eq!(
document.fields.get("embedding"),
Some(&DataValue::Vector(vec![0.25; 64]))
);
} else {
panic!("expected an Upsert");
}
}
#[test]
fn wal_v3_binary_round_trips_all_value_types() {
let wal_storage = make_storage();
let doc_storage = make_storage();
let log = DocumentLog::new(wal_storage, "test.log", doc_storage).unwrap();
let doc = Document::builder()
.add_field("title", DataValue::Text("hello".to_string()))
.add_field("count", DataValue::Int64(-7))
.add_field("score", DataValue::Float64(2.5))
.add_field("flag", DataValue::Bool(true))
.add_field("embedding", DataValue::Vector(vec![0.1, 0.2, 0.3, 0.4]))
.add_field("tags", DataValue::Int64Array(vec![1, 2, 3]))
.build();
log.append("ext_1", doc.clone()).unwrap();
log.append_delete(1, "ext_1").unwrap();
let records = log.read_all().unwrap();
assert_eq!(records.len(), 2);
match &records[0].entry {
LogEntry::Upsert {
doc_id,
external_id,
document,
} => {
assert_eq!(*doc_id, 1);
assert_eq!(external_id, "ext_1");
assert_eq!(document.fields, doc.fields, "all value types round-trip");
}
_ => panic!("expected Upsert first"),
}
match &records[1].entry {
LogEntry::Delete {
doc_id,
external_id,
} => {
assert_eq!(*doc_id, 1);
assert_eq!(external_id, "ext_1");
}
_ => panic!("expected Delete second"),
}
}
#[test]
fn wal_upgrade_v2_to_v3_after_truncate() {
use std::io::Read as _;
let wal_storage = make_storage();
let doc_storage = make_storage();
write_v2_file(&wal_storage, "test.log", &[sample_record(1, 1)]);
let log = DocumentLog::new(wal_storage.clone(), "test.log", doc_storage).unwrap();
assert_eq!(log.read_all().unwrap().len(), 1, "v2 record recovers");
log.truncate().unwrap();
let doc = Document::builder()
.add_field("embedding", DataValue::Vector(vec![0.5; 32]))
.build();
log.append("ext_2", doc).unwrap();
let mut header = [0u8; WAL_HEADER_LEN as usize];
wal_storage
.open_input("test.log")
.unwrap()
.read_exact(&mut header)
.unwrap();
assert_eq!(&header[0..4], WAL_MAGIC);
assert_eq!(header[4], WAL_VERSION, "recreated file is v3");
let records = log.read_all().unwrap();
assert_eq!(records.len(), 1, "post-upgrade v3 record recovers");
assert_eq!(records[0].seq, 2);
}
#[test]
fn wal_v3_payload_smaller_than_v2_for_vectors() {
let record = LogRecord {
seq: 1,
entry: LogEntry::Upsert {
doc_id: 1,
external_id: "doc-1".to_string(),
document: Document::builder()
.add_field(
"title",
DataValue::Text("a representative title".to_string()),
)
.add_field("embedding", DataValue::Vector(vec![0.123_456_7; 384]))
.build(),
},
};
let json = DocumentLog::encode_payload(&record, WalFormat::V2).unwrap();
let rkyv = DocumentLog::encode_payload(&record, WalFormat::V3).unwrap();
println!(
"WAL payload size (384-dim vector record): v2 JSON = {} B, v3 rkyv = {} B, ratio = {:.2}x",
json.len(),
rkyv.len(),
json.len() as f64 / rkyv.len() as f64,
);
assert!(
rkyv.len() < json.len(),
"v3 binary payload ({} B) must be smaller than v2 JSON ({} B)",
rkyv.len(),
json.len()
);
assert!(
rkyv.len() < json.len() / 2,
"expected a large reduction for vector-heavy records: v2 {} B vs v3 {} B",
json.len(),
rkyv.len()
);
}
#[test]
fn wal_v3_replay_time_vs_v2() {
use std::time::Instant;
const N: u64 = 2000;
let records: Vec<LogRecord> = (1..=N).map(|i| sample_record(i, i)).collect();
let v2_storage = make_storage();
let v2_doc = make_storage();
write_v2_file(&v2_storage, "test.log", &records);
let v2_log = DocumentLog::new(v2_storage, "test.log", v2_doc).unwrap();
let v3_storage = make_storage();
let v3_doc = make_storage();
let v3_log = DocumentLog::new(v3_storage, "test.log", v3_doc).unwrap();
for rec in &records {
if let LogEntry::Upsert {
external_id,
document,
..
} = &rec.entry
{
v3_log.append(external_id, document.clone()).unwrap();
}
}
let t0 = Instant::now();
let v2_recovered = v2_log.read_all().unwrap();
let v2_elapsed = t0.elapsed();
let t1 = Instant::now();
let v3_recovered = v3_log.read_all().unwrap();
let v3_elapsed = t1.elapsed();
assert_eq!(v2_recovered.len() as u64, N);
assert_eq!(v3_recovered.len() as u64, N);
println!(
"WAL replay ({N} vector records): v2 JSON = {:?}, v3 rkyv = {:?}, speedup = {:.2}x",
v2_elapsed,
v3_elapsed,
v2_elapsed.as_secs_f64() / v3_elapsed.as_secs_f64().max(f64::MIN_POSITIVE),
);
}
#[test]
fn test_set_next_doc_id() {
let log = make_log();
log.set_next_doc_id(100);
assert_eq!(log.next_doc_id(), 100);
log.set_next_doc_id(50);
assert_eq!(log.next_doc_id(), 100);
let doc = Document::builder()
.add_field("body", DataValue::Text("hello".to_string()))
.build();
let (doc_id, _) = log.append("ext_1", doc).unwrap();
assert_eq!(doc_id, 100);
}
#[test]
fn test_store_and_get_document() {
let log = make_log();
let doc = Document::builder()
.add_field("body", DataValue::Text("hello world".to_string()))
.build();
log.store_document(1, doc.clone());
let retrieved = log.get_document(1).unwrap();
assert!(retrieved.is_some());
assert_eq!(
retrieved.unwrap().fields.get("body"),
doc.fields.get("body")
);
log.commit_documents().unwrap();
let retrieved = log.get_document(1).unwrap();
assert!(retrieved.is_some());
}
fn append_named(log: &DocumentLog, external_id: &str) -> SeqNumber {
log.append(external_id, small_doc()).unwrap().1
}
fn surviving_ids(log: &DocumentLog) -> Vec<String> {
log.read_all()
.unwrap()
.into_iter()
.map(|r| match r.entry {
LogEntry::Upsert { external_id, .. } => external_id,
LogEntry::Delete { external_id, .. } => external_id,
})
.collect()
}
#[test]
fn truncate_retaining_after_keeps_only_later_records() {
let log = make_log();
append_named(&log, "a"); let retain_from = append_named(&log, "b"); append_named(&log, "c"); append_named(&log, "d");
log.truncate_retaining_after(retain_from).unwrap();
assert_eq!(
surviving_ids(&log),
vec!["c".to_string(), "d".to_string()],
"only records appended after the retain point must survive"
);
}
#[test]
fn truncate_retaining_after_is_a_full_truncate_when_nothing_raced() {
let log = make_log();
append_named(&log, "a");
let retain_from = append_named(&log, "b");
log.truncate_retaining_after(retain_from).unwrap();
assert!(
surviving_ids(&log).is_empty(),
"no record raced the retain point, so none should survive"
);
assert_eq!(
log.wal_storage.file_size(&log.wal_path).unwrap(),
0,
"the fast path must leave a zero-byte file, matching `truncate()`"
);
}
#[test]
fn truncate_still_wipes_everything() {
let log = make_log();
append_named(&log, "a");
append_named(&log, "b");
log.truncate().unwrap();
assert!(surviving_ids(&log).is_empty());
}
#[test]
fn truncate_retaining_after_preserves_a_racing_delete() {
let log = make_log();
let retain_from = append_named(&log, "a");
log.append_delete(1, "b").unwrap();
log.truncate_retaining_after(retain_from).unwrap();
assert_eq!(surviving_ids(&log), vec!["b".to_string()]);
}
#[test]
fn truncate_retaining_after_leaves_a_well_formed_wal_for_further_appends() {
let log = make_log();
let retain_from = append_named(&log, "a");
append_named(&log, "b");
log.truncate_retaining_after(retain_from).unwrap();
append_named(&log, "c");
assert_eq!(
surviving_ids(&log),
vec!["b".to_string(), "c".to_string()],
"a further append after the partial truncate must be readable \
alongside the retained tail"
);
}
}