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 fn reclaim_spine(&self) -> Option<Arc<EventStore>> {
65 None
66 }
67
68 fn max_request_bytes(&self) -> Option<usize> {
73 None
74 }
75
76 async fn apply_routed_batch(
80 &self,
81 route_key: &str,
82 items: Vec<RoutedBatchItem>,
83 ) -> Result<(), DurabilityError>;
84
85 async fn get_routed(
87 &self,
88 route_key: &str,
89 key: &[u8],
90 ) -> Result<Option<Vec<u8>>, DurabilityError>;
91
92 async fn append(
94 &self,
95 stream_key: &str,
96 payload: Vec<u8>,
97 expected_seq: u64,
98 ) -> Result<u64, DurabilityError>;
99
100 async fn read_from(
102 &self,
103 stream_key: &str,
104 offset: u64,
105 limit: usize,
106 ) -> Result<Vec<StoredEntry>, DurabilityError>;
107
108 async fn append_all(
116 &self,
117 stream_key: &str,
118 payloads: Vec<Vec<u8>>,
119 expected_seq: u64,
120 ) -> Result<u64, DurabilityError>;
121
122 async fn read_at(
124 &self,
125 stream_key: &str,
126 sequence: u64,
127 ) -> Result<Option<StoredEntry>, DurabilityError> {
128 Ok(self
129 .read_from(stream_key, sequence, 1)
130 .await?
131 .into_iter()
132 .next())
133 }
134
135 async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError>;
142
143 async fn read_stream_next_seq(&self, stream_key: &str) -> Result<Option<u64>, DurabilityError>;
152
153 async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError>;
155
156 async fn scan_page(
170 &self,
171 prefix: &str,
172 cursor: Option<ScanCursor>,
173 limit: usize,
174 ) -> Result<ScanPage, DurabilityError>;
175
176 async fn flush(&self) -> Result<(), DurabilityError>;
181
182 async fn truncate_before(
196 &self,
197 stream_key: &str,
198 before_seq: u64,
199 ) -> Result<u64, DurabilityError> {
200 Err(DurabilityError::TruncateUnsupported {
201 store: std::any::type_name::<Self>(),
202 stream_key: stream_key.to_owned(),
203 before_seq,
204 })
205 }
206
207 async fn scan_routed(
217 &self,
218 route_key: &str,
219 _from: &[u8],
220 _to: &[u8],
221 ) -> Result<Vec<(Vec<u8>, Vec<u8>)>, DurabilityError> {
222 Err(DurabilityError::RoutedScanUnsupported {
223 store: std::any::type_name::<Self>(),
224 route_key: route_key.to_owned(),
225 })
226 }
227
228 async fn remove_routed_range(
246 &self,
247 route_key: &str,
248 _from: &[u8],
249 _to: &[u8],
250 ) -> Result<u64, DurabilityError> {
251 Err(DurabilityError::RoutedRemoveUnsupported {
252 store: std::any::type_name::<Self>(),
253 route_key: route_key.to_owned(),
254 })
255 }
256
257 fn store_counts(&self) -> Option<StoreCountsHandle> {
264 None
265 }
266}
267
268#[derive(Clone, Debug)]
274pub struct HaematiteStore {
275 event_store: Arc<EventStore>,
276}
277
278impl HaematiteStore {
279 #[must_use]
281 pub const fn new(event_store: Arc<EventStore>) -> Self {
282 Self { event_store }
283 }
284
285 fn bounded_page(
297 &self,
298 stream_key: &str,
299 offset: u64,
300 limit: usize,
301 ) -> Result<Option<Vec<StoredEntry>>, DurabilityError> {
302 const TIMESTAMP_WIDTH: usize = std::mem::size_of::<u64>();
303
304 let Some(engine_from) = offset.checked_add(1) else {
306 return Ok(None);
307 };
308 let Some(engine_end) = u64::try_from(limit)
309 .ok()
310 .and_then(|limit| engine_from.checked_add(limit))
311 else {
312 return Ok(None);
313 };
314 let key = stream_key.as_bytes();
315 let from = haematite::encode_stream_key(key, engine_from);
316 let to = haematite::encode_stream_key(key, engine_end);
317 let entries = self
318 .event_store
319 .database()
320 .range_routed(key, &from, &to)
321 .map_err(ApiError::from)
322 .map_err(DurabilityError::from)?;
323 if entries.len() != limit {
324 return Ok(None);
325 }
326
327 let mut page = Vec::with_capacity(entries.len());
328 for (encoded_key, value) in entries {
329 let Some((decoded_key, engine_sequence)) = haematite::decode_stream_key(&encoded_key)
330 else {
331 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
332 format!("paged read key does not encode an event for stream {stream_key}"),
333 )));
334 };
335 if decoded_key != key {
336 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
337 format!("paged read key does not encode stream {stream_key}"),
338 )));
339 }
340 let sequence = engine_sequence.checked_sub(1).ok_or_else(|| {
341 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
342 "paged read event key has zero seq for stream {stream_key}"
343 )))
344 })?;
345 let Some(timestamp_bytes) = value.get(..TIMESTAMP_WIDTH) else {
346 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
347 format!(
348 "paged read event value is shorter than its timestamp for stream {stream_key}"
349 ),
350 )));
351 };
352 let timestamp = u64::from_be_bytes(timestamp_bytes.try_into().map_err(|_| {
353 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
354 "paged read event timestamp has the wrong width for stream {stream_key}"
355 )))
356 })?);
357 let Some(payload) = value.get(TIMESTAMP_WIDTH..) else {
358 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
359 format!("paged read event has no payload boundary for stream {stream_key}"),
360 )));
361 };
362 page.push(StoredEntry {
363 payload: payload.to_vec(),
364 sequence,
365 timestamp,
366 });
367 }
368 Ok(Some(page))
369 }
370}
371
372#[async_trait::async_trait]
373impl DurableStore for HaematiteStore {
374 fn reclaim_spine(&self) -> Option<Arc<EventStore>> {
375 Some(Arc::clone(&self.event_store))
376 }
377
378 fn max_request_bytes(&self) -> Option<usize> {
382 Some(self.event_store.database().queue_byte_limit().get())
383 }
384
385 async fn apply_routed_batch(
386 &self,
387 route_key: &str,
388 items: Vec<RoutedBatchItem>,
389 ) -> Result<(), DurabilityError> {
390 let items = items
391 .into_iter()
392 .map(|item| haematite::RoutedBatchItem {
393 key: item.key,
394 expected: item.expected,
395 value: item.value,
396 ttl: item.ttl,
397 })
398 .collect();
399 self.event_store
400 .database()
401 .apply_routed_batch(route_key.as_bytes(), items)
402 .map_err(ApiError::from)
403 .map_err(DurabilityError::from)
404 }
405
406 async fn get_routed(
407 &self,
408 route_key: &str,
409 key: &[u8],
410 ) -> Result<Option<Vec<u8>>, DurabilityError> {
411 self.event_store
412 .database()
413 .get_routed(route_key.as_bytes(), key)
414 .map_err(ApiError::from)
415 .map_err(DurabilityError::from)
416 }
417
418 async fn append(
419 &self,
420 stream_key: &str,
421 payload: Vec<u8>,
422 expected_seq: u64,
423 ) -> Result<u64, DurabilityError> {
424 let next_seq = self
432 .event_store
433 .append(stream_key.as_bytes(), &payload, expected_seq)
434 .map_err(DurabilityError::from)?;
435 next_seq.checked_sub(1).ok_or_else(|| {
436 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
437 "append returned next-seq 0 for stream {stream_key}"
438 )))
439 })
440 }
441
442 async fn append_all(
443 &self,
444 stream_key: &str,
445 payloads: Vec<Vec<u8>>,
446 expected_seq: u64,
447 ) -> Result<u64, DurabilityError> {
448 let borrowed: Vec<&[u8]> = payloads.iter().map(Vec::as_slice).collect();
449 self.event_store
450 .append_batch(stream_key.as_bytes(), &borrowed, expected_seq)
451 .map_err(DurabilityError::from)
452 }
453
454 async fn read_from(
455 &self,
456 stream_key: &str,
457 offset: u64,
458 limit: usize,
459 ) -> Result<Vec<StoredEntry>, DurabilityError> {
460 if limit > 0 {
483 if let Some(page) = self.bounded_page(stream_key, offset, limit)? {
484 account_engine_read(page.len(), false);
485 return Ok(page);
486 }
487 }
488 let mut events = self
489 .event_store
490 .read_from(stream_key.as_bytes(), offset)
491 .map_err(DurabilityError::from)?;
492 account_engine_read(events.len(), true);
493 events.truncate(limit);
494 Ok(events.into_iter().map(StoredEntry::from).collect())
495 }
496
497 async fn read_at(
498 &self,
499 stream_key: &str,
500 sequence: u64,
501 ) -> Result<Option<StoredEntry>, DurabilityError> {
502 const TIMESTAMP_WIDTH: usize = std::mem::size_of::<u64>();
503
504 let engine_sequence = sequence.checked_add(1).ok_or_else(|| {
505 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
506 "point read sequence overflow for stream {stream_key}"
507 )))
508 })?;
509 let event_key = haematite::encode_stream_key(stream_key.as_bytes(), engine_sequence);
510 let Some(value) = self
511 .event_store
512 .database()
513 .get_routed(stream_key.as_bytes(), &event_key)
514 .map_err(ApiError::from)
515 .map_err(DurabilityError::from)?
516 else {
517 return Ok(None);
518 };
519 let Some(timestamp_bytes) = value.get(..TIMESTAMP_WIDTH) else {
520 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
521 format!(
522 "point-read event value is shorter than its timestamp for stream {stream_key}"
523 ),
524 )));
525 };
526 let timestamp = u64::from_be_bytes(timestamp_bytes.try_into().map_err(|_| {
527 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
528 "point-read event timestamp has the wrong width for stream {stream_key}"
529 )))
530 })?);
531 let Some(payload) = value.get(TIMESTAMP_WIDTH..) else {
532 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
533 format!("point-read event has no payload boundary for stream {stream_key}"),
534 )));
535 };
536 Ok(Some(StoredEntry {
537 payload: payload.to_vec(),
538 sequence,
539 timestamp,
540 }))
541 }
542
543 async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
544 if new_value == 0 {
559 return self
560 .event_store
561 .read_value(key.as_bytes())
562 .map_err(DurabilityError::from)?
563 .map_or(Ok(()), |stored| {
564 Err(DurabilityError::CursorRegression {
565 stored,
566 attempted: old_value,
567 })
568 });
569 }
570 let expected = if old_value == 0 {
576 None
577 } else {
578 Some(old_value)
579 };
580 self.event_store
581 .cas(key.as_bytes(), expected, new_value)
582 .map_err(DurabilityError::from)
583 }
584
585 async fn read_stream_next_seq(&self, stream_key: &str) -> Result<Option<u64>, DurabilityError> {
586 self.event_store
587 .read_stream_next_seq(stream_key.as_bytes())
588 .map_err(DurabilityError::from)
589 }
590
591 async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
592 self.event_store
593 .read_value(key.as_bytes())
594 .map_err(DurabilityError::from)
595 }
596
597 async fn scan_page(
598 &self,
599 prefix: &str,
600 cursor: Option<ScanCursor>,
601 limit: usize,
602 ) -> Result<ScanPage, DurabilityError> {
603 if limit == 0 {
604 return Err(DurabilityError::ConfigError(
605 "scan_page limit must be nonzero".to_owned(),
606 ));
607 }
608
609 let mut entries: Vec<StoredEntry> = Vec::new();
610 let mut resume_at: Option<ScanCursor> = None;
611 let mut after: Option<String> = if let Some(cursor) = cursor {
614 if cursor.stream_key.starts_with(prefix) {
615 let page = self
616 .read_from(&cursor.stream_key, cursor.next_sequence, limit)
617 .await?;
618 resume_at = scan_resume_position(&cursor.stream_key, &page)?.or(resume_at);
619 entries.extend(page);
620 }
621 Some(cursor.stream_key)
622 } else {
623 None
624 };
625
626 while let Some(remaining) = limit
633 .checked_sub(entries.len())
634 .and_then(std::num::NonZeroUsize::new)
635 {
636 let streams = self
637 .event_store
638 .scan_prefix(
639 prefix.as_bytes(),
640 remaining,
641 after.as_deref().map(str::as_bytes),
642 )
643 .map_err(DurabilityError::from)?;
644 account_stream_enumeration(streams.streams.len());
645 let exhausted = streams.next_after.is_none() || streams.streams.is_empty();
646 for stream in streams.streams {
647 let stream_key = String::from_utf8(stream.stream_key).map_err(|error| {
652 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
653 "scanned stream key is not utf-8: {error}"
654 )))
655 })?;
656 let Some(remaining) = limit.checked_sub(entries.len()).filter(|rest| *rest > 0)
657 else {
658 break;
659 };
660 let page = self.read_from(&stream_key, 0, remaining).await?;
665 resume_at = scan_resume_position(&stream_key, &page)?.or(resume_at);
666 entries.extend(page);
667 after = Some(stream_key);
668 }
669 if exhausted {
670 break;
671 }
672 }
673
674 let next = if entries.len() == limit {
678 resume_at
679 } else {
680 None
681 };
682 Ok(ScanPage { entries, next })
683 }
684
685 async fn flush(&self) -> Result<(), DurabilityError> {
686 self.event_store.flush().map_err(DurabilityError::from)
687 }
688
689 async fn truncate_before(
692 &self,
693 stream_key: &str,
694 before_seq: u64,
695 ) -> Result<u64, DurabilityError> {
696 self.event_store
697 .truncate_stream(stream_key.as_bytes(), before_seq)
698 .map_err(DurabilityError::from)
699 }
700
701 async fn scan_routed(
706 &self,
707 route_key: &str,
708 from: &[u8],
709 to: &[u8],
710 ) -> Result<Vec<(Vec<u8>, Vec<u8>)>, DurabilityError> {
711 self.event_store
712 .database()
713 .range_routed(route_key.as_bytes(), from, to)
714 .map_err(ApiError::from)
715 .map_err(DurabilityError::from)
716 }
717
718 async fn remove_routed_range(
726 &self,
727 route_key: &str,
728 from: &[u8],
729 to: &[u8],
730 ) -> Result<u64, DurabilityError> {
731 let cut = self
732 .event_store
733 .database()
734 .delete_routed_range(route_key.as_bytes(), from, to)
735 .map_err(ApiError::from)
736 .map_err(DurabilityError::from)?;
737 Ok(u64::from(cut))
738 }
739}
740
741fn scan_resume_position(
744 stream_key: &str,
745 page: &[StoredEntry],
746) -> Result<Option<ScanCursor>, DurabilityError> {
747 let Some(last) = page.last() else {
748 return Ok(None);
749 };
750 let next_sequence = last.sequence.checked_add(1).ok_or_else(|| {
751 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
752 "scan page sequence overflow for stream {stream_key}"
753 )))
754 })?;
755 Ok(Some(ScanCursor {
756 stream_key: stream_key.to_owned(),
757 next_sequence,
758 }))
759}
760
761#[derive(Debug)]
787struct EphemeralGuard<S> {
788 ownership: Option<super::ephemeral_root::EphemeralOwnership>,
789 store: Option<S>,
790 dir: Option<TempDir>,
791}
792
793impl<S> Drop for EphemeralGuard<S> {
794 fn drop(&mut self) {
795 let store = self.store.take();
796 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(store)));
800 if let Err(panic) = outcome {
801 if let Some(dir) = self.dir.take() {
802 let leaked = dir.keep();
803 tracing::error!(
804 path = %leaked.display(),
805 "ephemeral store drop panicked; leaking its directory rather than \
806 removing it under possibly-live database workers"
807 );
808 }
809 if let Some(ownership) = self.ownership.take() {
810 std::mem::forget(ownership);
811 }
812 std::panic::resume_unwind(panic);
813 }
814 if let Some(dir) = self.dir.take() {
818 let path = dir.path().to_path_buf();
819 if let Err(error) = dir.close() {
820 tracing::error!(
821 path = %path.display(),
822 %error,
823 "ephemeral store directory removal failed; residue remains at the \
824 logged path"
825 );
826 }
827 }
828 }
829}
830
831#[derive(Debug)]
847pub struct EphemeralHaematiteStore {
848 guard: EphemeralGuard<HaematiteStore>,
849}
850
851impl EphemeralHaematiteStore {
852 fn new(database: Database, ephemeral_dir: TempDir) -> Self {
861 Self {
862 guard: EphemeralGuard {
863 ownership: None,
864 store: Some(HaematiteStore::new(Arc::new(EventStore::new(database)))),
865 dir: Some(ephemeral_dir),
866 },
867 }
868 }
869
870 fn store(&self) -> Result<&HaematiteStore, DurabilityError> {
877 self.guard
878 .store
879 .as_ref()
880 .ok_or(DurabilityError::EphemeralStoreDetached)
881 }
882
883 fn ephemeral_dir_path_owned(&self) -> Result<&Path, DurabilityError> {
884 self.guard
885 .dir
886 .as_ref()
887 .map(TempDir::path)
888 .ok_or(DurabilityError::EphemeralStoreDetached)
889 }
890
891 #[cfg(test)]
893 pub(crate) fn ephemeral_dir_path(&self) -> Option<&Path> {
894 self.guard.dir.as_ref().map(TempDir::path)
895 }
896}
897
898#[async_trait::async_trait]
899impl DurableStore for EphemeralHaematiteStore {
900 fn max_request_bytes(&self) -> Option<usize> {
901 self.store().ok().and_then(DurableStore::max_request_bytes)
902 }
903
904 async fn apply_routed_batch(
905 &self,
906 route_key: &str,
907 items: Vec<crate::durability::RoutedBatchItem>,
908 ) -> Result<(), crate::durability::DurabilityError> {
909 self.store()?.apply_routed_batch(route_key, items).await
910 }
911
912 async fn get_routed(
913 &self,
914 route_key: &str,
915 key: &[u8],
916 ) -> Result<Option<Vec<u8>>, crate::durability::DurabilityError> {
917 self.store()?.get_routed(route_key, key).await
918 }
919
920 async fn append(
921 &self,
922 stream_key: &str,
923 payload: Vec<u8>,
924 expected_seq: u64,
925 ) -> Result<u64, DurabilityError> {
926 self.store()?
927 .append(stream_key, payload, expected_seq)
928 .await
929 }
930
931 async fn append_all(
932 &self,
933 stream_key: &str,
934 payloads: Vec<Vec<u8>>,
935 expected_seq: u64,
936 ) -> Result<u64, DurabilityError> {
937 self.store()?
938 .append_all(stream_key, payloads, expected_seq)
939 .await
940 }
941
942 async fn read_from(
943 &self,
944 stream_key: &str,
945 offset: u64,
946 limit: usize,
947 ) -> Result<Vec<StoredEntry>, DurabilityError> {
948 self.store()?.read_from(stream_key, offset, limit).await
949 }
950
951 async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
952 self.store()?.cas(key, old_value, new_value).await
953 }
954
955 async fn read_stream_next_seq(&self, stream_key: &str) -> Result<Option<u64>, DurabilityError> {
956 self.store()?.read_stream_next_seq(stream_key).await
957 }
958
959 async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
960 self.store()?.read_value(key).await
961 }
962
963 async fn scan_page(
964 &self,
965 prefix: &str,
966 cursor: Option<ScanCursor>,
967 limit: usize,
968 ) -> Result<ScanPage, DurabilityError> {
969 self.store()?.scan_page(prefix, cursor, limit).await
970 }
971
972 async fn flush(&self) -> Result<(), DurabilityError> {
973 self.store()?.flush().await
974 }
975
976 async fn truncate_before(
977 &self,
978 stream_key: &str,
979 before_seq: u64,
980 ) -> Result<u64, DurabilityError> {
981 self.store()?.truncate_before(stream_key, before_seq).await
982 }
983
984 async fn scan_routed(
985 &self,
986 route_key: &str,
987 from: &[u8],
988 to: &[u8],
989 ) -> Result<Vec<(Vec<u8>, Vec<u8>)>, DurabilityError> {
990 self.store()?.scan_routed(route_key, from, to).await
991 }
992
993 async fn remove_routed_range(
994 &self,
995 route_key: &str,
996 from: &[u8],
997 to: &[u8],
998 ) -> Result<u64, DurabilityError> {
999 self.store()?.remove_routed_range(route_key, from, to).await
1000 }
1001}
1002
1003#[cfg(any(test, feature = "test-support"))]
1019pub fn open_ephemeral(shard_count: usize) -> Result<EphemeralHaematiteStore, DurabilityError> {
1020 open_ephemeral_in(ephemeral_tempdir(&std::env::temp_dir())?, shard_count)
1021}
1022
1023#[cfg(any(test, feature = "test-support"))]
1040pub fn open_ephemeral_rooted(
1041 root: &Path,
1042 shard_count: usize,
1043) -> Result<EphemeralHaematiteStore, DurabilityError> {
1044 open_ephemeral_in(ephemeral_tempdir(root)?, shard_count)
1045}
1046
1047pub(super) fn open_ephemeral_owned(
1048 root: Arc<super::EphemeralRoot>,
1049 shard_count: usize,
1050) -> Result<EphemeralHaematiteStore, DurabilityError> {
1051 let mut store = open_ephemeral_in(ephemeral_tempdir(root.path())?, shard_count)?;
1052 let path = store.ephemeral_dir_path_owned()?;
1053 let holder = super::ephemeral_root::lock_file(&path.join("holder.lock"))?;
1054 rustix::fs::flock(
1055 &holder,
1056 rustix::fs::FlockOperation::NonBlockingLockExclusive,
1057 )
1058 .map_err(super::ephemeral_root::root_error)?;
1059 store.guard.ownership = Some(super::ephemeral_root::EphemeralOwnership { root, holder });
1060 Ok(store)
1061}
1062
1063fn ephemeral_tempdir(root: &Path) -> Result<TempDir, DurabilityError> {
1065 let mut builder = tempfile::Builder::new();
1066 builder.prefix("liminal-durability-");
1067 builder.tempdir_in(root).map_err(|error| {
1068 DurabilityError::EphemeralStoreOpen(format!(
1069 "could not create temporary directory: {error}"
1070 ))
1071 })
1072}
1073
1074fn open_ephemeral_in(
1079 ephemeral_dir: TempDir,
1080 shard_count: usize,
1081) -> Result<EphemeralHaematiteStore, DurabilityError> {
1082 let database = Database::create(
1083 DatabaseConfig {
1084 data_dir: ephemeral_dir.path().to_path_buf(),
1085 shard_count,
1086 distributed: None,
1087 executor_threads: None,
1088 node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
1095 retention: haematite::Retention::reclaim(0),
1096 },
1097 haematite::RuntimeOptions {
1098 long_wait_interval: std::time::Duration::from_secs(5),
1099 reclaim_start: haematite::ReclaimStart::AtOpen,
1100 },
1101 )
1102 .map_err(|error| DurabilityError::EphemeralStoreOpen(error.to_string()))?;
1103 super::counting::record_store_opened();
1104 Ok(EphemeralHaematiteStore::new(database, ephemeral_dir))
1105}
1106
1107#[cfg(test)]
1115#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1116pub(crate) struct EngineReadAccounting {
1117 pub(crate) calls: usize,
1119 pub(crate) engine_entries: usize,
1121 pub(crate) unbounded_calls: usize,
1123 pub(crate) streams_enumerated: usize,
1125 pub(crate) counter_overflow_observed: bool,
1127}
1128
1129#[cfg(test)]
1130std::thread_local! {
1131 static ENGINE_READ_ACCOUNTING: std::cell::RefCell<Option<EngineReadAccounting>> =
1132 const { std::cell::RefCell::new(None) };
1133}
1134
1135#[cfg(test)]
1140pub(crate) struct EngineReadAccountingGuard {
1141 _not_send: std::marker::PhantomData<*const ()>,
1142}
1143
1144#[cfg(test)]
1145impl EngineReadAccountingGuard {
1146 pub(crate) fn start() -> Self {
1147 ENGINE_READ_ACCOUNTING.with(|accounting| {
1148 *accounting.borrow_mut() = Some(EngineReadAccounting::default());
1149 });
1150 Self {
1151 _not_send: std::marker::PhantomData,
1152 }
1153 }
1154
1155 #[allow(clippy::unused_self)]
1156 pub(crate) fn snapshot(&self) -> EngineReadAccounting {
1157 ENGINE_READ_ACCOUNTING
1158 .with(|accounting| accounting.borrow().as_ref().copied().unwrap_or_default())
1159 }
1160}
1161
1162#[cfg(test)]
1163impl Drop for EngineReadAccountingGuard {
1164 fn drop(&mut self) {
1165 ENGINE_READ_ACCOUNTING.with(|accounting| {
1166 *accounting.borrow_mut() = None;
1167 });
1168 }
1169}
1170
1171#[cfg(test)]
1173fn account_engine_read(engine_entries: usize, unbounded: bool) {
1174 ENGINE_READ_ACCOUNTING.with(|accounting| {
1175 if let Some(active) = accounting.borrow_mut().as_mut() {
1176 match (
1177 active.calls.checked_add(1),
1178 active.engine_entries.checked_add(engine_entries),
1179 ) {
1180 (Some(calls), Some(entries)) => {
1181 active.calls = calls;
1182 active.engine_entries = entries;
1183 }
1184 _ => active.counter_overflow_observed = true,
1185 }
1186 if unbounded {
1187 match active.unbounded_calls.checked_add(1) {
1188 Some(unbounded_calls) => active.unbounded_calls = unbounded_calls,
1189 None => active.counter_overflow_observed = true,
1190 }
1191 }
1192 }
1193 });
1194}
1195
1196#[cfg(not(test))]
1197const fn account_engine_read(_engine_entries: usize, _unbounded: bool) {}
1198
1199#[cfg(test)]
1202fn account_stream_enumeration(streams: usize) {
1203 ENGINE_READ_ACCOUNTING.with(|accounting| {
1204 if let Some(active) = accounting.borrow_mut().as_mut() {
1205 match active.streams_enumerated.checked_add(streams) {
1206 Some(enumerated) => active.streams_enumerated = enumerated,
1207 None => active.counter_overflow_observed = true,
1208 }
1209 }
1210 });
1211}
1212
1213#[cfg(not(test))]
1214const fn account_stream_enumeration(_streams: usize) {}
1215
1216#[cfg(test)]
1224pub(crate) fn page_in_memory_streams(
1225 streams: &std::collections::HashMap<String, Vec<StoredEntry>>,
1226 prefix: &str,
1227 cursor: Option<&ScanCursor>,
1228 limit: usize,
1229) -> Result<ScanPage, DurabilityError> {
1230 if limit == 0 {
1231 return Err(DurabilityError::ConfigError(
1232 "scan_page limit must be nonzero".to_owned(),
1233 ));
1234 }
1235 let mut stream_keys: Vec<&String> = streams
1236 .keys()
1237 .filter(|stream_key| stream_key.starts_with(prefix))
1238 .collect();
1239 stream_keys.sort_unstable();
1240
1241 let mut entries: Vec<StoredEntry> = Vec::new();
1242 let mut resume_at: Option<ScanCursor> = None;
1243 for stream_key in stream_keys {
1244 let Some(remaining) = limit.checked_sub(entries.len()).filter(|rest| *rest > 0) else {
1245 break;
1246 };
1247 let offset = match cursor {
1248 Some(cursor) if *stream_key < cursor.stream_key => continue,
1249 Some(cursor) if *stream_key == cursor.stream_key => cursor.next_sequence,
1250 _ => 0,
1251 };
1252 let Some(stream) = streams.get(stream_key) else {
1253 continue;
1254 };
1255 let page: Vec<StoredEntry> = stream
1256 .iter()
1257 .filter(|entry| entry.sequence >= offset)
1258 .take(remaining)
1259 .cloned()
1260 .collect();
1261 if let Some(last) = page.last() {
1262 let next_sequence = last.sequence.checked_add(1).ok_or_else(|| {
1263 DurabilityError::ConfigError(format!(
1264 "fake scan page sequence overflow for stream {stream_key}"
1265 ))
1266 })?;
1267 resume_at = Some(ScanCursor {
1268 stream_key: stream_key.clone(),
1269 next_sequence,
1270 });
1271 }
1272 entries.extend(page);
1273 }
1274
1275 let next = if entries.len() == limit {
1276 resume_at
1277 } else {
1278 None
1279 };
1280 Ok(ScanPage { entries, next })
1281}
1282
1283impl From<Event> for StoredEntry {
1284 fn from(event: Event) -> Self {
1285 Self {
1286 payload: event.payload,
1287 sequence: event.seq,
1288 timestamp: event.timestamp,
1289 }
1290 }
1291}
1292
1293impl From<ApiError> for DurabilityError {
1299 fn from(error: ApiError) -> Self {
1300 match error {
1301 ApiError::SequenceConflict(conflict) => conflict.into(),
1302 ApiError::CasMismatch(mismatch) => mismatch.into(),
1303 ApiError::Storage(haematite::DatabaseError::OutcomeUnknown { pid }) => {
1304 Self::OutcomeUnknown { pid }
1305 }
1306 other @ (ApiError::CorruptEvent(_)
1307 | ApiError::Storage(_)
1308 | ApiError::HistoryCompacted(_)) => Self::StoreError(other),
1309 }
1310 }
1311}
1312
1313#[cfg(test)]
1314#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1315mod ephemeral_lifecycle_tests {
1316 use std::path::{Path, PathBuf};
1321 use std::sync::{Arc, Mutex};
1322
1323 use super::super::bridge::block_on;
1324 use super::{
1325 DurableStore, EphemeralGuard, open_ephemeral, open_ephemeral_in, open_ephemeral_rooted,
1326 };
1327
1328 const TEST_SHARD_COUNT: usize = 2;
1329
1330 #[derive(Clone, Default)]
1339 struct CapturedLog(Arc<Mutex<Vec<u8>>>);
1340
1341 impl CapturedLog {
1342 fn text(&self) -> String {
1344 let bytes = self
1345 .0
1346 .lock()
1347 .expect("capture buffer is not poisoned")
1348 .clone();
1349 String::from_utf8(bytes).expect("tracing's fmt writer emits utf-8")
1350 }
1351
1352 fn capturing<R>(&self, body: impl FnOnce() -> R) -> R {
1368 static INSTALL: std::sync::Once = std::sync::Once::new();
1369 struct ResetOnDrop;
1371 impl Drop for ResetOnDrop {
1372 fn drop(&mut self) {
1373 ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = None);
1374 }
1375 }
1376 INSTALL.call_once(|| {
1377 let subscriber = tracing_subscriber::fmt()
1378 .with_writer(RoutedWriter)
1379 .with_ansi(false)
1380 .finish();
1381 tracing::subscriber::set_global_default(subscriber)
1382 .expect("no other global tracing subscriber is installed in this test binary");
1383 });
1384 ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = Some(self.clone()));
1385 let _reset = ResetOnDrop;
1386 body()
1387 }
1388 }
1389
1390 thread_local! {
1391 static ACTIVE_CAPTURE: std::cell::RefCell<Option<CapturedLog>> =
1394 const { std::cell::RefCell::new(None) };
1395 }
1396
1397 #[derive(Clone, Copy, Default)]
1401 struct RoutedWriter;
1402
1403 impl std::io::Write for RoutedWriter {
1404 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1405 ACTIVE_CAPTURE.with(|slot| {
1406 if let Some(capture) = slot.borrow().as_ref() {
1407 capture
1408 .0
1409 .lock()
1410 .map_err(|_| std::io::Error::other("capture buffer poisoned"))?
1411 .extend_from_slice(buf);
1412 }
1413 Ok(buf.len())
1414 })
1415 }
1416
1417 fn flush(&mut self) -> std::io::Result<()> {
1418 Ok(())
1419 }
1420 }
1421
1422 impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for RoutedWriter {
1423 type Writer = Self;
1424
1425 fn make_writer(&'writer self) -> Self::Writer {
1426 *self
1427 }
1428 }
1429
1430 #[cfg(unix)]
1436 fn set_mode(path: &Path, mode: u32) {
1437 use std::os::unix::fs::PermissionsExt;
1438
1439 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
1440 .expect("test can set permissions on a directory it created");
1441 }
1442
1443 struct OrderProbeStore {
1447 dir: PathBuf,
1448 }
1449
1450 impl Drop for OrderProbeStore {
1451 fn drop(&mut self) {
1452 assert!(
1453 self.dir.exists(),
1454 "the guard must drop the store BEFORE removing the directory"
1455 );
1456 }
1457 }
1458
1459 struct PanickingProbeStore;
1462
1463 impl Drop for PanickingProbeStore {
1464 fn drop(&mut self) {
1465 panic!("injected store-drop panic");
1466 }
1467 }
1468
1469 fn write_one_event(store: &dyn DurableStore) {
1472 block_on(store.append("lifecycle/probe", b"payload".to_vec(), 0))
1473 .expect("bridge completes synchronously")
1474 .expect("append to a fresh ephemeral stream succeeds");
1475 block_on(store.flush())
1476 .expect("bridge completes synchronously")
1477 .expect("flush of a live ephemeral store succeeds");
1478 }
1479
1480 #[test]
1483 fn ephemeral_dir_removed_after_last_handle_drops() {
1484 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
1485 let dir = store
1486 .ephemeral_dir_path()
1487 .expect("ephemeral store carries a guard dir")
1488 .to_path_buf();
1489 assert!(
1490 dir.exists(),
1491 "the guard directory exists while the store is live"
1492 );
1493
1494 write_one_event(&store);
1495 drop(store);
1496
1497 assert!(
1498 !dir.exists(),
1499 "the guard directory is removed on normal drop"
1500 );
1501 }
1502
1503 #[test]
1508 fn ephemeral_dir_survives_until_last_store_clone_drops() {
1509 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
1510 let dir = store
1511 .ephemeral_dir_path()
1512 .expect("ephemeral store carries a guard dir")
1513 .to_path_buf();
1514 write_one_event(&store);
1515
1516 let erased: Arc<dyn DurableStore> = Arc::new(store);
1517 let clone_a = Arc::clone(&erased);
1518 let clone_b = Arc::clone(&erased);
1519
1520 drop(erased);
1521 assert!(
1522 dir.exists(),
1523 "directory survives while store clones remain alive"
1524 );
1525 drop(clone_a);
1526 assert!(
1527 dir.exists(),
1528 "directory survives while one store clone remains alive"
1529 );
1530
1531 drop(clone_b);
1532 assert!(
1533 !dir.exists(),
1534 "the last store clone dropping removes the directory"
1535 );
1536 }
1537
1538 #[test]
1543 fn ephemeral_open_failure_rolls_back_directory() {
1544 let seeded = tempfile::Builder::new()
1545 .prefix("liminal-durability-test-")
1546 .tempdir()
1547 .expect("test can create a temp dir");
1548 let dir = seeded.path().to_path_buf();
1549 std::fs::write(dir.join("config.json"), b"not-a-valid-config")
1553 .expect("test can seed a conflicting config");
1554
1555 let result = open_ephemeral_in(seeded, TEST_SHARD_COUNT);
1556
1557 assert!(result.is_err(), "an injected open failure returns Err");
1558 assert!(
1559 !dir.exists(),
1560 "the guard removes the directory on open failure — zero residue"
1561 );
1562 }
1563
1564 #[test]
1567 fn repeated_ephemeral_cycles_each_own_distinct_dir_zero_residue() {
1568 let mut seen: Vec<PathBuf> = Vec::new();
1569 for _ in 0..5 {
1570 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
1571 let dir = store
1572 .ephemeral_dir_path()
1573 .expect("ephemeral store carries a guard dir")
1574 .to_path_buf();
1575 assert!(
1576 dir.exists(),
1577 "the cycle's directory exists while its store is live"
1578 );
1579 assert!(!seen.contains(&dir), "each cycle owns a distinct directory");
1580 seen.push(dir.clone());
1581
1582 write_one_event(&store);
1583 drop(store);
1584 assert!(
1585 !dir.exists(),
1586 "the cycle's directory is removed after its store drops"
1587 );
1588 }
1589 }
1590
1591 #[test]
1596 fn guard_drops_store_before_removing_directory() {
1597 let dir = tempfile::tempdir().expect("test can create a temp dir");
1598 let path = dir.path().to_path_buf();
1599 let guard = EphemeralGuard {
1600 ownership: None,
1601 store: Some(OrderProbeStore { dir: path.clone() }),
1602 dir: Some(dir),
1603 };
1604
1605 drop(guard);
1606
1607 assert!(!path.exists(), "a clean drop still removes the directory");
1608 }
1609
1610 #[test]
1614 fn guard_leaks_directory_when_store_drop_panics() {
1615 let dir = tempfile::tempdir().expect("test can create a temp dir");
1616 let path = dir.path().to_path_buf();
1617 let guard = EphemeralGuard {
1618 ownership: None,
1619 store: Some(PanickingProbeStore),
1620 dir: Some(dir),
1621 };
1622
1623 let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(guard)));
1624
1625 assert!(unwound.is_err(), "the injected store-drop panic propagates");
1626 assert!(
1627 path.exists(),
1628 "a panicking store drop leaks the directory instead of removing it"
1629 );
1630 std::fs::remove_dir_all(&path).expect("test cleans up the deliberately leaked directory");
1631 }
1632
1633 #[test]
1637 fn rooted_ephemeral_store_lives_and_dies_under_the_given_root() {
1638 let root = tempfile::tempdir().expect("test can create a temp root");
1639 let store =
1640 open_ephemeral_rooted(root.path(), TEST_SHARD_COUNT).expect("rooted open succeeds");
1641 let dir = store
1642 .ephemeral_dir_path()
1643 .expect("ephemeral store carries a guard dir")
1644 .to_path_buf();
1645 assert!(
1646 dir.starts_with(root.path()),
1647 "the guard directory is created under the supplied root"
1648 );
1649
1650 write_one_event(&store);
1651 drop(store);
1652
1653 assert!(!dir.exists(), "the rooted directory is removed on drop");
1654 }
1655
1656 #[test]
1665 fn ephemeral_dir_persists_across_unrelated_work_then_goes_on_clean_drop() {
1666 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
1667 let dir = store
1668 .ephemeral_dir_path()
1669 .expect("ephemeral store carries a guard dir")
1670 .to_path_buf();
1671 assert!(
1672 dir.exists(),
1673 "the directory exists as soon as the store does"
1674 );
1675
1676 for round in 0..3_u64 {
1677 block_on(store.append("clean-teardown/probe", b"payload".to_vec(), round))
1678 .expect("bridge completes synchronously")
1679 .expect("append to a live ephemeral store succeeds");
1680 assert!(
1681 dir.exists(),
1682 "the directory is still there after append round {round}"
1683 );
1684 }
1685 block_on(store.cas("clean-teardown/counter", 0, 7))
1686 .expect("bridge completes synchronously")
1687 .expect("cas on a live ephemeral store succeeds");
1688 let entries = block_on(store.read_from("clean-teardown/probe", 0, 10))
1689 .expect("bridge completes synchronously")
1690 .expect("read from a live ephemeral store succeeds");
1691 assert_eq!(entries.len(), 3, "every appended entry is readable back");
1692 assert!(
1693 dir.exists(),
1694 "the directory is still there after unrelated cas and read work"
1695 );
1696
1697 block_on(store.flush())
1698 .expect("bridge completes synchronously")
1699 .expect("flush of a live ephemeral store succeeds");
1700 drop(store);
1701
1702 assert!(
1703 !dir.exists(),
1704 "the clean drop removes the directory it kept alive throughout"
1705 );
1706 }
1707
1708 #[cfg(unix)]
1717 #[test]
1718 fn clean_drop_removal_failure_is_logged_and_never_panics() {
1719 let parent = tempfile::tempdir().expect("test can create a temp parent");
1720 let dir = tempfile::Builder::new()
1721 .prefix("liminal-durability-")
1722 .tempdir_in(parent.path())
1723 .expect("test can create a guard dir under the parent");
1724 let path = dir.path().to_path_buf();
1725 let guard = EphemeralGuard {
1726 ownership: None,
1727 store: Some(()),
1728 dir: Some(dir),
1729 };
1730
1731 set_mode(parent.path(), 0o500);
1732 let captured = CapturedLog::default();
1733 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1734 captured.capturing(|| drop(guard));
1735 }));
1736 set_mode(parent.path(), 0o700);
1739
1740 assert!(
1741 outcome.is_ok(),
1742 "a removal failure is reported, never raised as a panic"
1743 );
1744 let logged = captured.text();
1745 assert!(
1746 logged.contains("ERROR"),
1747 "the removal failure is logged at error level; captured: {logged:?}"
1748 );
1749 assert!(
1750 logged.contains(&path.display().to_string()),
1751 "the log names the directory that survived; captured: {logged:?}"
1752 );
1753 assert!(
1754 path.exists(),
1755 "the residue is left where the log says it is, not silently claimed removed"
1756 );
1757 }
1758
1759 #[test]
1763 fn clean_drop_that_succeeds_logs_nothing() {
1764 let dir = tempfile::tempdir().expect("test can create a temp dir");
1765 let path = dir.path().to_path_buf();
1766 let guard = EphemeralGuard {
1767 ownership: None,
1768 store: Some(()),
1769 dir: Some(dir),
1770 };
1771
1772 let captured = CapturedLog::default();
1773 captured.capturing(|| drop(guard));
1774
1775 assert!(!path.exists(), "the successful clean drop removed the dir");
1776 assert!(
1777 captured.text().is_empty(),
1778 "a successful removal is silent; captured: {:?}",
1779 captured.text()
1780 );
1781 }
1782
1783 #[test]
1790 fn panic_path_leak_is_logged_with_its_path() {
1791 let dir = tempfile::tempdir().expect("test can create a temp dir");
1792 let path = dir.path().to_path_buf();
1793 let guard = EphemeralGuard {
1794 ownership: None,
1795 store: Some(PanickingProbeStore),
1796 dir: Some(dir),
1797 };
1798
1799 let captured = CapturedLog::default();
1800 let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1801 captured.capturing(|| drop(guard));
1802 }));
1803
1804 assert!(unwound.is_err(), "the injected store-drop panic propagates");
1805 let logged = captured.text();
1806 assert!(
1807 logged.contains("ERROR"),
1808 "the sanctioned leak is logged at error level; captured: {logged:?}"
1809 );
1810 assert!(
1811 logged.contains(&path.display().to_string()),
1812 "the leak log names the leaked directory; captured: {logged:?}"
1813 );
1814 std::fs::remove_dir_all(&path).expect("test cleans up the deliberately leaked directory");
1815 }
1816}
1817
1818#[cfg(test)]
1819#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1820mod paged_read_shape_tests {
1821 use super::{DurableStore, EngineReadAccountingGuard};
1828 use crate::durability::bridge::block_on;
1829 use crate::durability::tests_fixtures;
1830
1831 const PAGE: usize = 64;
1834 const ROWS: u64 = 256;
1837 const STREAM: &str = "liminal/p0-60/paged-read-shape";
1838
1839 fn seeded() -> Result<impl DurableStore, Box<dyn std::error::Error>> {
1841 tests_fixtures::copied("paged-read")
1842 }
1843
1844 fn read_whole_stream(
1846 store: &impl DurableStore,
1847 page: usize,
1848 ) -> Result<usize, Box<dyn std::error::Error>> {
1849 let mut offset = 0_u64;
1850 let mut seen = 0_usize;
1851 loop {
1852 let entries = block_on(store.read_from(STREAM, offset, page))??;
1853 if entries.is_empty() {
1854 return Ok(seen);
1855 }
1856 for entry in &entries {
1857 assert_eq!(entry.sequence, offset, "paged read must stay contiguous");
1858 offset += 1;
1859 }
1860 seen = seen
1861 .checked_add(entries.len())
1862 .ok_or("row counter overflowed")?;
1863 }
1864 }
1865
1866 #[test]
1869 fn a_bounded_read_never_scans_beyond_its_page() -> Result<(), Box<dyn std::error::Error>> {
1870 let store = seeded()?;
1871
1872 let accounting = EngineReadAccountingGuard::start();
1874 let seen = read_whole_stream(&store, PAGE)?;
1875 let walk = accounting.snapshot();
1876 drop(accounting);
1877 assert_eq!(
1878 u64::try_from(seen)?,
1879 ROWS,
1880 "the walk must deliver every row"
1881 );
1882 assert!(
1883 !walk.counter_overflow_observed,
1884 "a saturated counter is not a measurement"
1885 );
1886 assert!(walk.calls > 0, "the walk must have reached the store");
1887 assert_eq!(
1888 u64::try_from(walk.engine_entries)?,
1889 ROWS,
1890 "a full stream read must scan each row exactly once instead of \
1891 re-scanning every suffix once per page"
1892 );
1893
1894 let accounting = EngineReadAccountingGuard::start();
1896 let head = block_on(store.read_from(STREAM, 0, PAGE))??;
1897 let head_read = accounting.snapshot();
1898 drop(accounting);
1899 assert_eq!(head.len(), PAGE, "a full page returns its limit");
1900 assert_eq!(
1901 head_read.engine_entries, PAGE,
1902 "the engine must be asked for one page, not for the whole stream"
1903 );
1904
1905 let middle_offset = ROWS / 2;
1910 let accounting = EngineReadAccountingGuard::start();
1911 let middle = block_on(store.read_from(STREAM, middle_offset, PAGE))??;
1912 let middle_read = accounting.snapshot();
1913 drop(accounting);
1914 assert_eq!(
1915 middle.len(),
1916 PAGE,
1917 "a full page mid-stream returns its limit"
1918 );
1919 assert_eq!(
1920 middle_read.engine_entries, PAGE,
1921 "a mid-stream page must not scan the rows that follow it"
1922 );
1923
1924 let accounting = EngineReadAccountingGuard::start();
1926 let past = block_on(store.read_from(STREAM, ROWS, PAGE))??;
1927 let past_read = accounting.snapshot();
1928 drop(accounting);
1929 assert!(past.is_empty(), "past the head is end of stream");
1930 assert_eq!(
1931 past_read.engine_entries, 0,
1932 "an end-of-stream page must not scan the stream"
1933 );
1934 Ok(())
1935 }
1936
1937 #[test]
1941 fn page_size_never_changes_the_answer() -> Result<(), Box<dyn std::error::Error>> {
1942 let store = seeded()?;
1943 let whole = block_on(store.read_from(STREAM, 0, usize::MAX))??;
1944 assert_eq!(u64::try_from(whole.len())?, ROWS);
1945
1946 for page in [1_usize, 7, 64, 255, 256, 257] {
1947 let mut offset = 0_u64;
1948 let mut collected = Vec::new();
1949 loop {
1950 let entries = block_on(store.read_from(STREAM, offset, page))??;
1951 if entries.is_empty() {
1952 break;
1953 }
1954 assert!(entries.len() <= page, "a page never exceeds its limit");
1955 offset = offset
1956 .checked_add(u64::try_from(entries.len())?)
1957 .ok_or("offset overflowed")?;
1958 collected.extend(entries);
1959 }
1960 assert_eq!(collected, whole, "page size {page} changed the answer");
1961 }
1962
1963 assert!(
1966 block_on(store.read_from(STREAM, 0, 0))??.is_empty(),
1967 "a zero limit reads nothing"
1968 );
1969
1970 for offset in [0_u64, 1, 63, 64, 65, 128, 255] {
1972 let suffix = block_on(store.read_from(STREAM, offset, usize::MAX))??;
1973 assert_eq!(
1974 suffix,
1975 whole[usize::try_from(offset)?..],
1976 "suffix from {offset} diverged"
1977 );
1978 }
1979 Ok(())
1980 }
1981}
1982
1983#[cfg(test)]
1984#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1985mod paged_scan_tests {
1986 use crate::durability::tests_fixtures;
1995 use std::collections::HashSet;
1996
1997 use super::{
1998 DurabilityError, DurableStore, EngineReadAccountingGuard, ScanCursor, StoredEntry,
1999 page_in_memory_streams,
2000 };
2001 use crate::durability::bridge::block_on;
2002
2003 const PREFIX: &str = "liminal/scan-bound/";
2004 const STREAMS: [&str; 3] = [
2007 "liminal/scan-bound/alpha",
2008 "liminal/scan-bound/bravo",
2009 "liminal/scan-bound/charlie",
2010 ];
2011 const ROWS_PER_STREAM: [u64; 3] = [7, 11, 5];
2014
2015 fn payload(stream: &str, sequence: u64) -> Vec<u8> {
2018 format!("{stream}#{sequence}").into_bytes()
2019 }
2020
2021 fn seed_census() -> Vec<Vec<u8>> {
2023 let mut census = Vec::new();
2024 for (stream, rows) in STREAMS.iter().zip(ROWS_PER_STREAM) {
2025 for sequence in 0..rows {
2026 census.push(payload(stream, sequence));
2027 }
2028 }
2029 census
2030 }
2031
2032 fn seeded() -> Result<impl DurableStore, Box<dyn std::error::Error>> {
2033 tests_fixtures::copied("paged-scan")
2034 }
2035
2036 #[test]
2040 fn a_full_scan_page_stops_at_its_limit_and_reports_where_to_resume()
2041 -> Result<(), Box<dyn std::error::Error>> {
2042 const LIMIT: usize = 9;
2043 let store = seeded()?;
2044
2045 let page = block_on(store.scan_page(PREFIX, None, LIMIT))??;
2046
2047 assert_eq!(
2048 page.entries.len(),
2049 LIMIT,
2050 "a seed larger than the limit must fill exactly one page"
2051 );
2052 let cursor = page
2053 .next
2054 .ok_or("a filled page must report a resume position")?;
2055 assert_eq!(cursor.stream_key, STREAMS[1]);
2058 assert_eq!(cursor.next_sequence, 2);
2059 Ok(())
2060 }
2061
2062 #[test]
2065 fn draining_every_page_delivers_the_seed_exactly_once() -> Result<(), Box<dyn std::error::Error>>
2066 {
2067 let store = seeded()?;
2068 let census = seed_census();
2069
2070 for limit in [1_usize, 2, 4, 7, 8, 13, 23, 24] {
2074 let mut drained: Vec<Vec<u8>> = Vec::new();
2075 let mut per_stream: Vec<(String, u64)> = Vec::new();
2076 let mut cursor: Option<ScanCursor> = None;
2077 let mut pages = 0_usize;
2078 loop {
2079 let page = block_on(store.scan_page(PREFIX, cursor, limit))??;
2080 assert!(
2081 page.entries.len() <= limit,
2082 "limit {limit} page {pages} exceeded its limit"
2083 );
2084 for entry in &page.entries {
2085 let text = String::from_utf8(entry.payload.clone())?;
2086 let stream = text
2087 .split_once('#')
2088 .ok_or("seeded payload names its stream")?
2089 .0
2090 .to_owned();
2091 per_stream.push((stream, entry.sequence));
2092 }
2093 drained.extend(page.entries.into_iter().map(|entry| entry.payload));
2094 pages = pages.checked_add(1).ok_or("page counter overflowed")?;
2095 assert!(pages <= 64, "limit {limit} did not terminate in 64 pages");
2096 let Some(next) = page.next else {
2097 break;
2098 };
2099 cursor = Some(next);
2100 }
2101
2102 let drained_set: HashSet<Vec<u8>> = drained.iter().cloned().collect();
2103 assert_eq!(
2104 drained.len(),
2105 drained_set.len(),
2106 "limit {limit} delivered a duplicate entry"
2107 );
2108 assert_eq!(
2109 drained_set,
2110 census.iter().cloned().collect::<HashSet<_>>(),
2111 "limit {limit} did not deliver exactly the seeded entries"
2112 );
2113
2114 for stream in STREAMS {
2115 let sequences: Vec<u64> = per_stream
2116 .iter()
2117 .filter(|(seen, _)| seen == stream)
2118 .map(|(_, sequence)| *sequence)
2119 .collect();
2120 assert!(
2121 sequences.windows(2).all(|pair| pair[0] < pair[1]),
2122 "limit {limit} broke sequence order within {stream}: {sequences:?}"
2123 );
2124 }
2125 }
2126 Ok(())
2127 }
2128
2129 #[test]
2132 fn a_short_page_ends_the_scan() -> Result<(), Box<dyn std::error::Error>> {
2133 let store = seeded()?;
2134 let total = usize::try_from(ROWS_PER_STREAM.iter().sum::<u64>())?;
2135
2136 let whole =
2137 block_on(store.scan_page(PREFIX, None, total.checked_add(1).ok_or("overflow")?))??;
2138 assert_eq!(
2139 whole.entries.len(),
2140 total,
2141 "the whole seed fits in one page"
2142 );
2143 assert!(
2144 whole.next.is_none(),
2145 "a page short of its limit terminates the scan"
2146 );
2147
2148 let exact = block_on(store.scan_page(PREFIX, None, total))??;
2151 assert_eq!(exact.entries.len(), total);
2152 let cursor = exact
2153 .next
2154 .ok_or("an exactly-full page reports a resume position")?;
2155 let terminator = block_on(store.scan_page(PREFIX, Some(cursor), total))??;
2156 assert!(
2157 terminator.entries.is_empty(),
2158 "the resume past the last entry finds nothing"
2159 );
2160 assert!(
2161 terminator.next.is_none(),
2162 "a final empty page is a legal terminator"
2163 );
2164
2165 let unmatched = block_on(store.scan_page("liminal/no-such-prefix/", None, 8))??;
2167 assert!(unmatched.entries.is_empty());
2168 assert!(unmatched.next.is_none());
2169 Ok(())
2170 }
2171
2172 #[test]
2184 fn a_page_orders_streams_by_key_not_by_discovery_order()
2185 -> Result<(), Box<dyn std::error::Error>> {
2186 const STREAM_COUNT: usize = 32;
2187 let mut streams = std::collections::HashMap::new();
2188 for index in 0..STREAM_COUNT {
2189 let stream_key = format!("{PREFIX}stream-{index:02}");
2190 let entry = StoredEntry {
2191 payload: stream_key.clone().into_bytes(),
2192 sequence: 0,
2193 timestamp: 0,
2194 };
2195 streams.insert(stream_key, vec![entry]);
2196 }
2197
2198 let page = page_in_memory_streams(&streams, PREFIX, None, STREAM_COUNT)?;
2199
2200 let delivered: Vec<String> = page
2201 .entries
2202 .into_iter()
2203 .map(|entry| String::from_utf8(entry.payload))
2204 .collect::<Result<_, _>>()?;
2205 let mut expected = delivered.clone();
2206 expected.sort_unstable();
2207 assert_eq!(
2208 delivered, expected,
2209 "a page must deliver its streams in key order"
2210 );
2211 Ok(())
2212 }
2213
2214 #[test]
2219 fn a_drained_walk_enumerates_each_stream_once() -> Result<(), Box<dyn std::error::Error>> {
2220 const WALK_STREAMS: usize = 48;
2221 const LIMIT: usize = 5;
2222 let store = tests_fixtures::copied("paged-walk")?;
2223
2224 let accounting = EngineReadAccountingGuard::start();
2225 let mut cursor = None;
2226 let mut delivered = 0_usize;
2227 let mut pages = 0_usize;
2228 loop {
2229 let page = block_on(store.scan_page(PREFIX, cursor, LIMIT))??;
2230 pages += 1;
2231 delivered += page.entries.len();
2232 match page.next {
2233 Some(next) => cursor = Some(next),
2234 None => break,
2235 }
2236 }
2237 let walk = accounting.snapshot();
2238 drop(accounting);
2239
2240 assert_eq!(delivered, WALK_STREAMS, "the walk delivers every stream");
2241 assert!(!walk.counter_overflow_observed, "{walk:?}");
2242 assert!(
2243 walk.streams_enumerated <= WALK_STREAMS + pages,
2244 "a walk of {WALK_STREAMS} streams in {pages} pages enumerated {} streams",
2245 walk.streams_enumerated
2246 );
2247 Ok(())
2248 }
2249
2250 #[test]
2253 fn a_zero_limit_is_refused() -> Result<(), Box<dyn std::error::Error>> {
2254 let store = seeded()?;
2255
2256 let refused = block_on(store.scan_page(PREFIX, None, 0))?;
2257
2258 assert!(
2259 matches!(refused, Err(DurabilityError::ConfigError(_))),
2260 "a zero limit is refused as a configuration error, got {refused:?}"
2261 );
2262 Ok(())
2263 }
2264}
2265
2266#[cfg(test)]
2267mod truncate_cost_tests {
2268 use std::error::Error;
2278 use std::sync::Arc;
2279
2280 use super::super::bridge::block_on;
2281 use super::{Database, DatabaseConfig, DurableStore, EventStore, HaematiteStore};
2282
2283 const STREAM: &str = "truncate-cost";
2284 const HISTORY: u64 = 10_000;
2286 const BATCH: u64 = 1_000;
2288 const PRESENT: u64 = 10;
2290 const OVERHEAD: u64 = 64;
2293
2294 #[test]
2295 fn a_prune_far_into_history_reads_only_what_it_deletes() -> Result<(), Box<dyn Error>> {
2296 let dir = tempfile::tempdir()?;
2297 let database = Database::create(
2298 DatabaseConfig {
2299 data_dir: dir.path().to_path_buf(),
2300 shard_count: 1,
2301 distributed: None,
2302 executor_threads: None,
2303 node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
2304 retention: haematite::Retention::reclaim(0),
2305 },
2306 haematite::RuntimeOptions {
2307 long_wait_interval: std::time::Duration::from_secs(5),
2308 reclaim_start: haematite::ReclaimStart::AtOpen,
2309 },
2310 )?;
2311 let events = Arc::new(EventStore::new(database));
2312 let store = HaematiteStore::new(Arc::clone(&events));
2313 let batch: Vec<Vec<u8>> = (0..BATCH).map(|_| vec![0x07]).collect();
2314 let mut next = 0;
2315 while next < HISTORY {
2316 block_on(store.append_all(STREAM, batch.clone(), next))??;
2317 next += BATCH;
2318 }
2319 block_on(store.truncate_before(STREAM, HISTORY - PRESENT))??;
2320
2321 let before = events.database().store_counts().node_gets;
2322 let deleted = block_on(store.truncate_before(STREAM, HISTORY))??;
2323 let reads = events.database().store_counts().node_gets - before;
2324 assert_eq!(
2325 (deleted, reads <= PRESENT + OVERHEAD),
2326 (PRESENT, true),
2327 "a prune of {deleted} rows at sequence {HISTORY} read {reads} nodes; at most \
2328 {PRESENT} plus {OVERHEAD} is owed: the prune walked the history, not the rows"
2329 );
2330 drop(store);
2331 drop(events);
2332 drop(dir);
2333 Ok(())
2334 }
2335}
2336
2337#[cfg(test)]
2338#[path = "store_batch_tests.rs"]
2339mod batch_tests;