1use haematite::{ApiError, Database, DatabaseConfig, Event, EventStore};
2
3use std::path::Path;
4use std::sync::Arc;
5
6use super::DurabilityError;
7
8use tempfile::TempDir;
9
10#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct StoredEntry {
13 pub payload: Vec<u8>,
15 pub sequence: u64,
17 pub timestamp: u64,
19}
20
21#[async_trait::async_trait]
23pub trait DurableStore: std::fmt::Debug + Send + Sync {
24 async fn append(
26 &self,
27 stream_key: &str,
28 payload: Vec<u8>,
29 expected_seq: u64,
30 ) -> Result<u64, DurabilityError>;
31
32 async fn read_from(
34 &self,
35 stream_key: &str,
36 offset: u64,
37 limit: usize,
38 ) -> Result<Vec<StoredEntry>, DurabilityError>;
39
40 async fn read_at(
42 &self,
43 stream_key: &str,
44 sequence: u64,
45 ) -> Result<Option<StoredEntry>, DurabilityError> {
46 Ok(self
47 .read_from(stream_key, sequence, 1)
48 .await?
49 .into_iter()
50 .next())
51 }
52
53 async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError>;
60
61 async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError>;
63
64 async fn scan(&self, prefix: &str) -> Result<Vec<StoredEntry>, DurabilityError>;
66
67 async fn flush(&self) -> Result<(), DurabilityError>;
72}
73
74#[derive(Clone, Debug)]
80pub struct HaematiteStore {
81 event_store: Arc<EventStore>,
82}
83
84impl HaematiteStore {
85 #[must_use]
87 pub const fn new(event_store: Arc<EventStore>) -> Self {
88 Self { event_store }
89 }
90
91 fn bounded_page(
103 &self,
104 stream_key: &str,
105 offset: u64,
106 limit: usize,
107 ) -> Result<Option<Vec<StoredEntry>>, DurabilityError> {
108 const TIMESTAMP_WIDTH: usize = std::mem::size_of::<u64>();
109
110 let Some(engine_from) = offset.checked_add(1) else {
112 return Ok(None);
113 };
114 let Some(engine_end) = u64::try_from(limit)
115 .ok()
116 .and_then(|limit| engine_from.checked_add(limit))
117 else {
118 return Ok(None);
119 };
120 let key = stream_key.as_bytes();
121 let from = haematite::encode_stream_key(key, engine_from);
122 let to = haematite::encode_stream_key(key, engine_end);
123 let entries = self
124 .event_store
125 .database()
126 .range_routed(key, &from, &to)
127 .map_err(ApiError::from)
128 .map_err(DurabilityError::from)?;
129 if entries.len() != limit {
130 return Ok(None);
131 }
132
133 let mut page = Vec::with_capacity(entries.len());
134 for (encoded_key, value) in entries {
135 let Some((decoded_key, engine_sequence)) = haematite::decode_stream_key(&encoded_key)
136 else {
137 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
138 format!("paged read key does not encode an event for stream {stream_key}"),
139 )));
140 };
141 if decoded_key != key {
142 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
143 format!("paged read key does not encode stream {stream_key}"),
144 )));
145 }
146 let sequence = engine_sequence.checked_sub(1).ok_or_else(|| {
147 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
148 "paged read event key has zero seq for stream {stream_key}"
149 )))
150 })?;
151 let Some(timestamp_bytes) = value.get(..TIMESTAMP_WIDTH) else {
152 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
153 format!(
154 "paged read event value is shorter than its timestamp for stream {stream_key}"
155 ),
156 )));
157 };
158 let timestamp = u64::from_be_bytes(timestamp_bytes.try_into().map_err(|_| {
159 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
160 "paged read event timestamp has the wrong width for stream {stream_key}"
161 )))
162 })?);
163 let Some(payload) = value.get(TIMESTAMP_WIDTH..) else {
164 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
165 format!("paged read event has no payload boundary for stream {stream_key}"),
166 )));
167 };
168 page.push(StoredEntry {
169 payload: payload.to_vec(),
170 sequence,
171 timestamp,
172 });
173 }
174 Ok(Some(page))
175 }
176}
177
178#[async_trait::async_trait]
179impl DurableStore for HaematiteStore {
180 async fn append(
181 &self,
182 stream_key: &str,
183 payload: Vec<u8>,
184 expected_seq: u64,
185 ) -> Result<u64, DurabilityError> {
186 let next_seq = self
194 .event_store
195 .append(stream_key.as_bytes(), &payload, expected_seq)
196 .map_err(DurabilityError::from)?;
197 next_seq.checked_sub(1).ok_or_else(|| {
198 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
199 "append returned next-seq 0 for stream {stream_key}"
200 )))
201 })
202 }
203
204 async fn read_from(
205 &self,
206 stream_key: &str,
207 offset: u64,
208 limit: usize,
209 ) -> Result<Vec<StoredEntry>, DurabilityError> {
210 if limit > 0 {
233 if let Some(page) = self.bounded_page(stream_key, offset, limit)? {
234 account_engine_read(page.len(), false);
235 return Ok(page);
236 }
237 }
238 let mut events = self
239 .event_store
240 .read_from(stream_key.as_bytes(), offset)
241 .map_err(DurabilityError::from)?;
242 account_engine_read(events.len(), true);
243 events.truncate(limit);
244 Ok(events.into_iter().map(StoredEntry::from).collect())
245 }
246
247 async fn read_at(
248 &self,
249 stream_key: &str,
250 sequence: u64,
251 ) -> Result<Option<StoredEntry>, DurabilityError> {
252 const TIMESTAMP_WIDTH: usize = std::mem::size_of::<u64>();
253
254 let engine_sequence = sequence.checked_add(1).ok_or_else(|| {
255 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
256 "point read sequence overflow for stream {stream_key}"
257 )))
258 })?;
259 let event_key = haematite::encode_stream_key(stream_key.as_bytes(), engine_sequence);
260 let Some(value) = self
261 .event_store
262 .database()
263 .get_routed(stream_key.as_bytes(), &event_key)
264 .map_err(ApiError::from)
265 .map_err(DurabilityError::from)?
266 else {
267 return Ok(None);
268 };
269 let Some(timestamp_bytes) = value.get(..TIMESTAMP_WIDTH) else {
270 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
271 format!(
272 "point-read event value is shorter than its timestamp for stream {stream_key}"
273 ),
274 )));
275 };
276 let timestamp = u64::from_be_bytes(timestamp_bytes.try_into().map_err(|_| {
277 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
278 "point-read event timestamp has the wrong width for stream {stream_key}"
279 )))
280 })?);
281 let Some(payload) = value.get(TIMESTAMP_WIDTH..) else {
282 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
283 format!("point-read event has no payload boundary for stream {stream_key}"),
284 )));
285 };
286 Ok(Some(StoredEntry {
287 payload: payload.to_vec(),
288 sequence,
289 timestamp,
290 }))
291 }
292
293 async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
294 if new_value == 0 {
309 return self
310 .event_store
311 .read_value(key.as_bytes())
312 .map_err(DurabilityError::from)?
313 .map_or(Ok(()), |stored| {
314 Err(DurabilityError::CursorRegression {
315 stored,
316 attempted: old_value,
317 })
318 });
319 }
320 let expected = if old_value == 0 {
326 None
327 } else {
328 Some(old_value)
329 };
330 self.event_store
331 .cas(key.as_bytes(), expected, new_value)
332 .map_err(DurabilityError::from)
333 }
334
335 async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
336 self.event_store
337 .read_value(key.as_bytes())
338 .map_err(DurabilityError::from)
339 }
340
341 async fn scan(&self, prefix: &str) -> Result<Vec<StoredEntry>, DurabilityError> {
342 let prefix_bytes = prefix.as_bytes().to_vec();
347 let matches = self
348 .event_store
349 .scan(|meta| meta.stream_key.starts_with(&prefix_bytes))
350 .map_err(DurabilityError::from)?;
351 let mut entries = Vec::new();
352 for stream in matches {
353 let events = self
354 .event_store
355 .read(&stream.stream_key)
356 .map_err(DurabilityError::from)?;
357 entries.extend(events.into_iter().map(StoredEntry::from));
358 }
359 Ok(entries)
360 }
361
362 async fn flush(&self) -> Result<(), DurabilityError> {
363 self.event_store.flush().map_err(DurabilityError::from)
364 }
365}
366
367#[derive(Debug)]
393struct EphemeralGuard<S> {
394 store: Option<S>,
395 dir: Option<TempDir>,
396}
397
398impl<S> Drop for EphemeralGuard<S> {
399 fn drop(&mut self) {
400 let store = self.store.take();
401 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(store)));
405 if let Err(panic) = outcome {
406 if let Some(dir) = self.dir.take() {
407 let leaked = dir.keep();
408 tracing::error!(
409 path = %leaked.display(),
410 "ephemeral store drop panicked; leaking its directory rather than \
411 removing it under possibly-live database workers"
412 );
413 }
414 std::panic::resume_unwind(panic);
415 }
416 if let Some(dir) = self.dir.take() {
420 let path = dir.path().to_path_buf();
421 if let Err(error) = dir.close() {
422 tracing::error!(
423 path = %path.display(),
424 %error,
425 "ephemeral store directory removal failed; residue remains at the \
426 logged path"
427 );
428 }
429 }
430 }
431}
432
433#[derive(Debug)]
449pub struct EphemeralHaematiteStore {
450 guard: EphemeralGuard<HaematiteStore>,
451}
452
453impl EphemeralHaematiteStore {
454 fn new(database: Database, ephemeral_dir: TempDir) -> Self {
463 Self {
464 guard: EphemeralGuard {
465 store: Some(HaematiteStore::new(Arc::new(EventStore::new(database)))),
466 dir: Some(ephemeral_dir),
467 },
468 }
469 }
470
471 fn store(&self) -> Result<&HaematiteStore, DurabilityError> {
478 self.guard
479 .store
480 .as_ref()
481 .ok_or(DurabilityError::EphemeralStoreDetached)
482 }
483
484 #[cfg(test)]
486 pub(crate) fn ephemeral_dir_path(&self) -> Option<&Path> {
487 self.guard.dir.as_ref().map(TempDir::path)
488 }
489}
490
491#[async_trait::async_trait]
492impl DurableStore for EphemeralHaematiteStore {
493 async fn append(
494 &self,
495 stream_key: &str,
496 payload: Vec<u8>,
497 expected_seq: u64,
498 ) -> Result<u64, DurabilityError> {
499 self.store()?
500 .append(stream_key, payload, expected_seq)
501 .await
502 }
503
504 async fn read_from(
505 &self,
506 stream_key: &str,
507 offset: u64,
508 limit: usize,
509 ) -> Result<Vec<StoredEntry>, DurabilityError> {
510 self.store()?.read_from(stream_key, offset, limit).await
511 }
512
513 async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
514 self.store()?.cas(key, old_value, new_value).await
515 }
516
517 async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
518 self.store()?.read_value(key).await
519 }
520
521 async fn scan(&self, prefix: &str) -> Result<Vec<StoredEntry>, DurabilityError> {
522 self.store()?.scan(prefix).await
523 }
524
525 async fn flush(&self) -> Result<(), DurabilityError> {
526 self.store()?.flush().await
527 }
528}
529
530pub fn open_ephemeral(shard_count: usize) -> Result<EphemeralHaematiteStore, DurabilityError> {
546 open_ephemeral_in(ephemeral_tempdir(None)?, shard_count)
547}
548
549#[cfg(any(test, feature = "test-support"))]
572pub fn open_ephemeral_rooted(
573 root: &Path,
574 shard_count: usize,
575) -> Result<EphemeralHaematiteStore, DurabilityError> {
576 open_ephemeral_in(ephemeral_tempdir(Some(root))?, shard_count)
577}
578
579fn ephemeral_tempdir(root: Option<&Path>) -> Result<TempDir, DurabilityError> {
582 let mut builder = tempfile::Builder::new();
583 builder.prefix("liminal-durability-");
584 root.map_or_else(|| builder.tempdir(), |root| builder.tempdir_in(root))
585 .map_err(|error| {
586 DurabilityError::EphemeralStoreOpen(format!(
587 "could not create temporary directory: {error}"
588 ))
589 })
590}
591
592fn open_ephemeral_in(
597 ephemeral_dir: TempDir,
598 shard_count: usize,
599) -> Result<EphemeralHaematiteStore, DurabilityError> {
600 let database = Database::create(DatabaseConfig {
601 data_dir: ephemeral_dir.path().to_path_buf(),
602 shard_count,
603 distributed: None,
604 executor_threads: None,
605 node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
612 })
613 .map_err(|error| DurabilityError::EphemeralStoreOpen(error.to_string()))?;
614 Ok(EphemeralHaematiteStore::new(database, ephemeral_dir))
615}
616
617#[cfg(test)]
625#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
626pub(crate) struct EngineReadAccounting {
627 pub(crate) calls: usize,
629 pub(crate) engine_entries: usize,
631 pub(crate) unbounded_calls: usize,
633 pub(crate) counter_overflow_observed: bool,
635}
636
637#[cfg(test)]
638std::thread_local! {
639 static ENGINE_READ_ACCOUNTING: std::cell::RefCell<Option<EngineReadAccounting>> =
640 const { std::cell::RefCell::new(None) };
641}
642
643#[cfg(test)]
648pub(crate) struct EngineReadAccountingGuard {
649 _not_send: std::marker::PhantomData<*const ()>,
650}
651
652#[cfg(test)]
653impl EngineReadAccountingGuard {
654 pub(crate) fn start() -> Self {
655 ENGINE_READ_ACCOUNTING.with(|accounting| {
656 *accounting.borrow_mut() = Some(EngineReadAccounting::default());
657 });
658 Self {
659 _not_send: std::marker::PhantomData,
660 }
661 }
662
663 #[allow(clippy::unused_self)]
664 pub(crate) fn snapshot(&self) -> EngineReadAccounting {
665 ENGINE_READ_ACCOUNTING
666 .with(|accounting| accounting.borrow().as_ref().copied().unwrap_or_default())
667 }
668}
669
670#[cfg(test)]
671impl Drop for EngineReadAccountingGuard {
672 fn drop(&mut self) {
673 ENGINE_READ_ACCOUNTING.with(|accounting| {
674 *accounting.borrow_mut() = None;
675 });
676 }
677}
678
679#[cfg(test)]
681fn account_engine_read(engine_entries: usize, unbounded: bool) {
682 ENGINE_READ_ACCOUNTING.with(|accounting| {
683 if let Some(active) = accounting.borrow_mut().as_mut() {
684 match (
685 active.calls.checked_add(1),
686 active.engine_entries.checked_add(engine_entries),
687 ) {
688 (Some(calls), Some(entries)) => {
689 active.calls = calls;
690 active.engine_entries = entries;
691 }
692 _ => active.counter_overflow_observed = true,
693 }
694 if unbounded {
695 match active.unbounded_calls.checked_add(1) {
696 Some(unbounded_calls) => active.unbounded_calls = unbounded_calls,
697 None => active.counter_overflow_observed = true,
698 }
699 }
700 }
701 });
702}
703
704#[cfg(not(test))]
705const fn account_engine_read(_engine_entries: usize, _unbounded: bool) {}
706
707impl From<Event> for StoredEntry {
708 fn from(event: Event) -> Self {
709 Self {
710 payload: event.payload,
711 sequence: event.seq,
712 timestamp: event.timestamp,
713 }
714 }
715}
716
717impl From<ApiError> for DurabilityError {
723 fn from(error: ApiError) -> Self {
724 match error {
725 ApiError::SequenceConflict(conflict) => conflict.into(),
726 ApiError::CasMismatch(mismatch) => mismatch.into(),
727 other @ (ApiError::CorruptEvent(_)
728 | ApiError::Storage(_)
729 | ApiError::HistoryCompacted(_)) => Self::StoreError(other),
730 }
731 }
732}
733
734#[cfg(test)]
735#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
736mod ephemeral_lifecycle_tests {
737 use std::path::{Path, PathBuf};
742 use std::sync::{Arc, Mutex};
743
744 use super::super::bridge::block_on;
745 use super::{
746 DurableStore, EphemeralGuard, open_ephemeral, open_ephemeral_in, open_ephemeral_rooted,
747 };
748
749 const TEST_SHARD_COUNT: usize = 2;
750
751 #[derive(Clone, Default)]
760 struct CapturedLog(Arc<Mutex<Vec<u8>>>);
761
762 impl CapturedLog {
763 fn text(&self) -> String {
765 let bytes = self
766 .0
767 .lock()
768 .expect("capture buffer is not poisoned")
769 .clone();
770 String::from_utf8(bytes).expect("tracing's fmt writer emits utf-8")
771 }
772
773 fn capturing<R>(&self, body: impl FnOnce() -> R) -> R {
789 static INSTALL: std::sync::Once = std::sync::Once::new();
790 struct ResetOnDrop;
792 impl Drop for ResetOnDrop {
793 fn drop(&mut self) {
794 ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = None);
795 }
796 }
797 INSTALL.call_once(|| {
798 let subscriber = tracing_subscriber::fmt()
799 .with_writer(RoutedWriter)
800 .with_ansi(false)
801 .finish();
802 tracing::subscriber::set_global_default(subscriber)
803 .expect("no other global tracing subscriber is installed in this test binary");
804 });
805 ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = Some(self.clone()));
806 let _reset = ResetOnDrop;
807 body()
808 }
809 }
810
811 thread_local! {
812 static ACTIVE_CAPTURE: std::cell::RefCell<Option<CapturedLog>> =
815 const { std::cell::RefCell::new(None) };
816 }
817
818 #[derive(Clone, Copy, Default)]
822 struct RoutedWriter;
823
824 impl std::io::Write for RoutedWriter {
825 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
826 ACTIVE_CAPTURE.with(|slot| {
827 if let Some(capture) = slot.borrow().as_ref() {
828 capture
829 .0
830 .lock()
831 .map_err(|_| std::io::Error::other("capture buffer poisoned"))?
832 .extend_from_slice(buf);
833 }
834 Ok(buf.len())
835 })
836 }
837
838 fn flush(&mut self) -> std::io::Result<()> {
839 Ok(())
840 }
841 }
842
843 impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for RoutedWriter {
844 type Writer = Self;
845
846 fn make_writer(&'writer self) -> Self::Writer {
847 *self
848 }
849 }
850
851 #[cfg(unix)]
857 fn set_mode(path: &Path, mode: u32) {
858 use std::os::unix::fs::PermissionsExt;
859
860 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
861 .expect("test can set permissions on a directory it created");
862 }
863
864 struct OrderProbeStore {
868 dir: PathBuf,
869 }
870
871 impl Drop for OrderProbeStore {
872 fn drop(&mut self) {
873 assert!(
874 self.dir.exists(),
875 "the guard must drop the store BEFORE removing the directory"
876 );
877 }
878 }
879
880 struct PanickingProbeStore;
883
884 impl Drop for PanickingProbeStore {
885 fn drop(&mut self) {
886 panic!("injected store-drop panic");
887 }
888 }
889
890 fn write_one_event(store: &dyn DurableStore) {
893 block_on(store.append("lifecycle/probe", b"payload".to_vec(), 0))
894 .expect("bridge completes synchronously")
895 .expect("append to a fresh ephemeral stream succeeds");
896 block_on(store.flush())
897 .expect("bridge completes synchronously")
898 .expect("flush of a live ephemeral store succeeds");
899 }
900
901 #[test]
904 fn ephemeral_dir_removed_after_last_handle_drops() {
905 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
906 let dir = store
907 .ephemeral_dir_path()
908 .expect("ephemeral store carries a guard dir")
909 .to_path_buf();
910 assert!(
911 dir.exists(),
912 "the guard directory exists while the store is live"
913 );
914
915 write_one_event(&store);
916 drop(store);
917
918 assert!(
919 !dir.exists(),
920 "the guard directory is removed on normal drop"
921 );
922 }
923
924 #[test]
929 fn ephemeral_dir_survives_until_last_store_clone_drops() {
930 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
931 let dir = store
932 .ephemeral_dir_path()
933 .expect("ephemeral store carries a guard dir")
934 .to_path_buf();
935 write_one_event(&store);
936
937 let erased: Arc<dyn DurableStore> = Arc::new(store);
938 let clone_a = Arc::clone(&erased);
939 let clone_b = Arc::clone(&erased);
940
941 drop(erased);
942 assert!(
943 dir.exists(),
944 "directory survives while store clones remain alive"
945 );
946 drop(clone_a);
947 assert!(
948 dir.exists(),
949 "directory survives while one store clone remains alive"
950 );
951
952 drop(clone_b);
953 assert!(
954 !dir.exists(),
955 "the last store clone dropping removes the directory"
956 );
957 }
958
959 #[test]
964 fn ephemeral_open_failure_rolls_back_directory() {
965 let seeded = tempfile::Builder::new()
966 .prefix("liminal-durability-test-")
967 .tempdir()
968 .expect("test can create a temp dir");
969 let dir = seeded.path().to_path_buf();
970 std::fs::write(dir.join("config.json"), b"not-a-valid-config")
974 .expect("test can seed a conflicting config");
975
976 let result = open_ephemeral_in(seeded, TEST_SHARD_COUNT);
977
978 assert!(result.is_err(), "an injected open failure returns Err");
979 assert!(
980 !dir.exists(),
981 "the guard removes the directory on open failure — zero residue"
982 );
983 }
984
985 #[test]
988 fn repeated_ephemeral_cycles_each_own_distinct_dir_zero_residue() {
989 let mut seen: Vec<PathBuf> = Vec::new();
990 for _ in 0..5 {
991 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
992 let dir = store
993 .ephemeral_dir_path()
994 .expect("ephemeral store carries a guard dir")
995 .to_path_buf();
996 assert!(
997 dir.exists(),
998 "the cycle's directory exists while its store is live"
999 );
1000 assert!(!seen.contains(&dir), "each cycle owns a distinct directory");
1001 seen.push(dir.clone());
1002
1003 write_one_event(&store);
1004 drop(store);
1005 assert!(
1006 !dir.exists(),
1007 "the cycle's directory is removed after its store drops"
1008 );
1009 }
1010 }
1011
1012 #[test]
1017 fn guard_drops_store_before_removing_directory() {
1018 let dir = tempfile::tempdir().expect("test can create a temp dir");
1019 let path = dir.path().to_path_buf();
1020 let guard = EphemeralGuard {
1021 store: Some(OrderProbeStore { dir: path.clone() }),
1022 dir: Some(dir),
1023 };
1024
1025 drop(guard);
1026
1027 assert!(!path.exists(), "a clean drop still removes the directory");
1028 }
1029
1030 #[test]
1034 fn guard_leaks_directory_when_store_drop_panics() {
1035 let dir = tempfile::tempdir().expect("test can create a temp dir");
1036 let path = dir.path().to_path_buf();
1037 let guard = EphemeralGuard {
1038 store: Some(PanickingProbeStore),
1039 dir: Some(dir),
1040 };
1041
1042 let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(guard)));
1043
1044 assert!(unwound.is_err(), "the injected store-drop panic propagates");
1045 assert!(
1046 path.exists(),
1047 "a panicking store drop leaks the directory instead of removing it"
1048 );
1049 std::fs::remove_dir_all(&path).expect("test cleans up the deliberately leaked directory");
1050 }
1051
1052 #[test]
1056 fn rooted_ephemeral_store_lives_and_dies_under_the_given_root() {
1057 let root = tempfile::tempdir().expect("test can create a temp root");
1058 let store =
1059 open_ephemeral_rooted(root.path(), TEST_SHARD_COUNT).expect("rooted open succeeds");
1060 let dir = store
1061 .ephemeral_dir_path()
1062 .expect("ephemeral store carries a guard dir")
1063 .to_path_buf();
1064 assert!(
1065 dir.starts_with(root.path()),
1066 "the guard directory is created under the supplied root"
1067 );
1068
1069 write_one_event(&store);
1070 drop(store);
1071
1072 assert!(!dir.exists(), "the rooted directory is removed on drop");
1073 }
1074
1075 #[test]
1084 fn ephemeral_dir_persists_across_unrelated_work_then_goes_on_clean_drop() {
1085 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
1086 let dir = store
1087 .ephemeral_dir_path()
1088 .expect("ephemeral store carries a guard dir")
1089 .to_path_buf();
1090 assert!(
1091 dir.exists(),
1092 "the directory exists as soon as the store does"
1093 );
1094
1095 for round in 0..3_u64 {
1096 block_on(store.append("clean-teardown/probe", b"payload".to_vec(), round))
1097 .expect("bridge completes synchronously")
1098 .expect("append to a live ephemeral store succeeds");
1099 assert!(
1100 dir.exists(),
1101 "the directory is still there after append round {round}"
1102 );
1103 }
1104 block_on(store.cas("clean-teardown/counter", 0, 7))
1105 .expect("bridge completes synchronously")
1106 .expect("cas on a live ephemeral store succeeds");
1107 let entries = block_on(store.read_from("clean-teardown/probe", 0, 10))
1108 .expect("bridge completes synchronously")
1109 .expect("read from a live ephemeral store succeeds");
1110 assert_eq!(entries.len(), 3, "every appended entry is readable back");
1111 assert!(
1112 dir.exists(),
1113 "the directory is still there after unrelated cas and read work"
1114 );
1115
1116 block_on(store.flush())
1117 .expect("bridge completes synchronously")
1118 .expect("flush of a live ephemeral store succeeds");
1119 drop(store);
1120
1121 assert!(
1122 !dir.exists(),
1123 "the clean drop removes the directory it kept alive throughout"
1124 );
1125 }
1126
1127 #[cfg(unix)]
1136 #[test]
1137 fn clean_drop_removal_failure_is_logged_and_never_panics() {
1138 let parent = tempfile::tempdir().expect("test can create a temp parent");
1139 let dir = tempfile::Builder::new()
1140 .prefix("liminal-durability-")
1141 .tempdir_in(parent.path())
1142 .expect("test can create a guard dir under the parent");
1143 let path = dir.path().to_path_buf();
1144 let guard = EphemeralGuard {
1145 store: Some(()),
1146 dir: Some(dir),
1147 };
1148
1149 set_mode(parent.path(), 0o500);
1150 let captured = CapturedLog::default();
1151 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1152 captured.capturing(|| drop(guard));
1153 }));
1154 set_mode(parent.path(), 0o700);
1157
1158 assert!(
1159 outcome.is_ok(),
1160 "a removal failure is reported, never raised as a panic"
1161 );
1162 let logged = captured.text();
1163 assert!(
1164 logged.contains("ERROR"),
1165 "the removal failure is logged at error level; captured: {logged:?}"
1166 );
1167 assert!(
1168 logged.contains(&path.display().to_string()),
1169 "the log names the directory that survived; captured: {logged:?}"
1170 );
1171 assert!(
1172 path.exists(),
1173 "the residue is left where the log says it is, not silently claimed removed"
1174 );
1175 }
1176
1177 #[test]
1181 fn clean_drop_that_succeeds_logs_nothing() {
1182 let dir = tempfile::tempdir().expect("test can create a temp dir");
1183 let path = dir.path().to_path_buf();
1184 let guard = EphemeralGuard {
1185 store: Some(()),
1186 dir: Some(dir),
1187 };
1188
1189 let captured = CapturedLog::default();
1190 captured.capturing(|| drop(guard));
1191
1192 assert!(!path.exists(), "the successful clean drop removed the dir");
1193 assert!(
1194 captured.text().is_empty(),
1195 "a successful removal is silent; captured: {:?}",
1196 captured.text()
1197 );
1198 }
1199
1200 #[test]
1207 fn panic_path_leak_is_logged_with_its_path() {
1208 let dir = tempfile::tempdir().expect("test can create a temp dir");
1209 let path = dir.path().to_path_buf();
1210 let guard = EphemeralGuard {
1211 store: Some(PanickingProbeStore),
1212 dir: Some(dir),
1213 };
1214
1215 let captured = CapturedLog::default();
1216 let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1217 captured.capturing(|| drop(guard));
1218 }));
1219
1220 assert!(unwound.is_err(), "the injected store-drop panic propagates");
1221 let logged = captured.text();
1222 assert!(
1223 logged.contains("ERROR"),
1224 "the sanctioned leak is logged at error level; captured: {logged:?}"
1225 );
1226 assert!(
1227 logged.contains(&path.display().to_string()),
1228 "the leak log names the leaked directory; captured: {logged:?}"
1229 );
1230 std::fs::remove_dir_all(&path).expect("test cleans up the deliberately leaked directory");
1231 }
1232}
1233
1234#[cfg(test)]
1235#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1236mod paged_read_shape_tests {
1237 use super::{DurableStore, EngineReadAccountingGuard, open_ephemeral};
1244 use crate::durability::bridge::block_on;
1245
1246 const PAGE: usize = 64;
1249 const ROWS: u64 = 256;
1252 const STREAM: &str = "liminal/p0-60/paged-read-shape";
1253
1254 fn seeded() -> Result<impl DurableStore, Box<dyn std::error::Error>> {
1256 let store = open_ephemeral(1)?;
1257 for sequence in 0..ROWS {
1258 block_on(store.append(STREAM, sequence.to_be_bytes().to_vec(), sequence))??;
1259 }
1260 block_on(store.flush())??;
1261 Ok(store)
1262 }
1263
1264 fn read_whole_stream(
1266 store: &impl DurableStore,
1267 page: usize,
1268 ) -> Result<usize, Box<dyn std::error::Error>> {
1269 let mut offset = 0_u64;
1270 let mut seen = 0_usize;
1271 loop {
1272 let entries = block_on(store.read_from(STREAM, offset, page))??;
1273 if entries.is_empty() {
1274 return Ok(seen);
1275 }
1276 for entry in &entries {
1277 assert_eq!(entry.sequence, offset, "paged read must stay contiguous");
1278 offset += 1;
1279 }
1280 seen = seen
1281 .checked_add(entries.len())
1282 .ok_or("row counter overflowed")?;
1283 }
1284 }
1285
1286 #[test]
1289 fn a_bounded_read_never_scans_beyond_its_page() -> Result<(), Box<dyn std::error::Error>> {
1290 let store = seeded()?;
1291
1292 let accounting = EngineReadAccountingGuard::start();
1294 let seen = read_whole_stream(&store, PAGE)?;
1295 let walk = accounting.snapshot();
1296 drop(accounting);
1297 assert_eq!(
1298 u64::try_from(seen)?,
1299 ROWS,
1300 "the walk must deliver every row"
1301 );
1302 assert!(
1303 !walk.counter_overflow_observed,
1304 "a saturated counter is not a measurement"
1305 );
1306 assert!(walk.calls > 0, "the walk must have reached the store");
1307 assert_eq!(
1308 u64::try_from(walk.engine_entries)?,
1309 ROWS,
1310 "a full stream read must scan each row exactly once instead of \
1311 re-scanning every suffix once per page"
1312 );
1313
1314 let accounting = EngineReadAccountingGuard::start();
1316 let head = block_on(store.read_from(STREAM, 0, PAGE))??;
1317 let head_read = accounting.snapshot();
1318 drop(accounting);
1319 assert_eq!(head.len(), PAGE, "a full page returns its limit");
1320 assert_eq!(
1321 head_read.engine_entries, PAGE,
1322 "the engine must be asked for one page, not for the whole stream"
1323 );
1324
1325 let middle_offset = ROWS / 2;
1330 let accounting = EngineReadAccountingGuard::start();
1331 let middle = block_on(store.read_from(STREAM, middle_offset, PAGE))??;
1332 let middle_read = accounting.snapshot();
1333 drop(accounting);
1334 assert_eq!(
1335 middle.len(),
1336 PAGE,
1337 "a full page mid-stream returns its limit"
1338 );
1339 assert_eq!(
1340 middle_read.engine_entries, PAGE,
1341 "a mid-stream page must not scan the rows that follow it"
1342 );
1343
1344 let accounting = EngineReadAccountingGuard::start();
1346 let past = block_on(store.read_from(STREAM, ROWS, PAGE))??;
1347 let past_read = accounting.snapshot();
1348 drop(accounting);
1349 assert!(past.is_empty(), "past the head is end of stream");
1350 assert_eq!(
1351 past_read.engine_entries, 0,
1352 "an end-of-stream page must not scan the stream"
1353 );
1354 Ok(())
1355 }
1356
1357 #[test]
1361 fn page_size_never_changes_the_answer() -> Result<(), Box<dyn std::error::Error>> {
1362 let store = seeded()?;
1363 let whole = block_on(store.read_from(STREAM, 0, usize::MAX))??;
1364 assert_eq!(u64::try_from(whole.len())?, ROWS);
1365
1366 for page in [1_usize, 7, 64, 255, 256, 257] {
1367 let mut offset = 0_u64;
1368 let mut collected = Vec::new();
1369 loop {
1370 let entries = block_on(store.read_from(STREAM, offset, page))??;
1371 if entries.is_empty() {
1372 break;
1373 }
1374 assert!(entries.len() <= page, "a page never exceeds its limit");
1375 offset = offset
1376 .checked_add(u64::try_from(entries.len())?)
1377 .ok_or("offset overflowed")?;
1378 collected.extend(entries);
1379 }
1380 assert_eq!(collected, whole, "page size {page} changed the answer");
1381 }
1382
1383 assert!(
1386 block_on(store.read_from(STREAM, 0, 0))??.is_empty(),
1387 "a zero limit reads nothing"
1388 );
1389
1390 for offset in [0_u64, 1, 63, 64, 65, 128, 255] {
1392 let suffix = block_on(store.read_from(STREAM, offset, usize::MAX))??;
1393 assert_eq!(
1394 suffix,
1395 whole[usize::try_from(offset)?..],
1396 "suffix from {offset} diverged"
1397 );
1398 }
1399 Ok(())
1400 }
1401}