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, Debug, PartialEq, Eq)]
26pub struct StoredEntry {
27 pub payload: Vec<u8>,
29 pub sequence: u64,
31 pub timestamp: u64,
33}
34
35#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct ScanCursor {
42 pub stream_key: String,
44 pub next_sequence: u64,
46}
47
48#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct ScanPage {
51 pub entries: Vec<StoredEntry>,
53 pub next: Option<ScanCursor>,
55}
56
57#[async_trait::async_trait]
59pub trait DurableStore: std::fmt::Debug + Send + Sync {
60 async fn apply_routed_batch(
64 &self,
65 route_key: &str,
66 items: Vec<RoutedBatchItem>,
67 ) -> Result<(), DurabilityError>;
68
69 async fn get_routed(
71 &self,
72 route_key: &str,
73 key: &[u8],
74 ) -> Result<Option<Vec<u8>>, DurabilityError>;
75
76 async fn append(
78 &self,
79 stream_key: &str,
80 payload: Vec<u8>,
81 expected_seq: u64,
82 ) -> Result<u64, DurabilityError>;
83
84 async fn read_from(
86 &self,
87 stream_key: &str,
88 offset: u64,
89 limit: usize,
90 ) -> Result<Vec<StoredEntry>, DurabilityError>;
91
92 async fn append_all(
100 &self,
101 stream_key: &str,
102 payloads: Vec<Vec<u8>>,
103 expected_seq: u64,
104 ) -> Result<u64, DurabilityError>;
105
106 async fn read_at(
108 &self,
109 stream_key: &str,
110 sequence: u64,
111 ) -> Result<Option<StoredEntry>, DurabilityError> {
112 Ok(self
113 .read_from(stream_key, sequence, 1)
114 .await?
115 .into_iter()
116 .next())
117 }
118
119 async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError>;
126
127 async fn read_stream_next_seq(&self, stream_key: &str) -> Result<Option<u64>, DurabilityError>;
136
137 async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError>;
139
140 async fn scan_page(
154 &self,
155 prefix: &str,
156 cursor: Option<ScanCursor>,
157 limit: usize,
158 ) -> Result<ScanPage, DurabilityError>;
159
160 async fn flush(&self) -> Result<(), DurabilityError>;
165
166 async fn truncate_before(
180 &self,
181 stream_key: &str,
182 before_seq: u64,
183 ) -> Result<u64, DurabilityError> {
184 Err(DurabilityError::TruncateUnsupported {
185 store: std::any::type_name::<Self>(),
186 stream_key: stream_key.to_owned(),
187 before_seq,
188 })
189 }
190
191 async fn scan_routed(
201 &self,
202 route_key: &str,
203 _from: &[u8],
204 _to: &[u8],
205 ) -> Result<Vec<(Vec<u8>, Vec<u8>)>, DurabilityError> {
206 Err(DurabilityError::RoutedScanUnsupported {
207 store: std::any::type_name::<Self>(),
208 route_key: route_key.to_owned(),
209 })
210 }
211
212 async fn remove_routed_range(
229 &self,
230 route_key: &str,
231 _from: &[u8],
232 _to: &[u8],
233 ) -> Result<u64, DurabilityError> {
234 Err(DurabilityError::RoutedRemoveUnsupported {
235 store: std::any::type_name::<Self>(),
236 route_key: route_key.to_owned(),
237 })
238 }
239
240 fn store_counts(&self) -> Option<StoreCountsHandle> {
247 None
248 }
249}
250
251#[derive(Clone, Debug)]
257pub struct HaematiteStore {
258 event_store: Arc<EventStore>,
259}
260
261impl HaematiteStore {
262 #[must_use]
264 pub const fn new(event_store: Arc<EventStore>) -> Self {
265 Self { event_store }
266 }
267
268 fn bounded_page(
280 &self,
281 stream_key: &str,
282 offset: u64,
283 limit: usize,
284 ) -> Result<Option<Vec<StoredEntry>>, DurabilityError> {
285 const TIMESTAMP_WIDTH: usize = std::mem::size_of::<u64>();
286
287 let Some(engine_from) = offset.checked_add(1) else {
289 return Ok(None);
290 };
291 let Some(engine_end) = u64::try_from(limit)
292 .ok()
293 .and_then(|limit| engine_from.checked_add(limit))
294 else {
295 return Ok(None);
296 };
297 let key = stream_key.as_bytes();
298 let from = haematite::encode_stream_key(key, engine_from);
299 let to = haematite::encode_stream_key(key, engine_end);
300 let entries = self
301 .event_store
302 .database()
303 .range_routed(key, &from, &to)
304 .map_err(ApiError::from)
305 .map_err(DurabilityError::from)?;
306 if entries.len() != limit {
307 return Ok(None);
308 }
309
310 let mut page = Vec::with_capacity(entries.len());
311 for (encoded_key, value) in entries {
312 let Some((decoded_key, engine_sequence)) = haematite::decode_stream_key(&encoded_key)
313 else {
314 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
315 format!("paged read key does not encode an event for stream {stream_key}"),
316 )));
317 };
318 if decoded_key != key {
319 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
320 format!("paged read key does not encode stream {stream_key}"),
321 )));
322 }
323 let sequence = engine_sequence.checked_sub(1).ok_or_else(|| {
324 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
325 "paged read event key has zero seq for stream {stream_key}"
326 )))
327 })?;
328 let Some(timestamp_bytes) = value.get(..TIMESTAMP_WIDTH) else {
329 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
330 format!(
331 "paged read event value is shorter than its timestamp for stream {stream_key}"
332 ),
333 )));
334 };
335 let timestamp = u64::from_be_bytes(timestamp_bytes.try_into().map_err(|_| {
336 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
337 "paged read event timestamp has the wrong width for stream {stream_key}"
338 )))
339 })?);
340 let Some(payload) = value.get(TIMESTAMP_WIDTH..) else {
341 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
342 format!("paged read event has no payload boundary for stream {stream_key}"),
343 )));
344 };
345 page.push(StoredEntry {
346 payload: payload.to_vec(),
347 sequence,
348 timestamp,
349 });
350 }
351 Ok(Some(page))
352 }
353}
354
355#[async_trait::async_trait]
356impl DurableStore for HaematiteStore {
357 async fn apply_routed_batch(
358 &self,
359 route_key: &str,
360 items: Vec<RoutedBatchItem>,
361 ) -> Result<(), DurabilityError> {
362 let items = items
363 .into_iter()
364 .map(|item| haematite::RoutedBatchItem {
365 key: item.key,
366 expected: item.expected,
367 value: item.value,
368 ttl: item.ttl,
369 })
370 .collect();
371 self.event_store
372 .database()
373 .apply_routed_batch(route_key.as_bytes(), items)
374 .map_err(ApiError::from)
375 .map_err(DurabilityError::from)
376 }
377
378 async fn get_routed(
379 &self,
380 route_key: &str,
381 key: &[u8],
382 ) -> Result<Option<Vec<u8>>, DurabilityError> {
383 self.event_store
384 .database()
385 .get_routed(route_key.as_bytes(), key)
386 .map_err(ApiError::from)
387 .map_err(DurabilityError::from)
388 }
389
390 async fn append(
391 &self,
392 stream_key: &str,
393 payload: Vec<u8>,
394 expected_seq: u64,
395 ) -> Result<u64, DurabilityError> {
396 let next_seq = self
404 .event_store
405 .append(stream_key.as_bytes(), &payload, expected_seq)
406 .map_err(DurabilityError::from)?;
407 next_seq.checked_sub(1).ok_or_else(|| {
408 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
409 "append returned next-seq 0 for stream {stream_key}"
410 )))
411 })
412 }
413
414 async fn append_all(
415 &self,
416 stream_key: &str,
417 payloads: Vec<Vec<u8>>,
418 expected_seq: u64,
419 ) -> Result<u64, DurabilityError> {
420 let borrowed: Vec<&[u8]> = payloads.iter().map(Vec::as_slice).collect();
421 self.event_store
422 .append_batch(stream_key.as_bytes(), &borrowed, expected_seq)
423 .map_err(DurabilityError::from)
424 }
425
426 async fn read_from(
427 &self,
428 stream_key: &str,
429 offset: u64,
430 limit: usize,
431 ) -> Result<Vec<StoredEntry>, DurabilityError> {
432 if limit > 0 {
455 if let Some(page) = self.bounded_page(stream_key, offset, limit)? {
456 account_engine_read(page.len(), false);
457 return Ok(page);
458 }
459 }
460 let mut events = self
461 .event_store
462 .read_from(stream_key.as_bytes(), offset)
463 .map_err(DurabilityError::from)?;
464 account_engine_read(events.len(), true);
465 events.truncate(limit);
466 Ok(events.into_iter().map(StoredEntry::from).collect())
467 }
468
469 async fn read_at(
470 &self,
471 stream_key: &str,
472 sequence: u64,
473 ) -> Result<Option<StoredEntry>, DurabilityError> {
474 const TIMESTAMP_WIDTH: usize = std::mem::size_of::<u64>();
475
476 let engine_sequence = sequence.checked_add(1).ok_or_else(|| {
477 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
478 "point read sequence overflow for stream {stream_key}"
479 )))
480 })?;
481 let event_key = haematite::encode_stream_key(stream_key.as_bytes(), engine_sequence);
482 let Some(value) = self
483 .event_store
484 .database()
485 .get_routed(stream_key.as_bytes(), &event_key)
486 .map_err(ApiError::from)
487 .map_err(DurabilityError::from)?
488 else {
489 return Ok(None);
490 };
491 let Some(timestamp_bytes) = value.get(..TIMESTAMP_WIDTH) else {
492 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
493 format!(
494 "point-read event value is shorter than its timestamp for stream {stream_key}"
495 ),
496 )));
497 };
498 let timestamp = u64::from_be_bytes(timestamp_bytes.try_into().map_err(|_| {
499 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
500 "point-read event timestamp has the wrong width for stream {stream_key}"
501 )))
502 })?);
503 let Some(payload) = value.get(TIMESTAMP_WIDTH..) else {
504 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
505 format!("point-read event has no payload boundary for stream {stream_key}"),
506 )));
507 };
508 Ok(Some(StoredEntry {
509 payload: payload.to_vec(),
510 sequence,
511 timestamp,
512 }))
513 }
514
515 async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
516 if new_value == 0 {
531 return self
532 .event_store
533 .read_value(key.as_bytes())
534 .map_err(DurabilityError::from)?
535 .map_or(Ok(()), |stored| {
536 Err(DurabilityError::CursorRegression {
537 stored,
538 attempted: old_value,
539 })
540 });
541 }
542 let expected = if old_value == 0 {
548 None
549 } else {
550 Some(old_value)
551 };
552 self.event_store
553 .cas(key.as_bytes(), expected, new_value)
554 .map_err(DurabilityError::from)
555 }
556
557 async fn read_stream_next_seq(&self, stream_key: &str) -> Result<Option<u64>, DurabilityError> {
558 self.event_store
559 .read_stream_next_seq(stream_key.as_bytes())
560 .map_err(DurabilityError::from)
561 }
562
563 async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
564 self.event_store
565 .read_value(key.as_bytes())
566 .map_err(DurabilityError::from)
567 }
568
569 async fn scan_page(
570 &self,
571 prefix: &str,
572 cursor: Option<ScanCursor>,
573 limit: usize,
574 ) -> Result<ScanPage, DurabilityError> {
575 if limit == 0 {
576 return Err(DurabilityError::ConfigError(
577 "scan_page limit must be nonzero".to_owned(),
578 ));
579 }
580
581 let mut entries: Vec<StoredEntry> = Vec::new();
582 let mut resume_at: Option<ScanCursor> = None;
583 let mut after: Option<String> = if let Some(cursor) = cursor {
586 if cursor.stream_key.starts_with(prefix) {
587 let page = self
588 .read_from(&cursor.stream_key, cursor.next_sequence, limit)
589 .await?;
590 resume_at = scan_resume_position(&cursor.stream_key, &page)?.or(resume_at);
591 entries.extend(page);
592 }
593 Some(cursor.stream_key)
594 } else {
595 None
596 };
597
598 while let Some(remaining) = limit
605 .checked_sub(entries.len())
606 .and_then(std::num::NonZeroUsize::new)
607 {
608 let streams = self
609 .event_store
610 .scan_prefix(
611 prefix.as_bytes(),
612 remaining,
613 after.as_deref().map(str::as_bytes),
614 )
615 .map_err(DurabilityError::from)?;
616 account_stream_enumeration(streams.streams.len());
617 let exhausted = streams.next_after.is_none() || streams.streams.is_empty();
618 for stream in streams.streams {
619 let stream_key = String::from_utf8(stream.stream_key).map_err(|error| {
624 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
625 "scanned stream key is not utf-8: {error}"
626 )))
627 })?;
628 let Some(remaining) = limit.checked_sub(entries.len()).filter(|rest| *rest > 0)
629 else {
630 break;
631 };
632 let page = self.read_from(&stream_key, 0, remaining).await?;
637 resume_at = scan_resume_position(&stream_key, &page)?.or(resume_at);
638 entries.extend(page);
639 after = Some(stream_key);
640 }
641 if exhausted {
642 break;
643 }
644 }
645
646 let next = if entries.len() == limit {
650 resume_at
651 } else {
652 None
653 };
654 Ok(ScanPage { entries, next })
655 }
656
657 async fn flush(&self) -> Result<(), DurabilityError> {
658 self.event_store.flush().map_err(DurabilityError::from)
659 }
660
661 async fn truncate_before(
664 &self,
665 stream_key: &str,
666 before_seq: u64,
667 ) -> Result<u64, DurabilityError> {
668 self.event_store
669 .truncate_stream(stream_key.as_bytes(), before_seq)
670 .map_err(DurabilityError::from)
671 }
672
673 async fn scan_routed(
678 &self,
679 route_key: &str,
680 from: &[u8],
681 to: &[u8],
682 ) -> Result<Vec<(Vec<u8>, Vec<u8>)>, DurabilityError> {
683 self.event_store
684 .database()
685 .range_routed(route_key.as_bytes(), from, to)
686 .map_err(ApiError::from)
687 .map_err(DurabilityError::from)
688 }
689
690 async fn remove_routed_range(
700 &self,
701 route_key: &str,
702 _from: &[u8],
703 _to: &[u8],
704 ) -> Result<u64, DurabilityError> {
705 Err(DurabilityError::RoutedRemoveUnsupported {
706 store: std::any::type_name::<Self>(),
707 route_key: route_key.to_owned(),
708 })
709 }
710}
711
712fn scan_resume_position(
715 stream_key: &str,
716 page: &[StoredEntry],
717) -> Result<Option<ScanCursor>, DurabilityError> {
718 let Some(last) = page.last() else {
719 return Ok(None);
720 };
721 let next_sequence = last.sequence.checked_add(1).ok_or_else(|| {
722 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
723 "scan page sequence overflow for stream {stream_key}"
724 )))
725 })?;
726 Ok(Some(ScanCursor {
727 stream_key: stream_key.to_owned(),
728 next_sequence,
729 }))
730}
731
732#[derive(Debug)]
758struct EphemeralGuard<S> {
759 ownership: Option<super::ephemeral_root::EphemeralOwnership>,
760 store: Option<S>,
761 dir: Option<TempDir>,
762}
763
764impl<S> Drop for EphemeralGuard<S> {
765 fn drop(&mut self) {
766 let store = self.store.take();
767 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(store)));
771 if let Err(panic) = outcome {
772 if let Some(dir) = self.dir.take() {
773 let leaked = dir.keep();
774 tracing::error!(
775 path = %leaked.display(),
776 "ephemeral store drop panicked; leaking its directory rather than \
777 removing it under possibly-live database workers"
778 );
779 }
780 if let Some(ownership) = self.ownership.take() {
781 std::mem::forget(ownership);
782 }
783 std::panic::resume_unwind(panic);
784 }
785 if let Some(dir) = self.dir.take() {
789 let path = dir.path().to_path_buf();
790 if let Err(error) = dir.close() {
791 tracing::error!(
792 path = %path.display(),
793 %error,
794 "ephemeral store directory removal failed; residue remains at the \
795 logged path"
796 );
797 }
798 }
799 }
800}
801
802#[derive(Debug)]
818pub struct EphemeralHaematiteStore {
819 guard: EphemeralGuard<HaematiteStore>,
820}
821
822impl EphemeralHaematiteStore {
823 fn new(database: Database, ephemeral_dir: TempDir) -> Self {
832 Self {
833 guard: EphemeralGuard {
834 ownership: None,
835 store: Some(HaematiteStore::new(Arc::new(EventStore::new(database)))),
836 dir: Some(ephemeral_dir),
837 },
838 }
839 }
840
841 fn store(&self) -> Result<&HaematiteStore, DurabilityError> {
848 self.guard
849 .store
850 .as_ref()
851 .ok_or(DurabilityError::EphemeralStoreDetached)
852 }
853
854 fn ephemeral_dir_path_owned(&self) -> Result<&Path, DurabilityError> {
855 self.guard
856 .dir
857 .as_ref()
858 .map(TempDir::path)
859 .ok_or(DurabilityError::EphemeralStoreDetached)
860 }
861
862 #[cfg(test)]
864 pub(crate) fn ephemeral_dir_path(&self) -> Option<&Path> {
865 self.guard.dir.as_ref().map(TempDir::path)
866 }
867}
868
869#[async_trait::async_trait]
870impl DurableStore for EphemeralHaematiteStore {
871 async fn apply_routed_batch(
872 &self,
873 route_key: &str,
874 items: Vec<crate::durability::RoutedBatchItem>,
875 ) -> Result<(), crate::durability::DurabilityError> {
876 self.store()?.apply_routed_batch(route_key, items).await
877 }
878
879 async fn get_routed(
880 &self,
881 route_key: &str,
882 key: &[u8],
883 ) -> Result<Option<Vec<u8>>, crate::durability::DurabilityError> {
884 self.store()?.get_routed(route_key, key).await
885 }
886
887 async fn append(
888 &self,
889 stream_key: &str,
890 payload: Vec<u8>,
891 expected_seq: u64,
892 ) -> Result<u64, DurabilityError> {
893 self.store()?
894 .append(stream_key, payload, expected_seq)
895 .await
896 }
897
898 async fn append_all(
899 &self,
900 stream_key: &str,
901 payloads: Vec<Vec<u8>>,
902 expected_seq: u64,
903 ) -> Result<u64, DurabilityError> {
904 self.store()?
905 .append_all(stream_key, payloads, expected_seq)
906 .await
907 }
908
909 async fn read_from(
910 &self,
911 stream_key: &str,
912 offset: u64,
913 limit: usize,
914 ) -> Result<Vec<StoredEntry>, DurabilityError> {
915 self.store()?.read_from(stream_key, offset, limit).await
916 }
917
918 async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
919 self.store()?.cas(key, old_value, new_value).await
920 }
921
922 async fn read_stream_next_seq(&self, stream_key: &str) -> Result<Option<u64>, DurabilityError> {
923 self.store()?.read_stream_next_seq(stream_key).await
924 }
925
926 async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
927 self.store()?.read_value(key).await
928 }
929
930 async fn scan_page(
931 &self,
932 prefix: &str,
933 cursor: Option<ScanCursor>,
934 limit: usize,
935 ) -> Result<ScanPage, DurabilityError> {
936 self.store()?.scan_page(prefix, cursor, limit).await
937 }
938
939 async fn flush(&self) -> Result<(), DurabilityError> {
940 self.store()?.flush().await
941 }
942
943 async fn truncate_before(
944 &self,
945 stream_key: &str,
946 before_seq: u64,
947 ) -> Result<u64, DurabilityError> {
948 self.store()?.truncate_before(stream_key, before_seq).await
949 }
950
951 async fn scan_routed(
952 &self,
953 route_key: &str,
954 from: &[u8],
955 to: &[u8],
956 ) -> Result<Vec<(Vec<u8>, Vec<u8>)>, DurabilityError> {
957 self.store()?.scan_routed(route_key, from, to).await
958 }
959
960 async fn remove_routed_range(
961 &self,
962 route_key: &str,
963 from: &[u8],
964 to: &[u8],
965 ) -> Result<u64, DurabilityError> {
966 self.store()?.remove_routed_range(route_key, from, to).await
967 }
968}
969
970#[cfg(any(test, feature = "test-support"))]
986pub fn open_ephemeral(shard_count: usize) -> Result<EphemeralHaematiteStore, DurabilityError> {
987 open_ephemeral_in(ephemeral_tempdir(&std::env::temp_dir())?, shard_count)
988}
989
990#[cfg(any(test, feature = "test-support"))]
1007pub fn open_ephemeral_rooted(
1008 root: &Path,
1009 shard_count: usize,
1010) -> Result<EphemeralHaematiteStore, DurabilityError> {
1011 open_ephemeral_in(ephemeral_tempdir(root)?, shard_count)
1012}
1013
1014pub(super) fn open_ephemeral_owned(
1015 root: Arc<super::EphemeralRoot>,
1016 shard_count: usize,
1017) -> Result<EphemeralHaematiteStore, DurabilityError> {
1018 let mut store = open_ephemeral_in(ephemeral_tempdir(root.path())?, shard_count)?;
1019 let path = store.ephemeral_dir_path_owned()?;
1020 let holder = super::ephemeral_root::lock_file(&path.join("holder.lock"))?;
1021 rustix::fs::flock(
1022 &holder,
1023 rustix::fs::FlockOperation::NonBlockingLockExclusive,
1024 )
1025 .map_err(super::ephemeral_root::root_error)?;
1026 store.guard.ownership = Some(super::ephemeral_root::EphemeralOwnership { root, holder });
1027 Ok(store)
1028}
1029
1030fn ephemeral_tempdir(root: &Path) -> Result<TempDir, DurabilityError> {
1032 let mut builder = tempfile::Builder::new();
1033 builder.prefix("liminal-durability-");
1034 builder.tempdir_in(root).map_err(|error| {
1035 DurabilityError::EphemeralStoreOpen(format!(
1036 "could not create temporary directory: {error}"
1037 ))
1038 })
1039}
1040
1041fn open_ephemeral_in(
1046 ephemeral_dir: TempDir,
1047 shard_count: usize,
1048) -> Result<EphemeralHaematiteStore, DurabilityError> {
1049 let database = Database::create(
1050 DatabaseConfig {
1051 data_dir: ephemeral_dir.path().to_path_buf(),
1052 shard_count,
1053 distributed: None,
1054 executor_threads: None,
1055 node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
1062 retention: haematite::Retention::reclaim(0),
1063 },
1064 haematite::RuntimeOptions {
1065 long_wait_interval: std::time::Duration::from_secs(5),
1066 },
1067 )
1068 .map_err(|error| DurabilityError::EphemeralStoreOpen(error.to_string()))?;
1069 super::counting::record_store_opened();
1070 Ok(EphemeralHaematiteStore::new(database, ephemeral_dir))
1071}
1072
1073#[cfg(test)]
1081#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1082pub(crate) struct EngineReadAccounting {
1083 pub(crate) calls: usize,
1085 pub(crate) engine_entries: usize,
1087 pub(crate) unbounded_calls: usize,
1089 pub(crate) streams_enumerated: usize,
1091 pub(crate) counter_overflow_observed: bool,
1093}
1094
1095#[cfg(test)]
1096std::thread_local! {
1097 static ENGINE_READ_ACCOUNTING: std::cell::RefCell<Option<EngineReadAccounting>> =
1098 const { std::cell::RefCell::new(None) };
1099}
1100
1101#[cfg(test)]
1106pub(crate) struct EngineReadAccountingGuard {
1107 _not_send: std::marker::PhantomData<*const ()>,
1108}
1109
1110#[cfg(test)]
1111impl EngineReadAccountingGuard {
1112 pub(crate) fn start() -> Self {
1113 ENGINE_READ_ACCOUNTING.with(|accounting| {
1114 *accounting.borrow_mut() = Some(EngineReadAccounting::default());
1115 });
1116 Self {
1117 _not_send: std::marker::PhantomData,
1118 }
1119 }
1120
1121 #[allow(clippy::unused_self)]
1122 pub(crate) fn snapshot(&self) -> EngineReadAccounting {
1123 ENGINE_READ_ACCOUNTING
1124 .with(|accounting| accounting.borrow().as_ref().copied().unwrap_or_default())
1125 }
1126}
1127
1128#[cfg(test)]
1129impl Drop for EngineReadAccountingGuard {
1130 fn drop(&mut self) {
1131 ENGINE_READ_ACCOUNTING.with(|accounting| {
1132 *accounting.borrow_mut() = None;
1133 });
1134 }
1135}
1136
1137#[cfg(test)]
1139fn account_engine_read(engine_entries: usize, unbounded: bool) {
1140 ENGINE_READ_ACCOUNTING.with(|accounting| {
1141 if let Some(active) = accounting.borrow_mut().as_mut() {
1142 match (
1143 active.calls.checked_add(1),
1144 active.engine_entries.checked_add(engine_entries),
1145 ) {
1146 (Some(calls), Some(entries)) => {
1147 active.calls = calls;
1148 active.engine_entries = entries;
1149 }
1150 _ => active.counter_overflow_observed = true,
1151 }
1152 if unbounded {
1153 match active.unbounded_calls.checked_add(1) {
1154 Some(unbounded_calls) => active.unbounded_calls = unbounded_calls,
1155 None => active.counter_overflow_observed = true,
1156 }
1157 }
1158 }
1159 });
1160}
1161
1162#[cfg(not(test))]
1163const fn account_engine_read(_engine_entries: usize, _unbounded: bool) {}
1164
1165#[cfg(test)]
1168fn account_stream_enumeration(streams: usize) {
1169 ENGINE_READ_ACCOUNTING.with(|accounting| {
1170 if let Some(active) = accounting.borrow_mut().as_mut() {
1171 match active.streams_enumerated.checked_add(streams) {
1172 Some(enumerated) => active.streams_enumerated = enumerated,
1173 None => active.counter_overflow_observed = true,
1174 }
1175 }
1176 });
1177}
1178
1179#[cfg(not(test))]
1180const fn account_stream_enumeration(_streams: usize) {}
1181
1182#[cfg(test)]
1190pub(crate) fn page_in_memory_streams(
1191 streams: &std::collections::HashMap<String, Vec<StoredEntry>>,
1192 prefix: &str,
1193 cursor: Option<&ScanCursor>,
1194 limit: usize,
1195) -> Result<ScanPage, DurabilityError> {
1196 if limit == 0 {
1197 return Err(DurabilityError::ConfigError(
1198 "scan_page limit must be nonzero".to_owned(),
1199 ));
1200 }
1201 let mut stream_keys: Vec<&String> = streams
1202 .keys()
1203 .filter(|stream_key| stream_key.starts_with(prefix))
1204 .collect();
1205 stream_keys.sort_unstable();
1206
1207 let mut entries: Vec<StoredEntry> = Vec::new();
1208 let mut resume_at: Option<ScanCursor> = None;
1209 for stream_key in stream_keys {
1210 let Some(remaining) = limit.checked_sub(entries.len()).filter(|rest| *rest > 0) else {
1211 break;
1212 };
1213 let offset = match cursor {
1214 Some(cursor) if *stream_key < cursor.stream_key => continue,
1215 Some(cursor) if *stream_key == cursor.stream_key => cursor.next_sequence,
1216 _ => 0,
1217 };
1218 let Some(stream) = streams.get(stream_key) else {
1219 continue;
1220 };
1221 let page: Vec<StoredEntry> = stream
1222 .iter()
1223 .filter(|entry| entry.sequence >= offset)
1224 .take(remaining)
1225 .cloned()
1226 .collect();
1227 if let Some(last) = page.last() {
1228 let next_sequence = last.sequence.checked_add(1).ok_or_else(|| {
1229 DurabilityError::ConfigError(format!(
1230 "fake scan page sequence overflow for stream {stream_key}"
1231 ))
1232 })?;
1233 resume_at = Some(ScanCursor {
1234 stream_key: stream_key.clone(),
1235 next_sequence,
1236 });
1237 }
1238 entries.extend(page);
1239 }
1240
1241 let next = if entries.len() == limit {
1242 resume_at
1243 } else {
1244 None
1245 };
1246 Ok(ScanPage { entries, next })
1247}
1248
1249impl From<Event> for StoredEntry {
1250 fn from(event: Event) -> Self {
1251 Self {
1252 payload: event.payload,
1253 sequence: event.seq,
1254 timestamp: event.timestamp,
1255 }
1256 }
1257}
1258
1259impl From<ApiError> for DurabilityError {
1265 fn from(error: ApiError) -> Self {
1266 match error {
1267 ApiError::SequenceConflict(conflict) => conflict.into(),
1268 ApiError::CasMismatch(mismatch) => mismatch.into(),
1269 ApiError::Storage(haematite::DatabaseError::OutcomeUnknown { pid }) => {
1270 Self::OutcomeUnknown { pid }
1271 }
1272 other @ (ApiError::CorruptEvent(_)
1273 | ApiError::Storage(_)
1274 | ApiError::HistoryCompacted(_)) => Self::StoreError(other),
1275 }
1276 }
1277}
1278
1279#[cfg(test)]
1280#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1281mod ephemeral_lifecycle_tests {
1282 use std::path::{Path, PathBuf};
1287 use std::sync::{Arc, Mutex};
1288
1289 use super::super::bridge::block_on;
1290 use super::{
1291 DurableStore, EphemeralGuard, open_ephemeral, open_ephemeral_in, open_ephemeral_rooted,
1292 };
1293
1294 const TEST_SHARD_COUNT: usize = 2;
1295
1296 #[derive(Clone, Default)]
1305 struct CapturedLog(Arc<Mutex<Vec<u8>>>);
1306
1307 impl CapturedLog {
1308 fn text(&self) -> String {
1310 let bytes = self
1311 .0
1312 .lock()
1313 .expect("capture buffer is not poisoned")
1314 .clone();
1315 String::from_utf8(bytes).expect("tracing's fmt writer emits utf-8")
1316 }
1317
1318 fn capturing<R>(&self, body: impl FnOnce() -> R) -> R {
1334 static INSTALL: std::sync::Once = std::sync::Once::new();
1335 struct ResetOnDrop;
1337 impl Drop for ResetOnDrop {
1338 fn drop(&mut self) {
1339 ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = None);
1340 }
1341 }
1342 INSTALL.call_once(|| {
1343 let subscriber = tracing_subscriber::fmt()
1344 .with_writer(RoutedWriter)
1345 .with_ansi(false)
1346 .finish();
1347 tracing::subscriber::set_global_default(subscriber)
1348 .expect("no other global tracing subscriber is installed in this test binary");
1349 });
1350 ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = Some(self.clone()));
1351 let _reset = ResetOnDrop;
1352 body()
1353 }
1354 }
1355
1356 thread_local! {
1357 static ACTIVE_CAPTURE: std::cell::RefCell<Option<CapturedLog>> =
1360 const { std::cell::RefCell::new(None) };
1361 }
1362
1363 #[derive(Clone, Copy, Default)]
1367 struct RoutedWriter;
1368
1369 impl std::io::Write for RoutedWriter {
1370 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1371 ACTIVE_CAPTURE.with(|slot| {
1372 if let Some(capture) = slot.borrow().as_ref() {
1373 capture
1374 .0
1375 .lock()
1376 .map_err(|_| std::io::Error::other("capture buffer poisoned"))?
1377 .extend_from_slice(buf);
1378 }
1379 Ok(buf.len())
1380 })
1381 }
1382
1383 fn flush(&mut self) -> std::io::Result<()> {
1384 Ok(())
1385 }
1386 }
1387
1388 impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for RoutedWriter {
1389 type Writer = Self;
1390
1391 fn make_writer(&'writer self) -> Self::Writer {
1392 *self
1393 }
1394 }
1395
1396 #[cfg(unix)]
1402 fn set_mode(path: &Path, mode: u32) {
1403 use std::os::unix::fs::PermissionsExt;
1404
1405 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
1406 .expect("test can set permissions on a directory it created");
1407 }
1408
1409 struct OrderProbeStore {
1413 dir: PathBuf,
1414 }
1415
1416 impl Drop for OrderProbeStore {
1417 fn drop(&mut self) {
1418 assert!(
1419 self.dir.exists(),
1420 "the guard must drop the store BEFORE removing the directory"
1421 );
1422 }
1423 }
1424
1425 struct PanickingProbeStore;
1428
1429 impl Drop for PanickingProbeStore {
1430 fn drop(&mut self) {
1431 panic!("injected store-drop panic");
1432 }
1433 }
1434
1435 fn write_one_event(store: &dyn DurableStore) {
1438 block_on(store.append("lifecycle/probe", b"payload".to_vec(), 0))
1439 .expect("bridge completes synchronously")
1440 .expect("append to a fresh ephemeral stream succeeds");
1441 block_on(store.flush())
1442 .expect("bridge completes synchronously")
1443 .expect("flush of a live ephemeral store succeeds");
1444 }
1445
1446 #[test]
1449 fn ephemeral_dir_removed_after_last_handle_drops() {
1450 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
1451 let dir = store
1452 .ephemeral_dir_path()
1453 .expect("ephemeral store carries a guard dir")
1454 .to_path_buf();
1455 assert!(
1456 dir.exists(),
1457 "the guard directory exists while the store is live"
1458 );
1459
1460 write_one_event(&store);
1461 drop(store);
1462
1463 assert!(
1464 !dir.exists(),
1465 "the guard directory is removed on normal drop"
1466 );
1467 }
1468
1469 #[test]
1474 fn ephemeral_dir_survives_until_last_store_clone_drops() {
1475 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
1476 let dir = store
1477 .ephemeral_dir_path()
1478 .expect("ephemeral store carries a guard dir")
1479 .to_path_buf();
1480 write_one_event(&store);
1481
1482 let erased: Arc<dyn DurableStore> = Arc::new(store);
1483 let clone_a = Arc::clone(&erased);
1484 let clone_b = Arc::clone(&erased);
1485
1486 drop(erased);
1487 assert!(
1488 dir.exists(),
1489 "directory survives while store clones remain alive"
1490 );
1491 drop(clone_a);
1492 assert!(
1493 dir.exists(),
1494 "directory survives while one store clone remains alive"
1495 );
1496
1497 drop(clone_b);
1498 assert!(
1499 !dir.exists(),
1500 "the last store clone dropping removes the directory"
1501 );
1502 }
1503
1504 #[test]
1509 fn ephemeral_open_failure_rolls_back_directory() {
1510 let seeded = tempfile::Builder::new()
1511 .prefix("liminal-durability-test-")
1512 .tempdir()
1513 .expect("test can create a temp dir");
1514 let dir = seeded.path().to_path_buf();
1515 std::fs::write(dir.join("config.json"), b"not-a-valid-config")
1519 .expect("test can seed a conflicting config");
1520
1521 let result = open_ephemeral_in(seeded, TEST_SHARD_COUNT);
1522
1523 assert!(result.is_err(), "an injected open failure returns Err");
1524 assert!(
1525 !dir.exists(),
1526 "the guard removes the directory on open failure — zero residue"
1527 );
1528 }
1529
1530 #[test]
1533 fn repeated_ephemeral_cycles_each_own_distinct_dir_zero_residue() {
1534 let mut seen: Vec<PathBuf> = Vec::new();
1535 for _ in 0..5 {
1536 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
1537 let dir = store
1538 .ephemeral_dir_path()
1539 .expect("ephemeral store carries a guard dir")
1540 .to_path_buf();
1541 assert!(
1542 dir.exists(),
1543 "the cycle's directory exists while its store is live"
1544 );
1545 assert!(!seen.contains(&dir), "each cycle owns a distinct directory");
1546 seen.push(dir.clone());
1547
1548 write_one_event(&store);
1549 drop(store);
1550 assert!(
1551 !dir.exists(),
1552 "the cycle's directory is removed after its store drops"
1553 );
1554 }
1555 }
1556
1557 #[test]
1562 fn guard_drops_store_before_removing_directory() {
1563 let dir = tempfile::tempdir().expect("test can create a temp dir");
1564 let path = dir.path().to_path_buf();
1565 let guard = EphemeralGuard {
1566 ownership: None,
1567 store: Some(OrderProbeStore { dir: path.clone() }),
1568 dir: Some(dir),
1569 };
1570
1571 drop(guard);
1572
1573 assert!(!path.exists(), "a clean drop still removes the directory");
1574 }
1575
1576 #[test]
1580 fn guard_leaks_directory_when_store_drop_panics() {
1581 let dir = tempfile::tempdir().expect("test can create a temp dir");
1582 let path = dir.path().to_path_buf();
1583 let guard = EphemeralGuard {
1584 ownership: None,
1585 store: Some(PanickingProbeStore),
1586 dir: Some(dir),
1587 };
1588
1589 let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(guard)));
1590
1591 assert!(unwound.is_err(), "the injected store-drop panic propagates");
1592 assert!(
1593 path.exists(),
1594 "a panicking store drop leaks the directory instead of removing it"
1595 );
1596 std::fs::remove_dir_all(&path).expect("test cleans up the deliberately leaked directory");
1597 }
1598
1599 #[test]
1603 fn rooted_ephemeral_store_lives_and_dies_under_the_given_root() {
1604 let root = tempfile::tempdir().expect("test can create a temp root");
1605 let store =
1606 open_ephemeral_rooted(root.path(), TEST_SHARD_COUNT).expect("rooted open succeeds");
1607 let dir = store
1608 .ephemeral_dir_path()
1609 .expect("ephemeral store carries a guard dir")
1610 .to_path_buf();
1611 assert!(
1612 dir.starts_with(root.path()),
1613 "the guard directory is created under the supplied root"
1614 );
1615
1616 write_one_event(&store);
1617 drop(store);
1618
1619 assert!(!dir.exists(), "the rooted directory is removed on drop");
1620 }
1621
1622 #[test]
1631 fn ephemeral_dir_persists_across_unrelated_work_then_goes_on_clean_drop() {
1632 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
1633 let dir = store
1634 .ephemeral_dir_path()
1635 .expect("ephemeral store carries a guard dir")
1636 .to_path_buf();
1637 assert!(
1638 dir.exists(),
1639 "the directory exists as soon as the store does"
1640 );
1641
1642 for round in 0..3_u64 {
1643 block_on(store.append("clean-teardown/probe", b"payload".to_vec(), round))
1644 .expect("bridge completes synchronously")
1645 .expect("append to a live ephemeral store succeeds");
1646 assert!(
1647 dir.exists(),
1648 "the directory is still there after append round {round}"
1649 );
1650 }
1651 block_on(store.cas("clean-teardown/counter", 0, 7))
1652 .expect("bridge completes synchronously")
1653 .expect("cas on a live ephemeral store succeeds");
1654 let entries = block_on(store.read_from("clean-teardown/probe", 0, 10))
1655 .expect("bridge completes synchronously")
1656 .expect("read from a live ephemeral store succeeds");
1657 assert_eq!(entries.len(), 3, "every appended entry is readable back");
1658 assert!(
1659 dir.exists(),
1660 "the directory is still there after unrelated cas and read work"
1661 );
1662
1663 block_on(store.flush())
1664 .expect("bridge completes synchronously")
1665 .expect("flush of a live ephemeral store succeeds");
1666 drop(store);
1667
1668 assert!(
1669 !dir.exists(),
1670 "the clean drop removes the directory it kept alive throughout"
1671 );
1672 }
1673
1674 #[cfg(unix)]
1683 #[test]
1684 fn clean_drop_removal_failure_is_logged_and_never_panics() {
1685 let parent = tempfile::tempdir().expect("test can create a temp parent");
1686 let dir = tempfile::Builder::new()
1687 .prefix("liminal-durability-")
1688 .tempdir_in(parent.path())
1689 .expect("test can create a guard dir under the parent");
1690 let path = dir.path().to_path_buf();
1691 let guard = EphemeralGuard {
1692 ownership: None,
1693 store: Some(()),
1694 dir: Some(dir),
1695 };
1696
1697 set_mode(parent.path(), 0o500);
1698 let captured = CapturedLog::default();
1699 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1700 captured.capturing(|| drop(guard));
1701 }));
1702 set_mode(parent.path(), 0o700);
1705
1706 assert!(
1707 outcome.is_ok(),
1708 "a removal failure is reported, never raised as a panic"
1709 );
1710 let logged = captured.text();
1711 assert!(
1712 logged.contains("ERROR"),
1713 "the removal failure is logged at error level; captured: {logged:?}"
1714 );
1715 assert!(
1716 logged.contains(&path.display().to_string()),
1717 "the log names the directory that survived; captured: {logged:?}"
1718 );
1719 assert!(
1720 path.exists(),
1721 "the residue is left where the log says it is, not silently claimed removed"
1722 );
1723 }
1724
1725 #[test]
1729 fn clean_drop_that_succeeds_logs_nothing() {
1730 let dir = tempfile::tempdir().expect("test can create a temp dir");
1731 let path = dir.path().to_path_buf();
1732 let guard = EphemeralGuard {
1733 ownership: None,
1734 store: Some(()),
1735 dir: Some(dir),
1736 };
1737
1738 let captured = CapturedLog::default();
1739 captured.capturing(|| drop(guard));
1740
1741 assert!(!path.exists(), "the successful clean drop removed the dir");
1742 assert!(
1743 captured.text().is_empty(),
1744 "a successful removal is silent; captured: {:?}",
1745 captured.text()
1746 );
1747 }
1748
1749 #[test]
1756 fn panic_path_leak_is_logged_with_its_path() {
1757 let dir = tempfile::tempdir().expect("test can create a temp dir");
1758 let path = dir.path().to_path_buf();
1759 let guard = EphemeralGuard {
1760 ownership: None,
1761 store: Some(PanickingProbeStore),
1762 dir: Some(dir),
1763 };
1764
1765 let captured = CapturedLog::default();
1766 let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1767 captured.capturing(|| drop(guard));
1768 }));
1769
1770 assert!(unwound.is_err(), "the injected store-drop panic propagates");
1771 let logged = captured.text();
1772 assert!(
1773 logged.contains("ERROR"),
1774 "the sanctioned leak is logged at error level; captured: {logged:?}"
1775 );
1776 assert!(
1777 logged.contains(&path.display().to_string()),
1778 "the leak log names the leaked directory; captured: {logged:?}"
1779 );
1780 std::fs::remove_dir_all(&path).expect("test cleans up the deliberately leaked directory");
1781 }
1782}
1783
1784#[cfg(test)]
1785#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1786mod paged_read_shape_tests {
1787 use super::{DurableStore, EngineReadAccountingGuard};
1794 use crate::durability::bridge::block_on;
1795 use crate::durability::tests_fixtures;
1796
1797 const PAGE: usize = 64;
1800 const ROWS: u64 = 256;
1803 const STREAM: &str = "liminal/p0-60/paged-read-shape";
1804
1805 fn seeded() -> Result<impl DurableStore, Box<dyn std::error::Error>> {
1807 tests_fixtures::copied("paged-read")
1808 }
1809
1810 fn read_whole_stream(
1812 store: &impl DurableStore,
1813 page: usize,
1814 ) -> Result<usize, Box<dyn std::error::Error>> {
1815 let mut offset = 0_u64;
1816 let mut seen = 0_usize;
1817 loop {
1818 let entries = block_on(store.read_from(STREAM, offset, page))??;
1819 if entries.is_empty() {
1820 return Ok(seen);
1821 }
1822 for entry in &entries {
1823 assert_eq!(entry.sequence, offset, "paged read must stay contiguous");
1824 offset += 1;
1825 }
1826 seen = seen
1827 .checked_add(entries.len())
1828 .ok_or("row counter overflowed")?;
1829 }
1830 }
1831
1832 #[test]
1835 fn a_bounded_read_never_scans_beyond_its_page() -> Result<(), Box<dyn std::error::Error>> {
1836 let store = seeded()?;
1837
1838 let accounting = EngineReadAccountingGuard::start();
1840 let seen = read_whole_stream(&store, PAGE)?;
1841 let walk = accounting.snapshot();
1842 drop(accounting);
1843 assert_eq!(
1844 u64::try_from(seen)?,
1845 ROWS,
1846 "the walk must deliver every row"
1847 );
1848 assert!(
1849 !walk.counter_overflow_observed,
1850 "a saturated counter is not a measurement"
1851 );
1852 assert!(walk.calls > 0, "the walk must have reached the store");
1853 assert_eq!(
1854 u64::try_from(walk.engine_entries)?,
1855 ROWS,
1856 "a full stream read must scan each row exactly once instead of \
1857 re-scanning every suffix once per page"
1858 );
1859
1860 let accounting = EngineReadAccountingGuard::start();
1862 let head = block_on(store.read_from(STREAM, 0, PAGE))??;
1863 let head_read = accounting.snapshot();
1864 drop(accounting);
1865 assert_eq!(head.len(), PAGE, "a full page returns its limit");
1866 assert_eq!(
1867 head_read.engine_entries, PAGE,
1868 "the engine must be asked for one page, not for the whole stream"
1869 );
1870
1871 let middle_offset = ROWS / 2;
1876 let accounting = EngineReadAccountingGuard::start();
1877 let middle = block_on(store.read_from(STREAM, middle_offset, PAGE))??;
1878 let middle_read = accounting.snapshot();
1879 drop(accounting);
1880 assert_eq!(
1881 middle.len(),
1882 PAGE,
1883 "a full page mid-stream returns its limit"
1884 );
1885 assert_eq!(
1886 middle_read.engine_entries, PAGE,
1887 "a mid-stream page must not scan the rows that follow it"
1888 );
1889
1890 let accounting = EngineReadAccountingGuard::start();
1892 let past = block_on(store.read_from(STREAM, ROWS, PAGE))??;
1893 let past_read = accounting.snapshot();
1894 drop(accounting);
1895 assert!(past.is_empty(), "past the head is end of stream");
1896 assert_eq!(
1897 past_read.engine_entries, 0,
1898 "an end-of-stream page must not scan the stream"
1899 );
1900 Ok(())
1901 }
1902
1903 #[test]
1907 fn page_size_never_changes_the_answer() -> Result<(), Box<dyn std::error::Error>> {
1908 let store = seeded()?;
1909 let whole = block_on(store.read_from(STREAM, 0, usize::MAX))??;
1910 assert_eq!(u64::try_from(whole.len())?, ROWS);
1911
1912 for page in [1_usize, 7, 64, 255, 256, 257] {
1913 let mut offset = 0_u64;
1914 let mut collected = Vec::new();
1915 loop {
1916 let entries = block_on(store.read_from(STREAM, offset, page))??;
1917 if entries.is_empty() {
1918 break;
1919 }
1920 assert!(entries.len() <= page, "a page never exceeds its limit");
1921 offset = offset
1922 .checked_add(u64::try_from(entries.len())?)
1923 .ok_or("offset overflowed")?;
1924 collected.extend(entries);
1925 }
1926 assert_eq!(collected, whole, "page size {page} changed the answer");
1927 }
1928
1929 assert!(
1932 block_on(store.read_from(STREAM, 0, 0))??.is_empty(),
1933 "a zero limit reads nothing"
1934 );
1935
1936 for offset in [0_u64, 1, 63, 64, 65, 128, 255] {
1938 let suffix = block_on(store.read_from(STREAM, offset, usize::MAX))??;
1939 assert_eq!(
1940 suffix,
1941 whole[usize::try_from(offset)?..],
1942 "suffix from {offset} diverged"
1943 );
1944 }
1945 Ok(())
1946 }
1947}
1948
1949#[cfg(test)]
1950#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1951mod paged_scan_tests {
1952 use crate::durability::tests_fixtures;
1961 use std::collections::HashSet;
1962
1963 use super::{
1964 DurabilityError, DurableStore, EngineReadAccountingGuard, ScanCursor, StoredEntry,
1965 page_in_memory_streams,
1966 };
1967 use crate::durability::bridge::block_on;
1968
1969 const PREFIX: &str = "liminal/scan-bound/";
1970 const STREAMS: [&str; 3] = [
1973 "liminal/scan-bound/alpha",
1974 "liminal/scan-bound/bravo",
1975 "liminal/scan-bound/charlie",
1976 ];
1977 const ROWS_PER_STREAM: [u64; 3] = [7, 11, 5];
1980
1981 fn payload(stream: &str, sequence: u64) -> Vec<u8> {
1984 format!("{stream}#{sequence}").into_bytes()
1985 }
1986
1987 fn seed_census() -> Vec<Vec<u8>> {
1989 let mut census = Vec::new();
1990 for (stream, rows) in STREAMS.iter().zip(ROWS_PER_STREAM) {
1991 for sequence in 0..rows {
1992 census.push(payload(stream, sequence));
1993 }
1994 }
1995 census
1996 }
1997
1998 fn seeded() -> Result<impl DurableStore, Box<dyn std::error::Error>> {
1999 tests_fixtures::copied("paged-scan")
2000 }
2001
2002 #[test]
2006 fn a_full_scan_page_stops_at_its_limit_and_reports_where_to_resume()
2007 -> Result<(), Box<dyn std::error::Error>> {
2008 const LIMIT: usize = 9;
2009 let store = seeded()?;
2010
2011 let page = block_on(store.scan_page(PREFIX, None, LIMIT))??;
2012
2013 assert_eq!(
2014 page.entries.len(),
2015 LIMIT,
2016 "a seed larger than the limit must fill exactly one page"
2017 );
2018 let cursor = page
2019 .next
2020 .ok_or("a filled page must report a resume position")?;
2021 assert_eq!(cursor.stream_key, STREAMS[1]);
2024 assert_eq!(cursor.next_sequence, 2);
2025 Ok(())
2026 }
2027
2028 #[test]
2031 fn draining_every_page_delivers_the_seed_exactly_once() -> Result<(), Box<dyn std::error::Error>>
2032 {
2033 let store = seeded()?;
2034 let census = seed_census();
2035
2036 for limit in [1_usize, 2, 4, 7, 8, 13, 23, 24] {
2040 let mut drained: Vec<Vec<u8>> = Vec::new();
2041 let mut per_stream: Vec<(String, u64)> = Vec::new();
2042 let mut cursor: Option<ScanCursor> = None;
2043 let mut pages = 0_usize;
2044 loop {
2045 let page = block_on(store.scan_page(PREFIX, cursor, limit))??;
2046 assert!(
2047 page.entries.len() <= limit,
2048 "limit {limit} page {pages} exceeded its limit"
2049 );
2050 for entry in &page.entries {
2051 let text = String::from_utf8(entry.payload.clone())?;
2052 let stream = text
2053 .split_once('#')
2054 .ok_or("seeded payload names its stream")?
2055 .0
2056 .to_owned();
2057 per_stream.push((stream, entry.sequence));
2058 }
2059 drained.extend(page.entries.into_iter().map(|entry| entry.payload));
2060 pages = pages.checked_add(1).ok_or("page counter overflowed")?;
2061 assert!(pages <= 64, "limit {limit} did not terminate in 64 pages");
2062 let Some(next) = page.next else {
2063 break;
2064 };
2065 cursor = Some(next);
2066 }
2067
2068 let drained_set: HashSet<Vec<u8>> = drained.iter().cloned().collect();
2069 assert_eq!(
2070 drained.len(),
2071 drained_set.len(),
2072 "limit {limit} delivered a duplicate entry"
2073 );
2074 assert_eq!(
2075 drained_set,
2076 census.iter().cloned().collect::<HashSet<_>>(),
2077 "limit {limit} did not deliver exactly the seeded entries"
2078 );
2079
2080 for stream in STREAMS {
2081 let sequences: Vec<u64> = per_stream
2082 .iter()
2083 .filter(|(seen, _)| seen == stream)
2084 .map(|(_, sequence)| *sequence)
2085 .collect();
2086 assert!(
2087 sequences.windows(2).all(|pair| pair[0] < pair[1]),
2088 "limit {limit} broke sequence order within {stream}: {sequences:?}"
2089 );
2090 }
2091 }
2092 Ok(())
2093 }
2094
2095 #[test]
2098 fn a_short_page_ends_the_scan() -> Result<(), Box<dyn std::error::Error>> {
2099 let store = seeded()?;
2100 let total = usize::try_from(ROWS_PER_STREAM.iter().sum::<u64>())?;
2101
2102 let whole =
2103 block_on(store.scan_page(PREFIX, None, total.checked_add(1).ok_or("overflow")?))??;
2104 assert_eq!(
2105 whole.entries.len(),
2106 total,
2107 "the whole seed fits in one page"
2108 );
2109 assert!(
2110 whole.next.is_none(),
2111 "a page short of its limit terminates the scan"
2112 );
2113
2114 let exact = block_on(store.scan_page(PREFIX, None, total))??;
2117 assert_eq!(exact.entries.len(), total);
2118 let cursor = exact
2119 .next
2120 .ok_or("an exactly-full page reports a resume position")?;
2121 let terminator = block_on(store.scan_page(PREFIX, Some(cursor), total))??;
2122 assert!(
2123 terminator.entries.is_empty(),
2124 "the resume past the last entry finds nothing"
2125 );
2126 assert!(
2127 terminator.next.is_none(),
2128 "a final empty page is a legal terminator"
2129 );
2130
2131 let unmatched = block_on(store.scan_page("liminal/no-such-prefix/", None, 8))??;
2133 assert!(unmatched.entries.is_empty());
2134 assert!(unmatched.next.is_none());
2135 Ok(())
2136 }
2137
2138 #[test]
2150 fn a_page_orders_streams_by_key_not_by_discovery_order()
2151 -> Result<(), Box<dyn std::error::Error>> {
2152 const STREAM_COUNT: usize = 32;
2153 let mut streams = std::collections::HashMap::new();
2154 for index in 0..STREAM_COUNT {
2155 let stream_key = format!("{PREFIX}stream-{index:02}");
2156 let entry = StoredEntry {
2157 payload: stream_key.clone().into_bytes(),
2158 sequence: 0,
2159 timestamp: 0,
2160 };
2161 streams.insert(stream_key, vec![entry]);
2162 }
2163
2164 let page = page_in_memory_streams(&streams, PREFIX, None, STREAM_COUNT)?;
2165
2166 let delivered: Vec<String> = page
2167 .entries
2168 .into_iter()
2169 .map(|entry| String::from_utf8(entry.payload))
2170 .collect::<Result<_, _>>()?;
2171 let mut expected = delivered.clone();
2172 expected.sort_unstable();
2173 assert_eq!(
2174 delivered, expected,
2175 "a page must deliver its streams in key order"
2176 );
2177 Ok(())
2178 }
2179
2180 #[test]
2185 fn a_drained_walk_enumerates_each_stream_once() -> Result<(), Box<dyn std::error::Error>> {
2186 const WALK_STREAMS: usize = 48;
2187 const LIMIT: usize = 5;
2188 let store = tests_fixtures::copied("paged-walk")?;
2189
2190 let accounting = EngineReadAccountingGuard::start();
2191 let mut cursor = None;
2192 let mut delivered = 0_usize;
2193 let mut pages = 0_usize;
2194 loop {
2195 let page = block_on(store.scan_page(PREFIX, cursor, LIMIT))??;
2196 pages += 1;
2197 delivered += page.entries.len();
2198 match page.next {
2199 Some(next) => cursor = Some(next),
2200 None => break,
2201 }
2202 }
2203 let walk = accounting.snapshot();
2204 drop(accounting);
2205
2206 assert_eq!(delivered, WALK_STREAMS, "the walk delivers every stream");
2207 assert!(!walk.counter_overflow_observed, "{walk:?}");
2208 assert!(
2209 walk.streams_enumerated <= WALK_STREAMS + pages,
2210 "a walk of {WALK_STREAMS} streams in {pages} pages enumerated {} streams",
2211 walk.streams_enumerated
2212 );
2213 Ok(())
2214 }
2215
2216 #[test]
2219 fn a_zero_limit_is_refused() -> Result<(), Box<dyn std::error::Error>> {
2220 let store = seeded()?;
2221
2222 let refused = block_on(store.scan_page(PREFIX, None, 0))?;
2223
2224 assert!(
2225 matches!(refused, Err(DurabilityError::ConfigError(_))),
2226 "a zero limit is refused as a configuration error, got {refused:?}"
2227 );
2228 Ok(())
2229 }
2230}
2231
2232#[cfg(test)]
2233mod truncate_cost_tests {
2234 use std::error::Error;
2244 use std::sync::Arc;
2245
2246 use super::super::bridge::block_on;
2247 use super::{Database, DatabaseConfig, DurableStore, EventStore, HaematiteStore};
2248
2249 const STREAM: &str = "truncate-cost";
2250 const HISTORY: u64 = 10_000;
2252 const BATCH: u64 = 1_000;
2254 const PRESENT: u64 = 10;
2256 const OVERHEAD: u64 = 64;
2259
2260 #[test]
2261 fn a_prune_far_into_history_reads_only_what_it_deletes() -> Result<(), Box<dyn Error>> {
2262 let dir = tempfile::tempdir()?;
2263 let database = Database::create(
2264 DatabaseConfig {
2265 data_dir: dir.path().to_path_buf(),
2266 shard_count: 1,
2267 distributed: None,
2268 executor_threads: None,
2269 node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
2270 retention: haematite::Retention::reclaim(0),
2271 },
2272 haematite::RuntimeOptions {
2273 long_wait_interval: std::time::Duration::from_secs(5),
2274 },
2275 )?;
2276 let events = Arc::new(EventStore::new(database));
2277 let store = HaematiteStore::new(Arc::clone(&events));
2278 let batch: Vec<Vec<u8>> = (0..BATCH).map(|_| vec![0x07]).collect();
2279 let mut next = 0;
2280 while next < HISTORY {
2281 block_on(store.append_all(STREAM, batch.clone(), next))??;
2282 next += BATCH;
2283 }
2284 block_on(store.truncate_before(STREAM, HISTORY - PRESENT))??;
2285
2286 let before = events.database().store_counts().node_gets;
2287 let deleted = block_on(store.truncate_before(STREAM, HISTORY))??;
2288 let reads = events.database().store_counts().node_gets - before;
2289 assert_eq!(
2290 (deleted, reads <= PRESENT + OVERHEAD),
2291 (PRESENT, true),
2292 "a prune of {deleted} rows at sequence {HISTORY} read {reads} nodes; at most \
2293 {PRESENT} plus {OVERHEAD} is owed: the prune walked the history, not the rows"
2294 );
2295 drop(store);
2296 drop(events);
2297 drop(dir);
2298 Ok(())
2299 }
2300}
2301
2302#[cfg(test)]
2303#[path = "store_batch_tests.rs"]
2304mod batch_tests;