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