Skip to main content

liminal/durability/
store.rs

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/// A conditional key/value replacement in one routed atomic commit.
12#[derive(Clone, Debug)]
13pub struct RoutedBatchItem {
14    /// The key routed with the entire operation.
15    pub key: Vec<u8>,
16    /// The original content hash; absence is an explicit precondition.
17    pub expected: Option<haematite::tree::Hash>,
18    /// The replacement bytes.
19    pub value: Vec<u8>,
20    /// The lifetime of the replacement, or no expiry.
21    pub ttl: Option<std::time::Duration>,
22}
23
24/// One change in an atomic routed batch of puts and deletes (DUR-028 R3).
25///
26/// A delete names the value it retires by its logical hash, so a key that
27/// is absent or holds another value refuses the whole batch: the commit
28/// that replaces a checkpoint body removes exactly the body it read.
29#[derive(Clone)]
30pub enum RoutedBatchEntry {
31    /// A conditional replacement, exactly as in
32    /// [`DurableStore::apply_routed_batch`].
33    Put(RoutedBatchItem),
34    /// Removal of `key`, whose current value hashes to `expected`.
35    Delete {
36        /// The key on the route, as the puts beside it are keyed.
37        key: Vec<u8>,
38        /// The logical hash of the value being removed
39        /// ([`haematite::tree::Hash::of`] its bytes).
40        expected: haematite::tree::Hash,
41    },
42}
43
44impl RoutedBatchEntry {
45    /// The key this entry writes or removes.
46    #[must_use]
47    pub fn key(&self) -> &[u8] {
48        match self {
49            Self::Put(item) => &item.key,
50            Self::Delete { key, .. } => key,
51        }
52    }
53}
54
55/// A delete prints its key as the text every route key is, not as the byte
56/// list a derived `Debug` would print, beside the hash it guards on.
57impl std::fmt::Debug for RoutedBatchEntry {
58    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            Self::Put(item) => formatter.debug_tuple("Put").field(item).finish(),
61            Self::Delete { key, expected } => formatter
62                .debug_struct("Delete")
63                .field("key", &String::from_utf8_lossy(key))
64                .field("expected", expected)
65                .finish(),
66        }
67    }
68}
69
70/// Entry read from a durable haematite stream.
71#[derive(Clone, Debug, PartialEq, Eq)]
72pub struct StoredEntry {
73    /// Opaque stored payload bytes.
74    pub payload: Vec<u8>,
75    /// Sequence number assigned by the stream.
76    pub sequence: u64,
77    /// Store timestamp associated with the entry.
78    pub timestamp: u64,
79}
80
81/// Position a paged prefix scan resumes from.
82///
83/// Names one stream and the first sequence not yet delivered from it. Streams
84/// sorting after `stream_key` are untouched by the resume and start at their
85/// own sequence 0.
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct ScanCursor {
88    /// Stream the next page resumes inside.
89    pub stream_key: String,
90    /// First sequence the next page reads from that stream.
91    pub next_sequence: u64,
92}
93
94/// One bounded page of a prefix scan.
95#[derive(Clone, Debug, PartialEq, Eq)]
96pub struct ScanPage {
97    /// Entries this page delivered, in (stream key, sequence) order.
98    pub entries: Vec<StoredEntry>,
99    /// Position the next page resumes from, or `None` when the scan is done.
100    pub next: Option<ScanCursor>,
101}
102
103/// Direct durability surface matching haematite's append/read/cas/paged-scan API.
104#[async_trait::async_trait]
105pub trait DurableStore: std::fmt::Debug + Send + Sync {
106    /// The haematite store behind this one, when it is the spine opened from
107    /// disk: the owner releases its held reclaim, watches its passes and
108    /// reads its status through it (DUR-021). A store that is not the spine
109    /// answers `None`.
110    fn reclaim_spine(&self) -> Option<Arc<EventStore>> {
111        None
112    }
113
114    /// The largest request this store admits, in bytes, or `None` when it
115    /// names no bound (DUR-024 R2). A caller assembling a batch keeps each
116    /// request under it; the bound is read here, from the open store, and is
117    /// never assumed or learnt from a refusal.
118    fn max_request_bytes(&self) -> Option<usize> {
119        None
120    }
121
122    /// Commits all conditional replacements on one route, or publishes none.
123    ///
124    /// No implementation may compose this operation from single writes.
125    async fn apply_routed_batch(
126        &self,
127        route_key: &str,
128        items: Vec<RoutedBatchItem>,
129    ) -> Result<(), DurabilityError>;
130
131    /// Commits conditional replacements and deletes on one route together,
132    /// or publishes none (DUR-028 R3).
133    ///
134    /// A batch of puts alone is [`Self::apply_routed_batch`]. A store that
135    /// cannot delete inside a batch refuses a batch with a delete by name,
136    /// before writing anything: composing it from a batch and single deletes
137    /// is never done in its place (Tom, 3 October 2026: a batch is one
138    /// durable operation, never leaf by leaf). A wrapper over a store that
139    /// can forwards this call, or its deletes are refused at the wrapper.
140    ///
141    /// # Errors
142    /// Returns [`DurabilityError::RoutedDeleteUnsupported`] from a store
143    /// that cannot delete in a batch, or the store's own refusal.
144    async fn apply_routed_entries(
145        &self,
146        route_key: &str,
147        entries: Vec<RoutedBatchEntry>,
148    ) -> Result<(), DurabilityError> {
149        let mut items = Vec::with_capacity(entries.len());
150        for entry in entries {
151            match entry {
152                RoutedBatchEntry::Put(item) => items.push(item),
153                RoutedBatchEntry::Delete { .. } => {
154                    return Err(DurabilityError::RoutedDeleteUnsupported {
155                        store: std::any::type_name::<Self>(),
156                        route_key: route_key.to_owned(),
157                    });
158                }
159            }
160        }
161        self.apply_routed_batch(route_key, items).await
162    }
163
164    /// Reads one key from the route used by the atomic writer.
165    async fn get_routed(
166        &self,
167        route_key: &str,
168        key: &[u8],
169    ) -> Result<Option<Vec<u8>>, DurabilityError>;
170
171    /// Appends `payload` to `stream_key` if `expected_seq` matches the stream head.
172    async fn append(
173        &self,
174        stream_key: &str,
175        payload: Vec<u8>,
176        expected_seq: u64,
177    ) -> Result<u64, DurabilityError>;
178
179    /// Reads entries from `stream_key` beginning at `offset`, up to `limit` entries.
180    async fn read_from(
181        &self,
182        stream_key: &str,
183        offset: u64,
184        limit: usize,
185    ) -> Result<Vec<StoredEntry>, DurabilityError>;
186
187    /// Atomically appends all payloads in order, starting at the expected head,
188    /// and returns the stream's next sequence.
189    ///
190    /// A conflict or a refusal before commit leaves every payload absent.
191    /// If the commit's reply is lost, the whole batch may be present; readers
192    /// must never observe a prefix. Implementations must submit one atomic
193    /// batch rather than compose individual appends.
194    async fn append_all(
195        &self,
196        stream_key: &str,
197        payloads: Vec<Vec<u8>>,
198        expected_seq: u64,
199    ) -> Result<u64, DurabilityError>;
200
201    /// Reads exactly the event at `sequence` without traversing its suffix.
202    async fn read_at(
203        &self,
204        stream_key: &str,
205        sequence: u64,
206    ) -> Result<Option<StoredEntry>, DurabilityError> {
207        Ok(self
208            .read_from(stream_key, sequence, 1)
209            .await?
210            .into_iter()
211            .next())
212    }
213
214    /// Atomically replaces a stored numeric value if it equals `old_value`.
215    ///
216    /// An `old_value` of `0` matches a key that is currently *absent* as well as
217    /// one explicitly stored as `0`: a fresh cursor is created on its first
218    /// checkpoint without a prior write. See [`HaematiteStore::cas`] for how this
219    /// "absent == 0" contract is preserved atomically over the real engine.
220    async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError>;
221
222    /// Reads the exclusive append head without reading stream rows.
223    ///
224    /// `None` means the stream has never held an appended row. A present
225    /// head is nonzero and is preserved when rows are pruned. Numeric values
226    /// written through compare-and-swap do not constitute a stream head.
227    ///
228    /// # Errors
229    /// Returns the store's failure or an explicit unsupported-operation refusal.
230    async fn read_stream_next_seq(&self, stream_key: &str) -> Result<Option<u64>, DurabilityError>;
231
232    /// Reads a numeric value previously updated through compare-and-swap.
233    async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError>;
234
235    /// Reads one page of at most `limit` entries from the streams whose keys
236    /// begin with `prefix`, in byte order of stream key and then sequence.
237    ///
238    /// `cursor` resumes a walk: `None` starts at the first matching stream,
239    /// `Some` starts at that cursor's position. `limit` must be nonzero.
240    ///
241    /// A returned `next` of `Some` means only that this page FILLED, never that
242    /// more entries exist: a follow-up call that finds nothing returns an empty
243    /// page with `next` of `None`, and that empty page is a legal terminator.
244    ///
245    /// The contract is resume-at-position, not a point-in-time snapshot. There
246    /// is no read view held across calls, so entries appended at or after the
247    /// cursor's position between two pages WILL appear in the later page.
248    async fn scan_page(
249        &self,
250        prefix: &str,
251        cursor: Option<ScanCursor>,
252        limit: usize,
253    ) -> Result<ScanPage, DurabilityError>;
254
255    /// Flushes buffered writes so completed durable operations are persisted.
256    ///
257    /// # Errors
258    /// Returns [`DurabilityError`] when the underlying store cannot complete the flush.
259    async fn flush(&self) -> Result<(), DurabilityError>;
260
261    /// Deletes every entry of `stream_key` with sequence below `before_seq`,
262    /// in one commit, and returns how many were deleted. Later entries are
263    /// untouched, the stream's head is kept (an append continues where it
264    /// would have, and no sequence is reused), and a second identical call
265    /// deletes nothing and returns 0.
266    ///
267    /// A store that cannot delete refuses by name rather than answering 0,
268    /// so a caller pruning a bounded stream is never told a cut happened
269    /// when nothing was removed.
270    ///
271    /// # Errors
272    /// Returns [`DurabilityError::TruncateUnsupported`] from a store that
273    /// cannot delete, or the store's own failure.
274    async fn truncate_before(
275        &self,
276        stream_key: &str,
277        before_seq: u64,
278    ) -> Result<u64, DurabilityError> {
279        Err(DurabilityError::TruncateUnsupported {
280            store: std::any::type_name::<Self>(),
281            stream_key: stream_key.to_owned(),
282            before_seq,
283        })
284    }
285
286    /// Reads the routed keys of one window on a route, with their values.
287    ///
288    /// The window is the half-open `[from, to)` in key order, and it is
289    /// physical: the caller derives it from the keys it wrote through
290    /// [`Self::apply_routed_batch`] on the same route (DUR-018 R3).
291    ///
292    /// # Errors
293    /// Returns [`DurabilityError::RoutedScanUnsupported`] from a store that
294    /// cannot list routed keys, or the store's own failure.
295    async fn scan_routed(
296        &self,
297        route_key: &str,
298        _from: &[u8],
299        _to: &[u8],
300    ) -> Result<Vec<(Vec<u8>, Vec<u8>)>, DurabilityError> {
301        Err(DurabilityError::RoutedScanUnsupported {
302            store: std::any::type_name::<Self>(),
303            route_key: route_key.to_owned(),
304        })
305    }
306
307    /// Deletes the routed keys of one window on a route in one commit.
308    ///
309    /// The window is the half-open `[from, to)`; the answer is how many keys
310    /// went, or, from a store that cuts the window without reading it, 1 when
311    /// anything went and 0 when nothing did. Keys outside the window, the route's other records included,
312    /// are untouched, and a second identical call deletes nothing and
313    /// returns 0.
314    ///
315    /// A store that cannot do it in one commit refuses by name rather than
316    /// composing single deletes (Tom, 3 October 2026: a batch is one durable
317    /// operation, never leaf by leaf), so a caller pruning a routed stream is
318    /// never told a cut happened when nothing was removed, and never pays a
319    /// commit per row.
320    ///
321    /// # Errors
322    /// Returns [`DurabilityError::RoutedRemoveUnsupported`] from a store that
323    /// cannot delete a window in one commit, or the store's own failure.
324    async fn remove_routed_range(
325        &self,
326        route_key: &str,
327        _from: &[u8],
328        _to: &[u8],
329    ) -> Result<u64, DurabilityError> {
330        Err(DurabilityError::RoutedRemoveUnsupported {
331            store: std::any::type_name::<Self>(),
332            route_key: route_key.to_owned(),
333        })
334    }
335
336    /// A reader of the counters of the work done through this store, when the
337    /// store counts it.
338    ///
339    /// Only a [`super::CountingStore`] counts; every other store answers
340    /// `None`. A store held as `Arc<dyn DurableStore>` is reachable no other
341    /// way, which is why the reader is asked of the store itself.
342    fn store_counts(&self) -> Option<StoreCountsHandle> {
343        None
344    }
345}
346
347/// `DurableStore` implementation that delegates directly to haematite's `EventStore`.
348///
349/// The real [`EventStore`] is synchronous (every call blocks on the owning
350/// shard actor's reply), so each `async` method below completes on its first
351/// poll. The synchronous bridge in [`super::bridge`] relies on exactly that.
352#[derive(Clone, Debug)]
353pub struct HaematiteStore {
354    event_store: Arc<EventStore>,
355}
356
357impl HaematiteStore {
358    /// Wraps a haematite `EventStore` handle.
359    #[must_use]
360    pub const fn new(event_store: Arc<EventStore>) -> Self {
361        Self { event_store }
362    }
363
364    /// Reads the half-open key window `[offset, offset + limit)` from one
365    /// stream, or `None` when the window did not fill.
366    ///
367    /// `None` is not "empty": it is "this window cannot answer on its own",
368    /// and the caller must fall through to the unbounded engine read. A window
369    /// short by even one row may be short because the stream ended, because
370    /// history was compacted, or because an entry inside it expired, and only
371    /// the engine's own read distinguishes those.
372    ///
373    /// `limit` must be nonzero; a zero limit has no window to fill and is the
374    /// caller's fall-through case.
375    fn bounded_page(
376        &self,
377        stream_key: &str,
378        offset: u64,
379        limit: usize,
380    ) -> Result<Option<Vec<StoredEntry>>, DurabilityError> {
381        const TIMESTAMP_WIDTH: usize = std::mem::size_of::<u64>();
382
383        // Engine keys are 1-based; the public API is 0-based.
384        let Some(engine_from) = offset.checked_add(1) else {
385            return Ok(None);
386        };
387        let Some(engine_end) = u64::try_from(limit)
388            .ok()
389            .and_then(|limit| engine_from.checked_add(limit))
390        else {
391            return Ok(None);
392        };
393        let key = stream_key.as_bytes();
394        let from = haematite::encode_stream_key(key, engine_from);
395        let to = haematite::encode_stream_key(key, engine_end);
396        let entries = self
397            .event_store
398            .database()
399            .range_routed(key, &from, &to)
400            .map_err(ApiError::from)
401            .map_err(DurabilityError::from)?;
402        if entries.len() != limit {
403            return Ok(None);
404        }
405
406        let mut page = Vec::with_capacity(entries.len());
407        for (encoded_key, value) in entries {
408            let Some((decoded_key, engine_sequence)) = haematite::decode_stream_key(&encoded_key)
409            else {
410                return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
411                    format!("paged read key does not encode an event for stream {stream_key}"),
412                )));
413            };
414            if decoded_key != key {
415                return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
416                    format!("paged read key does not encode stream {stream_key}"),
417                )));
418            }
419            let sequence = engine_sequence.checked_sub(1).ok_or_else(|| {
420                DurabilityError::StoreError(ApiError::CorruptEvent(format!(
421                    "paged read event key has zero seq for stream {stream_key}"
422                )))
423            })?;
424            let Some(timestamp_bytes) = value.get(..TIMESTAMP_WIDTH) else {
425                return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
426                    format!(
427                        "paged read event value is shorter than its timestamp for stream {stream_key}"
428                    ),
429                )));
430            };
431            let timestamp = u64::from_be_bytes(timestamp_bytes.try_into().map_err(|_| {
432                DurabilityError::StoreError(ApiError::CorruptEvent(format!(
433                    "paged read event timestamp has the wrong width for stream {stream_key}"
434                )))
435            })?);
436            let Some(payload) = value.get(TIMESTAMP_WIDTH..) else {
437                return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
438                    format!("paged read event has no payload boundary for stream {stream_key}"),
439                )));
440            };
441            page.push(StoredEntry {
442                payload: payload.to_vec(),
443                sequence,
444                timestamp,
445            });
446        }
447        Ok(Some(page))
448    }
449}
450
451#[async_trait::async_trait]
452impl DurableStore for HaematiteStore {
453    fn reclaim_spine(&self) -> Option<Arc<EventStore>> {
454        Some(Arc::clone(&self.event_store))
455    }
456
457    /// haematite's per-shard admission bound, read from the open database
458    /// (`Database::queue_byte_limit`, the value `DatabaseOpenOptions` opened
459    /// it with): a request over it is refused by name (`shard_queue_full`).
460    fn max_request_bytes(&self) -> Option<usize> {
461        Some(self.event_store.database().queue_byte_limit().get())
462    }
463
464    async fn apply_routed_batch(
465        &self,
466        route_key: &str,
467        items: Vec<RoutedBatchItem>,
468    ) -> Result<(), DurabilityError> {
469        let items = items
470            .into_iter()
471            .map(|item| haematite::RoutedBatchItem {
472                key: item.key,
473                expected: item.expected,
474                value: item.value,
475                ttl: item.ttl,
476            })
477            .collect();
478        self.event_store
479            .database()
480            .apply_routed_batch(route_key.as_bytes(), items)
481            .map_err(ApiError::from)
482            .map_err(DurabilityError::from)
483    }
484
485    async fn apply_routed_entries(
486        &self,
487        route_key: &str,
488        entries: Vec<RoutedBatchEntry>,
489    ) -> Result<(), DurabilityError> {
490        let entries = entries
491            .into_iter()
492            .map(|entry| match entry {
493                RoutedBatchEntry::Put(item) => {
494                    haematite::RoutedBatchEntry::Put(haematite::RoutedBatchItem {
495                        key: item.key,
496                        expected: item.expected,
497                        value: item.value,
498                        ttl: item.ttl,
499                    })
500                }
501                RoutedBatchEntry::Delete { key, expected } => {
502                    haematite::RoutedBatchEntry::Delete { key, expected }
503                }
504            })
505            .collect();
506        self.event_store
507            .database()
508            .apply_routed_entries(route_key.as_bytes(), entries)
509            .map_err(local_batch_error)
510    }
511
512    async fn get_routed(
513        &self,
514        route_key: &str,
515        key: &[u8],
516    ) -> Result<Option<Vec<u8>>, DurabilityError> {
517        self.event_store
518            .database()
519            .get_routed(route_key.as_bytes(), key)
520            .map_err(ApiError::from)
521            .map_err(DurabilityError::from)
522    }
523
524    async fn append(
525        &self,
526        stream_key: &str,
527        payload: Vec<u8>,
528        expected_seq: u64,
529    ) -> Result<u64, DurabilityError> {
530        // Contract bridge: liminal's `DurableStore::append` returns the *assigned
531        // event sequence* (0-based position of the just-appended event), which is
532        // exactly `expected_seq` for a single append. The real `EventStore::append`
533        // instead returns the stream's new next-sequence (`expected_seq + 1`), so
534        // subtract one to recover the assigned seq. A `0` next-seq is impossible
535        // after a successful single append, so the `checked_sub` cannot saturate
536        // silently; if it ever did the engine returned a contract-violating value.
537        let next_seq = self
538            .event_store
539            .append(stream_key.as_bytes(), &payload, expected_seq)
540            .map_err(DurabilityError::from)?;
541        next_seq.checked_sub(1).ok_or_else(|| {
542            DurabilityError::StoreError(ApiError::CorruptEvent(format!(
543                "append returned next-seq 0 for stream {stream_key}"
544            )))
545        })
546    }
547
548    async fn append_all(
549        &self,
550        stream_key: &str,
551        payloads: Vec<Vec<u8>>,
552        expected_seq: u64,
553    ) -> Result<u64, DurabilityError> {
554        let borrowed: Vec<&[u8]> = payloads.iter().map(Vec::as_slice).collect();
555        self.event_store
556            .append_batch(stream_key.as_bytes(), &borrowed, expected_seq)
557            .map_err(DurabilityError::from)
558    }
559
560    async fn read_from(
561        &self,
562        stream_key: &str,
563        offset: u64,
564        limit: usize,
565    ) -> Result<Vec<StoredEntry>, DurabilityError> {
566        // `EventStore::read_from` applies no limit: it materialises every event
567        // with seq >= offset, key and value copied across the shard-actor
568        // boundary, and truncating afterwards throws that work away. Paged
569        // replay therefore costs O(N^2) engine rows to deliver N (#60).
570        //
571        // Ask the engine for the page instead. Event keys are
572        // `stream_key || 0x00 || seq.to_be_bytes()` (haematite 0.8.1
573        // `api/event_store.rs:375`), so byte order is sequence order and a
574        // half-open key window names exactly one page. `range_routed` routes on
575        // the stream key — the same co-location `EventStore` uses for its own
576        // reads — and merges committed tree with WAL buffer, which is the
577        // identical mechanism behind the unbounded read (`db.rs:212`).
578        //
579        // A FULL window is the same answer the unbounded read gave: it holds
580        // `limit` live events, and key order makes those exactly the first
581        // `limit` events at or after `offset`. Anything SHORT falls through to
582        // the unbounded read, so the two answers the window cannot settle by
583        // itself stay the engine's own: the `HistoryCompacted` verdict at
584        // `offset == 0`, and the case where expiry or compaction leaves a hole
585        // inside the window. The fall-through costs a suffix scan only where
586        // the suffix is already shorter than a page — the end-of-stream read
587        // that terminates every walk.
588        if limit > 0 {
589            if let Some(page) = self.bounded_page(stream_key, offset, limit)? {
590                account_engine_read(page.len(), false);
591                return Ok(page);
592            }
593        }
594        let mut events = self
595            .event_store
596            .read_from(stream_key.as_bytes(), offset)
597            .map_err(DurabilityError::from)?;
598        account_engine_read(events.len(), true);
599        events.truncate(limit);
600        Ok(events.into_iter().map(StoredEntry::from).collect())
601    }
602
603    async fn read_at(
604        &self,
605        stream_key: &str,
606        sequence: u64,
607    ) -> Result<Option<StoredEntry>, DurabilityError> {
608        const TIMESTAMP_WIDTH: usize = std::mem::size_of::<u64>();
609
610        let engine_sequence = sequence.checked_add(1).ok_or_else(|| {
611            DurabilityError::StoreError(ApiError::CorruptEvent(format!(
612                "point read sequence overflow for stream {stream_key}"
613            )))
614        })?;
615        let event_key = haematite::encode_stream_key(stream_key.as_bytes(), engine_sequence);
616        let Some(value) = self
617            .event_store
618            .database()
619            .get_routed(stream_key.as_bytes(), &event_key)
620            .map_err(ApiError::from)
621            .map_err(DurabilityError::from)?
622        else {
623            return Ok(None);
624        };
625        let Some(timestamp_bytes) = value.get(..TIMESTAMP_WIDTH) else {
626            return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
627                format!(
628                    "point-read event value is shorter than its timestamp for stream {stream_key}"
629                ),
630            )));
631        };
632        let timestamp = u64::from_be_bytes(timestamp_bytes.try_into().map_err(|_| {
633            DurabilityError::StoreError(ApiError::CorruptEvent(format!(
634                "point-read event timestamp has the wrong width for stream {stream_key}"
635            )))
636        })?);
637        let Some(payload) = value.get(TIMESTAMP_WIDTH..) else {
638            return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
639                format!("point-read event has no payload boundary for stream {stream_key}"),
640            )));
641        };
642        Ok(Some(StoredEntry {
643            payload: payload.to_vec(),
644            sequence,
645            timestamp,
646        }))
647    }
648
649    async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
650        // Preserve liminal's "absent == 0" cursor contract faithfully over an
651        // engine that distinguishes `None` (absent) from `Some(0)` (a stored
652        // zero). The invariant that makes the mapping below correct: we NEVER
653        // persist a physical zero, so a logical value of 0 and physical absence
654        // always coincide.
655        //
656        // A `cas` whose target `new_value` is 0 must therefore write nothing — it
657        // only asserts the precondition. This is reachable as `cas(0, 0)` (a
658        // cursor checkpoint at offset 0; offsets are monotonic so they never CAS
659        // down to 0 from a higher value). Were we instead to let it store a
660        // physical zero, the *next* `cas(0, n)` — mapped to expect-absent `None`
661        // — would wrongly fail against the now-present key and permanently stall
662        // the cursor. Asserting via a read is race-free here precisely because no
663        // value is written, so there is no lost-update window.
664        if new_value == 0 {
665            return self
666                .event_store
667                .read_value(key.as_bytes())
668                .map_err(DurabilityError::from)?
669                .map_or(Ok(()), |stored| {
670                    Err(DurabilityError::CursorRegression {
671                        stored,
672                        attempted: old_value,
673                    })
674                });
675        }
676        // With a physical zero never stored, `old_value == 0` is exactly the
677        // expect-absent expectation. Any other `old_value` maps to `Some(_)`.
678        // This is a single CAS routed to the owning shard actor, where read,
679        // compare, and write run with no interleaving point (haematite's
680        // `ShardActor::cas`) — the engine's atomicity is preserved end to end.
681        let expected = if old_value == 0 {
682            None
683        } else {
684            Some(old_value)
685        };
686        self.event_store
687            .cas(key.as_bytes(), expected, new_value)
688            .map_err(DurabilityError::from)
689    }
690
691    async fn read_stream_next_seq(&self, stream_key: &str) -> Result<Option<u64>, DurabilityError> {
692        self.event_store
693            .read_stream_next_seq(stream_key.as_bytes())
694            .map_err(DurabilityError::from)
695    }
696
697    async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
698        self.event_store
699            .read_value(key.as_bytes())
700            .map_err(DurabilityError::from)
701    }
702
703    async fn scan_page(
704        &self,
705        prefix: &str,
706        cursor: Option<ScanCursor>,
707        limit: usize,
708    ) -> Result<ScanPage, DurabilityError> {
709        if limit == 0 {
710            return Err(DurabilityError::ConfigError(
711                "scan_page limit must be nonzero".to_owned(),
712            ));
713        }
714
715        let mut entries: Vec<StoredEntry> = Vec::new();
716        let mut resume_at: Option<ScanCursor> = None;
717        // The cursor's own stream resumes where the last page stopped; every
718        // stream after it starts at sequence 0.
719        let mut after: Option<String> = if let Some(cursor) = cursor {
720            if cursor.stream_key.starts_with(prefix) {
721                let page = self
722                    .read_from(&cursor.stream_key, cursor.next_sequence, limit)
723                    .await?;
724                resume_at = scan_resume_position(&cursor.stream_key, &page)?.or(resume_at);
725                entries.extend(page);
726            }
727            Some(cursor.stream_key)
728        } else {
729            None
730        };
731
732        // Haematite's `scan_prefix` enumerates streams in key order from a
733        // position, a bounded page at a time (G1), so a page lists only the
734        // streams after its cursor and never the whole prefix again: a walk
735        // drained page by page enumerates each matching stream once. A live
736        // stream holds at least one live event, so asking for as many streams
737        // as the page has room for never enumerates more than it can read.
738        while let Some(remaining) = limit
739            .checked_sub(entries.len())
740            .and_then(std::num::NonZeroUsize::new)
741        {
742            let streams = self
743                .event_store
744                .scan_prefix(
745                    prefix.as_bytes(),
746                    remaining,
747                    after.as_deref().map(str::as_bytes),
748                )
749                .map_err(DurabilityError::from)?;
750            account_stream_enumeration(streams.streams.len());
751            let exhausted = streams.next_after.is_none() || streams.streams.is_empty();
752            for stream in streams.streams {
753                // Every key liminal writes comes from a `&str`; a key that is
754                // not UTF-8 was written by something else and cannot be named
755                // in a cursor, so refuse rather than skip it into silent data
756                // loss.
757                let stream_key = String::from_utf8(stream.stream_key).map_err(|error| {
758                    DurabilityError::StoreError(ApiError::CorruptEvent(format!(
759                        "scanned stream key is not utf-8: {error}"
760                    )))
761                })?;
762                let Some(remaining) = limit.checked_sub(entries.len()).filter(|rest| *rest > 0)
763                else {
764                    break;
765                };
766                // The bounded `read_from` above, never `EventStore::read`:
767                // asking for exactly the remaining budget is what keeps a
768                // page's cost proportional to the page and not to the stream
769                // behind it.
770                let page = self.read_from(&stream_key, 0, remaining).await?;
771                resume_at = scan_resume_position(&stream_key, &page)?.or(resume_at);
772                entries.extend(page);
773                after = Some(stream_key);
774            }
775            if exhausted {
776                break;
777            }
778        }
779
780        // `next` reports only that the page FILLED. A stream that returned
781        // fewer entries than its budget has already been passed over by the
782        // loop, so a short page has nothing left to resume from.
783        let next = if entries.len() == limit {
784            resume_at
785        } else {
786            None
787        };
788        Ok(ScanPage { entries, next })
789    }
790
791    async fn flush(&self) -> Result<(), DurabilityError> {
792        self.event_store.flush().map_err(DurabilityError::from)
793    }
794
795    /// One haematite `truncate_stream` call. liminal's sequence is
796    /// haematite's API sequence, so `before_seq` passes through unchanged.
797    async fn truncate_before(
798        &self,
799        stream_key: &str,
800        before_seq: u64,
801    ) -> Result<u64, DurabilityError> {
802        self.event_store
803            .truncate_stream(stream_key.as_bytes(), before_seq)
804            .map_err(DurabilityError::from)
805    }
806
807    /// One haematite `range_routed` call on the route's shard.
808    ///
809    /// It reads the committed tree merged with the WAL buffer, the same read
810    /// behind every routed lookup.
811    async fn scan_routed(
812        &self,
813        route_key: &str,
814        from: &[u8],
815        to: &[u8],
816    ) -> Result<Vec<(Vec<u8>, Vec<u8>)>, DurabilityError> {
817        self.event_store
818            .database()
819            .range_routed(route_key.as_bytes(), from, to)
820            .map_err(ApiError::from)
821            .map_err(DurabilityError::from)
822    }
823
824    /// One haematite `delete_routed_range` call on the route's shard
825    /// (haematite 404dbcfb, 11 October 2026): the window is cut out of the
826    /// tree in one commit, reading only its two edge leaves and the path
827    /// above them, and every node inside goes to reclaim unread. It is
828    /// durable when it returns. Haematite does not count what it cut,
829    /// because counting would read the window, so the answer is 1 when
830    /// anything went and 0 when nothing did.
831    async fn remove_routed_range(
832        &self,
833        route_key: &str,
834        from: &[u8],
835        to: &[u8],
836    ) -> Result<u64, DurabilityError> {
837        let cut = self
838            .event_store
839            .database()
840            .delete_routed_range(route_key.as_bytes(), from, to)
841            .map_err(ApiError::from)
842            .map_err(DurabilityError::from)?;
843        Ok(u64::from(cut))
844    }
845}
846
847/// The position a scan resumes from after delivering `page` from
848/// `stream_key`, or `None` when the page delivered nothing from it.
849fn scan_resume_position(
850    stream_key: &str,
851    page: &[StoredEntry],
852) -> Result<Option<ScanCursor>, DurabilityError> {
853    let Some(last) = page.last() else {
854        return Ok(None);
855    };
856    let next_sequence = last.sequence.checked_add(1).ok_or_else(|| {
857        DurabilityError::StoreError(ApiError::CorruptEvent(format!(
858            "scan page sequence overflow for stream {stream_key}"
859        )))
860    })?;
861    Ok(Some(ScanCursor {
862        stream_key: stream_key.to_owned(),
863        next_sequence,
864    }))
865}
866
867/// Drop shell enforcing "close the store, then remove its directory" as
868/// explicit code rather than field declaration order.
869///
870/// Declaration order alone cannot express the unwind case: if dropping the
871/// store panics (a haematite worker failing to join), Rust would still drop
872/// the remaining fields during the unwind and remove the directory under
873/// possibly-live workers. This `Drop` drops the store inside `catch_unwind`;
874/// on unwind it DISARMS the directory guard — the directory is deliberately
875/// leaked, because visible residue is diagnosable while removal under live
876/// workers is filesystem corruption — logs the leaked path, and re-raises the
877/// panic. On the clean path the directory is removed after the store, HERE,
878/// by an explicit [`TempDir::close`] whose error is logged.
879///
880/// The explicitness is the point. Letting the `TempDir` field drop instead
881/// would remove the directory via `tempfile`'s own `Drop`, which is
882/// `let _ = remove_dir_all(..)` — the `io::Result` is discarded, so a removal
883/// that FAILED would be indistinguishable from one that succeeded and this
884/// doc's "the directory is removed" would be a claim no code could check.
885/// `close()` returns that error; the clean path reports it and leaves the
886/// residue where the log says it is. It never panics (a `Drop` that unwinds
887/// during another unwind aborts the process) and never masks: a failure to
888/// remove is a durability fact, not something to swallow.
889///
890/// Both fields are `Option` only so `drop` can move them out; they are `Some`
891/// for the shell's entire life outside `drop`.
892#[derive(Debug)]
893struct EphemeralGuard<S> {
894    ownership: Option<super::ephemeral_root::EphemeralOwnership>,
895    store: Option<S>,
896    dir: Option<TempDir>,
897}
898
899impl<S> Drop for EphemeralGuard<S> {
900    fn drop(&mut self) {
901        let store = self.store.take();
902        // AssertUnwindSafe: the closure owns everything it touches (the moved
903        // store), and the unwind path below observes no state the panicking
904        // drop could have left broken — it only disarms the guard and re-raises.
905        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(store)));
906        if let Err(panic) = outcome {
907            if let Some(dir) = self.dir.take() {
908                let leaked = dir.keep();
909                tracing::error!(
910                    path = %leaked.display(),
911                    "ephemeral store drop panicked; leaking its directory rather than \
912                     removing it under possibly-live database workers"
913                );
914            }
915            if let Some(ownership) = self.ownership.take() {
916                std::mem::forget(ownership);
917            }
918            std::panic::resume_unwind(panic);
919        }
920        // Clean path: the store is closed, its workers are joined and the
921        // writer lock is released, so removing the directory now is safe — and
922        // removing it EXPLICITLY is what makes a failure sayable.
923        if let Some(dir) = self.dir.take() {
924            let path = dir.path().to_path_buf();
925            if let Err(error) = dir.close() {
926                tracing::error!(
927                    path = %path.display(),
928                    %error,
929                    "ephemeral store directory removal failed; residue remains at the \
930                     logged path"
931                );
932            }
933        }
934    }
935}
936
937/// Exclusive-ownership ephemeral durable store: the sole owner of both the
938/// haematite database and the temporary directory that backs it.
939///
940/// [`HaematiteStore::new`] takes a *caller-supplied* `Arc<EventStore>`, so a
941/// clone of that inner handle can outlive any guard placed merely beside it —
942/// field declaration order proves nothing across that `Arc` boundary. This
943/// wrapper instead owns the database outright: [`super::EphemeralRoot::open_store`] constructs the
944/// inner `Arc` itself, this type never exposes it (no getter) and is deliberately
945/// **not `Clone`**, so the only handle a caller can hold is an
946/// `Arc<dyn DurableStore>` over the whole wrapper. When the last such clone
947/// drops, the [`EphemeralGuard`] drops the store FIRST — the database closes,
948/// its shard actors join and the data-dir writer lock releases on fd close —
949/// and only then removes the directory, logging the error if that removal
950/// fails; if closing the database panics, the directory is deliberately leaked
951/// instead (see [`EphemeralGuard`]).
952#[derive(Debug)]
953pub struct EphemeralHaematiteStore {
954    guard: EphemeralGuard<HaematiteStore>,
955}
956
957impl EphemeralHaematiteStore {
958    /// Takes an already-open ephemeral `Database` and the temporary directory it
959    /// was opened under, becoming their single exclusive owner.
960    ///
961    /// The inner `Arc<EventStore>` is created here and never leaves this type, so
962    /// no caller-supplied clone of it can exist to defeat the drop ordering.
963    /// `ephemeral_dir` must be the directory `database` lives in and must have
964    /// been created before the database was opened (so a failed open removed it
965    /// via the guard's `Drop`, before this constructor was ever reached).
966    fn new(database: Database, ephemeral_dir: TempDir) -> Self {
967        Self {
968            guard: EphemeralGuard {
969                ownership: None,
970                store: Some(HaematiteStore::new(Arc::new(EventStore::new(database)))),
971                dir: Some(ephemeral_dir),
972            },
973        }
974    }
975
976    /// Store handle behind the guard's teardown-only `Option`.
977    ///
978    /// `None` exists only inside [`EphemeralGuard::drop`], which cannot overlap
979    /// a `&self` call, so this error is unreachable by construction — it is a
980    /// typed refusal in place of a panic the workspace forbids, not a state a
981    /// caller can produce.
982    fn store(&self) -> Result<&HaematiteStore, DurabilityError> {
983        self.guard
984            .store
985            .as_ref()
986            .ok_or(DurabilityError::EphemeralStoreDetached)
987    }
988
989    fn ephemeral_dir_path_owned(&self) -> Result<&Path, DurabilityError> {
990        self.guard
991            .dir
992            .as_ref()
993            .map(TempDir::path)
994            .ok_or(DurabilityError::EphemeralStoreDetached)
995    }
996
997    /// Path of the guarding temporary directory, for lifecycle assertions only.
998    #[cfg(test)]
999    pub(crate) fn ephemeral_dir_path(&self) -> Option<&Path> {
1000        self.guard.dir.as_ref().map(TempDir::path)
1001    }
1002}
1003
1004#[async_trait::async_trait]
1005impl DurableStore for EphemeralHaematiteStore {
1006    fn max_request_bytes(&self) -> Option<usize> {
1007        self.store().ok().and_then(DurableStore::max_request_bytes)
1008    }
1009
1010    async fn apply_routed_batch(
1011        &self,
1012        route_key: &str,
1013        items: Vec<crate::durability::RoutedBatchItem>,
1014    ) -> Result<(), crate::durability::DurabilityError> {
1015        self.store()?.apply_routed_batch(route_key, items).await
1016    }
1017
1018    async fn apply_routed_entries(
1019        &self,
1020        route_key: &str,
1021        entries: Vec<RoutedBatchEntry>,
1022    ) -> Result<(), crate::durability::DurabilityError> {
1023        self.store()?.apply_routed_entries(route_key, entries).await
1024    }
1025
1026    async fn get_routed(
1027        &self,
1028        route_key: &str,
1029        key: &[u8],
1030    ) -> Result<Option<Vec<u8>>, crate::durability::DurabilityError> {
1031        self.store()?.get_routed(route_key, key).await
1032    }
1033
1034    async fn append(
1035        &self,
1036        stream_key: &str,
1037        payload: Vec<u8>,
1038        expected_seq: u64,
1039    ) -> Result<u64, DurabilityError> {
1040        self.store()?
1041            .append(stream_key, payload, expected_seq)
1042            .await
1043    }
1044
1045    async fn append_all(
1046        &self,
1047        stream_key: &str,
1048        payloads: Vec<Vec<u8>>,
1049        expected_seq: u64,
1050    ) -> Result<u64, DurabilityError> {
1051        self.store()?
1052            .append_all(stream_key, payloads, expected_seq)
1053            .await
1054    }
1055
1056    async fn read_from(
1057        &self,
1058        stream_key: &str,
1059        offset: u64,
1060        limit: usize,
1061    ) -> Result<Vec<StoredEntry>, DurabilityError> {
1062        self.store()?.read_from(stream_key, offset, limit).await
1063    }
1064
1065    async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
1066        self.store()?.cas(key, old_value, new_value).await
1067    }
1068
1069    async fn read_stream_next_seq(&self, stream_key: &str) -> Result<Option<u64>, DurabilityError> {
1070        self.store()?.read_stream_next_seq(stream_key).await
1071    }
1072
1073    async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
1074        self.store()?.read_value(key).await
1075    }
1076
1077    async fn scan_page(
1078        &self,
1079        prefix: &str,
1080        cursor: Option<ScanCursor>,
1081        limit: usize,
1082    ) -> Result<ScanPage, DurabilityError> {
1083        self.store()?.scan_page(prefix, cursor, limit).await
1084    }
1085
1086    async fn flush(&self) -> Result<(), DurabilityError> {
1087        self.store()?.flush().await
1088    }
1089
1090    async fn truncate_before(
1091        &self,
1092        stream_key: &str,
1093        before_seq: u64,
1094    ) -> Result<u64, DurabilityError> {
1095        self.store()?.truncate_before(stream_key, before_seq).await
1096    }
1097
1098    async fn scan_routed(
1099        &self,
1100        route_key: &str,
1101        from: &[u8],
1102        to: &[u8],
1103    ) -> Result<Vec<(Vec<u8>, Vec<u8>)>, DurabilityError> {
1104        self.store()?.scan_routed(route_key, from, to).await
1105    }
1106
1107    async fn remove_routed_range(
1108        &self,
1109        route_key: &str,
1110        from: &[u8],
1111        to: &[u8],
1112    ) -> Result<u64, DurabilityError> {
1113        self.store()?.remove_routed_range(route_key, from, to).await
1114    }
1115}
1116
1117/// Test construction seam for a self-owning ephemeral haematite store under a fresh temporary
1118/// directory below the system temp dir.
1119///
1120/// The directory is created BEFORE [`Database::create`], so every failure path —
1121/// including a haematite open/create error — removes it when the guard drops on
1122/// the error return; the returned store owns the guard on success. The database
1123/// is created directly in the (empty) temporary directory: haematite's `create`
1124/// accepts an existing empty dir and, on failure, removes only a directory *it*
1125/// created, never this pre-existing guard dir (haematite 0.4.1
1126/// `db/startup.rs`), so the `TempDir` is the sole owner of directory lifetime on
1127/// every path.
1128///
1129/// # Errors
1130/// Returns [`DurabilityError::EphemeralStoreOpen`] if haematite cannot create the
1131/// database; the temporary directory is already removed when this returns.
1132#[cfg(any(test, feature = "test-support"))]
1133pub fn open_ephemeral(shard_count: usize) -> Result<EphemeralHaematiteStore, DurabilityError> {
1134    open_ephemeral_in(ephemeral_tempdir(&std::env::temp_dir())?, shard_count)
1135}
1136
1137/// TEST SEAM: [`open_ephemeral`] with the temporary directory placed under
1138/// `root` instead of the system temp dir.
1139///
1140/// Rooting lets construction gates assert on an isolated directory instead of
1141/// scanning the shared temp dir. Same lifecycle contract as
1142/// [`open_ephemeral`] — the store owns and removes its directory; `root` must
1143/// already exist and must outlive the store.
1144///
1145/// This test seam leaves ownership of the parent with its caller. Production
1146/// stores instead retain an [`super::EphemeralRoot`] and its lock until every
1147/// store has closed, so the parent cannot be swept under a live store.
1148///
1149/// # Errors
1150/// Returns [`DurabilityError::EphemeralStoreOpen`] if the directory cannot be
1151/// created under `root` or haematite cannot create the database; no residue
1152/// remains under `root` when this returns an error.
1153#[cfg(any(test, feature = "test-support"))]
1154pub fn open_ephemeral_rooted(
1155    root: &Path,
1156    shard_count: usize,
1157) -> Result<EphemeralHaematiteStore, DurabilityError> {
1158    open_ephemeral_in(ephemeral_tempdir(root)?, shard_count)
1159}
1160
1161pub(super) fn open_ephemeral_owned(
1162    root: Arc<super::EphemeralRoot>,
1163    shard_count: usize,
1164) -> Result<EphemeralHaematiteStore, DurabilityError> {
1165    let mut store = open_ephemeral_in(ephemeral_tempdir(root.path())?, shard_count)?;
1166    let path = store.ephemeral_dir_path_owned()?;
1167    let holder = super::ephemeral_root::lock_file(&path.join("holder.lock"))?;
1168    rustix::fs::flock(
1169        &holder,
1170        rustix::fs::FlockOperation::NonBlockingLockExclusive,
1171    )
1172    .map_err(super::ephemeral_root::root_error)?;
1173    store.guard.ownership = Some(super::ephemeral_root::EphemeralOwnership { root, holder });
1174    Ok(store)
1175}
1176
1177/// Creates the guard directory for an ephemeral store under its chosen root.
1178fn ephemeral_tempdir(root: &Path) -> Result<TempDir, DurabilityError> {
1179    let mut builder = tempfile::Builder::new();
1180    builder.prefix("liminal-durability-");
1181    builder.tempdir_in(root).map_err(|error| {
1182        DurabilityError::EphemeralStoreOpen(format!(
1183            "could not create temporary directory: {error}"
1184        ))
1185    })
1186}
1187
1188/// Opens an ephemeral store inside an already-created guard directory.
1189///
1190/// Split out so the guard exists before `Database::create` and so lifecycle
1191/// tests can inject an open failure into a directory they pre-populated.
1192fn open_ephemeral_in(
1193    ephemeral_dir: TempDir,
1194    shard_count: usize,
1195) -> Result<EphemeralHaematiteStore, DurabilityError> {
1196    let database = Database::create(
1197        DatabaseConfig {
1198            data_dir: ephemeral_dir.path().to_path_buf(),
1199            shard_count,
1200            distributed: None,
1201            executor_threads: None,
1202            // haematite 0.8.3 requires this field and refuses `None` at validation;
1203            // `Unlimited` is the crate's explicit spelling of the pre-budget
1204            // (0.8.1) behaviour, said out loud — the behaviour-preserving choice
1205            // for a dependency hop. Selecting a real byte ceiling is a deployment
1206            // decision (box size, shard count, leaf weight) that belongs to its
1207            // own measured lane, not to this bump.
1208            node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
1209            retention: haematite::Retention::reclaim(0),
1210        },
1211        haematite::RuntimeOptions {
1212            long_wait_interval: std::time::Duration::from_secs(5),
1213            reclaim_start: haematite::ReclaimStart::AtOpen,
1214        },
1215    )
1216    .map_err(|error| DurabilityError::EphemeralStoreOpen(error.to_string()))?;
1217    super::counting::record_store_opened();
1218    Ok(EphemeralHaematiteStore::new(database, ephemeral_dir))
1219}
1220
1221/// Engine-read accounting for the paged-read shape (#60).
1222///
1223/// Counts what the ENGINE handed back, which is the quantity the page limit is
1224/// supposed to bound. A `DurableStore` decorator cannot see it: by the time a
1225/// wrapper observes the result it has already been cut to `limit`, so the
1226/// difference between "read one page" and "read the whole suffix and throw it
1227/// away" is invisible from outside this type.
1228#[cfg(test)]
1229#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1230pub(crate) struct EngineReadAccounting {
1231    /// `read_from` calls made while the guard was live.
1232    pub(crate) calls: usize,
1233    /// Entries the engine returned, summed before any truncation to `limit`.
1234    pub(crate) engine_entries: usize,
1235    /// Calls that fell through to the unbounded engine read.
1236    pub(crate) unbounded_calls: usize,
1237    /// Stream metadata entries the engine enumerated for prefix scans.
1238    pub(crate) streams_enumerated: usize,
1239    /// Set when a counter would have wrapped; a saturated count is never a pin.
1240    pub(crate) counter_overflow_observed: bool,
1241}
1242
1243#[cfg(test)]
1244std::thread_local! {
1245    static ENGINE_READ_ACCOUNTING: std::cell::RefCell<Option<EngineReadAccounting>> =
1246        const { std::cell::RefCell::new(None) };
1247}
1248
1249/// Scopes engine-read accounting to one thread and one measured region.
1250///
1251/// `!Send` so accounting cannot straddle a thread boundary and report a sum
1252/// whose addends came from different call stacks.
1253#[cfg(test)]
1254pub(crate) struct EngineReadAccountingGuard {
1255    _not_send: std::marker::PhantomData<*const ()>,
1256}
1257
1258#[cfg(test)]
1259impl EngineReadAccountingGuard {
1260    pub(crate) fn start() -> Self {
1261        ENGINE_READ_ACCOUNTING.with(|accounting| {
1262            *accounting.borrow_mut() = Some(EngineReadAccounting::default());
1263        });
1264        Self {
1265            _not_send: std::marker::PhantomData,
1266        }
1267    }
1268
1269    #[allow(clippy::unused_self)]
1270    pub(crate) fn snapshot(&self) -> EngineReadAccounting {
1271        ENGINE_READ_ACCOUNTING
1272            .with(|accounting| accounting.borrow().as_ref().copied().unwrap_or_default())
1273    }
1274}
1275
1276#[cfg(test)]
1277impl Drop for EngineReadAccountingGuard {
1278    fn drop(&mut self) {
1279        ENGINE_READ_ACCOUNTING.with(|accounting| {
1280            *accounting.borrow_mut() = None;
1281        });
1282    }
1283}
1284
1285/// Records one engine read. A no-op when no guard is live.
1286#[cfg(test)]
1287fn account_engine_read(engine_entries: usize, unbounded: bool) {
1288    ENGINE_READ_ACCOUNTING.with(|accounting| {
1289        if let Some(active) = accounting.borrow_mut().as_mut() {
1290            match (
1291                active.calls.checked_add(1),
1292                active.engine_entries.checked_add(engine_entries),
1293            ) {
1294                (Some(calls), Some(entries)) => {
1295                    active.calls = calls;
1296                    active.engine_entries = entries;
1297                }
1298                _ => active.counter_overflow_observed = true,
1299            }
1300            if unbounded {
1301                match active.unbounded_calls.checked_add(1) {
1302                    Some(unbounded_calls) => active.unbounded_calls = unbounded_calls,
1303                    None => active.counter_overflow_observed = true,
1304                }
1305            }
1306        }
1307    });
1308}
1309
1310#[cfg(not(test))]
1311const fn account_engine_read(_engine_entries: usize, _unbounded: bool) {}
1312
1313/// Records stream metadata entries a prefix scan enumerated. A no-op when no
1314/// guard is live.
1315#[cfg(test)]
1316fn account_stream_enumeration(streams: usize) {
1317    ENGINE_READ_ACCOUNTING.with(|accounting| {
1318        if let Some(active) = accounting.borrow_mut().as_mut() {
1319            match active.streams_enumerated.checked_add(streams) {
1320                Some(enumerated) => active.streams_enumerated = enumerated,
1321                None => active.counter_overflow_observed = true,
1322            }
1323        }
1324    });
1325}
1326
1327#[cfg(not(test))]
1328const fn account_stream_enumeration(_streams: usize) {}
1329
1330/// Reference paging over an in-memory stream map, for the crate's fake stores.
1331///
1332/// Shared rather than re-derived per fixture so the fakes cannot drift from
1333/// each other — or from [`HaematiteStore::scan_page`], whose cursor, ordering
1334/// and short-page rules this mirrors exactly. Entries are selected by their
1335/// `sequence` field rather than by position, so a fixture seeding a sparse or
1336/// offset stream is paged honestly.
1337#[cfg(test)]
1338pub(crate) fn page_in_memory_streams(
1339    streams: &std::collections::HashMap<String, Vec<StoredEntry>>,
1340    prefix: &str,
1341    cursor: Option<&ScanCursor>,
1342    limit: usize,
1343) -> Result<ScanPage, DurabilityError> {
1344    if limit == 0 {
1345        return Err(DurabilityError::ConfigError(
1346            "scan_page limit must be nonzero".to_owned(),
1347        ));
1348    }
1349    let mut stream_keys: Vec<&String> = streams
1350        .keys()
1351        .filter(|stream_key| stream_key.starts_with(prefix))
1352        .collect();
1353    stream_keys.sort_unstable();
1354
1355    let mut entries: Vec<StoredEntry> = Vec::new();
1356    let mut resume_at: Option<ScanCursor> = None;
1357    for stream_key in stream_keys {
1358        let Some(remaining) = limit.checked_sub(entries.len()).filter(|rest| *rest > 0) else {
1359            break;
1360        };
1361        let offset = match cursor {
1362            Some(cursor) if *stream_key < cursor.stream_key => continue,
1363            Some(cursor) if *stream_key == cursor.stream_key => cursor.next_sequence,
1364            _ => 0,
1365        };
1366        let Some(stream) = streams.get(stream_key) else {
1367            continue;
1368        };
1369        let page: Vec<StoredEntry> = stream
1370            .iter()
1371            .filter(|entry| entry.sequence >= offset)
1372            .take(remaining)
1373            .cloned()
1374            .collect();
1375        if let Some(last) = page.last() {
1376            let next_sequence = last.sequence.checked_add(1).ok_or_else(|| {
1377                DurabilityError::ConfigError(format!(
1378                    "fake scan page sequence overflow for stream {stream_key}"
1379                ))
1380            })?;
1381            resume_at = Some(ScanCursor {
1382                stream_key: stream_key.clone(),
1383                next_sequence,
1384            });
1385        }
1386        entries.extend(page);
1387    }
1388
1389    let next = if entries.len() == limit {
1390        resume_at
1391    } else {
1392        None
1393    };
1394    Ok(ScanPage { entries, next })
1395}
1396
1397impl From<Event> for StoredEntry {
1398    fn from(event: Event) -> Self {
1399        Self {
1400            payload: event.payload,
1401            sequence: event.seq,
1402            timestamp: event.timestamp,
1403        }
1404    }
1405}
1406
1407/// Maps a real-engine [`ApiError`] onto liminal's [`DurabilityError`].
1408///
1409/// The optimistic-concurrency variants route to their dedicated `DurabilityError`
1410/// cases (`SequenceConflict`, `CursorRegression`); everything else is a
1411/// store-level failure carried verbatim.
1412/// haematite's refusal of a routed batch of entries, as the store's own: a
1413/// hash precondition that failed is the refusal a put-only batch answers,
1414/// an unconfirmed publication is the unknown outcome the caller reads back
1415/// (`settle`), and every other refusal wrote nothing and is named as it is.
1416fn local_batch_error(error: haematite::LocalBatchError) -> DurabilityError {
1417    match error {
1418        haematite::LocalBatchError::CasMismatch { expected, actual } => {
1419            DurabilityError::StoreError(ApiError::Storage(
1420                haematite::DatabaseError::CasHashMismatch { expected, actual },
1421            ))
1422        }
1423        haematite::LocalBatchError::OutcomeUnknown { shard, .. } => {
1424            DurabilityError::OutcomeUnknown {
1425                pid: u64::try_from(shard).unwrap_or(u64::MAX),
1426            }
1427        }
1428        other => DurabilityError::ConfigError(format!("routed batch refused: {other}")),
1429    }
1430}
1431
1432impl From<ApiError> for DurabilityError {
1433    fn from(error: ApiError) -> Self {
1434        match error {
1435            ApiError::SequenceConflict(conflict) => conflict.into(),
1436            ApiError::CasMismatch(mismatch) => mismatch.into(),
1437            ApiError::Storage(haematite::DatabaseError::OutcomeUnknown { pid }) => {
1438                Self::OutcomeUnknown { pid }
1439            }
1440            other @ (ApiError::CorruptEvent(_)
1441            | ApiError::Storage(_)
1442            | ApiError::HistoryCompacted(_)) => Self::StoreError(other),
1443        }
1444    }
1445}
1446
1447#[cfg(test)]
1448#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1449mod ephemeral_lifecycle_tests {
1450    //! D3 §9 lifecycle gate. Each test names the gate it pins; all are permanent
1451    //! rule-1 assertions that the ephemeral store's directory has an enforced
1452    //! owner across every teardown path.
1453
1454    use std::path::{Path, PathBuf};
1455    use std::sync::{Arc, Mutex};
1456
1457    use super::super::bridge::block_on;
1458    use super::{
1459        DurableStore, EphemeralGuard, open_ephemeral, open_ephemeral_in, open_ephemeral_rooted,
1460    };
1461
1462    const TEST_SHARD_COUNT: usize = 2;
1463
1464    /// In-memory `tracing` sink, so a test can assert on what the teardown path
1465    /// LOGGED rather than on what it merely did.
1466    ///
1467    /// Every teardown assertion below runs against this one instrument, and
1468    /// [`panic_path_leak_is_logged_with_its_path`] is its positive control: it
1469    /// exercises the SAME predicate (`captured` contains the path and `ERROR`)
1470    /// against a log line that is emitted today. Without that control an empty
1471    /// capture would only measure the harness.
1472    #[derive(Clone, Default)]
1473    struct CapturedLog(Arc<Mutex<Vec<u8>>>);
1474
1475    impl CapturedLog {
1476        /// Everything written to the sink so far, as text.
1477        fn text(&self) -> String {
1478            let bytes = self
1479                .0
1480                .lock()
1481                .expect("capture buffer is not poisoned")
1482                .clone();
1483            String::from_utf8(bytes).expect("tracing's fmt writer emits utf-8")
1484        }
1485
1486        /// Runs `body` with this sink receiving everything the CURRENT THREAD
1487        /// logs, via one process-global subscriber and a thread-routed writer.
1488        ///
1489        /// Why not `tracing::subscriber::with_default`: a scoped subscriber
1490        /// registers a dispatcher on entry and deregisters it on exit, and
1491        /// tracing maintains global state (the per-callsite interest cache and
1492        /// the max-level hint) that is rebuilt on those edges. That produced a
1493        /// measured intermittently-EMPTY capture in this module — 3/40
1494        /// module-scoped runs raw; serializing the windows on a mutex cured
1495        /// the module-scoped loop (0/40) but the full-workspace battery still
1496        /// reproduced the empty capture with the mutex in place, so edge
1497        /// timing was not the whole mechanism. This design removes the CLASS:
1498        /// the global subscriber is installed exactly once and never
1499        /// deregistered, so no edge ever exists to re-poison the caches, and
1500        /// routing is thread-local so parallel tests cannot cross-capture.
1501        fn capturing<R>(&self, body: impl FnOnce() -> R) -> R {
1502            static INSTALL: std::sync::Once = std::sync::Once::new();
1503            /// Clears the thread's capture slot even when `body` unwinds.
1504            struct ResetOnDrop;
1505            impl Drop for ResetOnDrop {
1506                fn drop(&mut self) {
1507                    ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = None);
1508                }
1509            }
1510            INSTALL.call_once(|| {
1511                let subscriber = tracing_subscriber::fmt()
1512                    .with_writer(RoutedWriter)
1513                    .with_ansi(false)
1514                    .finish();
1515                tracing::subscriber::set_global_default(subscriber)
1516                    .expect("no other global tracing subscriber is installed in this test binary");
1517            });
1518            ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = Some(self.clone()));
1519            let _reset = ResetOnDrop;
1520            body()
1521        }
1522    }
1523
1524    thread_local! {
1525        /// The capture buffer receiving THIS thread's log output, if a
1526        /// [`CapturedLog::capturing`] window is active on it.
1527        static ACTIVE_CAPTURE: std::cell::RefCell<Option<CapturedLog>> =
1528            const { std::cell::RefCell::new(None) };
1529    }
1530
1531    /// The one writer the process-global subscriber owns: appends to the
1532    /// emitting thread's active capture buffer, and silently discards output
1533    /// from threads with no capture window open.
1534    #[derive(Clone, Copy, Default)]
1535    struct RoutedWriter;
1536
1537    impl std::io::Write for RoutedWriter {
1538        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1539            ACTIVE_CAPTURE.with(|slot| {
1540                if let Some(capture) = slot.borrow().as_ref() {
1541                    capture
1542                        .0
1543                        .lock()
1544                        .map_err(|_| std::io::Error::other("capture buffer poisoned"))?
1545                        .extend_from_slice(buf);
1546                }
1547                Ok(buf.len())
1548            })
1549        }
1550
1551        fn flush(&mut self) -> std::io::Result<()> {
1552            Ok(())
1553        }
1554    }
1555
1556    impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for RoutedWriter {
1557        type Writer = Self;
1558
1559        fn make_writer(&'writer self) -> Self::Writer {
1560            *self
1561        }
1562    }
1563
1564    /// Sets `path`'s mode, used to make a parent directory unwritable so that
1565    /// removing a directory INSIDE it fails at the final `rmdir`.
1566    ///
1567    /// That is the observed production failure shape: the contents go, the
1568    /// directory itself stays, and the removal error is the only witness.
1569    #[cfg(unix)]
1570    fn set_mode(path: &Path, mode: u32) {
1571        use std::os::unix::fs::PermissionsExt;
1572
1573        std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
1574            .expect("test can set permissions on a directory it created");
1575    }
1576
1577    /// Store stand-in whose `Drop` pins the guard's internal ordering: the
1578    /// directory must still exist at store-drop time, so this drop FAILS the
1579    /// test if the guard ever removes the directory first.
1580    struct OrderProbeStore {
1581        dir: PathBuf,
1582    }
1583
1584    impl Drop for OrderProbeStore {
1585        fn drop(&mut self) {
1586            assert!(
1587                self.dir.exists(),
1588                "the guard must drop the store BEFORE removing the directory"
1589            );
1590        }
1591    }
1592
1593    /// Store stand-in whose `Drop` panics, modelling a haematite worker failing
1594    /// to join while the database closes.
1595    struct PanickingProbeStore;
1596
1597    impl Drop for PanickingProbeStore {
1598        fn drop(&mut self) {
1599            panic!("injected store-drop panic");
1600        }
1601    }
1602
1603    /// Materialises shard directories and fds so the drop path actually has a
1604    /// live database to close before the guard removes the directory.
1605    fn write_one_event(store: &dyn DurableStore) {
1606        block_on(store.append("lifecycle/probe", b"payload".to_vec(), 0))
1607            .expect("bridge completes synchronously")
1608            .expect("append to a fresh ephemeral stream succeeds");
1609        block_on(store.flush())
1610            .expect("bridge completes synchronously")
1611            .expect("flush of a live ephemeral store succeeds");
1612    }
1613
1614    /// §9 gate — normal drop: the directory is removed once the last (here, only)
1615    /// handle drops.
1616    #[test]
1617    fn ephemeral_dir_removed_after_last_handle_drops() {
1618        let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
1619        let dir = store
1620            .ephemeral_dir_path()
1621            .expect("ephemeral store carries a guard dir")
1622            .to_path_buf();
1623        assert!(
1624            dir.exists(),
1625            "the guard directory exists while the store is live"
1626        );
1627
1628        write_one_event(&store);
1629        drop(store);
1630
1631        assert!(
1632            !dir.exists(),
1633            "the guard directory is removed on normal drop"
1634        );
1635    }
1636
1637    /// §9 gate — teardown with store-handle clones alive: the directory survives
1638    /// until the LAST `Arc<dyn DurableStore>` clone drops, then is removed. This
1639    /// is the `Arc`-shared-into-channel-handles case: clones share one wrapper,
1640    /// so none can close the database early.
1641    #[test]
1642    fn ephemeral_dir_survives_until_last_store_clone_drops() {
1643        let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
1644        let dir = store
1645            .ephemeral_dir_path()
1646            .expect("ephemeral store carries a guard dir")
1647            .to_path_buf();
1648        write_one_event(&store);
1649
1650        let erased: Arc<dyn DurableStore> = Arc::new(store);
1651        let clone_a = Arc::clone(&erased);
1652        let clone_b = Arc::clone(&erased);
1653
1654        drop(erased);
1655        assert!(
1656            dir.exists(),
1657            "directory survives while store clones remain alive"
1658        );
1659        drop(clone_a);
1660        assert!(
1661            dir.exists(),
1662            "directory survives while one store clone remains alive"
1663        );
1664
1665        drop(clone_b);
1666        assert!(
1667            !dir.exists(),
1668            "the last store clone dropping removes the directory"
1669        );
1670    }
1671
1672    /// §9 gate — startup rollback: an injected haematite open failure (a
1673    /// conflicting `config.json` pre-seeded into the guard dir) makes the
1674    /// constructor return `Err` AND leaves zero residue — the guard removes the
1675    /// directory independently of haematite's own cleanup.
1676    #[test]
1677    fn ephemeral_open_failure_rolls_back_directory() {
1678        let seeded = tempfile::Builder::new()
1679            .prefix("liminal-durability-test-")
1680            .tempdir()
1681            .expect("test can create a temp dir");
1682        let dir = seeded.path().to_path_buf();
1683        // A pre-existing `config.json` makes haematite refuse the create with
1684        // `DataDirAlreadyInitialised`; because the dir pre-existed the create,
1685        // haematite never removes it — only the guard does.
1686        std::fs::write(dir.join("config.json"), b"not-a-valid-config")
1687            .expect("test can seed a conflicting config");
1688
1689        let result = open_ephemeral_in(seeded, TEST_SHARD_COUNT);
1690
1691        assert!(result.is_err(), "an injected open failure returns Err");
1692        assert!(
1693            !dir.exists(),
1694            "the guard removes the directory on open failure — zero residue"
1695        );
1696    }
1697
1698    /// §9 gate — repeated start/stop: each cycle owns a distinct directory and
1699    /// leaves zero residue after it drops.
1700    #[test]
1701    fn repeated_ephemeral_cycles_each_own_distinct_dir_zero_residue() {
1702        let mut seen: Vec<PathBuf> = Vec::new();
1703        for _ in 0..5 {
1704            let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
1705            let dir = store
1706                .ephemeral_dir_path()
1707                .expect("ephemeral store carries a guard dir")
1708                .to_path_buf();
1709            assert!(
1710                dir.exists(),
1711                "the cycle's directory exists while its store is live"
1712            );
1713            assert!(!seen.contains(&dir), "each cycle owns a distinct directory");
1714            seen.push(dir.clone());
1715
1716            write_one_event(&store);
1717            drop(store);
1718            assert!(
1719                !dir.exists(),
1720                "the cycle's directory is removed after its store drops"
1721            );
1722        }
1723    }
1724
1725    /// §9 gate (drop-order pin): the guard drops the store strictly before it
1726    /// removes the directory. `OrderProbeStore::drop` asserts the directory
1727    /// still exists, so reversing the order inside [`EphemeralGuard`] fails this
1728    /// test rather than silently passing.
1729    #[test]
1730    fn guard_drops_store_before_removing_directory() {
1731        let dir = tempfile::tempdir().expect("test can create a temp dir");
1732        let path = dir.path().to_path_buf();
1733        let guard = EphemeralGuard {
1734            ownership: None,
1735            store: Some(OrderProbeStore { dir: path.clone() }),
1736            dir: Some(dir),
1737        };
1738
1739        drop(guard);
1740
1741        assert!(!path.exists(), "a clean drop still removes the directory");
1742    }
1743
1744    /// §9 gate (unwind pin): a panic while the store drops leaves the directory
1745    /// LEAKED, never removed under possibly-live workers, and the panic still
1746    /// propagates.
1747    #[test]
1748    fn guard_leaks_directory_when_store_drop_panics() {
1749        let dir = tempfile::tempdir().expect("test can create a temp dir");
1750        let path = dir.path().to_path_buf();
1751        let guard = EphemeralGuard {
1752            ownership: None,
1753            store: Some(PanickingProbeStore),
1754            dir: Some(dir),
1755        };
1756
1757        let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(guard)));
1758
1759        assert!(unwound.is_err(), "the injected store-drop panic propagates");
1760        assert!(
1761            path.exists(),
1762            "a panicking store drop leaks the directory instead of removing it"
1763        );
1764        std::fs::remove_dir_all(&path).expect("test cleans up the deliberately leaked directory");
1765    }
1766
1767    /// The rooted factory places (and removes) the guard directory under the
1768    /// caller-supplied root, which is what lets construction gates assert on an
1769    /// isolated root instead of scanning the system temp dir.
1770    #[test]
1771    fn rooted_ephemeral_store_lives_and_dies_under_the_given_root() {
1772        let root = tempfile::tempdir().expect("test can create a temp root");
1773        let store =
1774            open_ephemeral_rooted(root.path(), TEST_SHARD_COUNT).expect("rooted open succeeds");
1775        let dir = store
1776            .ephemeral_dir_path()
1777            .expect("ephemeral store carries a guard dir")
1778            .to_path_buf();
1779        assert!(
1780            dir.starts_with(root.path()),
1781            "the guard directory is created under the supplied root"
1782        );
1783
1784        write_one_event(&store);
1785        drop(store);
1786
1787        assert!(!dir.exists(), "the rooted directory is removed on drop");
1788    }
1789
1790    /// Clean-teardown gate (keepalive-honest shape): the guard directory is
1791    /// present for the store's WHOLE life — re-checked between unrelated
1792    /// operations that each succeed — and gone once the store drops cleanly.
1793    ///
1794    /// The "unrelated ops proceed" leg is what makes the final absence mean
1795    /// something: a directory that vanished early would take the appends,
1796    /// reads and CAS down with it, so this cannot pass by removing the
1797    /// directory too soon and cannot pass by never having created it.
1798    #[test]
1799    fn ephemeral_dir_persists_across_unrelated_work_then_goes_on_clean_drop() {
1800        let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
1801        let dir = store
1802            .ephemeral_dir_path()
1803            .expect("ephemeral store carries a guard dir")
1804            .to_path_buf();
1805        assert!(
1806            dir.exists(),
1807            "the directory exists as soon as the store does"
1808        );
1809
1810        for round in 0..3_u64 {
1811            block_on(store.append("clean-teardown/probe", b"payload".to_vec(), round))
1812                .expect("bridge completes synchronously")
1813                .expect("append to a live ephemeral store succeeds");
1814            assert!(
1815                dir.exists(),
1816                "the directory is still there after append round {round}"
1817            );
1818        }
1819        block_on(store.cas("clean-teardown/counter", 0, 7))
1820            .expect("bridge completes synchronously")
1821            .expect("cas on a live ephemeral store succeeds");
1822        let entries = block_on(store.read_from("clean-teardown/probe", 0, 10))
1823            .expect("bridge completes synchronously")
1824            .expect("read from a live ephemeral store succeeds");
1825        assert_eq!(entries.len(), 3, "every appended entry is readable back");
1826        assert!(
1827            dir.exists(),
1828            "the directory is still there after unrelated cas and read work"
1829        );
1830
1831        block_on(store.flush())
1832            .expect("bridge completes synchronously")
1833            .expect("flush of a live ephemeral store succeeds");
1834        drop(store);
1835
1836        assert!(
1837            !dir.exists(),
1838            "the clean drop removes the directory it kept alive throughout"
1839        );
1840    }
1841
1842    /// Clean-teardown gate: when removal FAILS on the clean path the guard
1843    /// LOGS the failure and its path, and does not panic.
1844    ///
1845    /// Injected the way it fails in production: the parent is made unwritable,
1846    /// so `remove_dir_all` clears the contents and then cannot unlink the
1847    /// directory itself. `tempfile`'s own `Drop` discards that error
1848    /// (`let _ = remove_dir_all(..)`), which is why this pin is red until the
1849    /// clean path calls `close()` and reports what it returns.
1850    #[cfg(unix)]
1851    #[test]
1852    fn clean_drop_removal_failure_is_logged_and_never_panics() {
1853        let parent = tempfile::tempdir().expect("test can create a temp parent");
1854        let dir = tempfile::Builder::new()
1855            .prefix("liminal-durability-")
1856            .tempdir_in(parent.path())
1857            .expect("test can create a guard dir under the parent");
1858        let path = dir.path().to_path_buf();
1859        let guard = EphemeralGuard {
1860            ownership: None,
1861            store: Some(()),
1862            dir: Some(dir),
1863        };
1864
1865        set_mode(parent.path(), 0o500);
1866        let captured = CapturedLog::default();
1867        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1868            captured.capturing(|| drop(guard));
1869        }));
1870        // Restored before the assertions so a failing assertion still leaves a
1871        // parent the outer `TempDir` can clean up.
1872        set_mode(parent.path(), 0o700);
1873
1874        assert!(
1875            outcome.is_ok(),
1876            "a removal failure is reported, never raised as a panic"
1877        );
1878        let logged = captured.text();
1879        assert!(
1880            logged.contains("ERROR"),
1881            "the removal failure is logged at error level; captured: {logged:?}"
1882        );
1883        assert!(
1884            logged.contains(&path.display().to_string()),
1885            "the log names the directory that survived; captured: {logged:?}"
1886        );
1887        assert!(
1888            path.exists(),
1889            "the residue is left where the log says it is, not silently claimed removed"
1890        );
1891    }
1892
1893    /// Clean-teardown gate (negative control for the capture instrument): a
1894    /// removal that SUCCEEDS logs nothing, so the assertion above discriminates
1895    /// failure from success rather than matching any teardown at all.
1896    #[test]
1897    fn clean_drop_that_succeeds_logs_nothing() {
1898        let dir = tempfile::tempdir().expect("test can create a temp dir");
1899        let path = dir.path().to_path_buf();
1900        let guard = EphemeralGuard {
1901            ownership: None,
1902            store: Some(()),
1903            dir: Some(dir),
1904        };
1905
1906        let captured = CapturedLog::default();
1907        captured.capturing(|| drop(guard));
1908
1909        assert!(!path.exists(), "the successful clean drop removed the dir");
1910        assert!(
1911            captured.text().is_empty(),
1912            "a successful removal is silent; captured: {:?}",
1913            captured.text()
1914        );
1915    }
1916
1917    /// Positive control for the capture instrument: the panic path's sanctioned
1918    /// leak line IS captured, path and all, by the same predicate the
1919    /// removal-failure gate uses.
1920    ///
1921    /// Without this, an empty capture would be a measurement of the harness
1922    /// rather than of the code under test.
1923    #[test]
1924    fn panic_path_leak_is_logged_with_its_path() {
1925        let dir = tempfile::tempdir().expect("test can create a temp dir");
1926        let path = dir.path().to_path_buf();
1927        let guard = EphemeralGuard {
1928            ownership: None,
1929            store: Some(PanickingProbeStore),
1930            dir: Some(dir),
1931        };
1932
1933        let captured = CapturedLog::default();
1934        let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1935            captured.capturing(|| drop(guard));
1936        }));
1937
1938        assert!(unwound.is_err(), "the injected store-drop panic propagates");
1939        let logged = captured.text();
1940        assert!(
1941            logged.contains("ERROR"),
1942            "the sanctioned leak is logged at error level; captured: {logged:?}"
1943        );
1944        assert!(
1945            logged.contains(&path.display().to_string()),
1946            "the leak log names the leaked directory; captured: {logged:?}"
1947        );
1948        std::fs::remove_dir_all(&path).expect("test cleans up the deliberately leaked directory");
1949    }
1950}
1951
1952#[cfg(test)]
1953#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1954#[path = "store_paged_read_shape_tests.rs"]
1955mod paged_read_shape_tests;
1956
1957#[cfg(test)]
1958#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1959mod paged_scan_tests {
1960    //! The prefix scan's bounded contract, against the real haematite-backed
1961    //! ephemeral store rather than a fake, so the pins exercise the engine path
1962    //! the production callers take.
1963    //!
1964    //! `scan_page` bounds the entry axis by its limit and the stream axis by
1965    //! its cursor: each page enumerates only the streams after the one it
1966    //! resumes in, so a drained walk lists every matching stream once.
1967
1968    use crate::durability::tests_fixtures;
1969    use std::collections::HashSet;
1970
1971    use super::{
1972        DurabilityError, DurableStore, EngineReadAccountingGuard, ScanCursor, StoredEntry,
1973        page_in_memory_streams,
1974    };
1975    use crate::durability::bridge::block_on;
1976
1977    const PREFIX: &str = "liminal/scan-bound/";
1978    /// Three streams, so a page boundary can fall inside one of them and the
1979    /// stream-ordering rule has something to order.
1980    const STREAMS: [&str; 3] = [
1981        "liminal/scan-bound/alpha",
1982        "liminal/scan-bound/bravo",
1983        "liminal/scan-bound/charlie",
1984    ];
1985    /// Entries per stream. Deliberately not equal across streams so a page that
1986    /// silently assumed uniform stream length would be caught.
1987    const ROWS_PER_STREAM: [u64; 3] = [7, 11, 5];
1988
1989    /// Payload naming the stream and sequence it was written at, so a drained
1990    /// page can be checked for identity, not merely for count.
1991    fn payload(stream: &str, sequence: u64) -> Vec<u8> {
1992        format!("{stream}#{sequence}").into_bytes()
1993    }
1994
1995    /// Every payload the seed writes, as the census a full drain must equal.
1996    fn seed_census() -> Vec<Vec<u8>> {
1997        let mut census = Vec::new();
1998        for (stream, rows) in STREAMS.iter().zip(ROWS_PER_STREAM) {
1999            for sequence in 0..rows {
2000                census.push(payload(stream, sequence));
2001            }
2002        }
2003        census
2004    }
2005
2006    fn seeded() -> Result<impl DurableStore, Box<dyn std::error::Error>> {
2007        tests_fixtures::copied("paged-scan")
2008    }
2009
2010    /// A page never exceeds its limit, and a page that fills reports a resume
2011    /// position. The seed spans three streams and more rows than the limit, so
2012    /// the limit is the only thing that can stop this page.
2013    #[test]
2014    fn a_full_scan_page_stops_at_its_limit_and_reports_where_to_resume()
2015    -> Result<(), Box<dyn std::error::Error>> {
2016        const LIMIT: usize = 9;
2017        let store = seeded()?;
2018
2019        let page = block_on(store.scan_page(PREFIX, None, LIMIT))??;
2020
2021        assert_eq!(
2022            page.entries.len(),
2023            LIMIT,
2024            "a seed larger than the limit must fill exactly one page"
2025        );
2026        let cursor = page
2027            .next
2028            .ok_or("a filled page must report a resume position")?;
2029        // 9 entries with alpha holding 7 means the boundary lands inside bravo,
2030        // two entries in — the mid-stream split the resume rule exists for.
2031        assert_eq!(cursor.stream_key, STREAMS[1]);
2032        assert_eq!(cursor.next_sequence, 2);
2033        Ok(())
2034    }
2035
2036    /// Draining every page reproduces the seed exactly: no losses, no
2037    /// duplicates, and each stream's entries still in sequence order.
2038    #[test]
2039    fn draining_every_page_delivers_the_seed_exactly_once() -> Result<(), Box<dyn std::error::Error>>
2040    {
2041        let store = seeded()?;
2042        let census = seed_census();
2043
2044        // Limits chosen to split mid-stream in different places, including 1
2045        // (every page boundary is a mid-stream boundary) and limits larger than
2046        // a single stream but smaller than the whole seed.
2047        for limit in [1_usize, 2, 4, 7, 8, 13, 23, 24] {
2048            let mut drained: Vec<Vec<u8>> = Vec::new();
2049            let mut per_stream: Vec<(String, u64)> = Vec::new();
2050            let mut cursor: Option<ScanCursor> = None;
2051            let mut pages = 0_usize;
2052            loop {
2053                let page = block_on(store.scan_page(PREFIX, cursor, limit))??;
2054                assert!(
2055                    page.entries.len() <= limit,
2056                    "limit {limit} page {pages} exceeded its limit"
2057                );
2058                for entry in &page.entries {
2059                    let text = String::from_utf8(entry.payload.clone())?;
2060                    let stream = text
2061                        .split_once('#')
2062                        .ok_or("seeded payload names its stream")?
2063                        .0
2064                        .to_owned();
2065                    per_stream.push((stream, entry.sequence));
2066                }
2067                drained.extend(page.entries.into_iter().map(|entry| entry.payload));
2068                pages = pages.checked_add(1).ok_or("page counter overflowed")?;
2069                assert!(pages <= 64, "limit {limit} did not terminate in 64 pages");
2070                let Some(next) = page.next else {
2071                    break;
2072                };
2073                cursor = Some(next);
2074            }
2075
2076            let drained_set: HashSet<Vec<u8>> = drained.iter().cloned().collect();
2077            assert_eq!(
2078                drained.len(),
2079                drained_set.len(),
2080                "limit {limit} delivered a duplicate entry"
2081            );
2082            assert_eq!(
2083                drained_set,
2084                census.iter().cloned().collect::<HashSet<_>>(),
2085                "limit {limit} did not deliver exactly the seeded entries"
2086            );
2087
2088            for stream in STREAMS {
2089                let sequences: Vec<u64> = per_stream
2090                    .iter()
2091                    .filter(|(seen, _)| seen == stream)
2092                    .map(|(_, sequence)| *sequence)
2093                    .collect();
2094                assert!(
2095                    sequences.windows(2).all(|pair| pair[0] < pair[1]),
2096                    "limit {limit} broke sequence order within {stream}: {sequences:?}"
2097                );
2098            }
2099        }
2100        Ok(())
2101    }
2102
2103    /// A page shorter than its limit ends the scan, and the empty page a
2104    /// filled-to-the-last-entry walk produces is a legal terminator too.
2105    #[test]
2106    fn a_short_page_ends_the_scan() -> Result<(), Box<dyn std::error::Error>> {
2107        let store = seeded()?;
2108        let total = usize::try_from(ROWS_PER_STREAM.iter().sum::<u64>())?;
2109
2110        let whole =
2111            block_on(store.scan_page(PREFIX, None, total.checked_add(1).ok_or("overflow")?))??;
2112        assert_eq!(
2113            whole.entries.len(),
2114            total,
2115            "the whole seed fits in one page"
2116        );
2117        assert!(
2118            whole.next.is_none(),
2119            "a page short of its limit terminates the scan"
2120        );
2121
2122        // Exactly-full: the page cannot know it is the last, so it reports a
2123        // resume position, and the follow-up empty page terminates instead.
2124        let exact = block_on(store.scan_page(PREFIX, None, total))??;
2125        assert_eq!(exact.entries.len(), total);
2126        let cursor = exact
2127            .next
2128            .ok_or("an exactly-full page reports a resume position")?;
2129        let terminator = block_on(store.scan_page(PREFIX, Some(cursor), total))??;
2130        assert!(
2131            terminator.entries.is_empty(),
2132            "the resume past the last entry finds nothing"
2133        );
2134        assert!(
2135            terminator.next.is_none(),
2136            "a final empty page is a legal terminator"
2137        );
2138
2139        // A prefix nothing matches is the same terminator on the first call.
2140        let unmatched = block_on(store.scan_page("liminal/no-such-prefix/", None, 8))??;
2141        assert!(unmatched.entries.is_empty());
2142        assert!(unmatched.next.is_none());
2143        Ok(())
2144    }
2145
2146    /// Page order follows stream key byte order, not the order the streams were
2147    /// discovered in — the property the cursor's "streams before mine are done"
2148    /// rule rests on.
2149    ///
2150    /// Pinned against [`page_in_memory_streams`] rather than the engine on
2151    /// purpose. The real impl takes its order from haematite's `scan_prefix`,
2152    /// whose contract is ascending key order, so a pin over the real store
2153    /// would test the engine rather than this rule. A `HashMap`'s iteration order is
2154    /// genuinely arbitrary and reseeded per process, so this pin has a source of
2155    /// disorder to discriminate against; with 32 streams an accidentally sorted
2156    /// traversal is not a case worth budgeting for.
2157    #[test]
2158    fn a_page_orders_streams_by_key_not_by_discovery_order()
2159    -> Result<(), Box<dyn std::error::Error>> {
2160        const STREAM_COUNT: usize = 32;
2161        let mut streams = std::collections::HashMap::new();
2162        for index in 0..STREAM_COUNT {
2163            let stream_key = format!("{PREFIX}stream-{index:02}");
2164            let entry = StoredEntry {
2165                payload: stream_key.clone().into_bytes(),
2166                sequence: 0,
2167                timestamp: 0,
2168            };
2169            streams.insert(stream_key, vec![entry]);
2170        }
2171
2172        let page = page_in_memory_streams(&streams, PREFIX, None, STREAM_COUNT)?;
2173
2174        let delivered: Vec<String> = page
2175            .entries
2176            .into_iter()
2177            .map(|entry| String::from_utf8(entry.payload))
2178            .collect::<Result<_, _>>()?;
2179        let mut expected = delivered.clone();
2180        expected.sort_unstable();
2181        assert_eq!(
2182            delivered, expected,
2183            "a page must deliver its streams in key order"
2184        );
2185        Ok(())
2186    }
2187
2188    /// A walk drained page by page enumerates each matching stream once, not
2189    /// once per page: a page resumes the stream enumeration after its cursor
2190    /// instead of listing the whole prefix again, so a walk of K streams costs
2191    /// one metadata pass over them however many pages it takes.
2192    #[test]
2193    fn a_drained_walk_enumerates_each_stream_once() -> Result<(), Box<dyn std::error::Error>> {
2194        const WALK_STREAMS: usize = 48;
2195        const LIMIT: usize = 5;
2196        let store = tests_fixtures::copied("paged-walk")?;
2197
2198        let accounting = EngineReadAccountingGuard::start();
2199        let mut cursor = None;
2200        let mut delivered = 0_usize;
2201        let mut pages = 0_usize;
2202        loop {
2203            let page = block_on(store.scan_page(PREFIX, cursor, LIMIT))??;
2204            pages += 1;
2205            delivered += page.entries.len();
2206            match page.next {
2207                Some(next) => cursor = Some(next),
2208                None => break,
2209            }
2210        }
2211        let walk = accounting.snapshot();
2212        drop(accounting);
2213
2214        assert_eq!(delivered, WALK_STREAMS, "the walk delivers every stream");
2215        assert!(!walk.counter_overflow_observed, "{walk:?}");
2216        assert!(
2217            walk.streams_enumerated <= WALK_STREAMS + pages,
2218            "a walk of {WALK_STREAMS} streams in {pages} pages enumerated {} streams",
2219            walk.streams_enumerated
2220        );
2221        Ok(())
2222    }
2223
2224    /// A zero limit has no page to fill and is a typed refusal, never a silent
2225    /// empty answer that a drain loop would read as the end of the scan.
2226    #[test]
2227    fn a_zero_limit_is_refused() -> Result<(), Box<dyn std::error::Error>> {
2228        let store = seeded()?;
2229
2230        let refused = block_on(store.scan_page(PREFIX, None, 0))?;
2231
2232        assert!(
2233            matches!(refused, Err(DurabilityError::ConfigError(_))),
2234            "a zero limit is refused as a configuration error, got {refused:?}"
2235        );
2236        Ok(())
2237    }
2238}
2239
2240#[cfg(test)]
2241mod truncate_cost_tests {
2242    //! A prune far into a stream's history does engine work in proportion to
2243    //! the rows it deletes, not to the sequence it cuts at (SRV-014 R7).
2244    //!
2245    //! haematite 0.15.0 staged a truncate by walking every sequence from 0 to
2246    //! the cut, so the self streams' prune grew with their history. The pinned
2247    //! rev reads only the stream's present entries. The pin counts the engine's
2248    //! node reads (cache hits included) across one prune of a few rows at a
2249    //! sequence far past them.
2250
2251    use std::error::Error;
2252    use std::sync::Arc;
2253
2254    use super::super::bridge::block_on;
2255    use super::{Database, DatabaseConfig, DurableStore, EventStore, HaematiteStore};
2256
2257    const STREAM: &str = "truncate-cost";
2258    /// The stream's history before the prune under test.
2259    const HISTORY: u64 = 10_000;
2260    /// Rows appended per batch while the history is written.
2261    const BATCH: u64 = 1_000;
2262    /// Rows still present when the prune under test runs.
2263    const PRESENT: u64 = 10;
2264    /// Node reads one prune may take beyond one per deleted row: the path
2265    /// down the tree, the commit, and the reclaim of the nodes it replaces.
2266    const OVERHEAD: u64 = 64;
2267
2268    #[test]
2269    fn a_prune_far_into_history_reads_only_what_it_deletes() -> Result<(), Box<dyn Error>> {
2270        let dir = tempfile::tempdir()?;
2271        let database = Database::create(
2272            DatabaseConfig {
2273                data_dir: dir.path().to_path_buf(),
2274                shard_count: 1,
2275                distributed: None,
2276                executor_threads: None,
2277                node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
2278                retention: haematite::Retention::reclaim(0),
2279            },
2280            haematite::RuntimeOptions {
2281                long_wait_interval: std::time::Duration::from_secs(5),
2282                reclaim_start: haematite::ReclaimStart::AtOpen,
2283            },
2284        )?;
2285        let events = Arc::new(EventStore::new(database));
2286        let store = HaematiteStore::new(Arc::clone(&events));
2287        let batch: Vec<Vec<u8>> = (0..BATCH).map(|_| vec![0x07]).collect();
2288        let mut next = 0;
2289        while next < HISTORY {
2290            block_on(store.append_all(STREAM, batch.clone(), next))??;
2291            next += BATCH;
2292        }
2293        block_on(store.truncate_before(STREAM, HISTORY - PRESENT))??;
2294
2295        let before = events.database().store_counts().node_gets;
2296        let deleted = block_on(store.truncate_before(STREAM, HISTORY))??;
2297        let reads = events.database().store_counts().node_gets - before;
2298        assert_eq!(
2299            (deleted, reads <= PRESENT + OVERHEAD),
2300            (PRESENT, true),
2301            "a prune of {deleted} rows at sequence {HISTORY} read {reads} nodes; at most \
2302             {PRESENT} plus {OVERHEAD} is owed: the prune walked the history, not the rows"
2303        );
2304        drop(store);
2305        drop(events);
2306        drop(dir);
2307        Ok(())
2308    }
2309}
2310
2311#[cfg(test)]
2312#[path = "store_batch_tests.rs"]
2313mod batch_tests;