use std::path::Path;
use std::sync::Arc;
use haematite::{ApiError, Database, DatabaseConfig, Event, EventStore};
use tempfile::TempDir;
use super::DurabilityError;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredEntry {
pub payload: Vec<u8>,
pub sequence: u64,
pub timestamp: u64,
}
#[async_trait::async_trait]
pub trait DurableStore: std::fmt::Debug + Send + Sync {
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 cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError>;
async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError>;
async fn scan(&self, prefix: &str) -> Result<Vec<StoredEntry>, DurabilityError>;
async fn flush(&self) -> Result<(), DurabilityError>;
}
#[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 }
}
}
#[async_trait::async_trait]
impl DurableStore for HaematiteStore {
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 read_from(
&self,
stream_key: &str,
offset: u64,
limit: usize,
) -> Result<Vec<StoredEntry>, DurabilityError> {
let mut events = self
.event_store
.read_from(stream_key.as_bytes(), offset)
.map_err(DurabilityError::from)?;
events.truncate(limit);
Ok(events.into_iter().map(StoredEntry::from).collect())
}
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_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
self.event_store
.read_value(key.as_bytes())
.map_err(DurabilityError::from)
}
async fn scan(&self, prefix: &str) -> Result<Vec<StoredEntry>, DurabilityError> {
let prefix_bytes = prefix.as_bytes().to_vec();
let matches = self
.event_store
.scan(|meta| meta.stream_key.starts_with(&prefix_bytes))
.map_err(DurabilityError::from)?;
let mut entries = Vec::new();
for stream in matches {
let events = self
.event_store
.read(&stream.stream_key)
.map_err(DurabilityError::from)?;
entries.extend(events.into_iter().map(StoredEntry::from));
}
Ok(entries)
}
async fn flush(&self) -> Result<(), DurabilityError> {
self.event_store.flush().map_err(DurabilityError::from)
}
}
#[derive(Debug)]
struct EphemeralGuard<S> {
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"
);
}
std::panic::resume_unwind(panic);
}
}
}
#[derive(Debug)]
pub struct EphemeralHaematiteStore {
guard: EphemeralGuard<HaematiteStore>,
}
impl EphemeralHaematiteStore {
fn new(database: Database, ephemeral_dir: TempDir) -> Self {
Self {
guard: EphemeralGuard {
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)
}
#[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 {
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 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_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
self.store()?.read_value(key).await
}
async fn scan(&self, prefix: &str) -> Result<Vec<StoredEntry>, DurabilityError> {
self.store()?.scan(prefix).await
}
async fn flush(&self) -> Result<(), DurabilityError> {
self.store()?.flush().await
}
}
pub fn open_ephemeral(shard_count: usize) -> Result<EphemeralHaematiteStore, DurabilityError> {
open_ephemeral_in(ephemeral_tempdir(None)?, 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(Some(root))?, shard_count)
}
fn ephemeral_tempdir(root: Option<&Path>) -> Result<TempDir, DurabilityError> {
let mut builder = tempfile::Builder::new();
builder.prefix("liminal-durability-");
root.map_or_else(|| builder.tempdir(), |root| 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,
})
.map_err(|error| DurabilityError::EphemeralStoreOpen(error.to_string()))?;
Ok(EphemeralHaematiteStore::new(database, ephemeral_dir))
}
impl From<Event> for StoredEntry {
fn from(event: Event) -> Self {
Self {
payload: event.payload,
sequence: event.seq,
timestamp: event.timestamp,
}
}
}
impl From<ApiError> for DurabilityError {
fn from(error: ApiError) -> Self {
match error {
ApiError::SequenceConflict(conflict) => conflict.into(),
ApiError::CasMismatch(mismatch) => mismatch.into(),
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::PathBuf;
use std::sync::Arc;
use super::super::bridge::block_on;
use super::{
DurableStore, EphemeralGuard, open_ephemeral, open_ephemeral_in, open_ephemeral_rooted,
};
const TEST_SHARD_COUNT: usize = 2;
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 {
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 {
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");
}
}