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;
7
8use tempfile::TempDir;
9
10/// Entry read from a durable haematite stream.
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct StoredEntry {
13    /// Opaque stored payload bytes.
14    pub payload: Vec<u8>,
15    /// Sequence number assigned by the stream.
16    pub sequence: u64,
17    /// Store timestamp associated with the entry.
18    pub timestamp: u64,
19}
20
21/// Direct durability surface matching haematite's append/read/cas/scan API.
22#[async_trait::async_trait]
23pub trait DurableStore: std::fmt::Debug + Send + Sync {
24    /// Appends `payload` to `stream_key` if `expected_seq` matches the stream head.
25    async fn append(
26        &self,
27        stream_key: &str,
28        payload: Vec<u8>,
29        expected_seq: u64,
30    ) -> Result<u64, DurabilityError>;
31
32    /// Reads entries from `stream_key` beginning at `offset`, up to `limit` entries.
33    async fn read_from(
34        &self,
35        stream_key: &str,
36        offset: u64,
37        limit: usize,
38    ) -> Result<Vec<StoredEntry>, DurabilityError>;
39
40    /// Reads exactly the event at `sequence` without traversing its suffix.
41    async fn read_at(
42        &self,
43        stream_key: &str,
44        sequence: u64,
45    ) -> Result<Option<StoredEntry>, DurabilityError> {
46        Ok(self
47            .read_from(stream_key, sequence, 1)
48            .await?
49            .into_iter()
50            .next())
51    }
52
53    /// Atomically replaces a stored numeric value if it equals `old_value`.
54    ///
55    /// An `old_value` of `0` matches a key that is currently *absent* as well as
56    /// one explicitly stored as `0`: a fresh cursor is created on its first
57    /// checkpoint without a prior write. See [`HaematiteStore::cas`] for how this
58    /// "absent == 0" contract is preserved atomically over the real engine.
59    async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError>;
60
61    /// Reads a numeric value previously updated through compare-and-swap.
62    async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError>;
63
64    /// Scans entries by store prefix.
65    async fn scan(&self, prefix: &str) -> Result<Vec<StoredEntry>, DurabilityError>;
66
67    /// Flushes buffered writes so completed durable operations are persisted.
68    ///
69    /// # Errors
70    /// Returns [`DurabilityError`] when the underlying store cannot complete the flush.
71    async fn flush(&self) -> Result<(), DurabilityError>;
72}
73
74/// `DurableStore` implementation that delegates directly to haematite's `EventStore`.
75///
76/// The real [`EventStore`] is synchronous (every call blocks on the owning
77/// shard actor's reply), so each `async` method below completes on its first
78/// poll. The synchronous bridge in [`super::bridge`] relies on exactly that.
79#[derive(Clone, Debug)]
80pub struct HaematiteStore {
81    event_store: Arc<EventStore>,
82}
83
84impl HaematiteStore {
85    /// Wraps a haematite `EventStore` handle.
86    #[must_use]
87    pub const fn new(event_store: Arc<EventStore>) -> Self {
88        Self { event_store }
89    }
90
91    /// Reads the half-open key window `[offset, offset + limit)` from one
92    /// stream, or `None` when the window did not fill.
93    ///
94    /// `None` is not "empty": it is "this window cannot answer on its own",
95    /// and the caller must fall through to the unbounded engine read. A window
96    /// short by even one row may be short because the stream ended, because
97    /// history was compacted, or because an entry inside it expired, and only
98    /// the engine's own read distinguishes those.
99    ///
100    /// `limit` must be nonzero; a zero limit has no window to fill and is the
101    /// caller's fall-through case.
102    fn bounded_page(
103        &self,
104        stream_key: &str,
105        offset: u64,
106        limit: usize,
107    ) -> Result<Option<Vec<StoredEntry>>, DurabilityError> {
108        const TIMESTAMP_WIDTH: usize = std::mem::size_of::<u64>();
109
110        // Engine keys are 1-based; the public API is 0-based.
111        let Some(engine_from) = offset.checked_add(1) else {
112            return Ok(None);
113        };
114        let Some(engine_end) = u64::try_from(limit)
115            .ok()
116            .and_then(|limit| engine_from.checked_add(limit))
117        else {
118            return Ok(None);
119        };
120        let key = stream_key.as_bytes();
121        let from = haematite::encode_stream_key(key, engine_from);
122        let to = haematite::encode_stream_key(key, engine_end);
123        let entries = self
124            .event_store
125            .database()
126            .range_routed(key, &from, &to)
127            .map_err(ApiError::from)
128            .map_err(DurabilityError::from)?;
129        if entries.len() != limit {
130            return Ok(None);
131        }
132
133        let mut page = Vec::with_capacity(entries.len());
134        for (encoded_key, value) in entries {
135            let Some((decoded_key, engine_sequence)) = haematite::decode_stream_key(&encoded_key)
136            else {
137                return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
138                    format!("paged read key does not encode an event for stream {stream_key}"),
139                )));
140            };
141            if decoded_key != key {
142                return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
143                    format!("paged read key does not encode stream {stream_key}"),
144                )));
145            }
146            let sequence = engine_sequence.checked_sub(1).ok_or_else(|| {
147                DurabilityError::StoreError(ApiError::CorruptEvent(format!(
148                    "paged read event key has zero seq for stream {stream_key}"
149                )))
150            })?;
151            let Some(timestamp_bytes) = value.get(..TIMESTAMP_WIDTH) else {
152                return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
153                    format!(
154                        "paged read event value is shorter than its timestamp for stream {stream_key}"
155                    ),
156                )));
157            };
158            let timestamp = u64::from_be_bytes(timestamp_bytes.try_into().map_err(|_| {
159                DurabilityError::StoreError(ApiError::CorruptEvent(format!(
160                    "paged read event timestamp has the wrong width for stream {stream_key}"
161                )))
162            })?);
163            let Some(payload) = value.get(TIMESTAMP_WIDTH..) else {
164                return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
165                    format!("paged read event has no payload boundary for stream {stream_key}"),
166                )));
167            };
168            page.push(StoredEntry {
169                payload: payload.to_vec(),
170                sequence,
171                timestamp,
172            });
173        }
174        Ok(Some(page))
175    }
176}
177
178#[async_trait::async_trait]
179impl DurableStore for HaematiteStore {
180    async fn append(
181        &self,
182        stream_key: &str,
183        payload: Vec<u8>,
184        expected_seq: u64,
185    ) -> Result<u64, DurabilityError> {
186        // Contract bridge: liminal's `DurableStore::append` returns the *assigned
187        // event sequence* (0-based position of the just-appended event), which is
188        // exactly `expected_seq` for a single append. The real `EventStore::append`
189        // instead returns the stream's new next-sequence (`expected_seq + 1`), so
190        // subtract one to recover the assigned seq. A `0` next-seq is impossible
191        // after a successful single append, so the `checked_sub` cannot saturate
192        // silently; if it ever did the engine returned a contract-violating value.
193        let next_seq = self
194            .event_store
195            .append(stream_key.as_bytes(), &payload, expected_seq)
196            .map_err(DurabilityError::from)?;
197        next_seq.checked_sub(1).ok_or_else(|| {
198            DurabilityError::StoreError(ApiError::CorruptEvent(format!(
199                "append returned next-seq 0 for stream {stream_key}"
200            )))
201        })
202    }
203
204    async fn read_from(
205        &self,
206        stream_key: &str,
207        offset: u64,
208        limit: usize,
209    ) -> Result<Vec<StoredEntry>, DurabilityError> {
210        // `EventStore::read_from` applies no limit: it materialises every event
211        // with seq >= offset, key and value copied across the shard-actor
212        // boundary, and truncating afterwards throws that work away. Paged
213        // replay therefore costs O(N^2) engine rows to deliver N (#60).
214        //
215        // Ask the engine for the page instead. Event keys are
216        // `stream_key || 0x00 || seq.to_be_bytes()` (haematite 0.8.1
217        // `api/event_store.rs:375`), so byte order is sequence order and a
218        // half-open key window names exactly one page. `range_routed` routes on
219        // the stream key — the same co-location `EventStore` uses for its own
220        // reads — and merges committed tree with WAL buffer, which is the
221        // identical mechanism behind the unbounded read (`db.rs:212`).
222        //
223        // A FULL window is the same answer the unbounded read gave: it holds
224        // `limit` live events, and key order makes those exactly the first
225        // `limit` events at or after `offset`. Anything SHORT falls through to
226        // the unbounded read, so the two answers the window cannot settle by
227        // itself stay the engine's own: the `HistoryCompacted` verdict at
228        // `offset == 0`, and the case where expiry or compaction leaves a hole
229        // inside the window. The fall-through costs a suffix scan only where
230        // the suffix is already shorter than a page — the end-of-stream read
231        // that terminates every walk.
232        if limit > 0 {
233            if let Some(page) = self.bounded_page(stream_key, offset, limit)? {
234                account_engine_read(page.len(), false);
235                return Ok(page);
236            }
237        }
238        let mut events = self
239            .event_store
240            .read_from(stream_key.as_bytes(), offset)
241            .map_err(DurabilityError::from)?;
242        account_engine_read(events.len(), true);
243        events.truncate(limit);
244        Ok(events.into_iter().map(StoredEntry::from).collect())
245    }
246
247    async fn read_at(
248        &self,
249        stream_key: &str,
250        sequence: u64,
251    ) -> Result<Option<StoredEntry>, DurabilityError> {
252        const TIMESTAMP_WIDTH: usize = std::mem::size_of::<u64>();
253
254        let engine_sequence = sequence.checked_add(1).ok_or_else(|| {
255            DurabilityError::StoreError(ApiError::CorruptEvent(format!(
256                "point read sequence overflow for stream {stream_key}"
257            )))
258        })?;
259        let event_key = haematite::encode_stream_key(stream_key.as_bytes(), engine_sequence);
260        let Some(value) = self
261            .event_store
262            .database()
263            .get_routed(stream_key.as_bytes(), &event_key)
264            .map_err(ApiError::from)
265            .map_err(DurabilityError::from)?
266        else {
267            return Ok(None);
268        };
269        let Some(timestamp_bytes) = value.get(..TIMESTAMP_WIDTH) else {
270            return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
271                format!(
272                    "point-read event value is shorter than its timestamp for stream {stream_key}"
273                ),
274            )));
275        };
276        let timestamp = u64::from_be_bytes(timestamp_bytes.try_into().map_err(|_| {
277            DurabilityError::StoreError(ApiError::CorruptEvent(format!(
278                "point-read event timestamp has the wrong width for stream {stream_key}"
279            )))
280        })?);
281        let Some(payload) = value.get(TIMESTAMP_WIDTH..) else {
282            return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
283                format!("point-read event has no payload boundary for stream {stream_key}"),
284            )));
285        };
286        Ok(Some(StoredEntry {
287            payload: payload.to_vec(),
288            sequence,
289            timestamp,
290        }))
291    }
292
293    async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
294        // Preserve liminal's "absent == 0" cursor contract faithfully over an
295        // engine that distinguishes `None` (absent) from `Some(0)` (a stored
296        // zero). The invariant that makes the mapping below correct: we NEVER
297        // persist a physical zero, so a logical value of 0 and physical absence
298        // always coincide.
299        //
300        // A `cas` whose target `new_value` is 0 must therefore write nothing — it
301        // only asserts the precondition. This is reachable as `cas(0, 0)` (a
302        // cursor checkpoint at offset 0; offsets are monotonic so they never CAS
303        // down to 0 from a higher value). Were we instead to let it store a
304        // physical zero, the *next* `cas(0, n)` — mapped to expect-absent `None`
305        // — would wrongly fail against the now-present key and permanently stall
306        // the cursor. Asserting via a read is race-free here precisely because no
307        // value is written, so there is no lost-update window.
308        if new_value == 0 {
309            return self
310                .event_store
311                .read_value(key.as_bytes())
312                .map_err(DurabilityError::from)?
313                .map_or(Ok(()), |stored| {
314                    Err(DurabilityError::CursorRegression {
315                        stored,
316                        attempted: old_value,
317                    })
318                });
319        }
320        // With a physical zero never stored, `old_value == 0` is exactly the
321        // expect-absent expectation. Any other `old_value` maps to `Some(_)`.
322        // This is a single CAS routed to the owning shard actor, where read,
323        // compare, and write run with no interleaving point (haematite's
324        // `ShardActor::cas`) — the engine's atomicity is preserved end to end.
325        let expected = if old_value == 0 {
326            None
327        } else {
328            Some(old_value)
329        };
330        self.event_store
331            .cas(key.as_bytes(), expected, new_value)
332            .map_err(DurabilityError::from)
333    }
334
335    async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
336        self.event_store
337            .read_value(key.as_bytes())
338            .map_err(DurabilityError::from)
339    }
340
341    async fn scan(&self, prefix: &str) -> Result<Vec<StoredEntry>, DurabilityError> {
342        // The real `scan` predicate yields stream *metadata* (key + next_seq),
343        // not events. Liminal's contract is to return the events of every stream
344        // whose key matches `prefix`, so collect the matching stream keys, then
345        // read each stream's full event list and flatten the results.
346        let prefix_bytes = prefix.as_bytes().to_vec();
347        let matches = self
348            .event_store
349            .scan(|meta| meta.stream_key.starts_with(&prefix_bytes))
350            .map_err(DurabilityError::from)?;
351        let mut entries = Vec::new();
352        for stream in matches {
353            let events = self
354                .event_store
355                .read(&stream.stream_key)
356                .map_err(DurabilityError::from)?;
357            entries.extend(events.into_iter().map(StoredEntry::from));
358        }
359        Ok(entries)
360    }
361
362    async fn flush(&self) -> Result<(), DurabilityError> {
363        self.event_store.flush().map_err(DurabilityError::from)
364    }
365}
366
367/// Drop shell enforcing "close the store, then remove its directory" as
368/// explicit code rather than field declaration order.
369///
370/// Declaration order alone cannot express the unwind case: if dropping the
371/// store panics (a haematite worker failing to join), Rust would still drop
372/// the remaining fields during the unwind and remove the directory under
373/// possibly-live workers. This `Drop` drops the store inside `catch_unwind`;
374/// on unwind it DISARMS the directory guard — the directory is deliberately
375/// leaked, because visible residue is diagnosable while removal under live
376/// workers is filesystem corruption — logs the leaked path, and re-raises the
377/// panic. On the clean path the directory is removed after the store, HERE,
378/// by an explicit [`TempDir::close`] whose error is logged.
379///
380/// The explicitness is the point. Letting the `TempDir` field drop instead
381/// would remove the directory via `tempfile`'s own `Drop`, which is
382/// `let _ = remove_dir_all(..)` — the `io::Result` is discarded, so a removal
383/// that FAILED would be indistinguishable from one that succeeded and this
384/// doc's "the directory is removed" would be a claim no code could check.
385/// `close()` returns that error; the clean path reports it and leaves the
386/// residue where the log says it is. It never panics (a `Drop` that unwinds
387/// during another unwind aborts the process) and never masks: a failure to
388/// remove is a durability fact, not something to swallow.
389///
390/// Both fields are `Option` only so `drop` can move them out; they are `Some`
391/// for the shell's entire life outside `drop`.
392#[derive(Debug)]
393struct EphemeralGuard<S> {
394    store: Option<S>,
395    dir: Option<TempDir>,
396}
397
398impl<S> Drop for EphemeralGuard<S> {
399    fn drop(&mut self) {
400        let store = self.store.take();
401        // AssertUnwindSafe: the closure owns everything it touches (the moved
402        // store), and the unwind path below observes no state the panicking
403        // drop could have left broken — it only disarms the guard and re-raises.
404        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(store)));
405        if let Err(panic) = outcome {
406            if let Some(dir) = self.dir.take() {
407                let leaked = dir.keep();
408                tracing::error!(
409                    path = %leaked.display(),
410                    "ephemeral store drop panicked; leaking its directory rather than \
411                     removing it under possibly-live database workers"
412                );
413            }
414            std::panic::resume_unwind(panic);
415        }
416        // Clean path: the store is closed, its workers are joined and the
417        // writer lock is released, so removing the directory now is safe — and
418        // removing it EXPLICITLY is what makes a failure sayable.
419        if let Some(dir) = self.dir.take() {
420            let path = dir.path().to_path_buf();
421            if let Err(error) = dir.close() {
422                tracing::error!(
423                    path = %path.display(),
424                    %error,
425                    "ephemeral store directory removal failed; residue remains at the \
426                     logged path"
427                );
428            }
429        }
430    }
431}
432
433/// Exclusive-ownership ephemeral durable store: the sole owner of both the
434/// haematite database and the temporary directory that backs it.
435///
436/// [`HaematiteStore::new`] takes a *caller-supplied* `Arc<EventStore>`, so a
437/// clone of that inner handle can outlive any guard placed merely beside it —
438/// field declaration order proves nothing across that `Arc` boundary. This
439/// wrapper instead owns the database outright: [`open_ephemeral`] constructs the
440/// inner `Arc` itself, this type never exposes it (no getter) and is deliberately
441/// **not `Clone`**, so the only handle a caller can hold is an
442/// `Arc<dyn DurableStore>` over the whole wrapper. When the last such clone
443/// drops, the [`EphemeralGuard`] drops the store FIRST — the database closes,
444/// its shard actors join and the data-dir writer lock releases on fd close —
445/// and only then removes the directory, logging the error if that removal
446/// fails; if closing the database panics, the directory is deliberately leaked
447/// instead (see [`EphemeralGuard`]).
448#[derive(Debug)]
449pub struct EphemeralHaematiteStore {
450    guard: EphemeralGuard<HaematiteStore>,
451}
452
453impl EphemeralHaematiteStore {
454    /// Takes an already-open ephemeral `Database` and the temporary directory it
455    /// was opened under, becoming their single exclusive owner.
456    ///
457    /// The inner `Arc<EventStore>` is created here and never leaves this type, so
458    /// no caller-supplied clone of it can exist to defeat the drop ordering.
459    /// `ephemeral_dir` must be the directory `database` lives in and must have
460    /// been created before the database was opened (so a failed open removed it
461    /// via the guard's `Drop`, before this constructor was ever reached).
462    fn new(database: Database, ephemeral_dir: TempDir) -> Self {
463        Self {
464            guard: EphemeralGuard {
465                store: Some(HaematiteStore::new(Arc::new(EventStore::new(database)))),
466                dir: Some(ephemeral_dir),
467            },
468        }
469    }
470
471    /// Store handle behind the guard's teardown-only `Option`.
472    ///
473    /// `None` exists only inside [`EphemeralGuard::drop`], which cannot overlap
474    /// a `&self` call, so this error is unreachable by construction — it is a
475    /// typed refusal in place of a panic the workspace forbids, not a state a
476    /// caller can produce.
477    fn store(&self) -> Result<&HaematiteStore, DurabilityError> {
478        self.guard
479            .store
480            .as_ref()
481            .ok_or(DurabilityError::EphemeralStoreDetached)
482    }
483
484    /// Path of the guarding temporary directory, for lifecycle assertions only.
485    #[cfg(test)]
486    pub(crate) fn ephemeral_dir_path(&self) -> Option<&Path> {
487        self.guard.dir.as_ref().map(TempDir::path)
488    }
489}
490
491#[async_trait::async_trait]
492impl DurableStore for EphemeralHaematiteStore {
493    async fn append(
494        &self,
495        stream_key: &str,
496        payload: Vec<u8>,
497        expected_seq: u64,
498    ) -> Result<u64, DurabilityError> {
499        self.store()?
500            .append(stream_key, payload, expected_seq)
501            .await
502    }
503
504    async fn read_from(
505        &self,
506        stream_key: &str,
507        offset: u64,
508        limit: usize,
509    ) -> Result<Vec<StoredEntry>, DurabilityError> {
510        self.store()?.read_from(stream_key, offset, limit).await
511    }
512
513    async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
514        self.store()?.cas(key, old_value, new_value).await
515    }
516
517    async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
518        self.store()?.read_value(key).await
519    }
520
521    async fn scan(&self, prefix: &str) -> Result<Vec<StoredEntry>, DurabilityError> {
522        self.store()?.scan(prefix).await
523    }
524
525    async fn flush(&self) -> Result<(), DurabilityError> {
526        self.store()?.flush().await
527    }
528}
529
530/// Opens a self-owning ephemeral haematite store under a fresh temporary
531/// directory below the system temp dir.
532///
533/// The directory is created BEFORE [`Database::create`], so every failure path —
534/// including a haematite open/create error — removes it when the guard drops on
535/// the error return; the returned store owns the guard on success. The database
536/// is created directly in the (empty) temporary directory: haematite's `create`
537/// accepts an existing empty dir and, on failure, removes only a directory *it*
538/// created, never this pre-existing guard dir (haematite 0.4.1
539/// `db/startup.rs`), so the `TempDir` is the sole owner of directory lifetime on
540/// every path.
541///
542/// # Errors
543/// Returns [`DurabilityError::EphemeralStoreOpen`] if haematite cannot create the
544/// database; the temporary directory is already removed when this returns.
545pub fn open_ephemeral(shard_count: usize) -> Result<EphemeralHaematiteStore, DurabilityError> {
546    open_ephemeral_in(ephemeral_tempdir(None)?, shard_count)
547}
548
549/// TEST SEAM: [`open_ephemeral`] with the temporary directory placed under
550/// `root` instead of the system temp dir.
551///
552/// Rooting lets construction gates assert on an isolated directory instead of
553/// scanning the shared temp dir. Same lifecycle contract as
554/// [`open_ephemeral`] — the store owns and removes its directory; `root` must
555/// already exist and must outlive the store.
556///
557/// That last requirement is why this is NOT a production API: the store's
558/// exclusive ownership of its directory (the D3 invariant) says nothing about
559/// the PARENT — a caller rooting the store inside a directory they own via
560/// their own guard can drop that guard while the store is live, deleting the
561/// database out from under its running workers. A general rooted API would
562/// need a root-ownership token so parent cleanup cannot outrun the store;
563/// that is deferred until a real embedder need arrives. Until then the
564/// function is gated to tests (`cfg(test)` in this crate, the default-off
565/// `test-support` feature for downstream test harnesses).
566///
567/// # Errors
568/// Returns [`DurabilityError::EphemeralStoreOpen`] if the directory cannot be
569/// created under `root` or haematite cannot create the database; no residue
570/// remains under `root` when this returns an error.
571#[cfg(any(test, feature = "test-support"))]
572pub fn open_ephemeral_rooted(
573    root: &Path,
574    shard_count: usize,
575) -> Result<EphemeralHaematiteStore, DurabilityError> {
576    open_ephemeral_in(ephemeral_tempdir(Some(root))?, shard_count)
577}
578
579/// Creates the guard directory for an ephemeral store, under `root` when given
580/// and under the system temp dir otherwise.
581fn ephemeral_tempdir(root: Option<&Path>) -> Result<TempDir, DurabilityError> {
582    let mut builder = tempfile::Builder::new();
583    builder.prefix("liminal-durability-");
584    root.map_or_else(|| builder.tempdir(), |root| builder.tempdir_in(root))
585        .map_err(|error| {
586            DurabilityError::EphemeralStoreOpen(format!(
587                "could not create temporary directory: {error}"
588            ))
589        })
590}
591
592/// Opens an ephemeral store inside an already-created guard directory.
593///
594/// Split out so the guard exists before `Database::create` and so lifecycle
595/// tests can inject an open failure into a directory they pre-populated.
596fn open_ephemeral_in(
597    ephemeral_dir: TempDir,
598    shard_count: usize,
599) -> Result<EphemeralHaematiteStore, DurabilityError> {
600    let database = Database::create(DatabaseConfig {
601        data_dir: ephemeral_dir.path().to_path_buf(),
602        shard_count,
603        distributed: None,
604        executor_threads: None,
605        // haematite 0.8.3 requires this field and refuses `None` at validation;
606        // `Unlimited` is the crate's explicit spelling of the pre-budget
607        // (0.8.1) behaviour, said out loud — the behaviour-preserving choice
608        // for a dependency hop. Selecting a real byte ceiling is a deployment
609        // decision (box size, shard count, leaf weight) that belongs to its
610        // own measured lane, not to this bump.
611        node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
612    })
613    .map_err(|error| DurabilityError::EphemeralStoreOpen(error.to_string()))?;
614    Ok(EphemeralHaematiteStore::new(database, ephemeral_dir))
615}
616
617/// Engine-read accounting for the paged-read shape (#60).
618///
619/// Counts what the ENGINE handed back, which is the quantity the page limit is
620/// supposed to bound. A `DurableStore` decorator cannot see it: by the time a
621/// wrapper observes the result it has already been cut to `limit`, so the
622/// difference between "read one page" and "read the whole suffix and throw it
623/// away" is invisible from outside this type.
624#[cfg(test)]
625#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
626pub(crate) struct EngineReadAccounting {
627    /// `read_from` calls made while the guard was live.
628    pub(crate) calls: usize,
629    /// Entries the engine returned, summed before any truncation to `limit`.
630    pub(crate) engine_entries: usize,
631    /// Calls that fell through to the unbounded engine read.
632    pub(crate) unbounded_calls: usize,
633    /// Set when a counter would have wrapped; a saturated count is never a pin.
634    pub(crate) counter_overflow_observed: bool,
635}
636
637#[cfg(test)]
638std::thread_local! {
639    static ENGINE_READ_ACCOUNTING: std::cell::RefCell<Option<EngineReadAccounting>> =
640        const { std::cell::RefCell::new(None) };
641}
642
643/// Scopes engine-read accounting to one thread and one measured region.
644///
645/// `!Send` so accounting cannot straddle a thread boundary and report a sum
646/// whose addends came from different call stacks.
647#[cfg(test)]
648pub(crate) struct EngineReadAccountingGuard {
649    _not_send: std::marker::PhantomData<*const ()>,
650}
651
652#[cfg(test)]
653impl EngineReadAccountingGuard {
654    pub(crate) fn start() -> Self {
655        ENGINE_READ_ACCOUNTING.with(|accounting| {
656            *accounting.borrow_mut() = Some(EngineReadAccounting::default());
657        });
658        Self {
659            _not_send: std::marker::PhantomData,
660        }
661    }
662
663    #[allow(clippy::unused_self)]
664    pub(crate) fn snapshot(&self) -> EngineReadAccounting {
665        ENGINE_READ_ACCOUNTING
666            .with(|accounting| accounting.borrow().as_ref().copied().unwrap_or_default())
667    }
668}
669
670#[cfg(test)]
671impl Drop for EngineReadAccountingGuard {
672    fn drop(&mut self) {
673        ENGINE_READ_ACCOUNTING.with(|accounting| {
674            *accounting.borrow_mut() = None;
675        });
676    }
677}
678
679/// Records one engine read. A no-op when no guard is live.
680#[cfg(test)]
681fn account_engine_read(engine_entries: usize, unbounded: bool) {
682    ENGINE_READ_ACCOUNTING.with(|accounting| {
683        if let Some(active) = accounting.borrow_mut().as_mut() {
684            match (
685                active.calls.checked_add(1),
686                active.engine_entries.checked_add(engine_entries),
687            ) {
688                (Some(calls), Some(entries)) => {
689                    active.calls = calls;
690                    active.engine_entries = entries;
691                }
692                _ => active.counter_overflow_observed = true,
693            }
694            if unbounded {
695                match active.unbounded_calls.checked_add(1) {
696                    Some(unbounded_calls) => active.unbounded_calls = unbounded_calls,
697                    None => active.counter_overflow_observed = true,
698                }
699            }
700        }
701    });
702}
703
704#[cfg(not(test))]
705const fn account_engine_read(_engine_entries: usize, _unbounded: bool) {}
706
707impl From<Event> for StoredEntry {
708    fn from(event: Event) -> Self {
709        Self {
710            payload: event.payload,
711            sequence: event.seq,
712            timestamp: event.timestamp,
713        }
714    }
715}
716
717/// Maps a real-engine [`ApiError`] onto liminal's [`DurabilityError`].
718///
719/// The optimistic-concurrency variants route to their dedicated `DurabilityError`
720/// cases (`SequenceConflict`, `CursorRegression`); everything else is a
721/// store-level failure carried verbatim.
722impl From<ApiError> for DurabilityError {
723    fn from(error: ApiError) -> Self {
724        match error {
725            ApiError::SequenceConflict(conflict) => conflict.into(),
726            ApiError::CasMismatch(mismatch) => mismatch.into(),
727            other @ (ApiError::CorruptEvent(_)
728            | ApiError::Storage(_)
729            | ApiError::HistoryCompacted(_)) => Self::StoreError(other),
730        }
731    }
732}
733
734#[cfg(test)]
735#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
736mod ephemeral_lifecycle_tests {
737    //! D3 §9 lifecycle gate. Each test names the gate it pins; all are permanent
738    //! rule-1 assertions that the ephemeral store's directory has an enforced
739    //! owner across every teardown path.
740
741    use std::path::{Path, PathBuf};
742    use std::sync::{Arc, Mutex};
743
744    use super::super::bridge::block_on;
745    use super::{
746        DurableStore, EphemeralGuard, open_ephemeral, open_ephemeral_in, open_ephemeral_rooted,
747    };
748
749    const TEST_SHARD_COUNT: usize = 2;
750
751    /// In-memory `tracing` sink, so a test can assert on what the teardown path
752    /// LOGGED rather than on what it merely did.
753    ///
754    /// Every teardown assertion below runs against this one instrument, and
755    /// [`panic_path_leak_is_logged_with_its_path`] is its positive control: it
756    /// exercises the SAME predicate (`captured` contains the path and `ERROR`)
757    /// against a log line that is emitted today. Without that control an empty
758    /// capture would only measure the harness.
759    #[derive(Clone, Default)]
760    struct CapturedLog(Arc<Mutex<Vec<u8>>>);
761
762    impl CapturedLog {
763        /// Everything written to the sink so far, as text.
764        fn text(&self) -> String {
765            let bytes = self
766                .0
767                .lock()
768                .expect("capture buffer is not poisoned")
769                .clone();
770            String::from_utf8(bytes).expect("tracing's fmt writer emits utf-8")
771        }
772
773        /// Runs `body` with this sink receiving everything the CURRENT THREAD
774        /// logs, via one process-global subscriber and a thread-routed writer.
775        ///
776        /// Why not `tracing::subscriber::with_default`: a scoped subscriber
777        /// registers a dispatcher on entry and deregisters it on exit, and
778        /// tracing maintains global state (the per-callsite interest cache and
779        /// the max-level hint) that is rebuilt on those edges. That produced a
780        /// measured intermittently-EMPTY capture in this module — 3/40
781        /// module-scoped runs raw; serializing the windows on a mutex cured
782        /// the module-scoped loop (0/40) but the full-workspace battery still
783        /// reproduced the empty capture with the mutex in place, so edge
784        /// timing was not the whole mechanism. This design removes the CLASS:
785        /// the global subscriber is installed exactly once and never
786        /// deregistered, so no edge ever exists to re-poison the caches, and
787        /// routing is thread-local so parallel tests cannot cross-capture.
788        fn capturing<R>(&self, body: impl FnOnce() -> R) -> R {
789            static INSTALL: std::sync::Once = std::sync::Once::new();
790            /// Clears the thread's capture slot even when `body` unwinds.
791            struct ResetOnDrop;
792            impl Drop for ResetOnDrop {
793                fn drop(&mut self) {
794                    ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = None);
795                }
796            }
797            INSTALL.call_once(|| {
798                let subscriber = tracing_subscriber::fmt()
799                    .with_writer(RoutedWriter)
800                    .with_ansi(false)
801                    .finish();
802                tracing::subscriber::set_global_default(subscriber)
803                    .expect("no other global tracing subscriber is installed in this test binary");
804            });
805            ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = Some(self.clone()));
806            let _reset = ResetOnDrop;
807            body()
808        }
809    }
810
811    thread_local! {
812        /// The capture buffer receiving THIS thread's log output, if a
813        /// [`CapturedLog::capturing`] window is active on it.
814        static ACTIVE_CAPTURE: std::cell::RefCell<Option<CapturedLog>> =
815            const { std::cell::RefCell::new(None) };
816    }
817
818    /// The one writer the process-global subscriber owns: appends to the
819    /// emitting thread's active capture buffer, and silently discards output
820    /// from threads with no capture window open.
821    #[derive(Clone, Copy, Default)]
822    struct RoutedWriter;
823
824    impl std::io::Write for RoutedWriter {
825        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
826            ACTIVE_CAPTURE.with(|slot| {
827                if let Some(capture) = slot.borrow().as_ref() {
828                    capture
829                        .0
830                        .lock()
831                        .map_err(|_| std::io::Error::other("capture buffer poisoned"))?
832                        .extend_from_slice(buf);
833                }
834                Ok(buf.len())
835            })
836        }
837
838        fn flush(&mut self) -> std::io::Result<()> {
839            Ok(())
840        }
841    }
842
843    impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for RoutedWriter {
844        type Writer = Self;
845
846        fn make_writer(&'writer self) -> Self::Writer {
847            *self
848        }
849    }
850
851    /// Sets `path`'s mode, used to make a parent directory unwritable so that
852    /// removing a directory INSIDE it fails at the final `rmdir`.
853    ///
854    /// That is the observed production failure shape: the contents go, the
855    /// directory itself stays, and the removal error is the only witness.
856    #[cfg(unix)]
857    fn set_mode(path: &Path, mode: u32) {
858        use std::os::unix::fs::PermissionsExt;
859
860        std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
861            .expect("test can set permissions on a directory it created");
862    }
863
864    /// Store stand-in whose `Drop` pins the guard's internal ordering: the
865    /// directory must still exist at store-drop time, so this drop FAILS the
866    /// test if the guard ever removes the directory first.
867    struct OrderProbeStore {
868        dir: PathBuf,
869    }
870
871    impl Drop for OrderProbeStore {
872        fn drop(&mut self) {
873            assert!(
874                self.dir.exists(),
875                "the guard must drop the store BEFORE removing the directory"
876            );
877        }
878    }
879
880    /// Store stand-in whose `Drop` panics, modelling a haematite worker failing
881    /// to join while the database closes.
882    struct PanickingProbeStore;
883
884    impl Drop for PanickingProbeStore {
885        fn drop(&mut self) {
886            panic!("injected store-drop panic");
887        }
888    }
889
890    /// Materialises shard directories and fds so the drop path actually has a
891    /// live database to close before the guard removes the directory.
892    fn write_one_event(store: &dyn DurableStore) {
893        block_on(store.append("lifecycle/probe", b"payload".to_vec(), 0))
894            .expect("bridge completes synchronously")
895            .expect("append to a fresh ephemeral stream succeeds");
896        block_on(store.flush())
897            .expect("bridge completes synchronously")
898            .expect("flush of a live ephemeral store succeeds");
899    }
900
901    /// §9 gate — normal drop: the directory is removed once the last (here, only)
902    /// handle drops.
903    #[test]
904    fn ephemeral_dir_removed_after_last_handle_drops() {
905        let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
906        let dir = store
907            .ephemeral_dir_path()
908            .expect("ephemeral store carries a guard dir")
909            .to_path_buf();
910        assert!(
911            dir.exists(),
912            "the guard directory exists while the store is live"
913        );
914
915        write_one_event(&store);
916        drop(store);
917
918        assert!(
919            !dir.exists(),
920            "the guard directory is removed on normal drop"
921        );
922    }
923
924    /// §9 gate — teardown with store-handle clones alive: the directory survives
925    /// until the LAST `Arc<dyn DurableStore>` clone drops, then is removed. This
926    /// is the `Arc`-shared-into-channel-handles case: clones share one wrapper,
927    /// so none can close the database early.
928    #[test]
929    fn ephemeral_dir_survives_until_last_store_clone_drops() {
930        let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
931        let dir = store
932            .ephemeral_dir_path()
933            .expect("ephemeral store carries a guard dir")
934            .to_path_buf();
935        write_one_event(&store);
936
937        let erased: Arc<dyn DurableStore> = Arc::new(store);
938        let clone_a = Arc::clone(&erased);
939        let clone_b = Arc::clone(&erased);
940
941        drop(erased);
942        assert!(
943            dir.exists(),
944            "directory survives while store clones remain alive"
945        );
946        drop(clone_a);
947        assert!(
948            dir.exists(),
949            "directory survives while one store clone remains alive"
950        );
951
952        drop(clone_b);
953        assert!(
954            !dir.exists(),
955            "the last store clone dropping removes the directory"
956        );
957    }
958
959    /// §9 gate — startup rollback: an injected haematite open failure (a
960    /// conflicting `config.json` pre-seeded into the guard dir) makes the
961    /// constructor return `Err` AND leaves zero residue — the guard removes the
962    /// directory independently of haematite's own cleanup.
963    #[test]
964    fn ephemeral_open_failure_rolls_back_directory() {
965        let seeded = tempfile::Builder::new()
966            .prefix("liminal-durability-test-")
967            .tempdir()
968            .expect("test can create a temp dir");
969        let dir = seeded.path().to_path_buf();
970        // A pre-existing `config.json` makes haematite refuse the create with
971        // `DataDirAlreadyInitialised`; because the dir pre-existed the create,
972        // haematite never removes it — only the guard does.
973        std::fs::write(dir.join("config.json"), b"not-a-valid-config")
974            .expect("test can seed a conflicting config");
975
976        let result = open_ephemeral_in(seeded, TEST_SHARD_COUNT);
977
978        assert!(result.is_err(), "an injected open failure returns Err");
979        assert!(
980            !dir.exists(),
981            "the guard removes the directory on open failure — zero residue"
982        );
983    }
984
985    /// §9 gate — repeated start/stop: each cycle owns a distinct directory and
986    /// leaves zero residue after it drops.
987    #[test]
988    fn repeated_ephemeral_cycles_each_own_distinct_dir_zero_residue() {
989        let mut seen: Vec<PathBuf> = Vec::new();
990        for _ in 0..5 {
991            let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
992            let dir = store
993                .ephemeral_dir_path()
994                .expect("ephemeral store carries a guard dir")
995                .to_path_buf();
996            assert!(
997                dir.exists(),
998                "the cycle's directory exists while its store is live"
999            );
1000            assert!(!seen.contains(&dir), "each cycle owns a distinct directory");
1001            seen.push(dir.clone());
1002
1003            write_one_event(&store);
1004            drop(store);
1005            assert!(
1006                !dir.exists(),
1007                "the cycle's directory is removed after its store drops"
1008            );
1009        }
1010    }
1011
1012    /// §9 gate (drop-order pin): the guard drops the store strictly before it
1013    /// removes the directory. `OrderProbeStore::drop` asserts the directory
1014    /// still exists, so reversing the order inside [`EphemeralGuard`] fails this
1015    /// test rather than silently passing.
1016    #[test]
1017    fn guard_drops_store_before_removing_directory() {
1018        let dir = tempfile::tempdir().expect("test can create a temp dir");
1019        let path = dir.path().to_path_buf();
1020        let guard = EphemeralGuard {
1021            store: Some(OrderProbeStore { dir: path.clone() }),
1022            dir: Some(dir),
1023        };
1024
1025        drop(guard);
1026
1027        assert!(!path.exists(), "a clean drop still removes the directory");
1028    }
1029
1030    /// §9 gate (unwind pin): a panic while the store drops leaves the directory
1031    /// LEAKED, never removed under possibly-live workers, and the panic still
1032    /// propagates.
1033    #[test]
1034    fn guard_leaks_directory_when_store_drop_panics() {
1035        let dir = tempfile::tempdir().expect("test can create a temp dir");
1036        let path = dir.path().to_path_buf();
1037        let guard = EphemeralGuard {
1038            store: Some(PanickingProbeStore),
1039            dir: Some(dir),
1040        };
1041
1042        let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(guard)));
1043
1044        assert!(unwound.is_err(), "the injected store-drop panic propagates");
1045        assert!(
1046            path.exists(),
1047            "a panicking store drop leaks the directory instead of removing it"
1048        );
1049        std::fs::remove_dir_all(&path).expect("test cleans up the deliberately leaked directory");
1050    }
1051
1052    /// The rooted factory places (and removes) the guard directory under the
1053    /// caller-supplied root, which is what lets construction gates assert on an
1054    /// isolated root instead of scanning the system temp dir.
1055    #[test]
1056    fn rooted_ephemeral_store_lives_and_dies_under_the_given_root() {
1057        let root = tempfile::tempdir().expect("test can create a temp root");
1058        let store =
1059            open_ephemeral_rooted(root.path(), TEST_SHARD_COUNT).expect("rooted open succeeds");
1060        let dir = store
1061            .ephemeral_dir_path()
1062            .expect("ephemeral store carries a guard dir")
1063            .to_path_buf();
1064        assert!(
1065            dir.starts_with(root.path()),
1066            "the guard directory is created under the supplied root"
1067        );
1068
1069        write_one_event(&store);
1070        drop(store);
1071
1072        assert!(!dir.exists(), "the rooted directory is removed on drop");
1073    }
1074
1075    /// Clean-teardown gate (keepalive-honest shape): the guard directory is
1076    /// present for the store's WHOLE life — re-checked between unrelated
1077    /// operations that each succeed — and gone once the store drops cleanly.
1078    ///
1079    /// The "unrelated ops proceed" leg is what makes the final absence mean
1080    /// something: a directory that vanished early would take the appends,
1081    /// reads and CAS down with it, so this cannot pass by removing the
1082    /// directory too soon and cannot pass by never having created it.
1083    #[test]
1084    fn ephemeral_dir_persists_across_unrelated_work_then_goes_on_clean_drop() {
1085        let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
1086        let dir = store
1087            .ephemeral_dir_path()
1088            .expect("ephemeral store carries a guard dir")
1089            .to_path_buf();
1090        assert!(
1091            dir.exists(),
1092            "the directory exists as soon as the store does"
1093        );
1094
1095        for round in 0..3_u64 {
1096            block_on(store.append("clean-teardown/probe", b"payload".to_vec(), round))
1097                .expect("bridge completes synchronously")
1098                .expect("append to a live ephemeral store succeeds");
1099            assert!(
1100                dir.exists(),
1101                "the directory is still there after append round {round}"
1102            );
1103        }
1104        block_on(store.cas("clean-teardown/counter", 0, 7))
1105            .expect("bridge completes synchronously")
1106            .expect("cas on a live ephemeral store succeeds");
1107        let entries = block_on(store.read_from("clean-teardown/probe", 0, 10))
1108            .expect("bridge completes synchronously")
1109            .expect("read from a live ephemeral store succeeds");
1110        assert_eq!(entries.len(), 3, "every appended entry is readable back");
1111        assert!(
1112            dir.exists(),
1113            "the directory is still there after unrelated cas and read work"
1114        );
1115
1116        block_on(store.flush())
1117            .expect("bridge completes synchronously")
1118            .expect("flush of a live ephemeral store succeeds");
1119        drop(store);
1120
1121        assert!(
1122            !dir.exists(),
1123            "the clean drop removes the directory it kept alive throughout"
1124        );
1125    }
1126
1127    /// Clean-teardown gate: when removal FAILS on the clean path the guard
1128    /// LOGS the failure and its path, and does not panic.
1129    ///
1130    /// Injected the way it fails in production: the parent is made unwritable,
1131    /// so `remove_dir_all` clears the contents and then cannot unlink the
1132    /// directory itself. `tempfile`'s own `Drop` discards that error
1133    /// (`let _ = remove_dir_all(..)`), which is why this pin is red until the
1134    /// clean path calls `close()` and reports what it returns.
1135    #[cfg(unix)]
1136    #[test]
1137    fn clean_drop_removal_failure_is_logged_and_never_panics() {
1138        let parent = tempfile::tempdir().expect("test can create a temp parent");
1139        let dir = tempfile::Builder::new()
1140            .prefix("liminal-durability-")
1141            .tempdir_in(parent.path())
1142            .expect("test can create a guard dir under the parent");
1143        let path = dir.path().to_path_buf();
1144        let guard = EphemeralGuard {
1145            store: Some(()),
1146            dir: Some(dir),
1147        };
1148
1149        set_mode(parent.path(), 0o500);
1150        let captured = CapturedLog::default();
1151        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1152            captured.capturing(|| drop(guard));
1153        }));
1154        // Restored before the assertions so a failing assertion still leaves a
1155        // parent the outer `TempDir` can clean up.
1156        set_mode(parent.path(), 0o700);
1157
1158        assert!(
1159            outcome.is_ok(),
1160            "a removal failure is reported, never raised as a panic"
1161        );
1162        let logged = captured.text();
1163        assert!(
1164            logged.contains("ERROR"),
1165            "the removal failure is logged at error level; captured: {logged:?}"
1166        );
1167        assert!(
1168            logged.contains(&path.display().to_string()),
1169            "the log names the directory that survived; captured: {logged:?}"
1170        );
1171        assert!(
1172            path.exists(),
1173            "the residue is left where the log says it is, not silently claimed removed"
1174        );
1175    }
1176
1177    /// Clean-teardown gate (negative control for the capture instrument): a
1178    /// removal that SUCCEEDS logs nothing, so the assertion above discriminates
1179    /// failure from success rather than matching any teardown at all.
1180    #[test]
1181    fn clean_drop_that_succeeds_logs_nothing() {
1182        let dir = tempfile::tempdir().expect("test can create a temp dir");
1183        let path = dir.path().to_path_buf();
1184        let guard = EphemeralGuard {
1185            store: Some(()),
1186            dir: Some(dir),
1187        };
1188
1189        let captured = CapturedLog::default();
1190        captured.capturing(|| drop(guard));
1191
1192        assert!(!path.exists(), "the successful clean drop removed the dir");
1193        assert!(
1194            captured.text().is_empty(),
1195            "a successful removal is silent; captured: {:?}",
1196            captured.text()
1197        );
1198    }
1199
1200    /// Positive control for the capture instrument: the panic path's sanctioned
1201    /// leak line IS captured, path and all, by the same predicate the
1202    /// removal-failure gate uses.
1203    ///
1204    /// Without this, an empty capture would be a measurement of the harness
1205    /// rather than of the code under test.
1206    #[test]
1207    fn panic_path_leak_is_logged_with_its_path() {
1208        let dir = tempfile::tempdir().expect("test can create a temp dir");
1209        let path = dir.path().to_path_buf();
1210        let guard = EphemeralGuard {
1211            store: Some(PanickingProbeStore),
1212            dir: Some(dir),
1213        };
1214
1215        let captured = CapturedLog::default();
1216        let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1217            captured.capturing(|| drop(guard));
1218        }));
1219
1220        assert!(unwound.is_err(), "the injected store-drop panic propagates");
1221        let logged = captured.text();
1222        assert!(
1223            logged.contains("ERROR"),
1224            "the sanctioned leak is logged at error level; captured: {logged:?}"
1225        );
1226        assert!(
1227            logged.contains(&path.display().to_string()),
1228            "the leak log names the leaked directory; captured: {logged:?}"
1229        );
1230        std::fs::remove_dir_all(&path).expect("test cleans up the deliberately leaked directory");
1231    }
1232}
1233
1234#[cfg(test)]
1235#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1236mod paged_read_shape_tests {
1237    //! Board #60. The page limit must be honoured by the ENGINE, not by a
1238    //! truncation applied after the engine has already materialised the suffix.
1239    //!
1240    //! These pins are counts, never durations: the defect is a read shape, and
1241    //! a shape is deterministic where a latency is not.
1242
1243    use super::{DurableStore, EngineReadAccountingGuard, open_ephemeral};
1244    use crate::durability::bridge::block_on;
1245
1246    /// Page size used by both production replay readers (`READ_BATCH_SIZE` and
1247    /// `UNIT2_OUTBOX_RESTORE_BATCH_ROWS` are both 64).
1248    const PAGE: usize = 64;
1249    /// Four pages. Enough that the quadratic and the linear shape differ by
1250    /// more than a factor of two, small enough that seeding stays cheap.
1251    const ROWS: u64 = 256;
1252    const STREAM: &str = "liminal/p0-60/paged-read-shape";
1253
1254    /// Seeds `ROWS` events into one stream.
1255    fn seeded() -> Result<impl DurableStore, Box<dyn std::error::Error>> {
1256        let store = open_ephemeral(1)?;
1257        for sequence in 0..ROWS {
1258            block_on(store.append(STREAM, sequence.to_be_bytes().to_vec(), sequence))??;
1259        }
1260        block_on(store.flush())??;
1261        Ok(store)
1262    }
1263
1264    /// Walks the whole stream one page at a time, exactly as replay does.
1265    fn read_whole_stream(
1266        store: &impl DurableStore,
1267        page: usize,
1268    ) -> Result<usize, Box<dyn std::error::Error>> {
1269        let mut offset = 0_u64;
1270        let mut seen = 0_usize;
1271        loop {
1272            let entries = block_on(store.read_from(STREAM, offset, page))??;
1273            if entries.is_empty() {
1274                return Ok(seen);
1275            }
1276            for entry in &entries {
1277                assert_eq!(entry.sequence, offset, "paged read must stay contiguous");
1278                offset += 1;
1279            }
1280            seen = seen
1281                .checked_add(entries.len())
1282                .ok_or("row counter overflowed")?;
1283        }
1284    }
1285
1286    /// Every read below is bounded by its `limit`, so no read costs more than
1287    /// the rows it returns. One seeded store carries all four shapes.
1288    #[test]
1289    fn a_bounded_read_never_scans_beyond_its_page() -> Result<(), Box<dyn std::error::Error>> {
1290        let store = seeded()?;
1291
1292        // 1. The whole stream, paged. O(N), not O(N^2).
1293        let accounting = EngineReadAccountingGuard::start();
1294        let seen = read_whole_stream(&store, PAGE)?;
1295        let walk = accounting.snapshot();
1296        drop(accounting);
1297        assert_eq!(
1298            u64::try_from(seen)?,
1299            ROWS,
1300            "the walk must deliver every row"
1301        );
1302        assert!(
1303            !walk.counter_overflow_observed,
1304            "a saturated counter is not a measurement"
1305        );
1306        assert!(walk.calls > 0, "the walk must have reached the store");
1307        assert_eq!(
1308            u64::try_from(walk.engine_entries)?,
1309            ROWS,
1310            "a full stream read must scan each row exactly once instead of \
1311             re-scanning every suffix once per page"
1312        );
1313
1314        // 2. One page from the head.
1315        let accounting = EngineReadAccountingGuard::start();
1316        let head = block_on(store.read_from(STREAM, 0, PAGE))??;
1317        let head_read = accounting.snapshot();
1318        drop(accounting);
1319        assert_eq!(head.len(), PAGE, "a full page returns its limit");
1320        assert_eq!(
1321            head_read.engine_entries, PAGE,
1322            "the engine must be asked for one page, not for the whole stream"
1323        );
1324
1325        // 3. One page from the MIDDLE. The rows after the page are the ones a
1326        //    suffix-scanning read would drag along; the rows before it are the
1327        //    ones the offset already excludes, so only a bounded upper edge can
1328        //    make this count come out at PAGE.
1329        let middle_offset = ROWS / 2;
1330        let accounting = EngineReadAccountingGuard::start();
1331        let middle = block_on(store.read_from(STREAM, middle_offset, PAGE))??;
1332        let middle_read = accounting.snapshot();
1333        drop(accounting);
1334        assert_eq!(
1335            middle.len(),
1336            PAGE,
1337            "a full page mid-stream returns its limit"
1338        );
1339        assert_eq!(
1340            middle_read.engine_entries, PAGE,
1341            "a mid-stream page must not scan the rows that follow it"
1342        );
1343
1344        // 4. Past the head: end of stream, and no scan.
1345        let accounting = EngineReadAccountingGuard::start();
1346        let past = block_on(store.read_from(STREAM, ROWS, PAGE))??;
1347        let past_read = accounting.snapshot();
1348        drop(accounting);
1349        assert!(past.is_empty(), "past the head is end of stream");
1350        assert_eq!(
1351            past_read.engine_entries, 0,
1352            "an end-of-stream page must not scan the stream"
1353        );
1354        Ok(())
1355    }
1356
1357    /// The equivalence the pushdown must preserve. This passes before and after
1358    /// the fix by design: it is the control that says the fix changed the read
1359    /// SHAPE and nothing else.
1360    #[test]
1361    fn page_size_never_changes_the_answer() -> Result<(), Box<dyn std::error::Error>> {
1362        let store = seeded()?;
1363        let whole = block_on(store.read_from(STREAM, 0, usize::MAX))??;
1364        assert_eq!(u64::try_from(whole.len())?, ROWS);
1365
1366        for page in [1_usize, 7, 64, 255, 256, 257] {
1367            let mut offset = 0_u64;
1368            let mut collected = Vec::new();
1369            loop {
1370                let entries = block_on(store.read_from(STREAM, offset, page))??;
1371                if entries.is_empty() {
1372                    break;
1373                }
1374                assert!(entries.len() <= page, "a page never exceeds its limit");
1375                offset = offset
1376                    .checked_add(u64::try_from(entries.len())?)
1377                    .ok_or("offset overflowed")?;
1378                collected.extend(entries);
1379            }
1380            assert_eq!(collected, whole, "page size {page} changed the answer");
1381        }
1382
1383        // A zero limit is the one page size that must return nothing, and it
1384        // must not be answered by a bounded window that silently agrees.
1385        assert!(
1386            block_on(store.read_from(STREAM, 0, 0))??.is_empty(),
1387            "a zero limit reads nothing"
1388        );
1389
1390        // Every suffix start agrees with the same suffix of the whole read.
1391        for offset in [0_u64, 1, 63, 64, 65, 128, 255] {
1392            let suffix = block_on(store.read_from(STREAM, offset, usize::MAX))??;
1393            assert_eq!(
1394                suffix,
1395                whole[usize::try_from(offset)?..],
1396                "suffix from {offset} diverged"
1397            );
1398        }
1399        Ok(())
1400    }
1401}