use haematite::{ApiError, Database, DatabaseConfig, Event, EventStore};
use std::path::Path;
use std::sync::Arc;
use super::DurabilityError;
use super::counting::StoreCountsHandle;
use tempfile::TempDir;
#[derive(Clone, Debug)]
pub struct RoutedBatchItem {
pub key: Vec<u8>,
pub expected: Option<haematite::tree::Hash>,
pub value: Vec<u8>,
pub ttl: Option<std::time::Duration>,
}
#[derive(Clone)]
pub enum RoutedBatchEntry {
Put(RoutedBatchItem),
Delete {
key: Vec<u8>,
expected: haematite::tree::Hash,
},
}
impl RoutedBatchEntry {
#[must_use]
pub fn key(&self) -> &[u8] {
match self {
Self::Put(item) => &item.key,
Self::Delete { key, .. } => key,
}
}
}
impl std::fmt::Debug for RoutedBatchEntry {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Put(item) => formatter.debug_tuple("Put").field(item).finish(),
Self::Delete { key, expected } => formatter
.debug_struct("Delete")
.field("key", &String::from_utf8_lossy(key))
.field("expected", expected)
.finish(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredEntry {
pub payload: Vec<u8>,
pub sequence: u64,
pub timestamp: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ScanCursor {
pub stream_key: String,
pub next_sequence: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ScanPage {
pub entries: Vec<StoredEntry>,
pub next: Option<ScanCursor>,
}
#[async_trait::async_trait]
pub trait DurableStore: std::fmt::Debug + Send + Sync {
fn reclaim_spine(&self) -> Option<Arc<EventStore>> {
None
}
fn max_request_bytes(&self) -> Option<usize> {
None
}
async fn apply_routed_batch(
&self,
route_key: &str,
items: Vec<RoutedBatchItem>,
) -> Result<(), DurabilityError>;
async fn apply_routed_entries(
&self,
route_key: &str,
entries: Vec<RoutedBatchEntry>,
) -> Result<(), DurabilityError> {
let mut items = Vec::with_capacity(entries.len());
for entry in entries {
match entry {
RoutedBatchEntry::Put(item) => items.push(item),
RoutedBatchEntry::Delete { .. } => {
return Err(DurabilityError::RoutedDeleteUnsupported {
store: std::any::type_name::<Self>(),
route_key: route_key.to_owned(),
});
}
}
}
self.apply_routed_batch(route_key, items).await
}
async fn get_routed(
&self,
route_key: &str,
key: &[u8],
) -> Result<Option<Vec<u8>>, DurabilityError>;
async fn append(
&self,
stream_key: &str,
payload: Vec<u8>,
expected_seq: u64,
) -> Result<u64, DurabilityError>;
async fn read_from(
&self,
stream_key: &str,
offset: u64,
limit: usize,
) -> Result<Vec<StoredEntry>, DurabilityError>;
async fn append_all(
&self,
stream_key: &str,
payloads: Vec<Vec<u8>>,
expected_seq: u64,
) -> Result<u64, DurabilityError>;
async fn read_at(
&self,
stream_key: &str,
sequence: u64,
) -> Result<Option<StoredEntry>, DurabilityError> {
Ok(self
.read_from(stream_key, sequence, 1)
.await?
.into_iter()
.next())
}
async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError>;
async fn read_stream_next_seq(&self, stream_key: &str) -> Result<Option<u64>, DurabilityError>;
async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError>;
async fn scan_page(
&self,
prefix: &str,
cursor: Option<ScanCursor>,
limit: usize,
) -> Result<ScanPage, DurabilityError>;
async fn flush(&self) -> Result<(), DurabilityError>;
async fn truncate_before(
&self,
stream_key: &str,
before_seq: u64,
) -> Result<u64, DurabilityError> {
Err(DurabilityError::TruncateUnsupported {
store: std::any::type_name::<Self>(),
stream_key: stream_key.to_owned(),
before_seq,
})
}
async fn scan_routed(
&self,
route_key: &str,
_from: &[u8],
_to: &[u8],
) -> Result<Vec<(Vec<u8>, Vec<u8>)>, DurabilityError> {
Err(DurabilityError::RoutedScanUnsupported {
store: std::any::type_name::<Self>(),
route_key: route_key.to_owned(),
})
}
async fn remove_routed_range(
&self,
route_key: &str,
_from: &[u8],
_to: &[u8],
) -> Result<u64, DurabilityError> {
Err(DurabilityError::RoutedRemoveUnsupported {
store: std::any::type_name::<Self>(),
route_key: route_key.to_owned(),
})
}
fn store_counts(&self) -> Option<StoreCountsHandle> {
None
}
}
#[derive(Clone, Debug)]
pub struct HaematiteStore {
event_store: Arc<EventStore>,
}
impl HaematiteStore {
#[must_use]
pub const fn new(event_store: Arc<EventStore>) -> Self {
Self { event_store }
}
fn bounded_page(
&self,
stream_key: &str,
offset: u64,
limit: usize,
) -> Result<Option<Vec<StoredEntry>>, DurabilityError> {
const TIMESTAMP_WIDTH: usize = std::mem::size_of::<u64>();
let Some(engine_from) = offset.checked_add(1) else {
return Ok(None);
};
let Some(engine_end) = u64::try_from(limit)
.ok()
.and_then(|limit| engine_from.checked_add(limit))
else {
return Ok(None);
};
let key = stream_key.as_bytes();
let from = haematite::encode_stream_key(key, engine_from);
let to = haematite::encode_stream_key(key, engine_end);
let entries = self
.event_store
.database()
.range_routed(key, &from, &to)
.map_err(ApiError::from)
.map_err(DurabilityError::from)?;
if entries.len() != limit {
return Ok(None);
}
let mut page = Vec::with_capacity(entries.len());
for (encoded_key, value) in entries {
let Some((decoded_key, engine_sequence)) = haematite::decode_stream_key(&encoded_key)
else {
return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
format!("paged read key does not encode an event for stream {stream_key}"),
)));
};
if decoded_key != key {
return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
format!("paged read key does not encode stream {stream_key}"),
)));
}
let sequence = engine_sequence.checked_sub(1).ok_or_else(|| {
DurabilityError::StoreError(ApiError::CorruptEvent(format!(
"paged read event key has zero seq for stream {stream_key}"
)))
})?;
let Some(timestamp_bytes) = value.get(..TIMESTAMP_WIDTH) else {
return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
format!(
"paged read event value is shorter than its timestamp for stream {stream_key}"
),
)));
};
let timestamp = u64::from_be_bytes(timestamp_bytes.try_into().map_err(|_| {
DurabilityError::StoreError(ApiError::CorruptEvent(format!(
"paged read event timestamp has the wrong width for stream {stream_key}"
)))
})?);
let Some(payload) = value.get(TIMESTAMP_WIDTH..) else {
return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
format!("paged read event has no payload boundary for stream {stream_key}"),
)));
};
page.push(StoredEntry {
payload: payload.to_vec(),
sequence,
timestamp,
});
}
Ok(Some(page))
}
}
#[async_trait::async_trait]
impl DurableStore for HaematiteStore {
fn reclaim_spine(&self) -> Option<Arc<EventStore>> {
Some(Arc::clone(&self.event_store))
}
fn max_request_bytes(&self) -> Option<usize> {
Some(self.event_store.database().queue_byte_limit().get())
}
async fn apply_routed_batch(
&self,
route_key: &str,
items: Vec<RoutedBatchItem>,
) -> Result<(), DurabilityError> {
let items = items
.into_iter()
.map(|item| haematite::RoutedBatchItem {
key: item.key,
expected: item.expected,
value: item.value,
ttl: item.ttl,
})
.collect();
self.event_store
.database()
.apply_routed_batch(route_key.as_bytes(), items)
.map_err(ApiError::from)
.map_err(DurabilityError::from)
}
async fn apply_routed_entries(
&self,
route_key: &str,
entries: Vec<RoutedBatchEntry>,
) -> Result<(), DurabilityError> {
let entries = entries
.into_iter()
.map(|entry| match entry {
RoutedBatchEntry::Put(item) => {
haematite::RoutedBatchEntry::Put(haematite::RoutedBatchItem {
key: item.key,
expected: item.expected,
value: item.value,
ttl: item.ttl,
})
}
RoutedBatchEntry::Delete { key, expected } => {
haematite::RoutedBatchEntry::Delete { key, expected }
}
})
.collect();
self.event_store
.database()
.apply_routed_entries(route_key.as_bytes(), entries)
.map_err(local_batch_error)
}
async fn get_routed(
&self,
route_key: &str,
key: &[u8],
) -> Result<Option<Vec<u8>>, DurabilityError> {
self.event_store
.database()
.get_routed(route_key.as_bytes(), key)
.map_err(ApiError::from)
.map_err(DurabilityError::from)
}
async fn append(
&self,
stream_key: &str,
payload: Vec<u8>,
expected_seq: u64,
) -> Result<u64, DurabilityError> {
let next_seq = self
.event_store
.append(stream_key.as_bytes(), &payload, expected_seq)
.map_err(DurabilityError::from)?;
next_seq.checked_sub(1).ok_or_else(|| {
DurabilityError::StoreError(ApiError::CorruptEvent(format!(
"append returned next-seq 0 for stream {stream_key}"
)))
})
}
async fn append_all(
&self,
stream_key: &str,
payloads: Vec<Vec<u8>>,
expected_seq: u64,
) -> Result<u64, DurabilityError> {
let borrowed: Vec<&[u8]> = payloads.iter().map(Vec::as_slice).collect();
self.event_store
.append_batch(stream_key.as_bytes(), &borrowed, expected_seq)
.map_err(DurabilityError::from)
}
async fn read_from(
&self,
stream_key: &str,
offset: u64,
limit: usize,
) -> Result<Vec<StoredEntry>, DurabilityError> {
if limit > 0 {
if let Some(page) = self.bounded_page(stream_key, offset, limit)? {
account_engine_read(page.len(), false);
return Ok(page);
}
}
let mut events = self
.event_store
.read_from(stream_key.as_bytes(), offset)
.map_err(DurabilityError::from)?;
account_engine_read(events.len(), true);
events.truncate(limit);
Ok(events.into_iter().map(StoredEntry::from).collect())
}
async fn read_at(
&self,
stream_key: &str,
sequence: u64,
) -> Result<Option<StoredEntry>, DurabilityError> {
const TIMESTAMP_WIDTH: usize = std::mem::size_of::<u64>();
let engine_sequence = sequence.checked_add(1).ok_or_else(|| {
DurabilityError::StoreError(ApiError::CorruptEvent(format!(
"point read sequence overflow for stream {stream_key}"
)))
})?;
let event_key = haematite::encode_stream_key(stream_key.as_bytes(), engine_sequence);
let Some(value) = self
.event_store
.database()
.get_routed(stream_key.as_bytes(), &event_key)
.map_err(ApiError::from)
.map_err(DurabilityError::from)?
else {
return Ok(None);
};
let Some(timestamp_bytes) = value.get(..TIMESTAMP_WIDTH) else {
return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
format!(
"point-read event value is shorter than its timestamp for stream {stream_key}"
),
)));
};
let timestamp = u64::from_be_bytes(timestamp_bytes.try_into().map_err(|_| {
DurabilityError::StoreError(ApiError::CorruptEvent(format!(
"point-read event timestamp has the wrong width for stream {stream_key}"
)))
})?);
let Some(payload) = value.get(TIMESTAMP_WIDTH..) else {
return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
format!("point-read event has no payload boundary for stream {stream_key}"),
)));
};
Ok(Some(StoredEntry {
payload: payload.to_vec(),
sequence,
timestamp,
}))
}
async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
if new_value == 0 {
return self
.event_store
.read_value(key.as_bytes())
.map_err(DurabilityError::from)?
.map_or(Ok(()), |stored| {
Err(DurabilityError::CursorRegression {
stored,
attempted: old_value,
})
});
}
let expected = if old_value == 0 {
None
} else {
Some(old_value)
};
self.event_store
.cas(key.as_bytes(), expected, new_value)
.map_err(DurabilityError::from)
}
async fn read_stream_next_seq(&self, stream_key: &str) -> Result<Option<u64>, DurabilityError> {
self.event_store
.read_stream_next_seq(stream_key.as_bytes())
.map_err(DurabilityError::from)
}
async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
self.event_store
.read_value(key.as_bytes())
.map_err(DurabilityError::from)
}
async fn scan_page(
&self,
prefix: &str,
cursor: Option<ScanCursor>,
limit: usize,
) -> Result<ScanPage, DurabilityError> {
if limit == 0 {
return Err(DurabilityError::ConfigError(
"scan_page limit must be nonzero".to_owned(),
));
}
let mut entries: Vec<StoredEntry> = Vec::new();
let mut resume_at: Option<ScanCursor> = None;
let mut after: Option<String> = if let Some(cursor) = cursor {
if cursor.stream_key.starts_with(prefix) {
let page = self
.read_from(&cursor.stream_key, cursor.next_sequence, limit)
.await?;
resume_at = scan_resume_position(&cursor.stream_key, &page)?.or(resume_at);
entries.extend(page);
}
Some(cursor.stream_key)
} else {
None
};
while let Some(remaining) = limit
.checked_sub(entries.len())
.and_then(std::num::NonZeroUsize::new)
{
let streams = self
.event_store
.scan_prefix(
prefix.as_bytes(),
remaining,
after.as_deref().map(str::as_bytes),
)
.map_err(DurabilityError::from)?;
account_stream_enumeration(streams.streams.len());
let exhausted = streams.next_after.is_none() || streams.streams.is_empty();
for stream in streams.streams {
let stream_key = String::from_utf8(stream.stream_key).map_err(|error| {
DurabilityError::StoreError(ApiError::CorruptEvent(format!(
"scanned stream key is not utf-8: {error}"
)))
})?;
let Some(remaining) = limit.checked_sub(entries.len()).filter(|rest| *rest > 0)
else {
break;
};
let page = self.read_from(&stream_key, 0, remaining).await?;
resume_at = scan_resume_position(&stream_key, &page)?.or(resume_at);
entries.extend(page);
after = Some(stream_key);
}
if exhausted {
break;
}
}
let next = if entries.len() == limit {
resume_at
} else {
None
};
Ok(ScanPage { entries, next })
}
async fn flush(&self) -> Result<(), DurabilityError> {
self.event_store.flush().map_err(DurabilityError::from)
}
async fn truncate_before(
&self,
stream_key: &str,
before_seq: u64,
) -> Result<u64, DurabilityError> {
self.event_store
.truncate_stream(stream_key.as_bytes(), before_seq)
.map_err(DurabilityError::from)
}
async fn scan_routed(
&self,
route_key: &str,
from: &[u8],
to: &[u8],
) -> Result<Vec<(Vec<u8>, Vec<u8>)>, DurabilityError> {
self.event_store
.database()
.range_routed(route_key.as_bytes(), from, to)
.map_err(ApiError::from)
.map_err(DurabilityError::from)
}
async fn remove_routed_range(
&self,
route_key: &str,
from: &[u8],
to: &[u8],
) -> Result<u64, DurabilityError> {
let cut = self
.event_store
.database()
.delete_routed_range(route_key.as_bytes(), from, to)
.map_err(ApiError::from)
.map_err(DurabilityError::from)?;
Ok(u64::from(cut))
}
}
fn scan_resume_position(
stream_key: &str,
page: &[StoredEntry],
) -> Result<Option<ScanCursor>, DurabilityError> {
let Some(last) = page.last() else {
return Ok(None);
};
let next_sequence = last.sequence.checked_add(1).ok_or_else(|| {
DurabilityError::StoreError(ApiError::CorruptEvent(format!(
"scan page sequence overflow for stream {stream_key}"
)))
})?;
Ok(Some(ScanCursor {
stream_key: stream_key.to_owned(),
next_sequence,
}))
}
#[derive(Debug)]
struct EphemeralGuard<S> {
ownership: Option<super::ephemeral_root::EphemeralOwnership>,
store: Option<S>,
dir: Option<TempDir>,
}
impl<S> Drop for EphemeralGuard<S> {
fn drop(&mut self) {
let store = self.store.take();
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(store)));
if let Err(panic) = outcome {
if let Some(dir) = self.dir.take() {
let leaked = dir.keep();
tracing::error!(
path = %leaked.display(),
"ephemeral store drop panicked; leaking its directory rather than \
removing it under possibly-live database workers"
);
}
if let Some(ownership) = self.ownership.take() {
std::mem::forget(ownership);
}
std::panic::resume_unwind(panic);
}
if let Some(dir) = self.dir.take() {
let path = dir.path().to_path_buf();
if let Err(error) = dir.close() {
tracing::error!(
path = %path.display(),
%error,
"ephemeral store directory removal failed; residue remains at the \
logged path"
);
}
}
}
}
#[derive(Debug)]
pub struct EphemeralHaematiteStore {
guard: EphemeralGuard<HaematiteStore>,
}
impl EphemeralHaematiteStore {
fn new(database: Database, ephemeral_dir: TempDir) -> Self {
Self {
guard: EphemeralGuard {
ownership: None,
store: Some(HaematiteStore::new(Arc::new(EventStore::new(database)))),
dir: Some(ephemeral_dir),
},
}
}
fn store(&self) -> Result<&HaematiteStore, DurabilityError> {
self.guard
.store
.as_ref()
.ok_or(DurabilityError::EphemeralStoreDetached)
}
fn ephemeral_dir_path_owned(&self) -> Result<&Path, DurabilityError> {
self.guard
.dir
.as_ref()
.map(TempDir::path)
.ok_or(DurabilityError::EphemeralStoreDetached)
}
#[cfg(test)]
pub(crate) fn ephemeral_dir_path(&self) -> Option<&Path> {
self.guard.dir.as_ref().map(TempDir::path)
}
}
#[async_trait::async_trait]
impl DurableStore for EphemeralHaematiteStore {
fn max_request_bytes(&self) -> Option<usize> {
self.store().ok().and_then(DurableStore::max_request_bytes)
}
async fn apply_routed_batch(
&self,
route_key: &str,
items: Vec<crate::durability::RoutedBatchItem>,
) -> Result<(), crate::durability::DurabilityError> {
self.store()?.apply_routed_batch(route_key, items).await
}
async fn apply_routed_entries(
&self,
route_key: &str,
entries: Vec<RoutedBatchEntry>,
) -> Result<(), crate::durability::DurabilityError> {
self.store()?.apply_routed_entries(route_key, entries).await
}
async fn get_routed(
&self,
route_key: &str,
key: &[u8],
) -> Result<Option<Vec<u8>>, crate::durability::DurabilityError> {
self.store()?.get_routed(route_key, key).await
}
async fn append(
&self,
stream_key: &str,
payload: Vec<u8>,
expected_seq: u64,
) -> Result<u64, DurabilityError> {
self.store()?
.append(stream_key, payload, expected_seq)
.await
}
async fn append_all(
&self,
stream_key: &str,
payloads: Vec<Vec<u8>>,
expected_seq: u64,
) -> Result<u64, DurabilityError> {
self.store()?
.append_all(stream_key, payloads, expected_seq)
.await
}
async fn read_from(
&self,
stream_key: &str,
offset: u64,
limit: usize,
) -> Result<Vec<StoredEntry>, DurabilityError> {
self.store()?.read_from(stream_key, offset, limit).await
}
async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
self.store()?.cas(key, old_value, new_value).await
}
async fn read_stream_next_seq(&self, stream_key: &str) -> Result<Option<u64>, DurabilityError> {
self.store()?.read_stream_next_seq(stream_key).await
}
async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
self.store()?.read_value(key).await
}
async fn scan_page(
&self,
prefix: &str,
cursor: Option<ScanCursor>,
limit: usize,
) -> Result<ScanPage, DurabilityError> {
self.store()?.scan_page(prefix, cursor, limit).await
}
async fn flush(&self) -> Result<(), DurabilityError> {
self.store()?.flush().await
}
async fn truncate_before(
&self,
stream_key: &str,
before_seq: u64,
) -> Result<u64, DurabilityError> {
self.store()?.truncate_before(stream_key, before_seq).await
}
async fn scan_routed(
&self,
route_key: &str,
from: &[u8],
to: &[u8],
) -> Result<Vec<(Vec<u8>, Vec<u8>)>, DurabilityError> {
self.store()?.scan_routed(route_key, from, to).await
}
async fn remove_routed_range(
&self,
route_key: &str,
from: &[u8],
to: &[u8],
) -> Result<u64, DurabilityError> {
self.store()?.remove_routed_range(route_key, from, to).await
}
}
#[cfg(any(test, feature = "test-support"))]
pub fn open_ephemeral(shard_count: usize) -> Result<EphemeralHaematiteStore, DurabilityError> {
open_ephemeral_in(ephemeral_tempdir(&std::env::temp_dir())?, shard_count)
}
#[cfg(any(test, feature = "test-support"))]
pub fn open_ephemeral_rooted(
root: &Path,
shard_count: usize,
) -> Result<EphemeralHaematiteStore, DurabilityError> {
open_ephemeral_in(ephemeral_tempdir(root)?, shard_count)
}
pub(super) fn open_ephemeral_owned(
root: Arc<super::EphemeralRoot>,
shard_count: usize,
) -> Result<EphemeralHaematiteStore, DurabilityError> {
let mut store = open_ephemeral_in(ephemeral_tempdir(root.path())?, shard_count)?;
let path = store.ephemeral_dir_path_owned()?;
let holder = super::ephemeral_root::lock_file(&path.join("holder.lock"))?;
rustix::fs::flock(
&holder,
rustix::fs::FlockOperation::NonBlockingLockExclusive,
)
.map_err(super::ephemeral_root::root_error)?;
store.guard.ownership = Some(super::ephemeral_root::EphemeralOwnership { root, holder });
Ok(store)
}
fn ephemeral_tempdir(root: &Path) -> Result<TempDir, DurabilityError> {
let mut builder = tempfile::Builder::new();
builder.prefix("liminal-durability-");
builder.tempdir_in(root).map_err(|error| {
DurabilityError::EphemeralStoreOpen(format!(
"could not create temporary directory: {error}"
))
})
}
fn open_ephemeral_in(
ephemeral_dir: TempDir,
shard_count: usize,
) -> Result<EphemeralHaematiteStore, DurabilityError> {
let database = Database::create(
DatabaseConfig {
data_dir: ephemeral_dir.path().to_path_buf(),
shard_count,
distributed: None,
executor_threads: None,
node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
retention: haematite::Retention::reclaim(0),
},
haematite::RuntimeOptions {
long_wait_interval: std::time::Duration::from_secs(5),
reclaim_start: haematite::ReclaimStart::AtOpen,
},
)
.map_err(|error| DurabilityError::EphemeralStoreOpen(error.to_string()))?;
super::counting::record_store_opened();
Ok(EphemeralHaematiteStore::new(database, ephemeral_dir))
}
#[cfg(test)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct EngineReadAccounting {
pub(crate) calls: usize,
pub(crate) engine_entries: usize,
pub(crate) unbounded_calls: usize,
pub(crate) streams_enumerated: usize,
pub(crate) counter_overflow_observed: bool,
}
#[cfg(test)]
std::thread_local! {
static ENGINE_READ_ACCOUNTING: std::cell::RefCell<Option<EngineReadAccounting>> =
const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
pub(crate) struct EngineReadAccountingGuard {
_not_send: std::marker::PhantomData<*const ()>,
}
#[cfg(test)]
impl EngineReadAccountingGuard {
pub(crate) fn start() -> Self {
ENGINE_READ_ACCOUNTING.with(|accounting| {
*accounting.borrow_mut() = Some(EngineReadAccounting::default());
});
Self {
_not_send: std::marker::PhantomData,
}
}
#[allow(clippy::unused_self)]
pub(crate) fn snapshot(&self) -> EngineReadAccounting {
ENGINE_READ_ACCOUNTING
.with(|accounting| accounting.borrow().as_ref().copied().unwrap_or_default())
}
}
#[cfg(test)]
impl Drop for EngineReadAccountingGuard {
fn drop(&mut self) {
ENGINE_READ_ACCOUNTING.with(|accounting| {
*accounting.borrow_mut() = None;
});
}
}
#[cfg(test)]
fn account_engine_read(engine_entries: usize, unbounded: bool) {
ENGINE_READ_ACCOUNTING.with(|accounting| {
if let Some(active) = accounting.borrow_mut().as_mut() {
match (
active.calls.checked_add(1),
active.engine_entries.checked_add(engine_entries),
) {
(Some(calls), Some(entries)) => {
active.calls = calls;
active.engine_entries = entries;
}
_ => active.counter_overflow_observed = true,
}
if unbounded {
match active.unbounded_calls.checked_add(1) {
Some(unbounded_calls) => active.unbounded_calls = unbounded_calls,
None => active.counter_overflow_observed = true,
}
}
}
});
}
#[cfg(not(test))]
const fn account_engine_read(_engine_entries: usize, _unbounded: bool) {}
#[cfg(test)]
fn account_stream_enumeration(streams: usize) {
ENGINE_READ_ACCOUNTING.with(|accounting| {
if let Some(active) = accounting.borrow_mut().as_mut() {
match active.streams_enumerated.checked_add(streams) {
Some(enumerated) => active.streams_enumerated = enumerated,
None => active.counter_overflow_observed = true,
}
}
});
}
#[cfg(not(test))]
const fn account_stream_enumeration(_streams: usize) {}
#[cfg(test)]
pub(crate) fn page_in_memory_streams(
streams: &std::collections::HashMap<String, Vec<StoredEntry>>,
prefix: &str,
cursor: Option<&ScanCursor>,
limit: usize,
) -> Result<ScanPage, DurabilityError> {
if limit == 0 {
return Err(DurabilityError::ConfigError(
"scan_page limit must be nonzero".to_owned(),
));
}
let mut stream_keys: Vec<&String> = streams
.keys()
.filter(|stream_key| stream_key.starts_with(prefix))
.collect();
stream_keys.sort_unstable();
let mut entries: Vec<StoredEntry> = Vec::new();
let mut resume_at: Option<ScanCursor> = None;
for stream_key in stream_keys {
let Some(remaining) = limit.checked_sub(entries.len()).filter(|rest| *rest > 0) else {
break;
};
let offset = match cursor {
Some(cursor) if *stream_key < cursor.stream_key => continue,
Some(cursor) if *stream_key == cursor.stream_key => cursor.next_sequence,
_ => 0,
};
let Some(stream) = streams.get(stream_key) else {
continue;
};
let page: Vec<StoredEntry> = stream
.iter()
.filter(|entry| entry.sequence >= offset)
.take(remaining)
.cloned()
.collect();
if let Some(last) = page.last() {
let next_sequence = last.sequence.checked_add(1).ok_or_else(|| {
DurabilityError::ConfigError(format!(
"fake scan page sequence overflow for stream {stream_key}"
))
})?;
resume_at = Some(ScanCursor {
stream_key: stream_key.clone(),
next_sequence,
});
}
entries.extend(page);
}
let next = if entries.len() == limit {
resume_at
} else {
None
};
Ok(ScanPage { entries, next })
}
impl From<Event> for StoredEntry {
fn from(event: Event) -> Self {
Self {
payload: event.payload,
sequence: event.seq,
timestamp: event.timestamp,
}
}
}
fn local_batch_error(error: haematite::LocalBatchError) -> DurabilityError {
match error {
haematite::LocalBatchError::CasMismatch { expected, actual } => {
DurabilityError::StoreError(ApiError::Storage(
haematite::DatabaseError::CasHashMismatch { expected, actual },
))
}
haematite::LocalBatchError::OutcomeUnknown { shard, .. } => {
DurabilityError::OutcomeUnknown {
pid: u64::try_from(shard).unwrap_or(u64::MAX),
}
}
other => DurabilityError::ConfigError(format!("routed batch refused: {other}")),
}
}
impl From<ApiError> for DurabilityError {
fn from(error: ApiError) -> Self {
match error {
ApiError::SequenceConflict(conflict) => conflict.into(),
ApiError::CasMismatch(mismatch) => mismatch.into(),
ApiError::Storage(haematite::DatabaseError::OutcomeUnknown { pid }) => {
Self::OutcomeUnknown { pid }
}
other @ (ApiError::CorruptEvent(_)
| ApiError::Storage(_)
| ApiError::HistoryCompacted(_)) => Self::StoreError(other),
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod ephemeral_lifecycle_tests {
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use super::super::bridge::block_on;
use super::{
DurableStore, EphemeralGuard, open_ephemeral, open_ephemeral_in, open_ephemeral_rooted,
};
const TEST_SHARD_COUNT: usize = 2;
#[derive(Clone, Default)]
struct CapturedLog(Arc<Mutex<Vec<u8>>>);
impl CapturedLog {
fn text(&self) -> String {
let bytes = self
.0
.lock()
.expect("capture buffer is not poisoned")
.clone();
String::from_utf8(bytes).expect("tracing's fmt writer emits utf-8")
}
fn capturing<R>(&self, body: impl FnOnce() -> R) -> R {
static INSTALL: std::sync::Once = std::sync::Once::new();
struct ResetOnDrop;
impl Drop for ResetOnDrop {
fn drop(&mut self) {
ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = None);
}
}
INSTALL.call_once(|| {
let subscriber = tracing_subscriber::fmt()
.with_writer(RoutedWriter)
.with_ansi(false)
.finish();
tracing::subscriber::set_global_default(subscriber)
.expect("no other global tracing subscriber is installed in this test binary");
});
ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = Some(self.clone()));
let _reset = ResetOnDrop;
body()
}
}
thread_local! {
static ACTIVE_CAPTURE: std::cell::RefCell<Option<CapturedLog>> =
const { std::cell::RefCell::new(None) };
}
#[derive(Clone, Copy, Default)]
struct RoutedWriter;
impl std::io::Write for RoutedWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
ACTIVE_CAPTURE.with(|slot| {
if let Some(capture) = slot.borrow().as_ref() {
capture
.0
.lock()
.map_err(|_| std::io::Error::other("capture buffer poisoned"))?
.extend_from_slice(buf);
}
Ok(buf.len())
})
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for RoutedWriter {
type Writer = Self;
fn make_writer(&'writer self) -> Self::Writer {
*self
}
}
#[cfg(unix)]
fn set_mode(path: &Path, mode: u32) {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
.expect("test can set permissions on a directory it created");
}
struct OrderProbeStore {
dir: PathBuf,
}
impl Drop for OrderProbeStore {
fn drop(&mut self) {
assert!(
self.dir.exists(),
"the guard must drop the store BEFORE removing the directory"
);
}
}
struct PanickingProbeStore;
impl Drop for PanickingProbeStore {
fn drop(&mut self) {
panic!("injected store-drop panic");
}
}
fn write_one_event(store: &dyn DurableStore) {
block_on(store.append("lifecycle/probe", b"payload".to_vec(), 0))
.expect("bridge completes synchronously")
.expect("append to a fresh ephemeral stream succeeds");
block_on(store.flush())
.expect("bridge completes synchronously")
.expect("flush of a live ephemeral store succeeds");
}
#[test]
fn ephemeral_dir_removed_after_last_handle_drops() {
let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
let dir = store
.ephemeral_dir_path()
.expect("ephemeral store carries a guard dir")
.to_path_buf();
assert!(
dir.exists(),
"the guard directory exists while the store is live"
);
write_one_event(&store);
drop(store);
assert!(
!dir.exists(),
"the guard directory is removed on normal drop"
);
}
#[test]
fn ephemeral_dir_survives_until_last_store_clone_drops() {
let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
let dir = store
.ephemeral_dir_path()
.expect("ephemeral store carries a guard dir")
.to_path_buf();
write_one_event(&store);
let erased: Arc<dyn DurableStore> = Arc::new(store);
let clone_a = Arc::clone(&erased);
let clone_b = Arc::clone(&erased);
drop(erased);
assert!(
dir.exists(),
"directory survives while store clones remain alive"
);
drop(clone_a);
assert!(
dir.exists(),
"directory survives while one store clone remains alive"
);
drop(clone_b);
assert!(
!dir.exists(),
"the last store clone dropping removes the directory"
);
}
#[test]
fn ephemeral_open_failure_rolls_back_directory() {
let seeded = tempfile::Builder::new()
.prefix("liminal-durability-test-")
.tempdir()
.expect("test can create a temp dir");
let dir = seeded.path().to_path_buf();
std::fs::write(dir.join("config.json"), b"not-a-valid-config")
.expect("test can seed a conflicting config");
let result = open_ephemeral_in(seeded, TEST_SHARD_COUNT);
assert!(result.is_err(), "an injected open failure returns Err");
assert!(
!dir.exists(),
"the guard removes the directory on open failure — zero residue"
);
}
#[test]
fn repeated_ephemeral_cycles_each_own_distinct_dir_zero_residue() {
let mut seen: Vec<PathBuf> = Vec::new();
for _ in 0..5 {
let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
let dir = store
.ephemeral_dir_path()
.expect("ephemeral store carries a guard dir")
.to_path_buf();
assert!(
dir.exists(),
"the cycle's directory exists while its store is live"
);
assert!(!seen.contains(&dir), "each cycle owns a distinct directory");
seen.push(dir.clone());
write_one_event(&store);
drop(store);
assert!(
!dir.exists(),
"the cycle's directory is removed after its store drops"
);
}
}
#[test]
fn guard_drops_store_before_removing_directory() {
let dir = tempfile::tempdir().expect("test can create a temp dir");
let path = dir.path().to_path_buf();
let guard = EphemeralGuard {
ownership: None,
store: Some(OrderProbeStore { dir: path.clone() }),
dir: Some(dir),
};
drop(guard);
assert!(!path.exists(), "a clean drop still removes the directory");
}
#[test]
fn guard_leaks_directory_when_store_drop_panics() {
let dir = tempfile::tempdir().expect("test can create a temp dir");
let path = dir.path().to_path_buf();
let guard = EphemeralGuard {
ownership: None,
store: Some(PanickingProbeStore),
dir: Some(dir),
};
let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(guard)));
assert!(unwound.is_err(), "the injected store-drop panic propagates");
assert!(
path.exists(),
"a panicking store drop leaks the directory instead of removing it"
);
std::fs::remove_dir_all(&path).expect("test cleans up the deliberately leaked directory");
}
#[test]
fn rooted_ephemeral_store_lives_and_dies_under_the_given_root() {
let root = tempfile::tempdir().expect("test can create a temp root");
let store =
open_ephemeral_rooted(root.path(), TEST_SHARD_COUNT).expect("rooted open succeeds");
let dir = store
.ephemeral_dir_path()
.expect("ephemeral store carries a guard dir")
.to_path_buf();
assert!(
dir.starts_with(root.path()),
"the guard directory is created under the supplied root"
);
write_one_event(&store);
drop(store);
assert!(!dir.exists(), "the rooted directory is removed on drop");
}
#[test]
fn ephemeral_dir_persists_across_unrelated_work_then_goes_on_clean_drop() {
let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
let dir = store
.ephemeral_dir_path()
.expect("ephemeral store carries a guard dir")
.to_path_buf();
assert!(
dir.exists(),
"the directory exists as soon as the store does"
);
for round in 0..3_u64 {
block_on(store.append("clean-teardown/probe", b"payload".to_vec(), round))
.expect("bridge completes synchronously")
.expect("append to a live ephemeral store succeeds");
assert!(
dir.exists(),
"the directory is still there after append round {round}"
);
}
block_on(store.cas("clean-teardown/counter", 0, 7))
.expect("bridge completes synchronously")
.expect("cas on a live ephemeral store succeeds");
let entries = block_on(store.read_from("clean-teardown/probe", 0, 10))
.expect("bridge completes synchronously")
.expect("read from a live ephemeral store succeeds");
assert_eq!(entries.len(), 3, "every appended entry is readable back");
assert!(
dir.exists(),
"the directory is still there after unrelated cas and read work"
);
block_on(store.flush())
.expect("bridge completes synchronously")
.expect("flush of a live ephemeral store succeeds");
drop(store);
assert!(
!dir.exists(),
"the clean drop removes the directory it kept alive throughout"
);
}
#[cfg(unix)]
#[test]
fn clean_drop_removal_failure_is_logged_and_never_panics() {
let parent = tempfile::tempdir().expect("test can create a temp parent");
let dir = tempfile::Builder::new()
.prefix("liminal-durability-")
.tempdir_in(parent.path())
.expect("test can create a guard dir under the parent");
let path = dir.path().to_path_buf();
let guard = EphemeralGuard {
ownership: None,
store: Some(()),
dir: Some(dir),
};
set_mode(parent.path(), 0o500);
let captured = CapturedLog::default();
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
captured.capturing(|| drop(guard));
}));
set_mode(parent.path(), 0o700);
assert!(
outcome.is_ok(),
"a removal failure is reported, never raised as a panic"
);
let logged = captured.text();
assert!(
logged.contains("ERROR"),
"the removal failure is logged at error level; captured: {logged:?}"
);
assert!(
logged.contains(&path.display().to_string()),
"the log names the directory that survived; captured: {logged:?}"
);
assert!(
path.exists(),
"the residue is left where the log says it is, not silently claimed removed"
);
}
#[test]
fn clean_drop_that_succeeds_logs_nothing() {
let dir = tempfile::tempdir().expect("test can create a temp dir");
let path = dir.path().to_path_buf();
let guard = EphemeralGuard {
ownership: None,
store: Some(()),
dir: Some(dir),
};
let captured = CapturedLog::default();
captured.capturing(|| drop(guard));
assert!(!path.exists(), "the successful clean drop removed the dir");
assert!(
captured.text().is_empty(),
"a successful removal is silent; captured: {:?}",
captured.text()
);
}
#[test]
fn panic_path_leak_is_logged_with_its_path() {
let dir = tempfile::tempdir().expect("test can create a temp dir");
let path = dir.path().to_path_buf();
let guard = EphemeralGuard {
ownership: None,
store: Some(PanickingProbeStore),
dir: Some(dir),
};
let captured = CapturedLog::default();
let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
captured.capturing(|| drop(guard));
}));
assert!(unwound.is_err(), "the injected store-drop panic propagates");
let logged = captured.text();
assert!(
logged.contains("ERROR"),
"the sanctioned leak is logged at error level; captured: {logged:?}"
);
assert!(
logged.contains(&path.display().to_string()),
"the leak log names the leaked directory; captured: {logged:?}"
);
std::fs::remove_dir_all(&path).expect("test cleans up the deliberately leaked directory");
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
#[path = "store_paged_read_shape_tests.rs"]
mod paged_read_shape_tests;
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod paged_scan_tests {
use crate::durability::tests_fixtures;
use std::collections::HashSet;
use super::{
DurabilityError, DurableStore, EngineReadAccountingGuard, ScanCursor, StoredEntry,
page_in_memory_streams,
};
use crate::durability::bridge::block_on;
const PREFIX: &str = "liminal/scan-bound/";
const STREAMS: [&str; 3] = [
"liminal/scan-bound/alpha",
"liminal/scan-bound/bravo",
"liminal/scan-bound/charlie",
];
const ROWS_PER_STREAM: [u64; 3] = [7, 11, 5];
fn payload(stream: &str, sequence: u64) -> Vec<u8> {
format!("{stream}#{sequence}").into_bytes()
}
fn seed_census() -> Vec<Vec<u8>> {
let mut census = Vec::new();
for (stream, rows) in STREAMS.iter().zip(ROWS_PER_STREAM) {
for sequence in 0..rows {
census.push(payload(stream, sequence));
}
}
census
}
fn seeded() -> Result<impl DurableStore, Box<dyn std::error::Error>> {
tests_fixtures::copied("paged-scan")
}
#[test]
fn a_full_scan_page_stops_at_its_limit_and_reports_where_to_resume()
-> Result<(), Box<dyn std::error::Error>> {
const LIMIT: usize = 9;
let store = seeded()?;
let page = block_on(store.scan_page(PREFIX, None, LIMIT))??;
assert_eq!(
page.entries.len(),
LIMIT,
"a seed larger than the limit must fill exactly one page"
);
let cursor = page
.next
.ok_or("a filled page must report a resume position")?;
assert_eq!(cursor.stream_key, STREAMS[1]);
assert_eq!(cursor.next_sequence, 2);
Ok(())
}
#[test]
fn draining_every_page_delivers_the_seed_exactly_once() -> Result<(), Box<dyn std::error::Error>>
{
let store = seeded()?;
let census = seed_census();
for limit in [1_usize, 2, 4, 7, 8, 13, 23, 24] {
let mut drained: Vec<Vec<u8>> = Vec::new();
let mut per_stream: Vec<(String, u64)> = Vec::new();
let mut cursor: Option<ScanCursor> = None;
let mut pages = 0_usize;
loop {
let page = block_on(store.scan_page(PREFIX, cursor, limit))??;
assert!(
page.entries.len() <= limit,
"limit {limit} page {pages} exceeded its limit"
);
for entry in &page.entries {
let text = String::from_utf8(entry.payload.clone())?;
let stream = text
.split_once('#')
.ok_or("seeded payload names its stream")?
.0
.to_owned();
per_stream.push((stream, entry.sequence));
}
drained.extend(page.entries.into_iter().map(|entry| entry.payload));
pages = pages.checked_add(1).ok_or("page counter overflowed")?;
assert!(pages <= 64, "limit {limit} did not terminate in 64 pages");
let Some(next) = page.next else {
break;
};
cursor = Some(next);
}
let drained_set: HashSet<Vec<u8>> = drained.iter().cloned().collect();
assert_eq!(
drained.len(),
drained_set.len(),
"limit {limit} delivered a duplicate entry"
);
assert_eq!(
drained_set,
census.iter().cloned().collect::<HashSet<_>>(),
"limit {limit} did not deliver exactly the seeded entries"
);
for stream in STREAMS {
let sequences: Vec<u64> = per_stream
.iter()
.filter(|(seen, _)| seen == stream)
.map(|(_, sequence)| *sequence)
.collect();
assert!(
sequences.windows(2).all(|pair| pair[0] < pair[1]),
"limit {limit} broke sequence order within {stream}: {sequences:?}"
);
}
}
Ok(())
}
#[test]
fn a_short_page_ends_the_scan() -> Result<(), Box<dyn std::error::Error>> {
let store = seeded()?;
let total = usize::try_from(ROWS_PER_STREAM.iter().sum::<u64>())?;
let whole =
block_on(store.scan_page(PREFIX, None, total.checked_add(1).ok_or("overflow")?))??;
assert_eq!(
whole.entries.len(),
total,
"the whole seed fits in one page"
);
assert!(
whole.next.is_none(),
"a page short of its limit terminates the scan"
);
let exact = block_on(store.scan_page(PREFIX, None, total))??;
assert_eq!(exact.entries.len(), total);
let cursor = exact
.next
.ok_or("an exactly-full page reports a resume position")?;
let terminator = block_on(store.scan_page(PREFIX, Some(cursor), total))??;
assert!(
terminator.entries.is_empty(),
"the resume past the last entry finds nothing"
);
assert!(
terminator.next.is_none(),
"a final empty page is a legal terminator"
);
let unmatched = block_on(store.scan_page("liminal/no-such-prefix/", None, 8))??;
assert!(unmatched.entries.is_empty());
assert!(unmatched.next.is_none());
Ok(())
}
#[test]
fn a_page_orders_streams_by_key_not_by_discovery_order()
-> Result<(), Box<dyn std::error::Error>> {
const STREAM_COUNT: usize = 32;
let mut streams = std::collections::HashMap::new();
for index in 0..STREAM_COUNT {
let stream_key = format!("{PREFIX}stream-{index:02}");
let entry = StoredEntry {
payload: stream_key.clone().into_bytes(),
sequence: 0,
timestamp: 0,
};
streams.insert(stream_key, vec![entry]);
}
let page = page_in_memory_streams(&streams, PREFIX, None, STREAM_COUNT)?;
let delivered: Vec<String> = page
.entries
.into_iter()
.map(|entry| String::from_utf8(entry.payload))
.collect::<Result<_, _>>()?;
let mut expected = delivered.clone();
expected.sort_unstable();
assert_eq!(
delivered, expected,
"a page must deliver its streams in key order"
);
Ok(())
}
#[test]
fn a_drained_walk_enumerates_each_stream_once() -> Result<(), Box<dyn std::error::Error>> {
const WALK_STREAMS: usize = 48;
const LIMIT: usize = 5;
let store = tests_fixtures::copied("paged-walk")?;
let accounting = EngineReadAccountingGuard::start();
let mut cursor = None;
let mut delivered = 0_usize;
let mut pages = 0_usize;
loop {
let page = block_on(store.scan_page(PREFIX, cursor, LIMIT))??;
pages += 1;
delivered += page.entries.len();
match page.next {
Some(next) => cursor = Some(next),
None => break,
}
}
let walk = accounting.snapshot();
drop(accounting);
assert_eq!(delivered, WALK_STREAMS, "the walk delivers every stream");
assert!(!walk.counter_overflow_observed, "{walk:?}");
assert!(
walk.streams_enumerated <= WALK_STREAMS + pages,
"a walk of {WALK_STREAMS} streams in {pages} pages enumerated {} streams",
walk.streams_enumerated
);
Ok(())
}
#[test]
fn a_zero_limit_is_refused() -> Result<(), Box<dyn std::error::Error>> {
let store = seeded()?;
let refused = block_on(store.scan_page(PREFIX, None, 0))?;
assert!(
matches!(refused, Err(DurabilityError::ConfigError(_))),
"a zero limit is refused as a configuration error, got {refused:?}"
);
Ok(())
}
}
#[cfg(test)]
mod truncate_cost_tests {
use std::error::Error;
use std::sync::Arc;
use super::super::bridge::block_on;
use super::{Database, DatabaseConfig, DurableStore, EventStore, HaematiteStore};
const STREAM: &str = "truncate-cost";
const HISTORY: u64 = 10_000;
const BATCH: u64 = 1_000;
const PRESENT: u64 = 10;
const OVERHEAD: u64 = 64;
#[test]
fn a_prune_far_into_history_reads_only_what_it_deletes() -> Result<(), Box<dyn Error>> {
let dir = tempfile::tempdir()?;
let database = Database::create(
DatabaseConfig {
data_dir: dir.path().to_path_buf(),
shard_count: 1,
distributed: None,
executor_threads: None,
node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
retention: haematite::Retention::reclaim(0),
},
haematite::RuntimeOptions {
long_wait_interval: std::time::Duration::from_secs(5),
reclaim_start: haematite::ReclaimStart::AtOpen,
},
)?;
let events = Arc::new(EventStore::new(database));
let store = HaematiteStore::new(Arc::clone(&events));
let batch: Vec<Vec<u8>> = (0..BATCH).map(|_| vec![0x07]).collect();
let mut next = 0;
while next < HISTORY {
block_on(store.append_all(STREAM, batch.clone(), next))??;
next += BATCH;
}
block_on(store.truncate_before(STREAM, HISTORY - PRESENT))??;
let before = events.database().store_counts().node_gets;
let deleted = block_on(store.truncate_before(STREAM, HISTORY))??;
let reads = events.database().store_counts().node_gets - before;
assert_eq!(
(deleted, reads <= PRESENT + OVERHEAD),
(PRESENT, true),
"a prune of {deleted} rows at sequence {HISTORY} read {reads} nodes; at most \
{PRESENT} plus {OVERHEAD} is owed: the prune walked the history, not the rows"
);
drop(store);
drop(events);
drop(dir);
Ok(())
}
}
#[cfg(test)]
#[path = "store_batch_tests.rs"]
mod batch_tests;