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 append_all(
48 &self,
49 stream_key: &str,
50 payloads: Vec<Vec<u8>>,
51 expected_seq: u64,
52 ) -> Result<u64, DurabilityError> {
53 let mut next = expected_seq;
54 for payload in payloads {
55 let assigned = self.append(stream_key, payload, next).await?;
56 if assigned != next {
57 return Err(DurabilityError::SequenceConflict {
58 expected: next,
59 actual: assigned,
60 });
61 }
62 next = next.checked_add(1).ok_or_else(|| {
63 DurabilityError::ConfigError(format!(
64 "stream {stream_key} sequence overflowed during a multi-event append"
65 ))
66 })?;
67 }
68 Ok(next)
69 }
70
71 async fn read_at(
73 &self,
74 stream_key: &str,
75 sequence: u64,
76 ) -> Result<Option<StoredEntry>, DurabilityError> {
77 Ok(self
78 .read_from(stream_key, sequence, 1)
79 .await?
80 .into_iter()
81 .next())
82 }
83
84 async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError>;
91
92 async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError>;
94
95 async fn scan(&self, prefix: &str) -> Result<Vec<StoredEntry>, DurabilityError>;
97
98 async fn flush(&self) -> Result<(), DurabilityError>;
103}
104
105#[derive(Clone, Debug)]
111pub struct HaematiteStore {
112 event_store: Arc<EventStore>,
113}
114
115impl HaematiteStore {
116 #[must_use]
118 pub const fn new(event_store: Arc<EventStore>) -> Self {
119 Self { event_store }
120 }
121
122 fn bounded_page(
134 &self,
135 stream_key: &str,
136 offset: u64,
137 limit: usize,
138 ) -> Result<Option<Vec<StoredEntry>>, DurabilityError> {
139 const TIMESTAMP_WIDTH: usize = std::mem::size_of::<u64>();
140
141 let Some(engine_from) = offset.checked_add(1) else {
143 return Ok(None);
144 };
145 let Some(engine_end) = u64::try_from(limit)
146 .ok()
147 .and_then(|limit| engine_from.checked_add(limit))
148 else {
149 return Ok(None);
150 };
151 let key = stream_key.as_bytes();
152 let from = haematite::encode_stream_key(key, engine_from);
153 let to = haematite::encode_stream_key(key, engine_end);
154 let entries = self
155 .event_store
156 .database()
157 .range_routed(key, &from, &to)
158 .map_err(ApiError::from)
159 .map_err(DurabilityError::from)?;
160 if entries.len() != limit {
161 return Ok(None);
162 }
163
164 let mut page = Vec::with_capacity(entries.len());
165 for (encoded_key, value) in entries {
166 let Some((decoded_key, engine_sequence)) = haematite::decode_stream_key(&encoded_key)
167 else {
168 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
169 format!("paged read key does not encode an event for stream {stream_key}"),
170 )));
171 };
172 if decoded_key != key {
173 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
174 format!("paged read key does not encode stream {stream_key}"),
175 )));
176 }
177 let sequence = engine_sequence.checked_sub(1).ok_or_else(|| {
178 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
179 "paged read event key has zero seq for stream {stream_key}"
180 )))
181 })?;
182 let Some(timestamp_bytes) = value.get(..TIMESTAMP_WIDTH) else {
183 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
184 format!(
185 "paged read event value is shorter than its timestamp for stream {stream_key}"
186 ),
187 )));
188 };
189 let timestamp = u64::from_be_bytes(timestamp_bytes.try_into().map_err(|_| {
190 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
191 "paged read event timestamp has the wrong width for stream {stream_key}"
192 )))
193 })?);
194 let Some(payload) = value.get(TIMESTAMP_WIDTH..) else {
195 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
196 format!("paged read event has no payload boundary for stream {stream_key}"),
197 )));
198 };
199 page.push(StoredEntry {
200 payload: payload.to_vec(),
201 sequence,
202 timestamp,
203 });
204 }
205 Ok(Some(page))
206 }
207}
208
209#[async_trait::async_trait]
210impl DurableStore for HaematiteStore {
211 async fn append(
212 &self,
213 stream_key: &str,
214 payload: Vec<u8>,
215 expected_seq: u64,
216 ) -> Result<u64, DurabilityError> {
217 let next_seq = self
225 .event_store
226 .append(stream_key.as_bytes(), &payload, expected_seq)
227 .map_err(DurabilityError::from)?;
228 next_seq.checked_sub(1).ok_or_else(|| {
229 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
230 "append returned next-seq 0 for stream {stream_key}"
231 )))
232 })
233 }
234
235 async fn append_all(
236 &self,
237 stream_key: &str,
238 payloads: Vec<Vec<u8>>,
239 expected_seq: u64,
240 ) -> Result<u64, DurabilityError> {
241 let borrowed: Vec<&[u8]> = payloads.iter().map(Vec::as_slice).collect();
242 self.event_store
243 .append_batch(stream_key.as_bytes(), &borrowed, expected_seq)
244 .map_err(DurabilityError::from)
245 }
246
247 async fn read_from(
248 &self,
249 stream_key: &str,
250 offset: u64,
251 limit: usize,
252 ) -> Result<Vec<StoredEntry>, DurabilityError> {
253 if limit > 0 {
276 if let Some(page) = self.bounded_page(stream_key, offset, limit)? {
277 account_engine_read(page.len(), false);
278 return Ok(page);
279 }
280 }
281 let mut events = self
282 .event_store
283 .read_from(stream_key.as_bytes(), offset)
284 .map_err(DurabilityError::from)?;
285 account_engine_read(events.len(), true);
286 events.truncate(limit);
287 Ok(events.into_iter().map(StoredEntry::from).collect())
288 }
289
290 async fn read_at(
291 &self,
292 stream_key: &str,
293 sequence: u64,
294 ) -> Result<Option<StoredEntry>, DurabilityError> {
295 const TIMESTAMP_WIDTH: usize = std::mem::size_of::<u64>();
296
297 let engine_sequence = sequence.checked_add(1).ok_or_else(|| {
298 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
299 "point read sequence overflow for stream {stream_key}"
300 )))
301 })?;
302 let event_key = haematite::encode_stream_key(stream_key.as_bytes(), engine_sequence);
303 let Some(value) = self
304 .event_store
305 .database()
306 .get_routed(stream_key.as_bytes(), &event_key)
307 .map_err(ApiError::from)
308 .map_err(DurabilityError::from)?
309 else {
310 return Ok(None);
311 };
312 let Some(timestamp_bytes) = value.get(..TIMESTAMP_WIDTH) else {
313 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
314 format!(
315 "point-read event value is shorter than its timestamp for stream {stream_key}"
316 ),
317 )));
318 };
319 let timestamp = u64::from_be_bytes(timestamp_bytes.try_into().map_err(|_| {
320 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
321 "point-read event timestamp has the wrong width for stream {stream_key}"
322 )))
323 })?);
324 let Some(payload) = value.get(TIMESTAMP_WIDTH..) else {
325 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
326 format!("point-read event has no payload boundary for stream {stream_key}"),
327 )));
328 };
329 Ok(Some(StoredEntry {
330 payload: payload.to_vec(),
331 sequence,
332 timestamp,
333 }))
334 }
335
336 async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
337 if new_value == 0 {
352 return self
353 .event_store
354 .read_value(key.as_bytes())
355 .map_err(DurabilityError::from)?
356 .map_or(Ok(()), |stored| {
357 Err(DurabilityError::CursorRegression {
358 stored,
359 attempted: old_value,
360 })
361 });
362 }
363 let expected = if old_value == 0 {
369 None
370 } else {
371 Some(old_value)
372 };
373 self.event_store
374 .cas(key.as_bytes(), expected, new_value)
375 .map_err(DurabilityError::from)
376 }
377
378 async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
379 self.event_store
380 .read_value(key.as_bytes())
381 .map_err(DurabilityError::from)
382 }
383
384 async fn scan(&self, prefix: &str) -> Result<Vec<StoredEntry>, DurabilityError> {
385 let prefix_bytes = prefix.as_bytes().to_vec();
390 let matches = self
391 .event_store
392 .scan(|meta| meta.stream_key.starts_with(&prefix_bytes))
393 .map_err(DurabilityError::from)?;
394 let mut entries = Vec::new();
395 for stream in matches {
396 let events = self
397 .event_store
398 .read(&stream.stream_key)
399 .map_err(DurabilityError::from)?;
400 entries.extend(events.into_iter().map(StoredEntry::from));
401 }
402 Ok(entries)
403 }
404
405 async fn flush(&self) -> Result<(), DurabilityError> {
406 self.event_store.flush().map_err(DurabilityError::from)
407 }
408}
409
410#[derive(Debug)]
436struct EphemeralGuard<S> {
437 store: Option<S>,
438 dir: Option<TempDir>,
439}
440
441impl<S> Drop for EphemeralGuard<S> {
442 fn drop(&mut self) {
443 let store = self.store.take();
444 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(store)));
448 if let Err(panic) = outcome {
449 if let Some(dir) = self.dir.take() {
450 let leaked = dir.keep();
451 tracing::error!(
452 path = %leaked.display(),
453 "ephemeral store drop panicked; leaking its directory rather than \
454 removing it under possibly-live database workers"
455 );
456 }
457 std::panic::resume_unwind(panic);
458 }
459 if let Some(dir) = self.dir.take() {
463 let path = dir.path().to_path_buf();
464 if let Err(error) = dir.close() {
465 tracing::error!(
466 path = %path.display(),
467 %error,
468 "ephemeral store directory removal failed; residue remains at the \
469 logged path"
470 );
471 }
472 }
473 }
474}
475
476#[derive(Debug)]
492pub struct EphemeralHaematiteStore {
493 guard: EphemeralGuard<HaematiteStore>,
494}
495
496impl EphemeralHaematiteStore {
497 fn new(database: Database, ephemeral_dir: TempDir) -> Self {
506 Self {
507 guard: EphemeralGuard {
508 store: Some(HaematiteStore::new(Arc::new(EventStore::new(database)))),
509 dir: Some(ephemeral_dir),
510 },
511 }
512 }
513
514 fn store(&self) -> Result<&HaematiteStore, DurabilityError> {
521 self.guard
522 .store
523 .as_ref()
524 .ok_or(DurabilityError::EphemeralStoreDetached)
525 }
526
527 #[cfg(test)]
529 pub(crate) fn ephemeral_dir_path(&self) -> Option<&Path> {
530 self.guard.dir.as_ref().map(TempDir::path)
531 }
532}
533
534#[async_trait::async_trait]
535impl DurableStore for EphemeralHaematiteStore {
536 async fn append(
537 &self,
538 stream_key: &str,
539 payload: Vec<u8>,
540 expected_seq: u64,
541 ) -> Result<u64, DurabilityError> {
542 self.store()?
543 .append(stream_key, payload, expected_seq)
544 .await
545 }
546
547 async fn append_all(
548 &self,
549 stream_key: &str,
550 payloads: Vec<Vec<u8>>,
551 expected_seq: u64,
552 ) -> Result<u64, DurabilityError> {
553 self.store()?
554 .append_all(stream_key, payloads, expected_seq)
555 .await
556 }
557
558 async fn read_from(
559 &self,
560 stream_key: &str,
561 offset: u64,
562 limit: usize,
563 ) -> Result<Vec<StoredEntry>, DurabilityError> {
564 self.store()?.read_from(stream_key, offset, limit).await
565 }
566
567 async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
568 self.store()?.cas(key, old_value, new_value).await
569 }
570
571 async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
572 self.store()?.read_value(key).await
573 }
574
575 async fn scan(&self, prefix: &str) -> Result<Vec<StoredEntry>, DurabilityError> {
576 self.store()?.scan(prefix).await
577 }
578
579 async fn flush(&self) -> Result<(), DurabilityError> {
580 self.store()?.flush().await
581 }
582}
583
584pub fn open_ephemeral(shard_count: usize) -> Result<EphemeralHaematiteStore, DurabilityError> {
600 open_ephemeral_in(ephemeral_tempdir(None)?, shard_count)
601}
602
603#[cfg(any(test, feature = "test-support"))]
626pub fn open_ephemeral_rooted(
627 root: &Path,
628 shard_count: usize,
629) -> Result<EphemeralHaematiteStore, DurabilityError> {
630 open_ephemeral_in(ephemeral_tempdir(Some(root))?, shard_count)
631}
632
633fn ephemeral_tempdir(root: Option<&Path>) -> Result<TempDir, DurabilityError> {
636 let mut builder = tempfile::Builder::new();
637 builder.prefix("liminal-durability-");
638 root.map_or_else(|| builder.tempdir(), |root| builder.tempdir_in(root))
639 .map_err(|error| {
640 DurabilityError::EphemeralStoreOpen(format!(
641 "could not create temporary directory: {error}"
642 ))
643 })
644}
645
646fn open_ephemeral_in(
651 ephemeral_dir: TempDir,
652 shard_count: usize,
653) -> Result<EphemeralHaematiteStore, DurabilityError> {
654 let database = Database::create(DatabaseConfig {
655 data_dir: ephemeral_dir.path().to_path_buf(),
656 shard_count,
657 distributed: None,
658 executor_threads: None,
659 node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
666 })
667 .map_err(|error| DurabilityError::EphemeralStoreOpen(error.to_string()))?;
668 Ok(EphemeralHaematiteStore::new(database, ephemeral_dir))
669}
670
671#[cfg(test)]
679#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
680pub(crate) struct EngineReadAccounting {
681 pub(crate) calls: usize,
683 pub(crate) engine_entries: usize,
685 pub(crate) unbounded_calls: usize,
687 pub(crate) counter_overflow_observed: bool,
689}
690
691#[cfg(test)]
692std::thread_local! {
693 static ENGINE_READ_ACCOUNTING: std::cell::RefCell<Option<EngineReadAccounting>> =
694 const { std::cell::RefCell::new(None) };
695}
696
697#[cfg(test)]
702pub(crate) struct EngineReadAccountingGuard {
703 _not_send: std::marker::PhantomData<*const ()>,
704}
705
706#[cfg(test)]
707impl EngineReadAccountingGuard {
708 pub(crate) fn start() -> Self {
709 ENGINE_READ_ACCOUNTING.with(|accounting| {
710 *accounting.borrow_mut() = Some(EngineReadAccounting::default());
711 });
712 Self {
713 _not_send: std::marker::PhantomData,
714 }
715 }
716
717 #[allow(clippy::unused_self)]
718 pub(crate) fn snapshot(&self) -> EngineReadAccounting {
719 ENGINE_READ_ACCOUNTING
720 .with(|accounting| accounting.borrow().as_ref().copied().unwrap_or_default())
721 }
722}
723
724#[cfg(test)]
725impl Drop for EngineReadAccountingGuard {
726 fn drop(&mut self) {
727 ENGINE_READ_ACCOUNTING.with(|accounting| {
728 *accounting.borrow_mut() = None;
729 });
730 }
731}
732
733#[cfg(test)]
735fn account_engine_read(engine_entries: usize, unbounded: bool) {
736 ENGINE_READ_ACCOUNTING.with(|accounting| {
737 if let Some(active) = accounting.borrow_mut().as_mut() {
738 match (
739 active.calls.checked_add(1),
740 active.engine_entries.checked_add(engine_entries),
741 ) {
742 (Some(calls), Some(entries)) => {
743 active.calls = calls;
744 active.engine_entries = entries;
745 }
746 _ => active.counter_overflow_observed = true,
747 }
748 if unbounded {
749 match active.unbounded_calls.checked_add(1) {
750 Some(unbounded_calls) => active.unbounded_calls = unbounded_calls,
751 None => active.counter_overflow_observed = true,
752 }
753 }
754 }
755 });
756}
757
758#[cfg(not(test))]
759const fn account_engine_read(_engine_entries: usize, _unbounded: bool) {}
760
761impl From<Event> for StoredEntry {
762 fn from(event: Event) -> Self {
763 Self {
764 payload: event.payload,
765 sequence: event.seq,
766 timestamp: event.timestamp,
767 }
768 }
769}
770
771impl From<ApiError> for DurabilityError {
777 fn from(error: ApiError) -> Self {
778 match error {
779 ApiError::SequenceConflict(conflict) => conflict.into(),
780 ApiError::CasMismatch(mismatch) => mismatch.into(),
781 other @ (ApiError::CorruptEvent(_)
782 | ApiError::Storage(_)
783 | ApiError::HistoryCompacted(_)) => Self::StoreError(other),
784 }
785 }
786}
787
788#[cfg(test)]
789#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
790mod ephemeral_lifecycle_tests {
791 use std::path::{Path, PathBuf};
796 use std::sync::{Arc, Mutex};
797
798 use super::super::bridge::block_on;
799 use super::{
800 DurableStore, EphemeralGuard, open_ephemeral, open_ephemeral_in, open_ephemeral_rooted,
801 };
802
803 const TEST_SHARD_COUNT: usize = 2;
804
805 #[derive(Clone, Default)]
814 struct CapturedLog(Arc<Mutex<Vec<u8>>>);
815
816 impl CapturedLog {
817 fn text(&self) -> String {
819 let bytes = self
820 .0
821 .lock()
822 .expect("capture buffer is not poisoned")
823 .clone();
824 String::from_utf8(bytes).expect("tracing's fmt writer emits utf-8")
825 }
826
827 fn capturing<R>(&self, body: impl FnOnce() -> R) -> R {
843 static INSTALL: std::sync::Once = std::sync::Once::new();
844 struct ResetOnDrop;
846 impl Drop for ResetOnDrop {
847 fn drop(&mut self) {
848 ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = None);
849 }
850 }
851 INSTALL.call_once(|| {
852 let subscriber = tracing_subscriber::fmt()
853 .with_writer(RoutedWriter)
854 .with_ansi(false)
855 .finish();
856 tracing::subscriber::set_global_default(subscriber)
857 .expect("no other global tracing subscriber is installed in this test binary");
858 });
859 ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = Some(self.clone()));
860 let _reset = ResetOnDrop;
861 body()
862 }
863 }
864
865 thread_local! {
866 static ACTIVE_CAPTURE: std::cell::RefCell<Option<CapturedLog>> =
869 const { std::cell::RefCell::new(None) };
870 }
871
872 #[derive(Clone, Copy, Default)]
876 struct RoutedWriter;
877
878 impl std::io::Write for RoutedWriter {
879 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
880 ACTIVE_CAPTURE.with(|slot| {
881 if let Some(capture) = slot.borrow().as_ref() {
882 capture
883 .0
884 .lock()
885 .map_err(|_| std::io::Error::other("capture buffer poisoned"))?
886 .extend_from_slice(buf);
887 }
888 Ok(buf.len())
889 })
890 }
891
892 fn flush(&mut self) -> std::io::Result<()> {
893 Ok(())
894 }
895 }
896
897 impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for RoutedWriter {
898 type Writer = Self;
899
900 fn make_writer(&'writer self) -> Self::Writer {
901 *self
902 }
903 }
904
905 #[cfg(unix)]
911 fn set_mode(path: &Path, mode: u32) {
912 use std::os::unix::fs::PermissionsExt;
913
914 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
915 .expect("test can set permissions on a directory it created");
916 }
917
918 struct OrderProbeStore {
922 dir: PathBuf,
923 }
924
925 impl Drop for OrderProbeStore {
926 fn drop(&mut self) {
927 assert!(
928 self.dir.exists(),
929 "the guard must drop the store BEFORE removing the directory"
930 );
931 }
932 }
933
934 struct PanickingProbeStore;
937
938 impl Drop for PanickingProbeStore {
939 fn drop(&mut self) {
940 panic!("injected store-drop panic");
941 }
942 }
943
944 fn write_one_event(store: &dyn DurableStore) {
947 block_on(store.append("lifecycle/probe", b"payload".to_vec(), 0))
948 .expect("bridge completes synchronously")
949 .expect("append to a fresh ephemeral stream succeeds");
950 block_on(store.flush())
951 .expect("bridge completes synchronously")
952 .expect("flush of a live ephemeral store succeeds");
953 }
954
955 #[test]
958 fn ephemeral_dir_removed_after_last_handle_drops() {
959 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
960 let dir = store
961 .ephemeral_dir_path()
962 .expect("ephemeral store carries a guard dir")
963 .to_path_buf();
964 assert!(
965 dir.exists(),
966 "the guard directory exists while the store is live"
967 );
968
969 write_one_event(&store);
970 drop(store);
971
972 assert!(
973 !dir.exists(),
974 "the guard directory is removed on normal drop"
975 );
976 }
977
978 #[test]
983 fn ephemeral_dir_survives_until_last_store_clone_drops() {
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 write_one_event(&store);
990
991 let erased: Arc<dyn DurableStore> = Arc::new(store);
992 let clone_a = Arc::clone(&erased);
993 let clone_b = Arc::clone(&erased);
994
995 drop(erased);
996 assert!(
997 dir.exists(),
998 "directory survives while store clones remain alive"
999 );
1000 drop(clone_a);
1001 assert!(
1002 dir.exists(),
1003 "directory survives while one store clone remains alive"
1004 );
1005
1006 drop(clone_b);
1007 assert!(
1008 !dir.exists(),
1009 "the last store clone dropping removes the directory"
1010 );
1011 }
1012
1013 #[test]
1018 fn ephemeral_open_failure_rolls_back_directory() {
1019 let seeded = tempfile::Builder::new()
1020 .prefix("liminal-durability-test-")
1021 .tempdir()
1022 .expect("test can create a temp dir");
1023 let dir = seeded.path().to_path_buf();
1024 std::fs::write(dir.join("config.json"), b"not-a-valid-config")
1028 .expect("test can seed a conflicting config");
1029
1030 let result = open_ephemeral_in(seeded, TEST_SHARD_COUNT);
1031
1032 assert!(result.is_err(), "an injected open failure returns Err");
1033 assert!(
1034 !dir.exists(),
1035 "the guard removes the directory on open failure — zero residue"
1036 );
1037 }
1038
1039 #[test]
1042 fn repeated_ephemeral_cycles_each_own_distinct_dir_zero_residue() {
1043 let mut seen: Vec<PathBuf> = Vec::new();
1044 for _ in 0..5 {
1045 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
1046 let dir = store
1047 .ephemeral_dir_path()
1048 .expect("ephemeral store carries a guard dir")
1049 .to_path_buf();
1050 assert!(
1051 dir.exists(),
1052 "the cycle's directory exists while its store is live"
1053 );
1054 assert!(!seen.contains(&dir), "each cycle owns a distinct directory");
1055 seen.push(dir.clone());
1056
1057 write_one_event(&store);
1058 drop(store);
1059 assert!(
1060 !dir.exists(),
1061 "the cycle's directory is removed after its store drops"
1062 );
1063 }
1064 }
1065
1066 #[test]
1071 fn guard_drops_store_before_removing_directory() {
1072 let dir = tempfile::tempdir().expect("test can create a temp dir");
1073 let path = dir.path().to_path_buf();
1074 let guard = EphemeralGuard {
1075 store: Some(OrderProbeStore { dir: path.clone() }),
1076 dir: Some(dir),
1077 };
1078
1079 drop(guard);
1080
1081 assert!(!path.exists(), "a clean drop still removes the directory");
1082 }
1083
1084 #[test]
1088 fn guard_leaks_directory_when_store_drop_panics() {
1089 let dir = tempfile::tempdir().expect("test can create a temp dir");
1090 let path = dir.path().to_path_buf();
1091 let guard = EphemeralGuard {
1092 store: Some(PanickingProbeStore),
1093 dir: Some(dir),
1094 };
1095
1096 let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(guard)));
1097
1098 assert!(unwound.is_err(), "the injected store-drop panic propagates");
1099 assert!(
1100 path.exists(),
1101 "a panicking store drop leaks the directory instead of removing it"
1102 );
1103 std::fs::remove_dir_all(&path).expect("test cleans up the deliberately leaked directory");
1104 }
1105
1106 #[test]
1110 fn rooted_ephemeral_store_lives_and_dies_under_the_given_root() {
1111 let root = tempfile::tempdir().expect("test can create a temp root");
1112 let store =
1113 open_ephemeral_rooted(root.path(), TEST_SHARD_COUNT).expect("rooted open succeeds");
1114 let dir = store
1115 .ephemeral_dir_path()
1116 .expect("ephemeral store carries a guard dir")
1117 .to_path_buf();
1118 assert!(
1119 dir.starts_with(root.path()),
1120 "the guard directory is created under the supplied root"
1121 );
1122
1123 write_one_event(&store);
1124 drop(store);
1125
1126 assert!(!dir.exists(), "the rooted directory is removed on drop");
1127 }
1128
1129 #[test]
1138 fn ephemeral_dir_persists_across_unrelated_work_then_goes_on_clean_drop() {
1139 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
1140 let dir = store
1141 .ephemeral_dir_path()
1142 .expect("ephemeral store carries a guard dir")
1143 .to_path_buf();
1144 assert!(
1145 dir.exists(),
1146 "the directory exists as soon as the store does"
1147 );
1148
1149 for round in 0..3_u64 {
1150 block_on(store.append("clean-teardown/probe", b"payload".to_vec(), round))
1151 .expect("bridge completes synchronously")
1152 .expect("append to a live ephemeral store succeeds");
1153 assert!(
1154 dir.exists(),
1155 "the directory is still there after append round {round}"
1156 );
1157 }
1158 block_on(store.cas("clean-teardown/counter", 0, 7))
1159 .expect("bridge completes synchronously")
1160 .expect("cas on a live ephemeral store succeeds");
1161 let entries = block_on(store.read_from("clean-teardown/probe", 0, 10))
1162 .expect("bridge completes synchronously")
1163 .expect("read from a live ephemeral store succeeds");
1164 assert_eq!(entries.len(), 3, "every appended entry is readable back");
1165 assert!(
1166 dir.exists(),
1167 "the directory is still there after unrelated cas and read work"
1168 );
1169
1170 block_on(store.flush())
1171 .expect("bridge completes synchronously")
1172 .expect("flush of a live ephemeral store succeeds");
1173 drop(store);
1174
1175 assert!(
1176 !dir.exists(),
1177 "the clean drop removes the directory it kept alive throughout"
1178 );
1179 }
1180
1181 #[cfg(unix)]
1190 #[test]
1191 fn clean_drop_removal_failure_is_logged_and_never_panics() {
1192 let parent = tempfile::tempdir().expect("test can create a temp parent");
1193 let dir = tempfile::Builder::new()
1194 .prefix("liminal-durability-")
1195 .tempdir_in(parent.path())
1196 .expect("test can create a guard dir under the parent");
1197 let path = dir.path().to_path_buf();
1198 let guard = EphemeralGuard {
1199 store: Some(()),
1200 dir: Some(dir),
1201 };
1202
1203 set_mode(parent.path(), 0o500);
1204 let captured = CapturedLog::default();
1205 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1206 captured.capturing(|| drop(guard));
1207 }));
1208 set_mode(parent.path(), 0o700);
1211
1212 assert!(
1213 outcome.is_ok(),
1214 "a removal failure is reported, never raised as a panic"
1215 );
1216 let logged = captured.text();
1217 assert!(
1218 logged.contains("ERROR"),
1219 "the removal failure is logged at error level; captured: {logged:?}"
1220 );
1221 assert!(
1222 logged.contains(&path.display().to_string()),
1223 "the log names the directory that survived; captured: {logged:?}"
1224 );
1225 assert!(
1226 path.exists(),
1227 "the residue is left where the log says it is, not silently claimed removed"
1228 );
1229 }
1230
1231 #[test]
1235 fn clean_drop_that_succeeds_logs_nothing() {
1236 let dir = tempfile::tempdir().expect("test can create a temp dir");
1237 let path = dir.path().to_path_buf();
1238 let guard = EphemeralGuard {
1239 store: Some(()),
1240 dir: Some(dir),
1241 };
1242
1243 let captured = CapturedLog::default();
1244 captured.capturing(|| drop(guard));
1245
1246 assert!(!path.exists(), "the successful clean drop removed the dir");
1247 assert!(
1248 captured.text().is_empty(),
1249 "a successful removal is silent; captured: {:?}",
1250 captured.text()
1251 );
1252 }
1253
1254 #[test]
1261 fn panic_path_leak_is_logged_with_its_path() {
1262 let dir = tempfile::tempdir().expect("test can create a temp dir");
1263 let path = dir.path().to_path_buf();
1264 let guard = EphemeralGuard {
1265 store: Some(PanickingProbeStore),
1266 dir: Some(dir),
1267 };
1268
1269 let captured = CapturedLog::default();
1270 let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1271 captured.capturing(|| drop(guard));
1272 }));
1273
1274 assert!(unwound.is_err(), "the injected store-drop panic propagates");
1275 let logged = captured.text();
1276 assert!(
1277 logged.contains("ERROR"),
1278 "the sanctioned leak is logged at error level; captured: {logged:?}"
1279 );
1280 assert!(
1281 logged.contains(&path.display().to_string()),
1282 "the leak log names the leaked directory; captured: {logged:?}"
1283 );
1284 std::fs::remove_dir_all(&path).expect("test cleans up the deliberately leaked directory");
1285 }
1286}
1287
1288#[cfg(test)]
1289#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1290mod paged_read_shape_tests {
1291 use super::{DurableStore, EngineReadAccountingGuard, open_ephemeral};
1298 use crate::durability::bridge::block_on;
1299
1300 const PAGE: usize = 64;
1303 const ROWS: u64 = 256;
1306 const STREAM: &str = "liminal/p0-60/paged-read-shape";
1307
1308 fn seeded() -> Result<impl DurableStore, Box<dyn std::error::Error>> {
1310 let store = open_ephemeral(1)?;
1311 for sequence in 0..ROWS {
1312 block_on(store.append(STREAM, sequence.to_be_bytes().to_vec(), sequence))??;
1313 }
1314 block_on(store.flush())??;
1315 Ok(store)
1316 }
1317
1318 fn read_whole_stream(
1320 store: &impl DurableStore,
1321 page: usize,
1322 ) -> Result<usize, Box<dyn std::error::Error>> {
1323 let mut offset = 0_u64;
1324 let mut seen = 0_usize;
1325 loop {
1326 let entries = block_on(store.read_from(STREAM, offset, page))??;
1327 if entries.is_empty() {
1328 return Ok(seen);
1329 }
1330 for entry in &entries {
1331 assert_eq!(entry.sequence, offset, "paged read must stay contiguous");
1332 offset += 1;
1333 }
1334 seen = seen
1335 .checked_add(entries.len())
1336 .ok_or("row counter overflowed")?;
1337 }
1338 }
1339
1340 #[test]
1343 fn a_bounded_read_never_scans_beyond_its_page() -> Result<(), Box<dyn std::error::Error>> {
1344 let store = seeded()?;
1345
1346 let accounting = EngineReadAccountingGuard::start();
1348 let seen = read_whole_stream(&store, PAGE)?;
1349 let walk = accounting.snapshot();
1350 drop(accounting);
1351 assert_eq!(
1352 u64::try_from(seen)?,
1353 ROWS,
1354 "the walk must deliver every row"
1355 );
1356 assert!(
1357 !walk.counter_overflow_observed,
1358 "a saturated counter is not a measurement"
1359 );
1360 assert!(walk.calls > 0, "the walk must have reached the store");
1361 assert_eq!(
1362 u64::try_from(walk.engine_entries)?,
1363 ROWS,
1364 "a full stream read must scan each row exactly once instead of \
1365 re-scanning every suffix once per page"
1366 );
1367
1368 let accounting = EngineReadAccountingGuard::start();
1370 let head = block_on(store.read_from(STREAM, 0, PAGE))??;
1371 let head_read = accounting.snapshot();
1372 drop(accounting);
1373 assert_eq!(head.len(), PAGE, "a full page returns its limit");
1374 assert_eq!(
1375 head_read.engine_entries, PAGE,
1376 "the engine must be asked for one page, not for the whole stream"
1377 );
1378
1379 let middle_offset = ROWS / 2;
1384 let accounting = EngineReadAccountingGuard::start();
1385 let middle = block_on(store.read_from(STREAM, middle_offset, PAGE))??;
1386 let middle_read = accounting.snapshot();
1387 drop(accounting);
1388 assert_eq!(
1389 middle.len(),
1390 PAGE,
1391 "a full page mid-stream returns its limit"
1392 );
1393 assert_eq!(
1394 middle_read.engine_entries, PAGE,
1395 "a mid-stream page must not scan the rows that follow it"
1396 );
1397
1398 let accounting = EngineReadAccountingGuard::start();
1400 let past = block_on(store.read_from(STREAM, ROWS, PAGE))??;
1401 let past_read = accounting.snapshot();
1402 drop(accounting);
1403 assert!(past.is_empty(), "past the head is end of stream");
1404 assert_eq!(
1405 past_read.engine_entries, 0,
1406 "an end-of-stream page must not scan the stream"
1407 );
1408 Ok(())
1409 }
1410
1411 #[test]
1415 fn page_size_never_changes_the_answer() -> Result<(), Box<dyn std::error::Error>> {
1416 let store = seeded()?;
1417 let whole = block_on(store.read_from(STREAM, 0, usize::MAX))??;
1418 assert_eq!(u64::try_from(whole.len())?, ROWS);
1419
1420 for page in [1_usize, 7, 64, 255, 256, 257] {
1421 let mut offset = 0_u64;
1422 let mut collected = Vec::new();
1423 loop {
1424 let entries = block_on(store.read_from(STREAM, offset, page))??;
1425 if entries.is_empty() {
1426 break;
1427 }
1428 assert!(entries.len() <= page, "a page never exceeds its limit");
1429 offset = offset
1430 .checked_add(u64::try_from(entries.len())?)
1431 .ok_or("offset overflowed")?;
1432 collected.extend(entries);
1433 }
1434 assert_eq!(collected, whole, "page size {page} changed the answer");
1435 }
1436
1437 assert!(
1440 block_on(store.read_from(STREAM, 0, 0))??.is_empty(),
1441 "a zero limit reads nothing"
1442 );
1443
1444 for offset in [0_u64, 1, 63, 64, 65, 128, 255] {
1446 let suffix = block_on(store.read_from(STREAM, offset, usize::MAX))??;
1447 assert_eq!(
1448 suffix,
1449 whole[usize::try_from(offset)?..],
1450 "suffix from {offset} diverged"
1451 );
1452 }
1453 Ok(())
1454 }
1455}