1use haematite::{ApiError, Database, DatabaseConfig, Event, EventStore};
2
3use std::path::Path;
4use std::sync::Arc;
5
6use super::DurabilityError;
7use super::counting::StoreCountsHandle;
8
9use tempfile::TempDir;
10
11#[derive(Clone, Debug)]
13pub struct RoutedBatchItem {
14 pub key: Vec<u8>,
16 pub expected: Option<haematite::tree::Hash>,
18 pub value: Vec<u8>,
20 pub ttl: Option<std::time::Duration>,
22}
23
24#[derive(Clone)]
30pub enum RoutedBatchEntry {
31 Put(RoutedBatchItem),
34 Delete {
36 key: Vec<u8>,
38 expected: haematite::tree::Hash,
41 },
42}
43
44impl RoutedBatchEntry {
45 #[must_use]
47 pub fn key(&self) -> &[u8] {
48 match self {
49 Self::Put(item) => &item.key,
50 Self::Delete { key, .. } => key,
51 }
52 }
53}
54
55impl std::fmt::Debug for RoutedBatchEntry {
58 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 match self {
60 Self::Put(item) => formatter.debug_tuple("Put").field(item).finish(),
61 Self::Delete { key, expected } => formatter
62 .debug_struct("Delete")
63 .field("key", &String::from_utf8_lossy(key))
64 .field("expected", expected)
65 .finish(),
66 }
67 }
68}
69
70#[derive(Clone, Debug, PartialEq, Eq)]
72pub struct StoredEntry {
73 pub payload: Vec<u8>,
75 pub sequence: u64,
77 pub timestamp: u64,
79}
80
81#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct ScanCursor {
88 pub stream_key: String,
90 pub next_sequence: u64,
92}
93
94#[derive(Clone, Debug, PartialEq, Eq)]
96pub struct ScanPage {
97 pub entries: Vec<StoredEntry>,
99 pub next: Option<ScanCursor>,
101}
102
103#[async_trait::async_trait]
105pub trait DurableStore: std::fmt::Debug + Send + Sync {
106 fn reclaim_spine(&self) -> Option<Arc<EventStore>> {
111 None
112 }
113
114 fn max_request_bytes(&self) -> Option<usize> {
119 None
120 }
121
122 async fn apply_routed_batch(
126 &self,
127 route_key: &str,
128 items: Vec<RoutedBatchItem>,
129 ) -> Result<(), DurabilityError>;
130
131 async fn apply_routed_entries(
145 &self,
146 route_key: &str,
147 entries: Vec<RoutedBatchEntry>,
148 ) -> Result<(), DurabilityError> {
149 let mut items = Vec::with_capacity(entries.len());
150 for entry in entries {
151 match entry {
152 RoutedBatchEntry::Put(item) => items.push(item),
153 RoutedBatchEntry::Delete { .. } => {
154 return Err(DurabilityError::RoutedDeleteUnsupported {
155 store: std::any::type_name::<Self>(),
156 route_key: route_key.to_owned(),
157 });
158 }
159 }
160 }
161 self.apply_routed_batch(route_key, items).await
162 }
163
164 async fn get_routed(
166 &self,
167 route_key: &str,
168 key: &[u8],
169 ) -> Result<Option<Vec<u8>>, DurabilityError>;
170
171 async fn append(
173 &self,
174 stream_key: &str,
175 payload: Vec<u8>,
176 expected_seq: u64,
177 ) -> Result<u64, DurabilityError>;
178
179 async fn read_from(
181 &self,
182 stream_key: &str,
183 offset: u64,
184 limit: usize,
185 ) -> Result<Vec<StoredEntry>, DurabilityError>;
186
187 async fn append_all(
195 &self,
196 stream_key: &str,
197 payloads: Vec<Vec<u8>>,
198 expected_seq: u64,
199 ) -> Result<u64, DurabilityError>;
200
201 async fn read_at(
203 &self,
204 stream_key: &str,
205 sequence: u64,
206 ) -> Result<Option<StoredEntry>, DurabilityError> {
207 Ok(self
208 .read_from(stream_key, sequence, 1)
209 .await?
210 .into_iter()
211 .next())
212 }
213
214 async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError>;
221
222 async fn read_stream_next_seq(&self, stream_key: &str) -> Result<Option<u64>, DurabilityError>;
231
232 async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError>;
234
235 async fn scan_page(
249 &self,
250 prefix: &str,
251 cursor: Option<ScanCursor>,
252 limit: usize,
253 ) -> Result<ScanPage, DurabilityError>;
254
255 async fn flush(&self) -> Result<(), DurabilityError>;
260
261 async fn truncate_before(
275 &self,
276 stream_key: &str,
277 before_seq: u64,
278 ) -> Result<u64, DurabilityError> {
279 Err(DurabilityError::TruncateUnsupported {
280 store: std::any::type_name::<Self>(),
281 stream_key: stream_key.to_owned(),
282 before_seq,
283 })
284 }
285
286 async fn scan_routed(
296 &self,
297 route_key: &str,
298 _from: &[u8],
299 _to: &[u8],
300 ) -> Result<Vec<(Vec<u8>, Vec<u8>)>, DurabilityError> {
301 Err(DurabilityError::RoutedScanUnsupported {
302 store: std::any::type_name::<Self>(),
303 route_key: route_key.to_owned(),
304 })
305 }
306
307 async fn remove_routed_range(
325 &self,
326 route_key: &str,
327 _from: &[u8],
328 _to: &[u8],
329 ) -> Result<u64, DurabilityError> {
330 Err(DurabilityError::RoutedRemoveUnsupported {
331 store: std::any::type_name::<Self>(),
332 route_key: route_key.to_owned(),
333 })
334 }
335
336 fn store_counts(&self) -> Option<StoreCountsHandle> {
343 None
344 }
345}
346
347#[derive(Clone, Debug)]
353pub struct HaematiteStore {
354 event_store: Arc<EventStore>,
355}
356
357impl HaematiteStore {
358 #[must_use]
360 pub const fn new(event_store: Arc<EventStore>) -> Self {
361 Self { event_store }
362 }
363
364 fn bounded_page(
376 &self,
377 stream_key: &str,
378 offset: u64,
379 limit: usize,
380 ) -> Result<Option<Vec<StoredEntry>>, DurabilityError> {
381 const TIMESTAMP_WIDTH: usize = std::mem::size_of::<u64>();
382
383 let Some(engine_from) = offset.checked_add(1) else {
385 return Ok(None);
386 };
387 let Some(engine_end) = u64::try_from(limit)
388 .ok()
389 .and_then(|limit| engine_from.checked_add(limit))
390 else {
391 return Ok(None);
392 };
393 let key = stream_key.as_bytes();
394 let from = haematite::encode_stream_key(key, engine_from);
395 let to = haematite::encode_stream_key(key, engine_end);
396 let entries = self
397 .event_store
398 .database()
399 .range_routed(key, &from, &to)
400 .map_err(ApiError::from)
401 .map_err(DurabilityError::from)?;
402 if entries.len() != limit {
403 return Ok(None);
404 }
405
406 let mut page = Vec::with_capacity(entries.len());
407 for (encoded_key, value) in entries {
408 let Some((decoded_key, engine_sequence)) = haematite::decode_stream_key(&encoded_key)
409 else {
410 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
411 format!("paged read key does not encode an event for stream {stream_key}"),
412 )));
413 };
414 if decoded_key != key {
415 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
416 format!("paged read key does not encode stream {stream_key}"),
417 )));
418 }
419 let sequence = engine_sequence.checked_sub(1).ok_or_else(|| {
420 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
421 "paged read event key has zero seq for stream {stream_key}"
422 )))
423 })?;
424 let Some(timestamp_bytes) = value.get(..TIMESTAMP_WIDTH) else {
425 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
426 format!(
427 "paged read event value is shorter than its timestamp for stream {stream_key}"
428 ),
429 )));
430 };
431 let timestamp = u64::from_be_bytes(timestamp_bytes.try_into().map_err(|_| {
432 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
433 "paged read event timestamp has the wrong width for stream {stream_key}"
434 )))
435 })?);
436 let Some(payload) = value.get(TIMESTAMP_WIDTH..) else {
437 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
438 format!("paged read event has no payload boundary for stream {stream_key}"),
439 )));
440 };
441 page.push(StoredEntry {
442 payload: payload.to_vec(),
443 sequence,
444 timestamp,
445 });
446 }
447 Ok(Some(page))
448 }
449}
450
451#[async_trait::async_trait]
452impl DurableStore for HaematiteStore {
453 fn reclaim_spine(&self) -> Option<Arc<EventStore>> {
454 Some(Arc::clone(&self.event_store))
455 }
456
457 fn max_request_bytes(&self) -> Option<usize> {
461 Some(self.event_store.database().queue_byte_limit().get())
462 }
463
464 async fn apply_routed_batch(
465 &self,
466 route_key: &str,
467 items: Vec<RoutedBatchItem>,
468 ) -> Result<(), DurabilityError> {
469 let items = items
470 .into_iter()
471 .map(|item| haematite::RoutedBatchItem {
472 key: item.key,
473 expected: item.expected,
474 value: item.value,
475 ttl: item.ttl,
476 })
477 .collect();
478 self.event_store
479 .database()
480 .apply_routed_batch(route_key.as_bytes(), items)
481 .map_err(ApiError::from)
482 .map_err(DurabilityError::from)
483 }
484
485 async fn apply_routed_entries(
486 &self,
487 route_key: &str,
488 entries: Vec<RoutedBatchEntry>,
489 ) -> Result<(), DurabilityError> {
490 let entries = entries
491 .into_iter()
492 .map(|entry| match entry {
493 RoutedBatchEntry::Put(item) => {
494 haematite::RoutedBatchEntry::Put(haematite::RoutedBatchItem {
495 key: item.key,
496 expected: item.expected,
497 value: item.value,
498 ttl: item.ttl,
499 })
500 }
501 RoutedBatchEntry::Delete { key, expected } => {
502 haematite::RoutedBatchEntry::Delete { key, expected }
503 }
504 })
505 .collect();
506 self.event_store
507 .database()
508 .apply_routed_entries(route_key.as_bytes(), entries)
509 .map_err(local_batch_error)
510 }
511
512 async fn get_routed(
513 &self,
514 route_key: &str,
515 key: &[u8],
516 ) -> Result<Option<Vec<u8>>, DurabilityError> {
517 self.event_store
518 .database()
519 .get_routed(route_key.as_bytes(), key)
520 .map_err(ApiError::from)
521 .map_err(DurabilityError::from)
522 }
523
524 async fn append(
525 &self,
526 stream_key: &str,
527 payload: Vec<u8>,
528 expected_seq: u64,
529 ) -> Result<u64, DurabilityError> {
530 let next_seq = self
538 .event_store
539 .append(stream_key.as_bytes(), &payload, expected_seq)
540 .map_err(DurabilityError::from)?;
541 next_seq.checked_sub(1).ok_or_else(|| {
542 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
543 "append returned next-seq 0 for stream {stream_key}"
544 )))
545 })
546 }
547
548 async fn append_all(
549 &self,
550 stream_key: &str,
551 payloads: Vec<Vec<u8>>,
552 expected_seq: u64,
553 ) -> Result<u64, DurabilityError> {
554 let borrowed: Vec<&[u8]> = payloads.iter().map(Vec::as_slice).collect();
555 self.event_store
556 .append_batch(stream_key.as_bytes(), &borrowed, expected_seq)
557 .map_err(DurabilityError::from)
558 }
559
560 async fn read_from(
561 &self,
562 stream_key: &str,
563 offset: u64,
564 limit: usize,
565 ) -> Result<Vec<StoredEntry>, DurabilityError> {
566 if limit > 0 {
589 if let Some(page) = self.bounded_page(stream_key, offset, limit)? {
590 account_engine_read(page.len(), false);
591 return Ok(page);
592 }
593 }
594 let mut events = self
595 .event_store
596 .read_from(stream_key.as_bytes(), offset)
597 .map_err(DurabilityError::from)?;
598 account_engine_read(events.len(), true);
599 events.truncate(limit);
600 Ok(events.into_iter().map(StoredEntry::from).collect())
601 }
602
603 async fn read_at(
604 &self,
605 stream_key: &str,
606 sequence: u64,
607 ) -> Result<Option<StoredEntry>, DurabilityError> {
608 const TIMESTAMP_WIDTH: usize = std::mem::size_of::<u64>();
609
610 let engine_sequence = sequence.checked_add(1).ok_or_else(|| {
611 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
612 "point read sequence overflow for stream {stream_key}"
613 )))
614 })?;
615 let event_key = haematite::encode_stream_key(stream_key.as_bytes(), engine_sequence);
616 let Some(value) = self
617 .event_store
618 .database()
619 .get_routed(stream_key.as_bytes(), &event_key)
620 .map_err(ApiError::from)
621 .map_err(DurabilityError::from)?
622 else {
623 return Ok(None);
624 };
625 let Some(timestamp_bytes) = value.get(..TIMESTAMP_WIDTH) else {
626 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
627 format!(
628 "point-read event value is shorter than its timestamp for stream {stream_key}"
629 ),
630 )));
631 };
632 let timestamp = u64::from_be_bytes(timestamp_bytes.try_into().map_err(|_| {
633 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
634 "point-read event timestamp has the wrong width for stream {stream_key}"
635 )))
636 })?);
637 let Some(payload) = value.get(TIMESTAMP_WIDTH..) else {
638 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
639 format!("point-read event has no payload boundary for stream {stream_key}"),
640 )));
641 };
642 Ok(Some(StoredEntry {
643 payload: payload.to_vec(),
644 sequence,
645 timestamp,
646 }))
647 }
648
649 async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
650 if new_value == 0 {
665 return self
666 .event_store
667 .read_value(key.as_bytes())
668 .map_err(DurabilityError::from)?
669 .map_or(Ok(()), |stored| {
670 Err(DurabilityError::CursorRegression {
671 stored,
672 attempted: old_value,
673 })
674 });
675 }
676 let expected = if old_value == 0 {
682 None
683 } else {
684 Some(old_value)
685 };
686 self.event_store
687 .cas(key.as_bytes(), expected, new_value)
688 .map_err(DurabilityError::from)
689 }
690
691 async fn read_stream_next_seq(&self, stream_key: &str) -> Result<Option<u64>, DurabilityError> {
692 self.event_store
693 .read_stream_next_seq(stream_key.as_bytes())
694 .map_err(DurabilityError::from)
695 }
696
697 async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
698 self.event_store
699 .read_value(key.as_bytes())
700 .map_err(DurabilityError::from)
701 }
702
703 async fn scan_page(
704 &self,
705 prefix: &str,
706 cursor: Option<ScanCursor>,
707 limit: usize,
708 ) -> Result<ScanPage, DurabilityError> {
709 if limit == 0 {
710 return Err(DurabilityError::ConfigError(
711 "scan_page limit must be nonzero".to_owned(),
712 ));
713 }
714
715 let mut entries: Vec<StoredEntry> = Vec::new();
716 let mut resume_at: Option<ScanCursor> = None;
717 let mut after: Option<String> = if let Some(cursor) = cursor {
720 if cursor.stream_key.starts_with(prefix) {
721 let page = self
722 .read_from(&cursor.stream_key, cursor.next_sequence, limit)
723 .await?;
724 resume_at = scan_resume_position(&cursor.stream_key, &page)?.or(resume_at);
725 entries.extend(page);
726 }
727 Some(cursor.stream_key)
728 } else {
729 None
730 };
731
732 while let Some(remaining) = limit
739 .checked_sub(entries.len())
740 .and_then(std::num::NonZeroUsize::new)
741 {
742 let streams = self
743 .event_store
744 .scan_prefix(
745 prefix.as_bytes(),
746 remaining,
747 after.as_deref().map(str::as_bytes),
748 )
749 .map_err(DurabilityError::from)?;
750 account_stream_enumeration(streams.streams.len());
751 let exhausted = streams.next_after.is_none() || streams.streams.is_empty();
752 for stream in streams.streams {
753 let stream_key = String::from_utf8(stream.stream_key).map_err(|error| {
758 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
759 "scanned stream key is not utf-8: {error}"
760 )))
761 })?;
762 let Some(remaining) = limit.checked_sub(entries.len()).filter(|rest| *rest > 0)
763 else {
764 break;
765 };
766 let page = self.read_from(&stream_key, 0, remaining).await?;
771 resume_at = scan_resume_position(&stream_key, &page)?.or(resume_at);
772 entries.extend(page);
773 after = Some(stream_key);
774 }
775 if exhausted {
776 break;
777 }
778 }
779
780 let next = if entries.len() == limit {
784 resume_at
785 } else {
786 None
787 };
788 Ok(ScanPage { entries, next })
789 }
790
791 async fn flush(&self) -> Result<(), DurabilityError> {
792 self.event_store.flush().map_err(DurabilityError::from)
793 }
794
795 async fn truncate_before(
798 &self,
799 stream_key: &str,
800 before_seq: u64,
801 ) -> Result<u64, DurabilityError> {
802 self.event_store
803 .truncate_stream(stream_key.as_bytes(), before_seq)
804 .map_err(DurabilityError::from)
805 }
806
807 async fn scan_routed(
812 &self,
813 route_key: &str,
814 from: &[u8],
815 to: &[u8],
816 ) -> Result<Vec<(Vec<u8>, Vec<u8>)>, DurabilityError> {
817 self.event_store
818 .database()
819 .range_routed(route_key.as_bytes(), from, to)
820 .map_err(ApiError::from)
821 .map_err(DurabilityError::from)
822 }
823
824 async fn remove_routed_range(
832 &self,
833 route_key: &str,
834 from: &[u8],
835 to: &[u8],
836 ) -> Result<u64, DurabilityError> {
837 let cut = self
838 .event_store
839 .database()
840 .delete_routed_range(route_key.as_bytes(), from, to)
841 .map_err(ApiError::from)
842 .map_err(DurabilityError::from)?;
843 Ok(u64::from(cut))
844 }
845}
846
847fn scan_resume_position(
850 stream_key: &str,
851 page: &[StoredEntry],
852) -> Result<Option<ScanCursor>, DurabilityError> {
853 let Some(last) = page.last() else {
854 return Ok(None);
855 };
856 let next_sequence = last.sequence.checked_add(1).ok_or_else(|| {
857 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
858 "scan page sequence overflow for stream {stream_key}"
859 )))
860 })?;
861 Ok(Some(ScanCursor {
862 stream_key: stream_key.to_owned(),
863 next_sequence,
864 }))
865}
866
867#[derive(Debug)]
893struct EphemeralGuard<S> {
894 ownership: Option<super::ephemeral_root::EphemeralOwnership>,
895 store: Option<S>,
896 dir: Option<TempDir>,
897}
898
899impl<S> Drop for EphemeralGuard<S> {
900 fn drop(&mut self) {
901 let store = self.store.take();
902 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(store)));
906 if let Err(panic) = outcome {
907 if let Some(dir) = self.dir.take() {
908 let leaked = dir.keep();
909 tracing::error!(
910 path = %leaked.display(),
911 "ephemeral store drop panicked; leaking its directory rather than \
912 removing it under possibly-live database workers"
913 );
914 }
915 if let Some(ownership) = self.ownership.take() {
916 std::mem::forget(ownership);
917 }
918 std::panic::resume_unwind(panic);
919 }
920 if let Some(dir) = self.dir.take() {
924 let path = dir.path().to_path_buf();
925 if let Err(error) = dir.close() {
926 tracing::error!(
927 path = %path.display(),
928 %error,
929 "ephemeral store directory removal failed; residue remains at the \
930 logged path"
931 );
932 }
933 }
934 }
935}
936
937#[derive(Debug)]
953pub struct EphemeralHaematiteStore {
954 guard: EphemeralGuard<HaematiteStore>,
955}
956
957impl EphemeralHaematiteStore {
958 fn new(database: Database, ephemeral_dir: TempDir) -> Self {
967 Self {
968 guard: EphemeralGuard {
969 ownership: None,
970 store: Some(HaematiteStore::new(Arc::new(EventStore::new(database)))),
971 dir: Some(ephemeral_dir),
972 },
973 }
974 }
975
976 fn store(&self) -> Result<&HaematiteStore, DurabilityError> {
983 self.guard
984 .store
985 .as_ref()
986 .ok_or(DurabilityError::EphemeralStoreDetached)
987 }
988
989 fn ephemeral_dir_path_owned(&self) -> Result<&Path, DurabilityError> {
990 self.guard
991 .dir
992 .as_ref()
993 .map(TempDir::path)
994 .ok_or(DurabilityError::EphemeralStoreDetached)
995 }
996
997 #[cfg(test)]
999 pub(crate) fn ephemeral_dir_path(&self) -> Option<&Path> {
1000 self.guard.dir.as_ref().map(TempDir::path)
1001 }
1002}
1003
1004#[async_trait::async_trait]
1005impl DurableStore for EphemeralHaematiteStore {
1006 fn max_request_bytes(&self) -> Option<usize> {
1007 self.store().ok().and_then(DurableStore::max_request_bytes)
1008 }
1009
1010 async fn apply_routed_batch(
1011 &self,
1012 route_key: &str,
1013 items: Vec<crate::durability::RoutedBatchItem>,
1014 ) -> Result<(), crate::durability::DurabilityError> {
1015 self.store()?.apply_routed_batch(route_key, items).await
1016 }
1017
1018 async fn apply_routed_entries(
1019 &self,
1020 route_key: &str,
1021 entries: Vec<RoutedBatchEntry>,
1022 ) -> Result<(), crate::durability::DurabilityError> {
1023 self.store()?.apply_routed_entries(route_key, entries).await
1024 }
1025
1026 async fn get_routed(
1027 &self,
1028 route_key: &str,
1029 key: &[u8],
1030 ) -> Result<Option<Vec<u8>>, crate::durability::DurabilityError> {
1031 self.store()?.get_routed(route_key, key).await
1032 }
1033
1034 async fn append(
1035 &self,
1036 stream_key: &str,
1037 payload: Vec<u8>,
1038 expected_seq: u64,
1039 ) -> Result<u64, DurabilityError> {
1040 self.store()?
1041 .append(stream_key, payload, expected_seq)
1042 .await
1043 }
1044
1045 async fn append_all(
1046 &self,
1047 stream_key: &str,
1048 payloads: Vec<Vec<u8>>,
1049 expected_seq: u64,
1050 ) -> Result<u64, DurabilityError> {
1051 self.store()?
1052 .append_all(stream_key, payloads, expected_seq)
1053 .await
1054 }
1055
1056 async fn read_from(
1057 &self,
1058 stream_key: &str,
1059 offset: u64,
1060 limit: usize,
1061 ) -> Result<Vec<StoredEntry>, DurabilityError> {
1062 self.store()?.read_from(stream_key, offset, limit).await
1063 }
1064
1065 async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
1066 self.store()?.cas(key, old_value, new_value).await
1067 }
1068
1069 async fn read_stream_next_seq(&self, stream_key: &str) -> Result<Option<u64>, DurabilityError> {
1070 self.store()?.read_stream_next_seq(stream_key).await
1071 }
1072
1073 async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
1074 self.store()?.read_value(key).await
1075 }
1076
1077 async fn scan_page(
1078 &self,
1079 prefix: &str,
1080 cursor: Option<ScanCursor>,
1081 limit: usize,
1082 ) -> Result<ScanPage, DurabilityError> {
1083 self.store()?.scan_page(prefix, cursor, limit).await
1084 }
1085
1086 async fn flush(&self) -> Result<(), DurabilityError> {
1087 self.store()?.flush().await
1088 }
1089
1090 async fn truncate_before(
1091 &self,
1092 stream_key: &str,
1093 before_seq: u64,
1094 ) -> Result<u64, DurabilityError> {
1095 self.store()?.truncate_before(stream_key, before_seq).await
1096 }
1097
1098 async fn scan_routed(
1099 &self,
1100 route_key: &str,
1101 from: &[u8],
1102 to: &[u8],
1103 ) -> Result<Vec<(Vec<u8>, Vec<u8>)>, DurabilityError> {
1104 self.store()?.scan_routed(route_key, from, to).await
1105 }
1106
1107 async fn remove_routed_range(
1108 &self,
1109 route_key: &str,
1110 from: &[u8],
1111 to: &[u8],
1112 ) -> Result<u64, DurabilityError> {
1113 self.store()?.remove_routed_range(route_key, from, to).await
1114 }
1115}
1116
1117#[cfg(any(test, feature = "test-support"))]
1133pub fn open_ephemeral(shard_count: usize) -> Result<EphemeralHaematiteStore, DurabilityError> {
1134 open_ephemeral_in(ephemeral_tempdir(&std::env::temp_dir())?, shard_count)
1135}
1136
1137#[cfg(any(test, feature = "test-support"))]
1154pub fn open_ephemeral_rooted(
1155 root: &Path,
1156 shard_count: usize,
1157) -> Result<EphemeralHaematiteStore, DurabilityError> {
1158 open_ephemeral_in(ephemeral_tempdir(root)?, shard_count)
1159}
1160
1161pub(super) fn open_ephemeral_owned(
1162 root: Arc<super::EphemeralRoot>,
1163 shard_count: usize,
1164) -> Result<EphemeralHaematiteStore, DurabilityError> {
1165 let mut store = open_ephemeral_in(ephemeral_tempdir(root.path())?, shard_count)?;
1166 let path = store.ephemeral_dir_path_owned()?;
1167 let holder = super::ephemeral_root::lock_file(&path.join("holder.lock"))?;
1168 rustix::fs::flock(
1169 &holder,
1170 rustix::fs::FlockOperation::NonBlockingLockExclusive,
1171 )
1172 .map_err(super::ephemeral_root::root_error)?;
1173 store.guard.ownership = Some(super::ephemeral_root::EphemeralOwnership { root, holder });
1174 Ok(store)
1175}
1176
1177fn ephemeral_tempdir(root: &Path) -> Result<TempDir, DurabilityError> {
1179 let mut builder = tempfile::Builder::new();
1180 builder.prefix("liminal-durability-");
1181 builder.tempdir_in(root).map_err(|error| {
1182 DurabilityError::EphemeralStoreOpen(format!(
1183 "could not create temporary directory: {error}"
1184 ))
1185 })
1186}
1187
1188fn open_ephemeral_in(
1193 ephemeral_dir: TempDir,
1194 shard_count: usize,
1195) -> Result<EphemeralHaematiteStore, DurabilityError> {
1196 let database = Database::create(
1197 DatabaseConfig {
1198 data_dir: ephemeral_dir.path().to_path_buf(),
1199 shard_count,
1200 distributed: None,
1201 executor_threads: None,
1202 node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
1209 retention: haematite::Retention::reclaim(0),
1210 },
1211 haematite::RuntimeOptions {
1212 long_wait_interval: std::time::Duration::from_secs(5),
1213 reclaim_start: haematite::ReclaimStart::AtOpen,
1214 },
1215 )
1216 .map_err(|error| DurabilityError::EphemeralStoreOpen(error.to_string()))?;
1217 super::counting::record_store_opened();
1218 Ok(EphemeralHaematiteStore::new(database, ephemeral_dir))
1219}
1220
1221#[cfg(test)]
1229#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1230pub(crate) struct EngineReadAccounting {
1231 pub(crate) calls: usize,
1233 pub(crate) engine_entries: usize,
1235 pub(crate) unbounded_calls: usize,
1237 pub(crate) streams_enumerated: usize,
1239 pub(crate) counter_overflow_observed: bool,
1241}
1242
1243#[cfg(test)]
1244std::thread_local! {
1245 static ENGINE_READ_ACCOUNTING: std::cell::RefCell<Option<EngineReadAccounting>> =
1246 const { std::cell::RefCell::new(None) };
1247}
1248
1249#[cfg(test)]
1254pub(crate) struct EngineReadAccountingGuard {
1255 _not_send: std::marker::PhantomData<*const ()>,
1256}
1257
1258#[cfg(test)]
1259impl EngineReadAccountingGuard {
1260 pub(crate) fn start() -> Self {
1261 ENGINE_READ_ACCOUNTING.with(|accounting| {
1262 *accounting.borrow_mut() = Some(EngineReadAccounting::default());
1263 });
1264 Self {
1265 _not_send: std::marker::PhantomData,
1266 }
1267 }
1268
1269 #[allow(clippy::unused_self)]
1270 pub(crate) fn snapshot(&self) -> EngineReadAccounting {
1271 ENGINE_READ_ACCOUNTING
1272 .with(|accounting| accounting.borrow().as_ref().copied().unwrap_or_default())
1273 }
1274}
1275
1276#[cfg(test)]
1277impl Drop for EngineReadAccountingGuard {
1278 fn drop(&mut self) {
1279 ENGINE_READ_ACCOUNTING.with(|accounting| {
1280 *accounting.borrow_mut() = None;
1281 });
1282 }
1283}
1284
1285#[cfg(test)]
1287fn account_engine_read(engine_entries: usize, unbounded: bool) {
1288 ENGINE_READ_ACCOUNTING.with(|accounting| {
1289 if let Some(active) = accounting.borrow_mut().as_mut() {
1290 match (
1291 active.calls.checked_add(1),
1292 active.engine_entries.checked_add(engine_entries),
1293 ) {
1294 (Some(calls), Some(entries)) => {
1295 active.calls = calls;
1296 active.engine_entries = entries;
1297 }
1298 _ => active.counter_overflow_observed = true,
1299 }
1300 if unbounded {
1301 match active.unbounded_calls.checked_add(1) {
1302 Some(unbounded_calls) => active.unbounded_calls = unbounded_calls,
1303 None => active.counter_overflow_observed = true,
1304 }
1305 }
1306 }
1307 });
1308}
1309
1310#[cfg(not(test))]
1311const fn account_engine_read(_engine_entries: usize, _unbounded: bool) {}
1312
1313#[cfg(test)]
1316fn account_stream_enumeration(streams: usize) {
1317 ENGINE_READ_ACCOUNTING.with(|accounting| {
1318 if let Some(active) = accounting.borrow_mut().as_mut() {
1319 match active.streams_enumerated.checked_add(streams) {
1320 Some(enumerated) => active.streams_enumerated = enumerated,
1321 None => active.counter_overflow_observed = true,
1322 }
1323 }
1324 });
1325}
1326
1327#[cfg(not(test))]
1328const fn account_stream_enumeration(_streams: usize) {}
1329
1330#[cfg(test)]
1338pub(crate) fn page_in_memory_streams(
1339 streams: &std::collections::HashMap<String, Vec<StoredEntry>>,
1340 prefix: &str,
1341 cursor: Option<&ScanCursor>,
1342 limit: usize,
1343) -> Result<ScanPage, DurabilityError> {
1344 if limit == 0 {
1345 return Err(DurabilityError::ConfigError(
1346 "scan_page limit must be nonzero".to_owned(),
1347 ));
1348 }
1349 let mut stream_keys: Vec<&String> = streams
1350 .keys()
1351 .filter(|stream_key| stream_key.starts_with(prefix))
1352 .collect();
1353 stream_keys.sort_unstable();
1354
1355 let mut entries: Vec<StoredEntry> = Vec::new();
1356 let mut resume_at: Option<ScanCursor> = None;
1357 for stream_key in stream_keys {
1358 let Some(remaining) = limit.checked_sub(entries.len()).filter(|rest| *rest > 0) else {
1359 break;
1360 };
1361 let offset = match cursor {
1362 Some(cursor) if *stream_key < cursor.stream_key => continue,
1363 Some(cursor) if *stream_key == cursor.stream_key => cursor.next_sequence,
1364 _ => 0,
1365 };
1366 let Some(stream) = streams.get(stream_key) else {
1367 continue;
1368 };
1369 let page: Vec<StoredEntry> = stream
1370 .iter()
1371 .filter(|entry| entry.sequence >= offset)
1372 .take(remaining)
1373 .cloned()
1374 .collect();
1375 if let Some(last) = page.last() {
1376 let next_sequence = last.sequence.checked_add(1).ok_or_else(|| {
1377 DurabilityError::ConfigError(format!(
1378 "fake scan page sequence overflow for stream {stream_key}"
1379 ))
1380 })?;
1381 resume_at = Some(ScanCursor {
1382 stream_key: stream_key.clone(),
1383 next_sequence,
1384 });
1385 }
1386 entries.extend(page);
1387 }
1388
1389 let next = if entries.len() == limit {
1390 resume_at
1391 } else {
1392 None
1393 };
1394 Ok(ScanPage { entries, next })
1395}
1396
1397impl From<Event> for StoredEntry {
1398 fn from(event: Event) -> Self {
1399 Self {
1400 payload: event.payload,
1401 sequence: event.seq,
1402 timestamp: event.timestamp,
1403 }
1404 }
1405}
1406
1407fn local_batch_error(error: haematite::LocalBatchError) -> DurabilityError {
1417 match error {
1418 haematite::LocalBatchError::CasMismatch { expected, actual } => {
1419 DurabilityError::StoreError(ApiError::Storage(
1420 haematite::DatabaseError::CasHashMismatch { expected, actual },
1421 ))
1422 }
1423 haematite::LocalBatchError::OutcomeUnknown { shard, .. } => {
1424 DurabilityError::OutcomeUnknown {
1425 pid: u64::try_from(shard).unwrap_or(u64::MAX),
1426 }
1427 }
1428 other => DurabilityError::ConfigError(format!("routed batch refused: {other}")),
1429 }
1430}
1431
1432impl From<ApiError> for DurabilityError {
1433 fn from(error: ApiError) -> Self {
1434 match error {
1435 ApiError::SequenceConflict(conflict) => conflict.into(),
1436 ApiError::CasMismatch(mismatch) => mismatch.into(),
1437 ApiError::Storage(haematite::DatabaseError::OutcomeUnknown { pid }) => {
1438 Self::OutcomeUnknown { pid }
1439 }
1440 other @ (ApiError::CorruptEvent(_)
1441 | ApiError::Storage(_)
1442 | ApiError::HistoryCompacted(_)) => Self::StoreError(other),
1443 }
1444 }
1445}
1446
1447#[cfg(test)]
1448#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1449mod ephemeral_lifecycle_tests {
1450 use std::path::{Path, PathBuf};
1455 use std::sync::{Arc, Mutex};
1456
1457 use super::super::bridge::block_on;
1458 use super::{
1459 DurableStore, EphemeralGuard, open_ephemeral, open_ephemeral_in, open_ephemeral_rooted,
1460 };
1461
1462 const TEST_SHARD_COUNT: usize = 2;
1463
1464 #[derive(Clone, Default)]
1473 struct CapturedLog(Arc<Mutex<Vec<u8>>>);
1474
1475 impl CapturedLog {
1476 fn text(&self) -> String {
1478 let bytes = self
1479 .0
1480 .lock()
1481 .expect("capture buffer is not poisoned")
1482 .clone();
1483 String::from_utf8(bytes).expect("tracing's fmt writer emits utf-8")
1484 }
1485
1486 fn capturing<R>(&self, body: impl FnOnce() -> R) -> R {
1502 static INSTALL: std::sync::Once = std::sync::Once::new();
1503 struct ResetOnDrop;
1505 impl Drop for ResetOnDrop {
1506 fn drop(&mut self) {
1507 ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = None);
1508 }
1509 }
1510 INSTALL.call_once(|| {
1511 let subscriber = tracing_subscriber::fmt()
1512 .with_writer(RoutedWriter)
1513 .with_ansi(false)
1514 .finish();
1515 tracing::subscriber::set_global_default(subscriber)
1516 .expect("no other global tracing subscriber is installed in this test binary");
1517 });
1518 ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = Some(self.clone()));
1519 let _reset = ResetOnDrop;
1520 body()
1521 }
1522 }
1523
1524 thread_local! {
1525 static ACTIVE_CAPTURE: std::cell::RefCell<Option<CapturedLog>> =
1528 const { std::cell::RefCell::new(None) };
1529 }
1530
1531 #[derive(Clone, Copy, Default)]
1535 struct RoutedWriter;
1536
1537 impl std::io::Write for RoutedWriter {
1538 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1539 ACTIVE_CAPTURE.with(|slot| {
1540 if let Some(capture) = slot.borrow().as_ref() {
1541 capture
1542 .0
1543 .lock()
1544 .map_err(|_| std::io::Error::other("capture buffer poisoned"))?
1545 .extend_from_slice(buf);
1546 }
1547 Ok(buf.len())
1548 })
1549 }
1550
1551 fn flush(&mut self) -> std::io::Result<()> {
1552 Ok(())
1553 }
1554 }
1555
1556 impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for RoutedWriter {
1557 type Writer = Self;
1558
1559 fn make_writer(&'writer self) -> Self::Writer {
1560 *self
1561 }
1562 }
1563
1564 #[cfg(unix)]
1570 fn set_mode(path: &Path, mode: u32) {
1571 use std::os::unix::fs::PermissionsExt;
1572
1573 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
1574 .expect("test can set permissions on a directory it created");
1575 }
1576
1577 struct OrderProbeStore {
1581 dir: PathBuf,
1582 }
1583
1584 impl Drop for OrderProbeStore {
1585 fn drop(&mut self) {
1586 assert!(
1587 self.dir.exists(),
1588 "the guard must drop the store BEFORE removing the directory"
1589 );
1590 }
1591 }
1592
1593 struct PanickingProbeStore;
1596
1597 impl Drop for PanickingProbeStore {
1598 fn drop(&mut self) {
1599 panic!("injected store-drop panic");
1600 }
1601 }
1602
1603 fn write_one_event(store: &dyn DurableStore) {
1606 block_on(store.append("lifecycle/probe", b"payload".to_vec(), 0))
1607 .expect("bridge completes synchronously")
1608 .expect("append to a fresh ephemeral stream succeeds");
1609 block_on(store.flush())
1610 .expect("bridge completes synchronously")
1611 .expect("flush of a live ephemeral store succeeds");
1612 }
1613
1614 #[test]
1617 fn ephemeral_dir_removed_after_last_handle_drops() {
1618 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
1619 let dir = store
1620 .ephemeral_dir_path()
1621 .expect("ephemeral store carries a guard dir")
1622 .to_path_buf();
1623 assert!(
1624 dir.exists(),
1625 "the guard directory exists while the store is live"
1626 );
1627
1628 write_one_event(&store);
1629 drop(store);
1630
1631 assert!(
1632 !dir.exists(),
1633 "the guard directory is removed on normal drop"
1634 );
1635 }
1636
1637 #[test]
1642 fn ephemeral_dir_survives_until_last_store_clone_drops() {
1643 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
1644 let dir = store
1645 .ephemeral_dir_path()
1646 .expect("ephemeral store carries a guard dir")
1647 .to_path_buf();
1648 write_one_event(&store);
1649
1650 let erased: Arc<dyn DurableStore> = Arc::new(store);
1651 let clone_a = Arc::clone(&erased);
1652 let clone_b = Arc::clone(&erased);
1653
1654 drop(erased);
1655 assert!(
1656 dir.exists(),
1657 "directory survives while store clones remain alive"
1658 );
1659 drop(clone_a);
1660 assert!(
1661 dir.exists(),
1662 "directory survives while one store clone remains alive"
1663 );
1664
1665 drop(clone_b);
1666 assert!(
1667 !dir.exists(),
1668 "the last store clone dropping removes the directory"
1669 );
1670 }
1671
1672 #[test]
1677 fn ephemeral_open_failure_rolls_back_directory() {
1678 let seeded = tempfile::Builder::new()
1679 .prefix("liminal-durability-test-")
1680 .tempdir()
1681 .expect("test can create a temp dir");
1682 let dir = seeded.path().to_path_buf();
1683 std::fs::write(dir.join("config.json"), b"not-a-valid-config")
1687 .expect("test can seed a conflicting config");
1688
1689 let result = open_ephemeral_in(seeded, TEST_SHARD_COUNT);
1690
1691 assert!(result.is_err(), "an injected open failure returns Err");
1692 assert!(
1693 !dir.exists(),
1694 "the guard removes the directory on open failure — zero residue"
1695 );
1696 }
1697
1698 #[test]
1701 fn repeated_ephemeral_cycles_each_own_distinct_dir_zero_residue() {
1702 let mut seen: Vec<PathBuf> = Vec::new();
1703 for _ in 0..5 {
1704 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
1705 let dir = store
1706 .ephemeral_dir_path()
1707 .expect("ephemeral store carries a guard dir")
1708 .to_path_buf();
1709 assert!(
1710 dir.exists(),
1711 "the cycle's directory exists while its store is live"
1712 );
1713 assert!(!seen.contains(&dir), "each cycle owns a distinct directory");
1714 seen.push(dir.clone());
1715
1716 write_one_event(&store);
1717 drop(store);
1718 assert!(
1719 !dir.exists(),
1720 "the cycle's directory is removed after its store drops"
1721 );
1722 }
1723 }
1724
1725 #[test]
1730 fn guard_drops_store_before_removing_directory() {
1731 let dir = tempfile::tempdir().expect("test can create a temp dir");
1732 let path = dir.path().to_path_buf();
1733 let guard = EphemeralGuard {
1734 ownership: None,
1735 store: Some(OrderProbeStore { dir: path.clone() }),
1736 dir: Some(dir),
1737 };
1738
1739 drop(guard);
1740
1741 assert!(!path.exists(), "a clean drop still removes the directory");
1742 }
1743
1744 #[test]
1748 fn guard_leaks_directory_when_store_drop_panics() {
1749 let dir = tempfile::tempdir().expect("test can create a temp dir");
1750 let path = dir.path().to_path_buf();
1751 let guard = EphemeralGuard {
1752 ownership: None,
1753 store: Some(PanickingProbeStore),
1754 dir: Some(dir),
1755 };
1756
1757 let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(guard)));
1758
1759 assert!(unwound.is_err(), "the injected store-drop panic propagates");
1760 assert!(
1761 path.exists(),
1762 "a panicking store drop leaks the directory instead of removing it"
1763 );
1764 std::fs::remove_dir_all(&path).expect("test cleans up the deliberately leaked directory");
1765 }
1766
1767 #[test]
1771 fn rooted_ephemeral_store_lives_and_dies_under_the_given_root() {
1772 let root = tempfile::tempdir().expect("test can create a temp root");
1773 let store =
1774 open_ephemeral_rooted(root.path(), TEST_SHARD_COUNT).expect("rooted open succeeds");
1775 let dir = store
1776 .ephemeral_dir_path()
1777 .expect("ephemeral store carries a guard dir")
1778 .to_path_buf();
1779 assert!(
1780 dir.starts_with(root.path()),
1781 "the guard directory is created under the supplied root"
1782 );
1783
1784 write_one_event(&store);
1785 drop(store);
1786
1787 assert!(!dir.exists(), "the rooted directory is removed on drop");
1788 }
1789
1790 #[test]
1799 fn ephemeral_dir_persists_across_unrelated_work_then_goes_on_clean_drop() {
1800 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
1801 let dir = store
1802 .ephemeral_dir_path()
1803 .expect("ephemeral store carries a guard dir")
1804 .to_path_buf();
1805 assert!(
1806 dir.exists(),
1807 "the directory exists as soon as the store does"
1808 );
1809
1810 for round in 0..3_u64 {
1811 block_on(store.append("clean-teardown/probe", b"payload".to_vec(), round))
1812 .expect("bridge completes synchronously")
1813 .expect("append to a live ephemeral store succeeds");
1814 assert!(
1815 dir.exists(),
1816 "the directory is still there after append round {round}"
1817 );
1818 }
1819 block_on(store.cas("clean-teardown/counter", 0, 7))
1820 .expect("bridge completes synchronously")
1821 .expect("cas on a live ephemeral store succeeds");
1822 let entries = block_on(store.read_from("clean-teardown/probe", 0, 10))
1823 .expect("bridge completes synchronously")
1824 .expect("read from a live ephemeral store succeeds");
1825 assert_eq!(entries.len(), 3, "every appended entry is readable back");
1826 assert!(
1827 dir.exists(),
1828 "the directory is still there after unrelated cas and read work"
1829 );
1830
1831 block_on(store.flush())
1832 .expect("bridge completes synchronously")
1833 .expect("flush of a live ephemeral store succeeds");
1834 drop(store);
1835
1836 assert!(
1837 !dir.exists(),
1838 "the clean drop removes the directory it kept alive throughout"
1839 );
1840 }
1841
1842 #[cfg(unix)]
1851 #[test]
1852 fn clean_drop_removal_failure_is_logged_and_never_panics() {
1853 let parent = tempfile::tempdir().expect("test can create a temp parent");
1854 let dir = tempfile::Builder::new()
1855 .prefix("liminal-durability-")
1856 .tempdir_in(parent.path())
1857 .expect("test can create a guard dir under the parent");
1858 let path = dir.path().to_path_buf();
1859 let guard = EphemeralGuard {
1860 ownership: None,
1861 store: Some(()),
1862 dir: Some(dir),
1863 };
1864
1865 set_mode(parent.path(), 0o500);
1866 let captured = CapturedLog::default();
1867 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1868 captured.capturing(|| drop(guard));
1869 }));
1870 set_mode(parent.path(), 0o700);
1873
1874 assert!(
1875 outcome.is_ok(),
1876 "a removal failure is reported, never raised as a panic"
1877 );
1878 let logged = captured.text();
1879 assert!(
1880 logged.contains("ERROR"),
1881 "the removal failure is logged at error level; captured: {logged:?}"
1882 );
1883 assert!(
1884 logged.contains(&path.display().to_string()),
1885 "the log names the directory that survived; captured: {logged:?}"
1886 );
1887 assert!(
1888 path.exists(),
1889 "the residue is left where the log says it is, not silently claimed removed"
1890 );
1891 }
1892
1893 #[test]
1897 fn clean_drop_that_succeeds_logs_nothing() {
1898 let dir = tempfile::tempdir().expect("test can create a temp dir");
1899 let path = dir.path().to_path_buf();
1900 let guard = EphemeralGuard {
1901 ownership: None,
1902 store: Some(()),
1903 dir: Some(dir),
1904 };
1905
1906 let captured = CapturedLog::default();
1907 captured.capturing(|| drop(guard));
1908
1909 assert!(!path.exists(), "the successful clean drop removed the dir");
1910 assert!(
1911 captured.text().is_empty(),
1912 "a successful removal is silent; captured: {:?}",
1913 captured.text()
1914 );
1915 }
1916
1917 #[test]
1924 fn panic_path_leak_is_logged_with_its_path() {
1925 let dir = tempfile::tempdir().expect("test can create a temp dir");
1926 let path = dir.path().to_path_buf();
1927 let guard = EphemeralGuard {
1928 ownership: None,
1929 store: Some(PanickingProbeStore),
1930 dir: Some(dir),
1931 };
1932
1933 let captured = CapturedLog::default();
1934 let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1935 captured.capturing(|| drop(guard));
1936 }));
1937
1938 assert!(unwound.is_err(), "the injected store-drop panic propagates");
1939 let logged = captured.text();
1940 assert!(
1941 logged.contains("ERROR"),
1942 "the sanctioned leak is logged at error level; captured: {logged:?}"
1943 );
1944 assert!(
1945 logged.contains(&path.display().to_string()),
1946 "the leak log names the leaked directory; captured: {logged:?}"
1947 );
1948 std::fs::remove_dir_all(&path).expect("test cleans up the deliberately leaked directory");
1949 }
1950}
1951
1952#[cfg(test)]
1953#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1954#[path = "store_paged_read_shape_tests.rs"]
1955mod paged_read_shape_tests;
1956
1957#[cfg(test)]
1958#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1959mod paged_scan_tests {
1960 use crate::durability::tests_fixtures;
1969 use std::collections::HashSet;
1970
1971 use super::{
1972 DurabilityError, DurableStore, EngineReadAccountingGuard, ScanCursor, StoredEntry,
1973 page_in_memory_streams,
1974 };
1975 use crate::durability::bridge::block_on;
1976
1977 const PREFIX: &str = "liminal/scan-bound/";
1978 const STREAMS: [&str; 3] = [
1981 "liminal/scan-bound/alpha",
1982 "liminal/scan-bound/bravo",
1983 "liminal/scan-bound/charlie",
1984 ];
1985 const ROWS_PER_STREAM: [u64; 3] = [7, 11, 5];
1988
1989 fn payload(stream: &str, sequence: u64) -> Vec<u8> {
1992 format!("{stream}#{sequence}").into_bytes()
1993 }
1994
1995 fn seed_census() -> Vec<Vec<u8>> {
1997 let mut census = Vec::new();
1998 for (stream, rows) in STREAMS.iter().zip(ROWS_PER_STREAM) {
1999 for sequence in 0..rows {
2000 census.push(payload(stream, sequence));
2001 }
2002 }
2003 census
2004 }
2005
2006 fn seeded() -> Result<impl DurableStore, Box<dyn std::error::Error>> {
2007 tests_fixtures::copied("paged-scan")
2008 }
2009
2010 #[test]
2014 fn a_full_scan_page_stops_at_its_limit_and_reports_where_to_resume()
2015 -> Result<(), Box<dyn std::error::Error>> {
2016 const LIMIT: usize = 9;
2017 let store = seeded()?;
2018
2019 let page = block_on(store.scan_page(PREFIX, None, LIMIT))??;
2020
2021 assert_eq!(
2022 page.entries.len(),
2023 LIMIT,
2024 "a seed larger than the limit must fill exactly one page"
2025 );
2026 let cursor = page
2027 .next
2028 .ok_or("a filled page must report a resume position")?;
2029 assert_eq!(cursor.stream_key, STREAMS[1]);
2032 assert_eq!(cursor.next_sequence, 2);
2033 Ok(())
2034 }
2035
2036 #[test]
2039 fn draining_every_page_delivers_the_seed_exactly_once() -> Result<(), Box<dyn std::error::Error>>
2040 {
2041 let store = seeded()?;
2042 let census = seed_census();
2043
2044 for limit in [1_usize, 2, 4, 7, 8, 13, 23, 24] {
2048 let mut drained: Vec<Vec<u8>> = Vec::new();
2049 let mut per_stream: Vec<(String, u64)> = Vec::new();
2050 let mut cursor: Option<ScanCursor> = None;
2051 let mut pages = 0_usize;
2052 loop {
2053 let page = block_on(store.scan_page(PREFIX, cursor, limit))??;
2054 assert!(
2055 page.entries.len() <= limit,
2056 "limit {limit} page {pages} exceeded its limit"
2057 );
2058 for entry in &page.entries {
2059 let text = String::from_utf8(entry.payload.clone())?;
2060 let stream = text
2061 .split_once('#')
2062 .ok_or("seeded payload names its stream")?
2063 .0
2064 .to_owned();
2065 per_stream.push((stream, entry.sequence));
2066 }
2067 drained.extend(page.entries.into_iter().map(|entry| entry.payload));
2068 pages = pages.checked_add(1).ok_or("page counter overflowed")?;
2069 assert!(pages <= 64, "limit {limit} did not terminate in 64 pages");
2070 let Some(next) = page.next else {
2071 break;
2072 };
2073 cursor = Some(next);
2074 }
2075
2076 let drained_set: HashSet<Vec<u8>> = drained.iter().cloned().collect();
2077 assert_eq!(
2078 drained.len(),
2079 drained_set.len(),
2080 "limit {limit} delivered a duplicate entry"
2081 );
2082 assert_eq!(
2083 drained_set,
2084 census.iter().cloned().collect::<HashSet<_>>(),
2085 "limit {limit} did not deliver exactly the seeded entries"
2086 );
2087
2088 for stream in STREAMS {
2089 let sequences: Vec<u64> = per_stream
2090 .iter()
2091 .filter(|(seen, _)| seen == stream)
2092 .map(|(_, sequence)| *sequence)
2093 .collect();
2094 assert!(
2095 sequences.windows(2).all(|pair| pair[0] < pair[1]),
2096 "limit {limit} broke sequence order within {stream}: {sequences:?}"
2097 );
2098 }
2099 }
2100 Ok(())
2101 }
2102
2103 #[test]
2106 fn a_short_page_ends_the_scan() -> Result<(), Box<dyn std::error::Error>> {
2107 let store = seeded()?;
2108 let total = usize::try_from(ROWS_PER_STREAM.iter().sum::<u64>())?;
2109
2110 let whole =
2111 block_on(store.scan_page(PREFIX, None, total.checked_add(1).ok_or("overflow")?))??;
2112 assert_eq!(
2113 whole.entries.len(),
2114 total,
2115 "the whole seed fits in one page"
2116 );
2117 assert!(
2118 whole.next.is_none(),
2119 "a page short of its limit terminates the scan"
2120 );
2121
2122 let exact = block_on(store.scan_page(PREFIX, None, total))??;
2125 assert_eq!(exact.entries.len(), total);
2126 let cursor = exact
2127 .next
2128 .ok_or("an exactly-full page reports a resume position")?;
2129 let terminator = block_on(store.scan_page(PREFIX, Some(cursor), total))??;
2130 assert!(
2131 terminator.entries.is_empty(),
2132 "the resume past the last entry finds nothing"
2133 );
2134 assert!(
2135 terminator.next.is_none(),
2136 "a final empty page is a legal terminator"
2137 );
2138
2139 let unmatched = block_on(store.scan_page("liminal/no-such-prefix/", None, 8))??;
2141 assert!(unmatched.entries.is_empty());
2142 assert!(unmatched.next.is_none());
2143 Ok(())
2144 }
2145
2146 #[test]
2158 fn a_page_orders_streams_by_key_not_by_discovery_order()
2159 -> Result<(), Box<dyn std::error::Error>> {
2160 const STREAM_COUNT: usize = 32;
2161 let mut streams = std::collections::HashMap::new();
2162 for index in 0..STREAM_COUNT {
2163 let stream_key = format!("{PREFIX}stream-{index:02}");
2164 let entry = StoredEntry {
2165 payload: stream_key.clone().into_bytes(),
2166 sequence: 0,
2167 timestamp: 0,
2168 };
2169 streams.insert(stream_key, vec![entry]);
2170 }
2171
2172 let page = page_in_memory_streams(&streams, PREFIX, None, STREAM_COUNT)?;
2173
2174 let delivered: Vec<String> = page
2175 .entries
2176 .into_iter()
2177 .map(|entry| String::from_utf8(entry.payload))
2178 .collect::<Result<_, _>>()?;
2179 let mut expected = delivered.clone();
2180 expected.sort_unstable();
2181 assert_eq!(
2182 delivered, expected,
2183 "a page must deliver its streams in key order"
2184 );
2185 Ok(())
2186 }
2187
2188 #[test]
2193 fn a_drained_walk_enumerates_each_stream_once() -> Result<(), Box<dyn std::error::Error>> {
2194 const WALK_STREAMS: usize = 48;
2195 const LIMIT: usize = 5;
2196 let store = tests_fixtures::copied("paged-walk")?;
2197
2198 let accounting = EngineReadAccountingGuard::start();
2199 let mut cursor = None;
2200 let mut delivered = 0_usize;
2201 let mut pages = 0_usize;
2202 loop {
2203 let page = block_on(store.scan_page(PREFIX, cursor, LIMIT))??;
2204 pages += 1;
2205 delivered += page.entries.len();
2206 match page.next {
2207 Some(next) => cursor = Some(next),
2208 None => break,
2209 }
2210 }
2211 let walk = accounting.snapshot();
2212 drop(accounting);
2213
2214 assert_eq!(delivered, WALK_STREAMS, "the walk delivers every stream");
2215 assert!(!walk.counter_overflow_observed, "{walk:?}");
2216 assert!(
2217 walk.streams_enumerated <= WALK_STREAMS + pages,
2218 "a walk of {WALK_STREAMS} streams in {pages} pages enumerated {} streams",
2219 walk.streams_enumerated
2220 );
2221 Ok(())
2222 }
2223
2224 #[test]
2227 fn a_zero_limit_is_refused() -> Result<(), Box<dyn std::error::Error>> {
2228 let store = seeded()?;
2229
2230 let refused = block_on(store.scan_page(PREFIX, None, 0))?;
2231
2232 assert!(
2233 matches!(refused, Err(DurabilityError::ConfigError(_))),
2234 "a zero limit is refused as a configuration error, got {refused:?}"
2235 );
2236 Ok(())
2237 }
2238}
2239
2240#[cfg(test)]
2241mod truncate_cost_tests {
2242 use std::error::Error;
2252 use std::sync::Arc;
2253
2254 use super::super::bridge::block_on;
2255 use super::{Database, DatabaseConfig, DurableStore, EventStore, HaematiteStore};
2256
2257 const STREAM: &str = "truncate-cost";
2258 const HISTORY: u64 = 10_000;
2260 const BATCH: u64 = 1_000;
2262 const PRESENT: u64 = 10;
2264 const OVERHEAD: u64 = 64;
2267
2268 #[test]
2269 fn a_prune_far_into_history_reads_only_what_it_deletes() -> Result<(), Box<dyn Error>> {
2270 let dir = tempfile::tempdir()?;
2271 let database = Database::create(
2272 DatabaseConfig {
2273 data_dir: dir.path().to_path_buf(),
2274 shard_count: 1,
2275 distributed: None,
2276 executor_threads: None,
2277 node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
2278 retention: haematite::Retention::reclaim(0),
2279 },
2280 haematite::RuntimeOptions {
2281 long_wait_interval: std::time::Duration::from_secs(5),
2282 reclaim_start: haematite::ReclaimStart::AtOpen,
2283 },
2284 )?;
2285 let events = Arc::new(EventStore::new(database));
2286 let store = HaematiteStore::new(Arc::clone(&events));
2287 let batch: Vec<Vec<u8>> = (0..BATCH).map(|_| vec![0x07]).collect();
2288 let mut next = 0;
2289 while next < HISTORY {
2290 block_on(store.append_all(STREAM, batch.clone(), next))??;
2291 next += BATCH;
2292 }
2293 block_on(store.truncate_before(STREAM, HISTORY - PRESENT))??;
2294
2295 let before = events.database().store_counts().node_gets;
2296 let deleted = block_on(store.truncate_before(STREAM, HISTORY))??;
2297 let reads = events.database().store_counts().node_gets - before;
2298 assert_eq!(
2299 (deleted, reads <= PRESENT + OVERHEAD),
2300 (PRESENT, true),
2301 "a prune of {deleted} rows at sequence {HISTORY} read {reads} nodes; at most \
2302 {PRESENT} plus {OVERHEAD} is owed: the prune walked the history, not the rows"
2303 );
2304 drop(store);
2305 drop(events);
2306 drop(dir);
2307 Ok(())
2308 }
2309}
2310
2311#[cfg(test)]
2312#[path = "store_batch_tests.rs"]
2313mod batch_tests;