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 })
606 .map_err(|error| DurabilityError::EphemeralStoreOpen(error.to_string()))?;
607 Ok(EphemeralHaematiteStore::new(database, ephemeral_dir))
608}
609
610#[cfg(test)]
618#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
619pub(crate) struct EngineReadAccounting {
620 pub(crate) calls: usize,
622 pub(crate) engine_entries: usize,
624 pub(crate) unbounded_calls: usize,
626 pub(crate) counter_overflow_observed: bool,
628}
629
630#[cfg(test)]
631std::thread_local! {
632 static ENGINE_READ_ACCOUNTING: std::cell::RefCell<Option<EngineReadAccounting>> =
633 const { std::cell::RefCell::new(None) };
634}
635
636#[cfg(test)]
641pub(crate) struct EngineReadAccountingGuard {
642 _not_send: std::marker::PhantomData<*const ()>,
643}
644
645#[cfg(test)]
646impl EngineReadAccountingGuard {
647 pub(crate) fn start() -> Self {
648 ENGINE_READ_ACCOUNTING.with(|accounting| {
649 *accounting.borrow_mut() = Some(EngineReadAccounting::default());
650 });
651 Self {
652 _not_send: std::marker::PhantomData,
653 }
654 }
655
656 #[allow(clippy::unused_self)]
657 pub(crate) fn snapshot(&self) -> EngineReadAccounting {
658 ENGINE_READ_ACCOUNTING
659 .with(|accounting| accounting.borrow().as_ref().copied().unwrap_or_default())
660 }
661}
662
663#[cfg(test)]
664impl Drop for EngineReadAccountingGuard {
665 fn drop(&mut self) {
666 ENGINE_READ_ACCOUNTING.with(|accounting| {
667 *accounting.borrow_mut() = None;
668 });
669 }
670}
671
672#[cfg(test)]
674fn account_engine_read(engine_entries: usize, unbounded: bool) {
675 ENGINE_READ_ACCOUNTING.with(|accounting| {
676 if let Some(active) = accounting.borrow_mut().as_mut() {
677 match (
678 active.calls.checked_add(1),
679 active.engine_entries.checked_add(engine_entries),
680 ) {
681 (Some(calls), Some(entries)) => {
682 active.calls = calls;
683 active.engine_entries = entries;
684 }
685 _ => active.counter_overflow_observed = true,
686 }
687 if unbounded {
688 match active.unbounded_calls.checked_add(1) {
689 Some(unbounded_calls) => active.unbounded_calls = unbounded_calls,
690 None => active.counter_overflow_observed = true,
691 }
692 }
693 }
694 });
695}
696
697#[cfg(not(test))]
698const fn account_engine_read(_engine_entries: usize, _unbounded: bool) {}
699
700impl From<Event> for StoredEntry {
701 fn from(event: Event) -> Self {
702 Self {
703 payload: event.payload,
704 sequence: event.seq,
705 timestamp: event.timestamp,
706 }
707 }
708}
709
710impl From<ApiError> for DurabilityError {
716 fn from(error: ApiError) -> Self {
717 match error {
718 ApiError::SequenceConflict(conflict) => conflict.into(),
719 ApiError::CasMismatch(mismatch) => mismatch.into(),
720 other @ (ApiError::CorruptEvent(_)
721 | ApiError::Storage(_)
722 | ApiError::HistoryCompacted(_)) => Self::StoreError(other),
723 }
724 }
725}
726
727#[cfg(test)]
728#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
729mod ephemeral_lifecycle_tests {
730 use std::path::{Path, PathBuf};
735 use std::sync::{Arc, Mutex};
736
737 use super::super::bridge::block_on;
738 use super::{
739 DurableStore, EphemeralGuard, open_ephemeral, open_ephemeral_in, open_ephemeral_rooted,
740 };
741
742 const TEST_SHARD_COUNT: usize = 2;
743
744 #[derive(Clone, Default)]
753 struct CapturedLog(Arc<Mutex<Vec<u8>>>);
754
755 impl CapturedLog {
756 fn text(&self) -> String {
758 let bytes = self
759 .0
760 .lock()
761 .expect("capture buffer is not poisoned")
762 .clone();
763 String::from_utf8(bytes).expect("tracing's fmt writer emits utf-8")
764 }
765
766 fn capturing<R>(&self, body: impl FnOnce() -> R) -> R {
782 static INSTALL: std::sync::Once = std::sync::Once::new();
783 struct ResetOnDrop;
785 impl Drop for ResetOnDrop {
786 fn drop(&mut self) {
787 ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = None);
788 }
789 }
790 INSTALL.call_once(|| {
791 let subscriber = tracing_subscriber::fmt()
792 .with_writer(RoutedWriter)
793 .with_ansi(false)
794 .finish();
795 tracing::subscriber::set_global_default(subscriber)
796 .expect("no other global tracing subscriber is installed in this test binary");
797 });
798 ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = Some(self.clone()));
799 let _reset = ResetOnDrop;
800 body()
801 }
802 }
803
804 thread_local! {
805 static ACTIVE_CAPTURE: std::cell::RefCell<Option<CapturedLog>> =
808 const { std::cell::RefCell::new(None) };
809 }
810
811 #[derive(Clone, Copy, Default)]
815 struct RoutedWriter;
816
817 impl std::io::Write for RoutedWriter {
818 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
819 ACTIVE_CAPTURE.with(|slot| {
820 if let Some(capture) = slot.borrow().as_ref() {
821 capture
822 .0
823 .lock()
824 .map_err(|_| std::io::Error::other("capture buffer poisoned"))?
825 .extend_from_slice(buf);
826 }
827 Ok(buf.len())
828 })
829 }
830
831 fn flush(&mut self) -> std::io::Result<()> {
832 Ok(())
833 }
834 }
835
836 impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for RoutedWriter {
837 type Writer = Self;
838
839 fn make_writer(&'writer self) -> Self::Writer {
840 *self
841 }
842 }
843
844 #[cfg(unix)]
850 fn set_mode(path: &Path, mode: u32) {
851 use std::os::unix::fs::PermissionsExt;
852
853 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
854 .expect("test can set permissions on a directory it created");
855 }
856
857 struct OrderProbeStore {
861 dir: PathBuf,
862 }
863
864 impl Drop for OrderProbeStore {
865 fn drop(&mut self) {
866 assert!(
867 self.dir.exists(),
868 "the guard must drop the store BEFORE removing the directory"
869 );
870 }
871 }
872
873 struct PanickingProbeStore;
876
877 impl Drop for PanickingProbeStore {
878 fn drop(&mut self) {
879 panic!("injected store-drop panic");
880 }
881 }
882
883 fn write_one_event(store: &dyn DurableStore) {
886 block_on(store.append("lifecycle/probe", b"payload".to_vec(), 0))
887 .expect("bridge completes synchronously")
888 .expect("append to a fresh ephemeral stream succeeds");
889 block_on(store.flush())
890 .expect("bridge completes synchronously")
891 .expect("flush of a live ephemeral store succeeds");
892 }
893
894 #[test]
897 fn ephemeral_dir_removed_after_last_handle_drops() {
898 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
899 let dir = store
900 .ephemeral_dir_path()
901 .expect("ephemeral store carries a guard dir")
902 .to_path_buf();
903 assert!(
904 dir.exists(),
905 "the guard directory exists while the store is live"
906 );
907
908 write_one_event(&store);
909 drop(store);
910
911 assert!(
912 !dir.exists(),
913 "the guard directory is removed on normal drop"
914 );
915 }
916
917 #[test]
922 fn ephemeral_dir_survives_until_last_store_clone_drops() {
923 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
924 let dir = store
925 .ephemeral_dir_path()
926 .expect("ephemeral store carries a guard dir")
927 .to_path_buf();
928 write_one_event(&store);
929
930 let erased: Arc<dyn DurableStore> = Arc::new(store);
931 let clone_a = Arc::clone(&erased);
932 let clone_b = Arc::clone(&erased);
933
934 drop(erased);
935 assert!(
936 dir.exists(),
937 "directory survives while store clones remain alive"
938 );
939 drop(clone_a);
940 assert!(
941 dir.exists(),
942 "directory survives while one store clone remains alive"
943 );
944
945 drop(clone_b);
946 assert!(
947 !dir.exists(),
948 "the last store clone dropping removes the directory"
949 );
950 }
951
952 #[test]
957 fn ephemeral_open_failure_rolls_back_directory() {
958 let seeded = tempfile::Builder::new()
959 .prefix("liminal-durability-test-")
960 .tempdir()
961 .expect("test can create a temp dir");
962 let dir = seeded.path().to_path_buf();
963 std::fs::write(dir.join("config.json"), b"not-a-valid-config")
967 .expect("test can seed a conflicting config");
968
969 let result = open_ephemeral_in(seeded, TEST_SHARD_COUNT);
970
971 assert!(result.is_err(), "an injected open failure returns Err");
972 assert!(
973 !dir.exists(),
974 "the guard removes the directory on open failure — zero residue"
975 );
976 }
977
978 #[test]
981 fn repeated_ephemeral_cycles_each_own_distinct_dir_zero_residue() {
982 let mut seen: Vec<PathBuf> = Vec::new();
983 for _ in 0..5 {
984 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
985 let dir = store
986 .ephemeral_dir_path()
987 .expect("ephemeral store carries a guard dir")
988 .to_path_buf();
989 assert!(
990 dir.exists(),
991 "the cycle's directory exists while its store is live"
992 );
993 assert!(!seen.contains(&dir), "each cycle owns a distinct directory");
994 seen.push(dir.clone());
995
996 write_one_event(&store);
997 drop(store);
998 assert!(
999 !dir.exists(),
1000 "the cycle's directory is removed after its store drops"
1001 );
1002 }
1003 }
1004
1005 #[test]
1010 fn guard_drops_store_before_removing_directory() {
1011 let dir = tempfile::tempdir().expect("test can create a temp dir");
1012 let path = dir.path().to_path_buf();
1013 let guard = EphemeralGuard {
1014 store: Some(OrderProbeStore { dir: path.clone() }),
1015 dir: Some(dir),
1016 };
1017
1018 drop(guard);
1019
1020 assert!(!path.exists(), "a clean drop still removes the directory");
1021 }
1022
1023 #[test]
1027 fn guard_leaks_directory_when_store_drop_panics() {
1028 let dir = tempfile::tempdir().expect("test can create a temp dir");
1029 let path = dir.path().to_path_buf();
1030 let guard = EphemeralGuard {
1031 store: Some(PanickingProbeStore),
1032 dir: Some(dir),
1033 };
1034
1035 let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(guard)));
1036
1037 assert!(unwound.is_err(), "the injected store-drop panic propagates");
1038 assert!(
1039 path.exists(),
1040 "a panicking store drop leaks the directory instead of removing it"
1041 );
1042 std::fs::remove_dir_all(&path).expect("test cleans up the deliberately leaked directory");
1043 }
1044
1045 #[test]
1049 fn rooted_ephemeral_store_lives_and_dies_under_the_given_root() {
1050 let root = tempfile::tempdir().expect("test can create a temp root");
1051 let store =
1052 open_ephemeral_rooted(root.path(), TEST_SHARD_COUNT).expect("rooted open succeeds");
1053 let dir = store
1054 .ephemeral_dir_path()
1055 .expect("ephemeral store carries a guard dir")
1056 .to_path_buf();
1057 assert!(
1058 dir.starts_with(root.path()),
1059 "the guard directory is created under the supplied root"
1060 );
1061
1062 write_one_event(&store);
1063 drop(store);
1064
1065 assert!(!dir.exists(), "the rooted directory is removed on drop");
1066 }
1067
1068 #[test]
1077 fn ephemeral_dir_persists_across_unrelated_work_then_goes_on_clean_drop() {
1078 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
1079 let dir = store
1080 .ephemeral_dir_path()
1081 .expect("ephemeral store carries a guard dir")
1082 .to_path_buf();
1083 assert!(
1084 dir.exists(),
1085 "the directory exists as soon as the store does"
1086 );
1087
1088 for round in 0..3_u64 {
1089 block_on(store.append("clean-teardown/probe", b"payload".to_vec(), round))
1090 .expect("bridge completes synchronously")
1091 .expect("append to a live ephemeral store succeeds");
1092 assert!(
1093 dir.exists(),
1094 "the directory is still there after append round {round}"
1095 );
1096 }
1097 block_on(store.cas("clean-teardown/counter", 0, 7))
1098 .expect("bridge completes synchronously")
1099 .expect("cas on a live ephemeral store succeeds");
1100 let entries = block_on(store.read_from("clean-teardown/probe", 0, 10))
1101 .expect("bridge completes synchronously")
1102 .expect("read from a live ephemeral store succeeds");
1103 assert_eq!(entries.len(), 3, "every appended entry is readable back");
1104 assert!(
1105 dir.exists(),
1106 "the directory is still there after unrelated cas and read work"
1107 );
1108
1109 block_on(store.flush())
1110 .expect("bridge completes synchronously")
1111 .expect("flush of a live ephemeral store succeeds");
1112 drop(store);
1113
1114 assert!(
1115 !dir.exists(),
1116 "the clean drop removes the directory it kept alive throughout"
1117 );
1118 }
1119
1120 #[cfg(unix)]
1129 #[test]
1130 fn clean_drop_removal_failure_is_logged_and_never_panics() {
1131 let parent = tempfile::tempdir().expect("test can create a temp parent");
1132 let dir = tempfile::Builder::new()
1133 .prefix("liminal-durability-")
1134 .tempdir_in(parent.path())
1135 .expect("test can create a guard dir under the parent");
1136 let path = dir.path().to_path_buf();
1137 let guard = EphemeralGuard {
1138 store: Some(()),
1139 dir: Some(dir),
1140 };
1141
1142 set_mode(parent.path(), 0o500);
1143 let captured = CapturedLog::default();
1144 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1145 captured.capturing(|| drop(guard));
1146 }));
1147 set_mode(parent.path(), 0o700);
1150
1151 assert!(
1152 outcome.is_ok(),
1153 "a removal failure is reported, never raised as a panic"
1154 );
1155 let logged = captured.text();
1156 assert!(
1157 logged.contains("ERROR"),
1158 "the removal failure is logged at error level; captured: {logged:?}"
1159 );
1160 assert!(
1161 logged.contains(&path.display().to_string()),
1162 "the log names the directory that survived; captured: {logged:?}"
1163 );
1164 assert!(
1165 path.exists(),
1166 "the residue is left where the log says it is, not silently claimed removed"
1167 );
1168 }
1169
1170 #[test]
1174 fn clean_drop_that_succeeds_logs_nothing() {
1175 let dir = tempfile::tempdir().expect("test can create a temp dir");
1176 let path = dir.path().to_path_buf();
1177 let guard = EphemeralGuard {
1178 store: Some(()),
1179 dir: Some(dir),
1180 };
1181
1182 let captured = CapturedLog::default();
1183 captured.capturing(|| drop(guard));
1184
1185 assert!(!path.exists(), "the successful clean drop removed the dir");
1186 assert!(
1187 captured.text().is_empty(),
1188 "a successful removal is silent; captured: {:?}",
1189 captured.text()
1190 );
1191 }
1192
1193 #[test]
1200 fn panic_path_leak_is_logged_with_its_path() {
1201 let dir = tempfile::tempdir().expect("test can create a temp dir");
1202 let path = dir.path().to_path_buf();
1203 let guard = EphemeralGuard {
1204 store: Some(PanickingProbeStore),
1205 dir: Some(dir),
1206 };
1207
1208 let captured = CapturedLog::default();
1209 let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1210 captured.capturing(|| drop(guard));
1211 }));
1212
1213 assert!(unwound.is_err(), "the injected store-drop panic propagates");
1214 let logged = captured.text();
1215 assert!(
1216 logged.contains("ERROR"),
1217 "the sanctioned leak is logged at error level; captured: {logged:?}"
1218 );
1219 assert!(
1220 logged.contains(&path.display().to_string()),
1221 "the leak log names the leaked directory; captured: {logged:?}"
1222 );
1223 std::fs::remove_dir_all(&path).expect("test cleans up the deliberately leaked directory");
1224 }
1225}
1226
1227#[cfg(test)]
1228#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1229mod paged_read_shape_tests {
1230 use super::{DurableStore, EngineReadAccountingGuard, open_ephemeral};
1237 use crate::durability::bridge::block_on;
1238
1239 const PAGE: usize = 64;
1242 const ROWS: u64 = 256;
1245 const STREAM: &str = "liminal/p0-60/paged-read-shape";
1246
1247 fn seeded() -> Result<impl DurableStore, Box<dyn std::error::Error>> {
1249 let store = open_ephemeral(1)?;
1250 for sequence in 0..ROWS {
1251 block_on(store.append(STREAM, sequence.to_be_bytes().to_vec(), sequence))??;
1252 }
1253 block_on(store.flush())??;
1254 Ok(store)
1255 }
1256
1257 fn read_whole_stream(
1259 store: &impl DurableStore,
1260 page: usize,
1261 ) -> Result<usize, Box<dyn std::error::Error>> {
1262 let mut offset = 0_u64;
1263 let mut seen = 0_usize;
1264 loop {
1265 let entries = block_on(store.read_from(STREAM, offset, page))??;
1266 if entries.is_empty() {
1267 return Ok(seen);
1268 }
1269 for entry in &entries {
1270 assert_eq!(entry.sequence, offset, "paged read must stay contiguous");
1271 offset += 1;
1272 }
1273 seen = seen
1274 .checked_add(entries.len())
1275 .ok_or("row counter overflowed")?;
1276 }
1277 }
1278
1279 #[test]
1282 fn a_bounded_read_never_scans_beyond_its_page() -> Result<(), Box<dyn std::error::Error>> {
1283 let store = seeded()?;
1284
1285 let accounting = EngineReadAccountingGuard::start();
1287 let seen = read_whole_stream(&store, PAGE)?;
1288 let walk = accounting.snapshot();
1289 drop(accounting);
1290 assert_eq!(
1291 u64::try_from(seen)?,
1292 ROWS,
1293 "the walk must deliver every row"
1294 );
1295 assert!(
1296 !walk.counter_overflow_observed,
1297 "a saturated counter is not a measurement"
1298 );
1299 assert!(walk.calls > 0, "the walk must have reached the store");
1300 assert_eq!(
1301 u64::try_from(walk.engine_entries)?,
1302 ROWS,
1303 "a full stream read must scan each row exactly once instead of \
1304 re-scanning every suffix once per page"
1305 );
1306
1307 let accounting = EngineReadAccountingGuard::start();
1309 let head = block_on(store.read_from(STREAM, 0, PAGE))??;
1310 let head_read = accounting.snapshot();
1311 drop(accounting);
1312 assert_eq!(head.len(), PAGE, "a full page returns its limit");
1313 assert_eq!(
1314 head_read.engine_entries, PAGE,
1315 "the engine must be asked for one page, not for the whole stream"
1316 );
1317
1318 let middle_offset = ROWS / 2;
1323 let accounting = EngineReadAccountingGuard::start();
1324 let middle = block_on(store.read_from(STREAM, middle_offset, PAGE))??;
1325 let middle_read = accounting.snapshot();
1326 drop(accounting);
1327 assert_eq!(
1328 middle.len(),
1329 PAGE,
1330 "a full page mid-stream returns its limit"
1331 );
1332 assert_eq!(
1333 middle_read.engine_entries, PAGE,
1334 "a mid-stream page must not scan the rows that follow it"
1335 );
1336
1337 let accounting = EngineReadAccountingGuard::start();
1339 let past = block_on(store.read_from(STREAM, ROWS, PAGE))??;
1340 let past_read = accounting.snapshot();
1341 drop(accounting);
1342 assert!(past.is_empty(), "past the head is end of stream");
1343 assert_eq!(
1344 past_read.engine_entries, 0,
1345 "an end-of-stream page must not scan the stream"
1346 );
1347 Ok(())
1348 }
1349
1350 #[test]
1354 fn page_size_never_changes_the_answer() -> Result<(), Box<dyn std::error::Error>> {
1355 let store = seeded()?;
1356 let whole = block_on(store.read_from(STREAM, 0, usize::MAX))??;
1357 assert_eq!(u64::try_from(whole.len())?, ROWS);
1358
1359 for page in [1_usize, 7, 64, 255, 256, 257] {
1360 let mut offset = 0_u64;
1361 let mut collected = Vec::new();
1362 loop {
1363 let entries = block_on(store.read_from(STREAM, offset, page))??;
1364 if entries.is_empty() {
1365 break;
1366 }
1367 assert!(entries.len() <= page, "a page never exceeds its limit");
1368 offset = offset
1369 .checked_add(u64::try_from(entries.len())?)
1370 .ok_or("offset overflowed")?;
1371 collected.extend(entries);
1372 }
1373 assert_eq!(collected, whole, "page size {page} changed the answer");
1374 }
1375
1376 assert!(
1379 block_on(store.read_from(STREAM, 0, 0))??.is_empty(),
1380 "a zero limit reads nothing"
1381 );
1382
1383 for offset in [0_u64, 1, 63, 64, 65, 128, 255] {
1385 let suffix = block_on(store.read_from(STREAM, offset, usize::MAX))??;
1386 assert_eq!(
1387 suffix,
1388 whole[usize::try_from(offset)?..],
1389 "suffix from {offset} diverged"
1390 );
1391 }
1392 Ok(())
1393 }
1394}