Skip to main content

liminal/durability/
store.rs

1use std::path::Path;
2use std::sync::Arc;
3
4use haematite::{ApiError, Database, DatabaseConfig, Event, EventStore};
5use tempfile::TempDir;
6
7use super::DurabilityError;
8
9/// Entry read from a durable haematite stream.
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct StoredEntry {
12    /// Opaque stored payload bytes.
13    pub payload: Vec<u8>,
14    /// Sequence number assigned by the stream.
15    pub sequence: u64,
16    /// Store timestamp associated with the entry.
17    pub timestamp: u64,
18}
19
20/// Direct durability surface matching haematite's append/read/cas/scan API.
21#[async_trait::async_trait]
22pub trait DurableStore: std::fmt::Debug + Send + Sync {
23    /// Appends `payload` to `stream_key` if `expected_seq` matches the stream head.
24    async fn append(
25        &self,
26        stream_key: &str,
27        payload: Vec<u8>,
28        expected_seq: u64,
29    ) -> Result<u64, DurabilityError>;
30
31    /// Reads entries from `stream_key` beginning at `offset`, up to `limit` entries.
32    async fn read_from(
33        &self,
34        stream_key: &str,
35        offset: u64,
36        limit: usize,
37    ) -> Result<Vec<StoredEntry>, DurabilityError>;
38
39    /// Atomically replaces a stored numeric value if it equals `old_value`.
40    ///
41    /// An `old_value` of `0` matches a key that is currently *absent* as well as
42    /// one explicitly stored as `0`: a fresh cursor is created on its first
43    /// checkpoint without a prior write. See [`HaematiteStore::cas`] for how this
44    /// "absent == 0" contract is preserved atomically over the real engine.
45    async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError>;
46
47    /// Reads a numeric value previously updated through compare-and-swap.
48    async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError>;
49
50    /// Scans entries by store prefix.
51    async fn scan(&self, prefix: &str) -> Result<Vec<StoredEntry>, DurabilityError>;
52
53    /// Flushes buffered writes so completed durable operations are persisted.
54    ///
55    /// # Errors
56    /// Returns [`DurabilityError`] when the underlying store cannot complete the flush.
57    async fn flush(&self) -> Result<(), DurabilityError>;
58}
59
60/// `DurableStore` implementation that delegates directly to haematite's `EventStore`.
61///
62/// The real [`EventStore`] is synchronous (every call blocks on the owning
63/// shard actor's reply), so each `async` method below completes on its first
64/// poll. The synchronous bridge in [`super::bridge`] relies on exactly that.
65#[derive(Clone, Debug)]
66pub struct HaematiteStore {
67    event_store: Arc<EventStore>,
68}
69
70impl HaematiteStore {
71    /// Wraps a haematite `EventStore` handle.
72    #[must_use]
73    pub const fn new(event_store: Arc<EventStore>) -> Self {
74        Self { event_store }
75    }
76}
77
78#[async_trait::async_trait]
79impl DurableStore for HaematiteStore {
80    async fn append(
81        &self,
82        stream_key: &str,
83        payload: Vec<u8>,
84        expected_seq: u64,
85    ) -> Result<u64, DurabilityError> {
86        // Contract bridge: liminal's `DurableStore::append` returns the *assigned
87        // event sequence* (0-based position of the just-appended event), which is
88        // exactly `expected_seq` for a single append. The real `EventStore::append`
89        // instead returns the stream's new next-sequence (`expected_seq + 1`), so
90        // subtract one to recover the assigned seq. A `0` next-seq is impossible
91        // after a successful single append, so the `checked_sub` cannot saturate
92        // silently; if it ever did the engine returned a contract-violating value.
93        let next_seq = self
94            .event_store
95            .append(stream_key.as_bytes(), &payload, expected_seq)
96            .map_err(DurabilityError::from)?;
97        next_seq.checked_sub(1).ok_or_else(|| {
98            DurabilityError::StoreError(ApiError::CorruptEvent(format!(
99                "append returned next-seq 0 for stream {stream_key}"
100            )))
101        })
102    }
103
104    async fn read_from(
105        &self,
106        stream_key: &str,
107        offset: u64,
108        limit: usize,
109    ) -> Result<Vec<StoredEntry>, DurabilityError> {
110        // The real `read_from` returns every event with seq >= offset and applies
111        // no limit; truncate to `limit` entries to honour the trait contract.
112        let mut events = self
113            .event_store
114            .read_from(stream_key.as_bytes(), offset)
115            .map_err(DurabilityError::from)?;
116        events.truncate(limit);
117        Ok(events.into_iter().map(StoredEntry::from).collect())
118    }
119
120    async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
121        // Preserve liminal's "absent == 0" cursor contract faithfully over an
122        // engine that distinguishes `None` (absent) from `Some(0)` (a stored
123        // zero). The invariant that makes the mapping below correct: we NEVER
124        // persist a physical zero, so a logical value of 0 and physical absence
125        // always coincide.
126        //
127        // A `cas` whose target `new_value` is 0 must therefore write nothing — it
128        // only asserts the precondition. This is reachable as `cas(0, 0)` (a
129        // cursor checkpoint at offset 0; offsets are monotonic so they never CAS
130        // down to 0 from a higher value). Were we instead to let it store a
131        // physical zero, the *next* `cas(0, n)` — mapped to expect-absent `None`
132        // — would wrongly fail against the now-present key and permanently stall
133        // the cursor. Asserting via a read is race-free here precisely because no
134        // value is written, so there is no lost-update window.
135        if new_value == 0 {
136            return self
137                .event_store
138                .read_value(key.as_bytes())
139                .map_err(DurabilityError::from)?
140                .map_or(Ok(()), |stored| {
141                    Err(DurabilityError::CursorRegression {
142                        stored,
143                        attempted: old_value,
144                    })
145                });
146        }
147        // With a physical zero never stored, `old_value == 0` is exactly the
148        // expect-absent expectation. Any other `old_value` maps to `Some(_)`.
149        // This is a single CAS routed to the owning shard actor, where read,
150        // compare, and write run with no interleaving point (haematite's
151        // `ShardActor::cas`) — the engine's atomicity is preserved end to end.
152        let expected = if old_value == 0 {
153            None
154        } else {
155            Some(old_value)
156        };
157        self.event_store
158            .cas(key.as_bytes(), expected, new_value)
159            .map_err(DurabilityError::from)
160    }
161
162    async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
163        self.event_store
164            .read_value(key.as_bytes())
165            .map_err(DurabilityError::from)
166    }
167
168    async fn scan(&self, prefix: &str) -> Result<Vec<StoredEntry>, DurabilityError> {
169        // The real `scan` predicate yields stream *metadata* (key + next_seq),
170        // not events. Liminal's contract is to return the events of every stream
171        // whose key matches `prefix`, so collect the matching stream keys, then
172        // read each stream's full event list and flatten the results.
173        let prefix_bytes = prefix.as_bytes().to_vec();
174        let matches = self
175            .event_store
176            .scan(|meta| meta.stream_key.starts_with(&prefix_bytes))
177            .map_err(DurabilityError::from)?;
178        let mut entries = Vec::new();
179        for stream in matches {
180            let events = self
181                .event_store
182                .read(&stream.stream_key)
183                .map_err(DurabilityError::from)?;
184            entries.extend(events.into_iter().map(StoredEntry::from));
185        }
186        Ok(entries)
187    }
188
189    async fn flush(&self) -> Result<(), DurabilityError> {
190        self.event_store.flush().map_err(DurabilityError::from)
191    }
192}
193
194/// Drop shell enforcing "close the store, then remove its directory" as
195/// explicit code rather than field declaration order.
196///
197/// Declaration order alone cannot express the unwind case: if dropping the
198/// store panics (a haematite worker failing to join), Rust would still drop
199/// the remaining fields during the unwind and remove the directory under
200/// possibly-live workers. This `Drop` drops the store inside `catch_unwind`;
201/// on unwind it DISARMS the directory guard — the directory is deliberately
202/// leaked, because visible residue is diagnosable while removal under live
203/// workers is filesystem corruption — logs the leaked path, and re-raises the
204/// panic. On the clean path the directory is removed after the store, by the
205/// ordinary field drop that follows this `Drop`.
206///
207/// Both fields are `Option` only so `drop` can move them out; they are `Some`
208/// for the shell's entire life outside `drop`.
209#[derive(Debug)]
210struct EphemeralGuard<S> {
211    store: Option<S>,
212    dir: Option<TempDir>,
213}
214
215impl<S> Drop for EphemeralGuard<S> {
216    fn drop(&mut self) {
217        let store = self.store.take();
218        // AssertUnwindSafe: the closure owns everything it touches (the moved
219        // store), and the unwind path below observes no state the panicking
220        // drop could have left broken — it only disarms the guard and re-raises.
221        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(store)));
222        if let Err(panic) = outcome {
223            if let Some(dir) = self.dir.take() {
224                let leaked = dir.keep();
225                tracing::error!(
226                    path = %leaked.display(),
227                    "ephemeral store drop panicked; leaking its directory rather than \
228                     removing it under possibly-live database workers"
229                );
230            }
231            std::panic::resume_unwind(panic);
232        }
233    }
234}
235
236/// Exclusive-ownership ephemeral durable store: the sole owner of both the
237/// haematite database and the temporary directory that backs it.
238///
239/// [`HaematiteStore::new`] takes a *caller-supplied* `Arc<EventStore>`, so a
240/// clone of that inner handle can outlive any guard placed merely beside it —
241/// field declaration order proves nothing across that `Arc` boundary. This
242/// wrapper instead owns the database outright: [`open_ephemeral`] constructs the
243/// inner `Arc` itself, this type never exposes it (no getter) and is deliberately
244/// **not `Clone`**, so the only handle a caller can hold is an
245/// `Arc<dyn DurableStore>` over the whole wrapper. When the last such clone
246/// drops, the [`EphemeralGuard`] drops the store FIRST — the database closes,
247/// its shard actors join and the data-dir writer lock releases on fd close —
248/// and only then removes the directory; if closing the database panics, the
249/// directory is deliberately leaked instead (see [`EphemeralGuard`]).
250#[derive(Debug)]
251pub struct EphemeralHaematiteStore {
252    guard: EphemeralGuard<HaematiteStore>,
253}
254
255impl EphemeralHaematiteStore {
256    /// Takes an already-open ephemeral `Database` and the temporary directory it
257    /// was opened under, becoming their single exclusive owner.
258    ///
259    /// The inner `Arc<EventStore>` is created here and never leaves this type, so
260    /// no caller-supplied clone of it can exist to defeat the drop ordering.
261    /// `ephemeral_dir` must be the directory `database` lives in and must have
262    /// been created before the database was opened (so a failed open removed it
263    /// via the guard's `Drop`, before this constructor was ever reached).
264    fn new(database: Database, ephemeral_dir: TempDir) -> Self {
265        Self {
266            guard: EphemeralGuard {
267                store: Some(HaematiteStore::new(Arc::new(EventStore::new(database)))),
268                dir: Some(ephemeral_dir),
269            },
270        }
271    }
272
273    /// Store handle behind the guard's teardown-only `Option`.
274    ///
275    /// `None` exists only inside [`EphemeralGuard::drop`], which cannot overlap
276    /// a `&self` call, so this error is unreachable by construction — it is a
277    /// typed refusal in place of a panic the workspace forbids, not a state a
278    /// caller can produce.
279    fn store(&self) -> Result<&HaematiteStore, DurabilityError> {
280        self.guard
281            .store
282            .as_ref()
283            .ok_or(DurabilityError::EphemeralStoreDetached)
284    }
285
286    /// Path of the guarding temporary directory, for lifecycle assertions only.
287    #[cfg(test)]
288    pub(crate) fn ephemeral_dir_path(&self) -> Option<&Path> {
289        self.guard.dir.as_ref().map(TempDir::path)
290    }
291}
292
293#[async_trait::async_trait]
294impl DurableStore for EphemeralHaematiteStore {
295    async fn append(
296        &self,
297        stream_key: &str,
298        payload: Vec<u8>,
299        expected_seq: u64,
300    ) -> Result<u64, DurabilityError> {
301        self.store()?
302            .append(stream_key, payload, expected_seq)
303            .await
304    }
305
306    async fn read_from(
307        &self,
308        stream_key: &str,
309        offset: u64,
310        limit: usize,
311    ) -> Result<Vec<StoredEntry>, DurabilityError> {
312        self.store()?.read_from(stream_key, offset, limit).await
313    }
314
315    async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
316        self.store()?.cas(key, old_value, new_value).await
317    }
318
319    async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
320        self.store()?.read_value(key).await
321    }
322
323    async fn scan(&self, prefix: &str) -> Result<Vec<StoredEntry>, DurabilityError> {
324        self.store()?.scan(prefix).await
325    }
326
327    async fn flush(&self) -> Result<(), DurabilityError> {
328        self.store()?.flush().await
329    }
330}
331
332/// Opens a self-owning ephemeral haematite store under a fresh temporary
333/// directory below the system temp dir.
334///
335/// The directory is created BEFORE [`Database::create`], so every failure path —
336/// including a haematite open/create error — removes it when the guard drops on
337/// the error return; the returned store owns the guard on success. The database
338/// is created directly in the (empty) temporary directory: haematite's `create`
339/// accepts an existing empty dir and, on failure, removes only a directory *it*
340/// created, never this pre-existing guard dir (haematite 0.4.1
341/// `db/startup.rs`), so the `TempDir` is the sole owner of directory lifetime on
342/// every path.
343///
344/// # Errors
345/// Returns [`DurabilityError::EphemeralStoreOpen`] if haematite cannot create the
346/// database; the temporary directory is already removed when this returns.
347pub fn open_ephemeral(shard_count: usize) -> Result<EphemeralHaematiteStore, DurabilityError> {
348    open_ephemeral_in(ephemeral_tempdir(None)?, shard_count)
349}
350
351/// TEST SEAM: [`open_ephemeral`] with the temporary directory placed under
352/// `root` instead of the system temp dir.
353///
354/// Rooting lets construction gates assert on an isolated directory instead of
355/// scanning the shared temp dir. Same lifecycle contract as
356/// [`open_ephemeral`] — the store owns and removes its directory; `root` must
357/// already exist and must outlive the store.
358///
359/// That last requirement is why this is NOT a production API: the store's
360/// exclusive ownership of its directory (the D3 invariant) says nothing about
361/// the PARENT — a caller rooting the store inside a directory they own via
362/// their own guard can drop that guard while the store is live, deleting the
363/// database out from under its running workers. A general rooted API would
364/// need a root-ownership token so parent cleanup cannot outrun the store;
365/// that is deferred until a real embedder need arrives. Until then the
366/// function is gated to tests (`cfg(test)` in this crate, the default-off
367/// `test-support` feature for downstream test harnesses).
368///
369/// # Errors
370/// Returns [`DurabilityError::EphemeralStoreOpen`] if the directory cannot be
371/// created under `root` or haematite cannot create the database; no residue
372/// remains under `root` when this returns an error.
373#[cfg(any(test, feature = "test-support"))]
374pub fn open_ephemeral_rooted(
375    root: &Path,
376    shard_count: usize,
377) -> Result<EphemeralHaematiteStore, DurabilityError> {
378    open_ephemeral_in(ephemeral_tempdir(Some(root))?, shard_count)
379}
380
381/// Creates the guard directory for an ephemeral store, under `root` when given
382/// and under the system temp dir otherwise.
383fn ephemeral_tempdir(root: Option<&Path>) -> Result<TempDir, DurabilityError> {
384    let mut builder = tempfile::Builder::new();
385    builder.prefix("liminal-durability-");
386    root.map_or_else(|| builder.tempdir(), |root| builder.tempdir_in(root))
387        .map_err(|error| {
388            DurabilityError::EphemeralStoreOpen(format!(
389                "could not create temporary directory: {error}"
390            ))
391        })
392}
393
394/// Opens an ephemeral store inside an already-created guard directory.
395///
396/// Split out so the guard exists before `Database::create` and so lifecycle
397/// tests can inject an open failure into a directory they pre-populated.
398fn open_ephemeral_in(
399    ephemeral_dir: TempDir,
400    shard_count: usize,
401) -> Result<EphemeralHaematiteStore, DurabilityError> {
402    let database = Database::create(DatabaseConfig {
403        data_dir: ephemeral_dir.path().to_path_buf(),
404        shard_count,
405        sweep_interval: None,
406        distributed: None,
407    })
408    .map_err(|error| DurabilityError::EphemeralStoreOpen(error.to_string()))?;
409    Ok(EphemeralHaematiteStore::new(database, ephemeral_dir))
410}
411
412impl From<Event> for StoredEntry {
413    fn from(event: Event) -> Self {
414        Self {
415            payload: event.payload,
416            sequence: event.seq,
417            timestamp: event.timestamp,
418        }
419    }
420}
421
422/// Maps a real-engine [`ApiError`] onto liminal's [`DurabilityError`].
423///
424/// The optimistic-concurrency variants route to their dedicated `DurabilityError`
425/// cases (`SequenceConflict`, `CursorRegression`); everything else is a
426/// store-level failure carried verbatim.
427impl From<ApiError> for DurabilityError {
428    fn from(error: ApiError) -> Self {
429        match error {
430            ApiError::SequenceConflict(conflict) => conflict.into(),
431            ApiError::CasMismatch(mismatch) => mismatch.into(),
432            other @ (ApiError::CorruptEvent(_)
433            | ApiError::Storage(_)
434            | ApiError::HistoryCompacted(_)) => Self::StoreError(other),
435        }
436    }
437}
438
439#[cfg(test)]
440#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
441mod ephemeral_lifecycle_tests {
442    //! D3 §9 lifecycle gate. Each test names the gate it pins; all are permanent
443    //! rule-1 assertions that the ephemeral store's directory has an enforced
444    //! owner across every teardown path.
445
446    use std::path::PathBuf;
447    use std::sync::Arc;
448
449    use super::super::bridge::block_on;
450    use super::{
451        DurableStore, EphemeralGuard, open_ephemeral, open_ephemeral_in, open_ephemeral_rooted,
452    };
453
454    const TEST_SHARD_COUNT: usize = 2;
455
456    /// Store stand-in whose `Drop` pins the guard's internal ordering: the
457    /// directory must still exist at store-drop time, so this drop FAILS the
458    /// test if the guard ever removes the directory first.
459    struct OrderProbeStore {
460        dir: PathBuf,
461    }
462
463    impl Drop for OrderProbeStore {
464        fn drop(&mut self) {
465            assert!(
466                self.dir.exists(),
467                "the guard must drop the store BEFORE removing the directory"
468            );
469        }
470    }
471
472    /// Store stand-in whose `Drop` panics, modelling a haematite worker failing
473    /// to join while the database closes.
474    struct PanickingProbeStore;
475
476    impl Drop for PanickingProbeStore {
477        fn drop(&mut self) {
478            panic!("injected store-drop panic");
479        }
480    }
481
482    /// Materialises shard directories and fds so the drop path actually has a
483    /// live database to close before the guard removes the directory.
484    fn write_one_event(store: &dyn DurableStore) {
485        block_on(store.append("lifecycle/probe", b"payload".to_vec(), 0))
486            .expect("bridge completes synchronously")
487            .expect("append to a fresh ephemeral stream succeeds");
488        block_on(store.flush())
489            .expect("bridge completes synchronously")
490            .expect("flush of a live ephemeral store succeeds");
491    }
492
493    /// §9 gate — normal drop: the directory is removed once the last (here, only)
494    /// handle drops.
495    #[test]
496    fn ephemeral_dir_removed_after_last_handle_drops() {
497        let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
498        let dir = store
499            .ephemeral_dir_path()
500            .expect("ephemeral store carries a guard dir")
501            .to_path_buf();
502        assert!(
503            dir.exists(),
504            "the guard directory exists while the store is live"
505        );
506
507        write_one_event(&store);
508        drop(store);
509
510        assert!(
511            !dir.exists(),
512            "the guard directory is removed on normal drop"
513        );
514    }
515
516    /// §9 gate — teardown with store-handle clones alive: the directory survives
517    /// until the LAST `Arc<dyn DurableStore>` clone drops, then is removed. This
518    /// is the `Arc`-shared-into-channel-handles case: clones share one wrapper,
519    /// so none can close the database early.
520    #[test]
521    fn ephemeral_dir_survives_until_last_store_clone_drops() {
522        let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
523        let dir = store
524            .ephemeral_dir_path()
525            .expect("ephemeral store carries a guard dir")
526            .to_path_buf();
527        write_one_event(&store);
528
529        let erased: Arc<dyn DurableStore> = Arc::new(store);
530        let clone_a = Arc::clone(&erased);
531        let clone_b = Arc::clone(&erased);
532
533        drop(erased);
534        assert!(
535            dir.exists(),
536            "directory survives while store clones remain alive"
537        );
538        drop(clone_a);
539        assert!(
540            dir.exists(),
541            "directory survives while one store clone remains alive"
542        );
543
544        drop(clone_b);
545        assert!(
546            !dir.exists(),
547            "the last store clone dropping removes the directory"
548        );
549    }
550
551    /// §9 gate — startup rollback: an injected haematite open failure (a
552    /// conflicting `config.json` pre-seeded into the guard dir) makes the
553    /// constructor return `Err` AND leaves zero residue — the guard removes the
554    /// directory independently of haematite's own cleanup.
555    #[test]
556    fn ephemeral_open_failure_rolls_back_directory() {
557        let seeded = tempfile::Builder::new()
558            .prefix("liminal-durability-test-")
559            .tempdir()
560            .expect("test can create a temp dir");
561        let dir = seeded.path().to_path_buf();
562        // A pre-existing `config.json` makes haematite refuse the create with
563        // `DataDirAlreadyInitialised`; because the dir pre-existed the create,
564        // haematite never removes it — only the guard does.
565        std::fs::write(dir.join("config.json"), b"not-a-valid-config")
566            .expect("test can seed a conflicting config");
567
568        let result = open_ephemeral_in(seeded, TEST_SHARD_COUNT);
569
570        assert!(result.is_err(), "an injected open failure returns Err");
571        assert!(
572            !dir.exists(),
573            "the guard removes the directory on open failure — zero residue"
574        );
575    }
576
577    /// §9 gate — repeated start/stop: each cycle owns a distinct directory and
578    /// leaves zero residue after it drops.
579    #[test]
580    fn repeated_ephemeral_cycles_each_own_distinct_dir_zero_residue() {
581        let mut seen: Vec<PathBuf> = Vec::new();
582        for _ in 0..5 {
583            let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
584            let dir = store
585                .ephemeral_dir_path()
586                .expect("ephemeral store carries a guard dir")
587                .to_path_buf();
588            assert!(
589                dir.exists(),
590                "the cycle's directory exists while its store is live"
591            );
592            assert!(!seen.contains(&dir), "each cycle owns a distinct directory");
593            seen.push(dir.clone());
594
595            write_one_event(&store);
596            drop(store);
597            assert!(
598                !dir.exists(),
599                "the cycle's directory is removed after its store drops"
600            );
601        }
602    }
603
604    /// §9 gate (drop-order pin): the guard drops the store strictly before it
605    /// removes the directory. `OrderProbeStore::drop` asserts the directory
606    /// still exists, so reversing the order inside [`EphemeralGuard`] fails this
607    /// test rather than silently passing.
608    #[test]
609    fn guard_drops_store_before_removing_directory() {
610        let dir = tempfile::tempdir().expect("test can create a temp dir");
611        let path = dir.path().to_path_buf();
612        let guard = EphemeralGuard {
613            store: Some(OrderProbeStore { dir: path.clone() }),
614            dir: Some(dir),
615        };
616
617        drop(guard);
618
619        assert!(!path.exists(), "a clean drop still removes the directory");
620    }
621
622    /// §9 gate (unwind pin): a panic while the store drops leaves the directory
623    /// LEAKED, never removed under possibly-live workers, and the panic still
624    /// propagates.
625    #[test]
626    fn guard_leaks_directory_when_store_drop_panics() {
627        let dir = tempfile::tempdir().expect("test can create a temp dir");
628        let path = dir.path().to_path_buf();
629        let guard = EphemeralGuard {
630            store: Some(PanickingProbeStore),
631            dir: Some(dir),
632        };
633
634        let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(guard)));
635
636        assert!(unwound.is_err(), "the injected store-drop panic propagates");
637        assert!(
638            path.exists(),
639            "a panicking store drop leaks the directory instead of removing it"
640        );
641        std::fs::remove_dir_all(&path).expect("test cleans up the deliberately leaked directory");
642    }
643
644    /// The rooted factory places (and removes) the guard directory under the
645    /// caller-supplied root, which is what lets construction gates assert on an
646    /// isolated root instead of scanning the system temp dir.
647    #[test]
648    fn rooted_ephemeral_store_lives_and_dies_under_the_given_root() {
649        let root = tempfile::tempdir().expect("test can create a temp root");
650        let store =
651            open_ephemeral_rooted(root.path(), TEST_SHARD_COUNT).expect("rooted open succeeds");
652        let dir = store
653            .ephemeral_dir_path()
654            .expect("ephemeral store carries a guard dir")
655            .to_path_buf();
656        assert!(
657            dir.starts_with(root.path()),
658            "the guard directory is created under the supplied root"
659        );
660
661        write_one_event(&store);
662        drop(store);
663
664        assert!(!dir.exists(), "the rooted directory is removed on drop");
665    }
666}