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