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