liminal/durability/store.rs
1use haematite::{ApiError, Database, DatabaseConfig, Event, EventStore};
2
3use std::path::Path;
4use std::sync::Arc;
5
6use super::DurabilityError;
7
8use tempfile::TempDir;
9
10/// Entry read from a durable haematite stream.
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct StoredEntry {
13 /// Opaque stored payload bytes.
14 pub payload: Vec<u8>,
15 /// Sequence number assigned by the stream.
16 pub sequence: u64,
17 /// Store timestamp associated with the entry.
18 pub timestamp: u64,
19}
20
21/// Direct durability surface matching haematite's append/read/cas/scan API.
22#[async_trait::async_trait]
23pub trait DurableStore: std::fmt::Debug + Send + Sync {
24 /// Appends `payload` to `stream_key` if `expected_seq` matches the stream head.
25 async fn append(
26 &self,
27 stream_key: &str,
28 payload: Vec<u8>,
29 expected_seq: u64,
30 ) -> Result<u64, DurabilityError>;
31
32 /// Reads entries from `stream_key` beginning at `offset`, up to `limit` entries.
33 async fn read_from(
34 &self,
35 stream_key: &str,
36 offset: u64,
37 limit: usize,
38 ) -> Result<Vec<StoredEntry>, DurabilityError>;
39
40 /// Reads exactly the event at `sequence` without traversing its suffix.
41 async fn read_at(
42 &self,
43 stream_key: &str,
44 sequence: u64,
45 ) -> Result<Option<StoredEntry>, DurabilityError> {
46 Ok(self
47 .read_from(stream_key, sequence, 1)
48 .await?
49 .into_iter()
50 .next())
51 }
52
53 /// Atomically replaces a stored numeric value if it equals `old_value`.
54 ///
55 /// An `old_value` of `0` matches a key that is currently *absent* as well as
56 /// one explicitly stored as `0`: a fresh cursor is created on its first
57 /// checkpoint without a prior write. See [`HaematiteStore::cas`] for how this
58 /// "absent == 0" contract is preserved atomically over the real engine.
59 async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError>;
60
61 /// Reads a numeric value previously updated through compare-and-swap.
62 async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError>;
63
64 /// Scans entries by store prefix.
65 async fn scan(&self, prefix: &str) -> Result<Vec<StoredEntry>, DurabilityError>;
66
67 /// Flushes buffered writes so completed durable operations are persisted.
68 ///
69 /// # Errors
70 /// Returns [`DurabilityError`] when the underlying store cannot complete the flush.
71 async fn flush(&self) -> Result<(), DurabilityError>;
72}
73
74/// `DurableStore` implementation that delegates directly to haematite's `EventStore`.
75///
76/// The real [`EventStore`] is synchronous (every call blocks on the owning
77/// shard actor's reply), so each `async` method below completes on its first
78/// poll. The synchronous bridge in [`super::bridge`] relies on exactly that.
79#[derive(Clone, Debug)]
80pub struct HaematiteStore {
81 event_store: Arc<EventStore>,
82}
83
84impl HaematiteStore {
85 /// Wraps a haematite `EventStore` handle.
86 #[must_use]
87 pub const fn new(event_store: Arc<EventStore>) -> Self {
88 Self { event_store }
89 }
90}
91
92#[async_trait::async_trait]
93impl DurableStore for HaematiteStore {
94 async fn append(
95 &self,
96 stream_key: &str,
97 payload: Vec<u8>,
98 expected_seq: u64,
99 ) -> Result<u64, DurabilityError> {
100 // Contract bridge: liminal's `DurableStore::append` returns the *assigned
101 // event sequence* (0-based position of the just-appended event), which is
102 // exactly `expected_seq` for a single append. The real `EventStore::append`
103 // instead returns the stream's new next-sequence (`expected_seq + 1`), so
104 // subtract one to recover the assigned seq. A `0` next-seq is impossible
105 // after a successful single append, so the `checked_sub` cannot saturate
106 // silently; if it ever did the engine returned a contract-violating value.
107 let next_seq = self
108 .event_store
109 .append(stream_key.as_bytes(), &payload, expected_seq)
110 .map_err(DurabilityError::from)?;
111 next_seq.checked_sub(1).ok_or_else(|| {
112 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
113 "append returned next-seq 0 for stream {stream_key}"
114 )))
115 })
116 }
117
118 async fn read_from(
119 &self,
120 stream_key: &str,
121 offset: u64,
122 limit: usize,
123 ) -> Result<Vec<StoredEntry>, DurabilityError> {
124 // The real `read_from` returns every event with seq >= offset and applies
125 // no limit; truncate to `limit` entries to honour the trait contract.
126 let mut events = self
127 .event_store
128 .read_from(stream_key.as_bytes(), offset)
129 .map_err(DurabilityError::from)?;
130 events.truncate(limit);
131 Ok(events.into_iter().map(StoredEntry::from).collect())
132 }
133
134 async fn read_at(
135 &self,
136 stream_key: &str,
137 sequence: u64,
138 ) -> Result<Option<StoredEntry>, DurabilityError> {
139 const TIMESTAMP_WIDTH: usize = std::mem::size_of::<u64>();
140
141 let engine_sequence = sequence.checked_add(1).ok_or_else(|| {
142 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
143 "point read sequence overflow for stream {stream_key}"
144 )))
145 })?;
146 let event_key = haematite::encode_stream_key(stream_key.as_bytes(), engine_sequence);
147 let Some(value) = self
148 .event_store
149 .database()
150 .get_routed(stream_key.as_bytes(), &event_key)
151 .map_err(ApiError::from)
152 .map_err(DurabilityError::from)?
153 else {
154 return Ok(None);
155 };
156 let Some(timestamp_bytes) = value.get(..TIMESTAMP_WIDTH) else {
157 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
158 format!(
159 "point-read event value is shorter than its timestamp for stream {stream_key}"
160 ),
161 )));
162 };
163 let timestamp = u64::from_be_bytes(timestamp_bytes.try_into().map_err(|_| {
164 DurabilityError::StoreError(ApiError::CorruptEvent(format!(
165 "point-read event timestamp has the wrong width for stream {stream_key}"
166 )))
167 })?);
168 let Some(payload) = value.get(TIMESTAMP_WIDTH..) else {
169 return Err(DurabilityError::StoreError(ApiError::CorruptEvent(
170 format!("point-read event has no payload boundary for stream {stream_key}"),
171 )));
172 };
173 Ok(Some(StoredEntry {
174 payload: payload.to_vec(),
175 sequence,
176 timestamp,
177 }))
178 }
179
180 async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
181 // Preserve liminal's "absent == 0" cursor contract faithfully over an
182 // engine that distinguishes `None` (absent) from `Some(0)` (a stored
183 // zero). The invariant that makes the mapping below correct: we NEVER
184 // persist a physical zero, so a logical value of 0 and physical absence
185 // always coincide.
186 //
187 // A `cas` whose target `new_value` is 0 must therefore write nothing — it
188 // only asserts the precondition. This is reachable as `cas(0, 0)` (a
189 // cursor checkpoint at offset 0; offsets are monotonic so they never CAS
190 // down to 0 from a higher value). Were we instead to let it store a
191 // physical zero, the *next* `cas(0, n)` — mapped to expect-absent `None`
192 // — would wrongly fail against the now-present key and permanently stall
193 // the cursor. Asserting via a read is race-free here precisely because no
194 // value is written, so there is no lost-update window.
195 if new_value == 0 {
196 return self
197 .event_store
198 .read_value(key.as_bytes())
199 .map_err(DurabilityError::from)?
200 .map_or(Ok(()), |stored| {
201 Err(DurabilityError::CursorRegression {
202 stored,
203 attempted: old_value,
204 })
205 });
206 }
207 // With a physical zero never stored, `old_value == 0` is exactly the
208 // expect-absent expectation. Any other `old_value` maps to `Some(_)`.
209 // This is a single CAS routed to the owning shard actor, where read,
210 // compare, and write run with no interleaving point (haematite's
211 // `ShardActor::cas`) — the engine's atomicity is preserved end to end.
212 let expected = if old_value == 0 {
213 None
214 } else {
215 Some(old_value)
216 };
217 self.event_store
218 .cas(key.as_bytes(), expected, new_value)
219 .map_err(DurabilityError::from)
220 }
221
222 async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
223 self.event_store
224 .read_value(key.as_bytes())
225 .map_err(DurabilityError::from)
226 }
227
228 async fn scan(&self, prefix: &str) -> Result<Vec<StoredEntry>, DurabilityError> {
229 // The real `scan` predicate yields stream *metadata* (key + next_seq),
230 // not events. Liminal's contract is to return the events of every stream
231 // whose key matches `prefix`, so collect the matching stream keys, then
232 // read each stream's full event list and flatten the results.
233 let prefix_bytes = prefix.as_bytes().to_vec();
234 let matches = self
235 .event_store
236 .scan(|meta| meta.stream_key.starts_with(&prefix_bytes))
237 .map_err(DurabilityError::from)?;
238 let mut entries = Vec::new();
239 for stream in matches {
240 let events = self
241 .event_store
242 .read(&stream.stream_key)
243 .map_err(DurabilityError::from)?;
244 entries.extend(events.into_iter().map(StoredEntry::from));
245 }
246 Ok(entries)
247 }
248
249 async fn flush(&self) -> Result<(), DurabilityError> {
250 self.event_store.flush().map_err(DurabilityError::from)
251 }
252}
253
254/// Drop shell enforcing "close the store, then remove its directory" as
255/// explicit code rather than field declaration order.
256///
257/// Declaration order alone cannot express the unwind case: if dropping the
258/// store panics (a haematite worker failing to join), Rust would still drop
259/// the remaining fields during the unwind and remove the directory under
260/// possibly-live workers. This `Drop` drops the store inside `catch_unwind`;
261/// on unwind it DISARMS the directory guard — the directory is deliberately
262/// leaked, because visible residue is diagnosable while removal under live
263/// workers is filesystem corruption — logs the leaked path, and re-raises the
264/// panic. On the clean path the directory is removed after the store, HERE,
265/// by an explicit [`TempDir::close`] whose error is logged.
266///
267/// The explicitness is the point. Letting the `TempDir` field drop instead
268/// would remove the directory via `tempfile`'s own `Drop`, which is
269/// `let _ = remove_dir_all(..)` — the `io::Result` is discarded, so a removal
270/// that FAILED would be indistinguishable from one that succeeded and this
271/// doc's "the directory is removed" would be a claim no code could check.
272/// `close()` returns that error; the clean path reports it and leaves the
273/// residue where the log says it is. It never panics (a `Drop` that unwinds
274/// during another unwind aborts the process) and never masks: a failure to
275/// remove is a durability fact, not something to swallow.
276///
277/// Both fields are `Option` only so `drop` can move them out; they are `Some`
278/// for the shell's entire life outside `drop`.
279#[derive(Debug)]
280struct EphemeralGuard<S> {
281 store: Option<S>,
282 dir: Option<TempDir>,
283}
284
285impl<S> Drop for EphemeralGuard<S> {
286 fn drop(&mut self) {
287 let store = self.store.take();
288 // AssertUnwindSafe: the closure owns everything it touches (the moved
289 // store), and the unwind path below observes no state the panicking
290 // drop could have left broken — it only disarms the guard and re-raises.
291 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(store)));
292 if let Err(panic) = outcome {
293 if let Some(dir) = self.dir.take() {
294 let leaked = dir.keep();
295 tracing::error!(
296 path = %leaked.display(),
297 "ephemeral store drop panicked; leaking its directory rather than \
298 removing it under possibly-live database workers"
299 );
300 }
301 std::panic::resume_unwind(panic);
302 }
303 // Clean path: the store is closed, its workers are joined and the
304 // writer lock is released, so removing the directory now is safe — and
305 // removing it EXPLICITLY is what makes a failure sayable.
306 if let Some(dir) = self.dir.take() {
307 let path = dir.path().to_path_buf();
308 if let Err(error) = dir.close() {
309 tracing::error!(
310 path = %path.display(),
311 %error,
312 "ephemeral store directory removal failed; residue remains at the \
313 logged path"
314 );
315 }
316 }
317 }
318}
319
320/// Exclusive-ownership ephemeral durable store: the sole owner of both the
321/// haematite database and the temporary directory that backs it.
322///
323/// [`HaematiteStore::new`] takes a *caller-supplied* `Arc<EventStore>`, so a
324/// clone of that inner handle can outlive any guard placed merely beside it —
325/// field declaration order proves nothing across that `Arc` boundary. This
326/// wrapper instead owns the database outright: [`open_ephemeral`] constructs the
327/// inner `Arc` itself, this type never exposes it (no getter) and is deliberately
328/// **not `Clone`**, so the only handle a caller can hold is an
329/// `Arc<dyn DurableStore>` over the whole wrapper. When the last such clone
330/// drops, the [`EphemeralGuard`] drops the store FIRST — the database closes,
331/// its shard actors join and the data-dir writer lock releases on fd close —
332/// and only then removes the directory, logging the error if that removal
333/// fails; if closing the database panics, the directory is deliberately leaked
334/// instead (see [`EphemeralGuard`]).
335#[derive(Debug)]
336pub struct EphemeralHaematiteStore {
337 guard: EphemeralGuard<HaematiteStore>,
338}
339
340impl EphemeralHaematiteStore {
341 /// Takes an already-open ephemeral `Database` and the temporary directory it
342 /// was opened under, becoming their single exclusive owner.
343 ///
344 /// The inner `Arc<EventStore>` is created here and never leaves this type, so
345 /// no caller-supplied clone of it can exist to defeat the drop ordering.
346 /// `ephemeral_dir` must be the directory `database` lives in and must have
347 /// been created before the database was opened (so a failed open removed it
348 /// via the guard's `Drop`, before this constructor was ever reached).
349 fn new(database: Database, ephemeral_dir: TempDir) -> Self {
350 Self {
351 guard: EphemeralGuard {
352 store: Some(HaematiteStore::new(Arc::new(EventStore::new(database)))),
353 dir: Some(ephemeral_dir),
354 },
355 }
356 }
357
358 /// Store handle behind the guard's teardown-only `Option`.
359 ///
360 /// `None` exists only inside [`EphemeralGuard::drop`], which cannot overlap
361 /// a `&self` call, so this error is unreachable by construction — it is a
362 /// typed refusal in place of a panic the workspace forbids, not a state a
363 /// caller can produce.
364 fn store(&self) -> Result<&HaematiteStore, DurabilityError> {
365 self.guard
366 .store
367 .as_ref()
368 .ok_or(DurabilityError::EphemeralStoreDetached)
369 }
370
371 /// Path of the guarding temporary directory, for lifecycle assertions only.
372 #[cfg(test)]
373 pub(crate) fn ephemeral_dir_path(&self) -> Option<&Path> {
374 self.guard.dir.as_ref().map(TempDir::path)
375 }
376}
377
378#[async_trait::async_trait]
379impl DurableStore for EphemeralHaematiteStore {
380 async fn append(
381 &self,
382 stream_key: &str,
383 payload: Vec<u8>,
384 expected_seq: u64,
385 ) -> Result<u64, DurabilityError> {
386 self.store()?
387 .append(stream_key, payload, expected_seq)
388 .await
389 }
390
391 async fn read_from(
392 &self,
393 stream_key: &str,
394 offset: u64,
395 limit: usize,
396 ) -> Result<Vec<StoredEntry>, DurabilityError> {
397 self.store()?.read_from(stream_key, offset, limit).await
398 }
399
400 async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
401 self.store()?.cas(key, old_value, new_value).await
402 }
403
404 async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
405 self.store()?.read_value(key).await
406 }
407
408 async fn scan(&self, prefix: &str) -> Result<Vec<StoredEntry>, DurabilityError> {
409 self.store()?.scan(prefix).await
410 }
411
412 async fn flush(&self) -> Result<(), DurabilityError> {
413 self.store()?.flush().await
414 }
415}
416
417/// Opens a self-owning ephemeral haematite store under a fresh temporary
418/// directory below the system temp dir.
419///
420/// The directory is created BEFORE [`Database::create`], so every failure path —
421/// including a haematite open/create error — removes it when the guard drops on
422/// the error return; the returned store owns the guard on success. The database
423/// is created directly in the (empty) temporary directory: haematite's `create`
424/// accepts an existing empty dir and, on failure, removes only a directory *it*
425/// created, never this pre-existing guard dir (haematite 0.4.1
426/// `db/startup.rs`), so the `TempDir` is the sole owner of directory lifetime on
427/// every path.
428///
429/// # Errors
430/// Returns [`DurabilityError::EphemeralStoreOpen`] if haematite cannot create the
431/// database; the temporary directory is already removed when this returns.
432pub fn open_ephemeral(shard_count: usize) -> Result<EphemeralHaematiteStore, DurabilityError> {
433 open_ephemeral_in(ephemeral_tempdir(None)?, shard_count)
434}
435
436/// TEST SEAM: [`open_ephemeral`] with the temporary directory placed under
437/// `root` instead of the system temp dir.
438///
439/// Rooting lets construction gates assert on an isolated directory instead of
440/// scanning the shared temp dir. Same lifecycle contract as
441/// [`open_ephemeral`] — the store owns and removes its directory; `root` must
442/// already exist and must outlive the store.
443///
444/// That last requirement is why this is NOT a production API: the store's
445/// exclusive ownership of its directory (the D3 invariant) says nothing about
446/// the PARENT — a caller rooting the store inside a directory they own via
447/// their own guard can drop that guard while the store is live, deleting the
448/// database out from under its running workers. A general rooted API would
449/// need a root-ownership token so parent cleanup cannot outrun the store;
450/// that is deferred until a real embedder need arrives. Until then the
451/// function is gated to tests (`cfg(test)` in this crate, the default-off
452/// `test-support` feature for downstream test harnesses).
453///
454/// # Errors
455/// Returns [`DurabilityError::EphemeralStoreOpen`] if the directory cannot be
456/// created under `root` or haematite cannot create the database; no residue
457/// remains under `root` when this returns an error.
458#[cfg(any(test, feature = "test-support"))]
459pub fn open_ephemeral_rooted(
460 root: &Path,
461 shard_count: usize,
462) -> Result<EphemeralHaematiteStore, DurabilityError> {
463 open_ephemeral_in(ephemeral_tempdir(Some(root))?, shard_count)
464}
465
466/// Creates the guard directory for an ephemeral store, under `root` when given
467/// and under the system temp dir otherwise.
468fn ephemeral_tempdir(root: Option<&Path>) -> Result<TempDir, DurabilityError> {
469 let mut builder = tempfile::Builder::new();
470 builder.prefix("liminal-durability-");
471 root.map_or_else(|| builder.tempdir(), |root| builder.tempdir_in(root))
472 .map_err(|error| {
473 DurabilityError::EphemeralStoreOpen(format!(
474 "could not create temporary directory: {error}"
475 ))
476 })
477}
478
479/// Opens an ephemeral store inside an already-created guard directory.
480///
481/// Split out so the guard exists before `Database::create` and so lifecycle
482/// tests can inject an open failure into a directory they pre-populated.
483fn open_ephemeral_in(
484 ephemeral_dir: TempDir,
485 shard_count: usize,
486) -> Result<EphemeralHaematiteStore, DurabilityError> {
487 let database = Database::create(DatabaseConfig {
488 data_dir: ephemeral_dir.path().to_path_buf(),
489 shard_count,
490 distributed: None,
491 executor_threads: None,
492 })
493 .map_err(|error| DurabilityError::EphemeralStoreOpen(error.to_string()))?;
494 Ok(EphemeralHaematiteStore::new(database, ephemeral_dir))
495}
496
497impl From<Event> for StoredEntry {
498 fn from(event: Event) -> Self {
499 Self {
500 payload: event.payload,
501 sequence: event.seq,
502 timestamp: event.timestamp,
503 }
504 }
505}
506
507/// Maps a real-engine [`ApiError`] onto liminal's [`DurabilityError`].
508///
509/// The optimistic-concurrency variants route to their dedicated `DurabilityError`
510/// cases (`SequenceConflict`, `CursorRegression`); everything else is a
511/// store-level failure carried verbatim.
512impl From<ApiError> for DurabilityError {
513 fn from(error: ApiError) -> Self {
514 match error {
515 ApiError::SequenceConflict(conflict) => conflict.into(),
516 ApiError::CasMismatch(mismatch) => mismatch.into(),
517 other @ (ApiError::CorruptEvent(_)
518 | ApiError::Storage(_)
519 | ApiError::HistoryCompacted(_)) => Self::StoreError(other),
520 }
521 }
522}
523
524#[cfg(test)]
525#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
526mod ephemeral_lifecycle_tests {
527 //! D3 §9 lifecycle gate. Each test names the gate it pins; all are permanent
528 //! rule-1 assertions that the ephemeral store's directory has an enforced
529 //! owner across every teardown path.
530
531 use std::path::{Path, PathBuf};
532 use std::sync::{Arc, Mutex};
533
534 use super::super::bridge::block_on;
535 use super::{
536 DurableStore, EphemeralGuard, open_ephemeral, open_ephemeral_in, open_ephemeral_rooted,
537 };
538
539 const TEST_SHARD_COUNT: usize = 2;
540
541 /// In-memory `tracing` sink, so a test can assert on what the teardown path
542 /// LOGGED rather than on what it merely did.
543 ///
544 /// Every teardown assertion below runs against this one instrument, and
545 /// [`panic_path_leak_is_logged_with_its_path`] is its positive control: it
546 /// exercises the SAME predicate (`captured` contains the path and `ERROR`)
547 /// against a log line that is emitted today. Without that control an empty
548 /// capture would only measure the harness.
549 #[derive(Clone, Default)]
550 struct CapturedLog(Arc<Mutex<Vec<u8>>>);
551
552 impl CapturedLog {
553 /// Everything written to the sink so far, as text.
554 fn text(&self) -> String {
555 let bytes = self
556 .0
557 .lock()
558 .expect("capture buffer is not poisoned")
559 .clone();
560 String::from_utf8(bytes).expect("tracing's fmt writer emits utf-8")
561 }
562
563 /// Runs `body` with this sink receiving everything the CURRENT THREAD
564 /// logs, via one process-global subscriber and a thread-routed writer.
565 ///
566 /// Why not `tracing::subscriber::with_default`: a scoped subscriber
567 /// registers a dispatcher on entry and deregisters it on exit, and
568 /// tracing maintains global state (the per-callsite interest cache and
569 /// the max-level hint) that is rebuilt on those edges. That produced a
570 /// measured intermittently-EMPTY capture in this module — 3/40
571 /// module-scoped runs raw; serializing the windows on a mutex cured
572 /// the module-scoped loop (0/40) but the full-workspace battery still
573 /// reproduced the empty capture with the mutex in place, so edge
574 /// timing was not the whole mechanism. This design removes the CLASS:
575 /// the global subscriber is installed exactly once and never
576 /// deregistered, so no edge ever exists to re-poison the caches, and
577 /// routing is thread-local so parallel tests cannot cross-capture.
578 fn capturing<R>(&self, body: impl FnOnce() -> R) -> R {
579 static INSTALL: std::sync::Once = std::sync::Once::new();
580 /// Clears the thread's capture slot even when `body` unwinds.
581 struct ResetOnDrop;
582 impl Drop for ResetOnDrop {
583 fn drop(&mut self) {
584 ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = None);
585 }
586 }
587 INSTALL.call_once(|| {
588 let subscriber = tracing_subscriber::fmt()
589 .with_writer(RoutedWriter)
590 .with_ansi(false)
591 .finish();
592 tracing::subscriber::set_global_default(subscriber)
593 .expect("no other global tracing subscriber is installed in this test binary");
594 });
595 ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = Some(self.clone()));
596 let _reset = ResetOnDrop;
597 body()
598 }
599 }
600
601 thread_local! {
602 /// The capture buffer receiving THIS thread's log output, if a
603 /// [`CapturedLog::capturing`] window is active on it.
604 static ACTIVE_CAPTURE: std::cell::RefCell<Option<CapturedLog>> =
605 const { std::cell::RefCell::new(None) };
606 }
607
608 /// The one writer the process-global subscriber owns: appends to the
609 /// emitting thread's active capture buffer, and silently discards output
610 /// from threads with no capture window open.
611 #[derive(Clone, Copy, Default)]
612 struct RoutedWriter;
613
614 impl std::io::Write for RoutedWriter {
615 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
616 ACTIVE_CAPTURE.with(|slot| {
617 if let Some(capture) = slot.borrow().as_ref() {
618 capture
619 .0
620 .lock()
621 .map_err(|_| std::io::Error::other("capture buffer poisoned"))?
622 .extend_from_slice(buf);
623 }
624 Ok(buf.len())
625 })
626 }
627
628 fn flush(&mut self) -> std::io::Result<()> {
629 Ok(())
630 }
631 }
632
633 impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for RoutedWriter {
634 type Writer = Self;
635
636 fn make_writer(&'writer self) -> Self::Writer {
637 *self
638 }
639 }
640
641 /// Sets `path`'s mode, used to make a parent directory unwritable so that
642 /// removing a directory INSIDE it fails at the final `rmdir`.
643 ///
644 /// That is the observed production failure shape: the contents go, the
645 /// directory itself stays, and the removal error is the only witness.
646 #[cfg(unix)]
647 fn set_mode(path: &Path, mode: u32) {
648 use std::os::unix::fs::PermissionsExt;
649
650 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
651 .expect("test can set permissions on a directory it created");
652 }
653
654 /// Store stand-in whose `Drop` pins the guard's internal ordering: the
655 /// directory must still exist at store-drop time, so this drop FAILS the
656 /// test if the guard ever removes the directory first.
657 struct OrderProbeStore {
658 dir: PathBuf,
659 }
660
661 impl Drop for OrderProbeStore {
662 fn drop(&mut self) {
663 assert!(
664 self.dir.exists(),
665 "the guard must drop the store BEFORE removing the directory"
666 );
667 }
668 }
669
670 /// Store stand-in whose `Drop` panics, modelling a haematite worker failing
671 /// to join while the database closes.
672 struct PanickingProbeStore;
673
674 impl Drop for PanickingProbeStore {
675 fn drop(&mut self) {
676 panic!("injected store-drop panic");
677 }
678 }
679
680 /// Materialises shard directories and fds so the drop path actually has a
681 /// live database to close before the guard removes the directory.
682 fn write_one_event(store: &dyn DurableStore) {
683 block_on(store.append("lifecycle/probe", b"payload".to_vec(), 0))
684 .expect("bridge completes synchronously")
685 .expect("append to a fresh ephemeral stream succeeds");
686 block_on(store.flush())
687 .expect("bridge completes synchronously")
688 .expect("flush of a live ephemeral store succeeds");
689 }
690
691 /// §9 gate — normal drop: the directory is removed once the last (here, only)
692 /// handle drops.
693 #[test]
694 fn ephemeral_dir_removed_after_last_handle_drops() {
695 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
696 let dir = store
697 .ephemeral_dir_path()
698 .expect("ephemeral store carries a guard dir")
699 .to_path_buf();
700 assert!(
701 dir.exists(),
702 "the guard directory exists while the store is live"
703 );
704
705 write_one_event(&store);
706 drop(store);
707
708 assert!(
709 !dir.exists(),
710 "the guard directory is removed on normal drop"
711 );
712 }
713
714 /// §9 gate — teardown with store-handle clones alive: the directory survives
715 /// until the LAST `Arc<dyn DurableStore>` clone drops, then is removed. This
716 /// is the `Arc`-shared-into-channel-handles case: clones share one wrapper,
717 /// so none can close the database early.
718 #[test]
719 fn ephemeral_dir_survives_until_last_store_clone_drops() {
720 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
721 let dir = store
722 .ephemeral_dir_path()
723 .expect("ephemeral store carries a guard dir")
724 .to_path_buf();
725 write_one_event(&store);
726
727 let erased: Arc<dyn DurableStore> = Arc::new(store);
728 let clone_a = Arc::clone(&erased);
729 let clone_b = Arc::clone(&erased);
730
731 drop(erased);
732 assert!(
733 dir.exists(),
734 "directory survives while store clones remain alive"
735 );
736 drop(clone_a);
737 assert!(
738 dir.exists(),
739 "directory survives while one store clone remains alive"
740 );
741
742 drop(clone_b);
743 assert!(
744 !dir.exists(),
745 "the last store clone dropping removes the directory"
746 );
747 }
748
749 /// §9 gate — startup rollback: an injected haematite open failure (a
750 /// conflicting `config.json` pre-seeded into the guard dir) makes the
751 /// constructor return `Err` AND leaves zero residue — the guard removes the
752 /// directory independently of haematite's own cleanup.
753 #[test]
754 fn ephemeral_open_failure_rolls_back_directory() {
755 let seeded = tempfile::Builder::new()
756 .prefix("liminal-durability-test-")
757 .tempdir()
758 .expect("test can create a temp dir");
759 let dir = seeded.path().to_path_buf();
760 // A pre-existing `config.json` makes haematite refuse the create with
761 // `DataDirAlreadyInitialised`; because the dir pre-existed the create,
762 // haematite never removes it — only the guard does.
763 std::fs::write(dir.join("config.json"), b"not-a-valid-config")
764 .expect("test can seed a conflicting config");
765
766 let result = open_ephemeral_in(seeded, TEST_SHARD_COUNT);
767
768 assert!(result.is_err(), "an injected open failure returns Err");
769 assert!(
770 !dir.exists(),
771 "the guard removes the directory on open failure — zero residue"
772 );
773 }
774
775 /// §9 gate — repeated start/stop: each cycle owns a distinct directory and
776 /// leaves zero residue after it drops.
777 #[test]
778 fn repeated_ephemeral_cycles_each_own_distinct_dir_zero_residue() {
779 let mut seen: Vec<PathBuf> = Vec::new();
780 for _ in 0..5 {
781 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
782 let dir = store
783 .ephemeral_dir_path()
784 .expect("ephemeral store carries a guard dir")
785 .to_path_buf();
786 assert!(
787 dir.exists(),
788 "the cycle's directory exists while its store is live"
789 );
790 assert!(!seen.contains(&dir), "each cycle owns a distinct directory");
791 seen.push(dir.clone());
792
793 write_one_event(&store);
794 drop(store);
795 assert!(
796 !dir.exists(),
797 "the cycle's directory is removed after its store drops"
798 );
799 }
800 }
801
802 /// §9 gate (drop-order pin): the guard drops the store strictly before it
803 /// removes the directory. `OrderProbeStore::drop` asserts the directory
804 /// still exists, so reversing the order inside [`EphemeralGuard`] fails this
805 /// test rather than silently passing.
806 #[test]
807 fn guard_drops_store_before_removing_directory() {
808 let dir = tempfile::tempdir().expect("test can create a temp dir");
809 let path = dir.path().to_path_buf();
810 let guard = EphemeralGuard {
811 store: Some(OrderProbeStore { dir: path.clone() }),
812 dir: Some(dir),
813 };
814
815 drop(guard);
816
817 assert!(!path.exists(), "a clean drop still removes the directory");
818 }
819
820 /// §9 gate (unwind pin): a panic while the store drops leaves the directory
821 /// LEAKED, never removed under possibly-live workers, and the panic still
822 /// propagates.
823 #[test]
824 fn guard_leaks_directory_when_store_drop_panics() {
825 let dir = tempfile::tempdir().expect("test can create a temp dir");
826 let path = dir.path().to_path_buf();
827 let guard = EphemeralGuard {
828 store: Some(PanickingProbeStore),
829 dir: Some(dir),
830 };
831
832 let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(guard)));
833
834 assert!(unwound.is_err(), "the injected store-drop panic propagates");
835 assert!(
836 path.exists(),
837 "a panicking store drop leaks the directory instead of removing it"
838 );
839 std::fs::remove_dir_all(&path).expect("test cleans up the deliberately leaked directory");
840 }
841
842 /// The rooted factory places (and removes) the guard directory under the
843 /// caller-supplied root, which is what lets construction gates assert on an
844 /// isolated root instead of scanning the system temp dir.
845 #[test]
846 fn rooted_ephemeral_store_lives_and_dies_under_the_given_root() {
847 let root = tempfile::tempdir().expect("test can create a temp root");
848 let store =
849 open_ephemeral_rooted(root.path(), TEST_SHARD_COUNT).expect("rooted open succeeds");
850 let dir = store
851 .ephemeral_dir_path()
852 .expect("ephemeral store carries a guard dir")
853 .to_path_buf();
854 assert!(
855 dir.starts_with(root.path()),
856 "the guard directory is created under the supplied root"
857 );
858
859 write_one_event(&store);
860 drop(store);
861
862 assert!(!dir.exists(), "the rooted directory is removed on drop");
863 }
864
865 /// Clean-teardown gate (keepalive-honest shape): the guard directory is
866 /// present for the store's WHOLE life — re-checked between unrelated
867 /// operations that each succeed — and gone once the store drops cleanly.
868 ///
869 /// The "unrelated ops proceed" leg is what makes the final absence mean
870 /// something: a directory that vanished early would take the appends,
871 /// reads and CAS down with it, so this cannot pass by removing the
872 /// directory too soon and cannot pass by never having created it.
873 #[test]
874 fn ephemeral_dir_persists_across_unrelated_work_then_goes_on_clean_drop() {
875 let store = open_ephemeral(TEST_SHARD_COUNT).expect("ephemeral open succeeds");
876 let dir = store
877 .ephemeral_dir_path()
878 .expect("ephemeral store carries a guard dir")
879 .to_path_buf();
880 assert!(
881 dir.exists(),
882 "the directory exists as soon as the store does"
883 );
884
885 for round in 0..3_u64 {
886 block_on(store.append("clean-teardown/probe", b"payload".to_vec(), round))
887 .expect("bridge completes synchronously")
888 .expect("append to a live ephemeral store succeeds");
889 assert!(
890 dir.exists(),
891 "the directory is still there after append round {round}"
892 );
893 }
894 block_on(store.cas("clean-teardown/counter", 0, 7))
895 .expect("bridge completes synchronously")
896 .expect("cas on a live ephemeral store succeeds");
897 let entries = block_on(store.read_from("clean-teardown/probe", 0, 10))
898 .expect("bridge completes synchronously")
899 .expect("read from a live ephemeral store succeeds");
900 assert_eq!(entries.len(), 3, "every appended entry is readable back");
901 assert!(
902 dir.exists(),
903 "the directory is still there after unrelated cas and read work"
904 );
905
906 block_on(store.flush())
907 .expect("bridge completes synchronously")
908 .expect("flush of a live ephemeral store succeeds");
909 drop(store);
910
911 assert!(
912 !dir.exists(),
913 "the clean drop removes the directory it kept alive throughout"
914 );
915 }
916
917 /// Clean-teardown gate: when removal FAILS on the clean path the guard
918 /// LOGS the failure and its path, and does not panic.
919 ///
920 /// Injected the way it fails in production: the parent is made unwritable,
921 /// so `remove_dir_all` clears the contents and then cannot unlink the
922 /// directory itself. `tempfile`'s own `Drop` discards that error
923 /// (`let _ = remove_dir_all(..)`), which is why this pin is red until the
924 /// clean path calls `close()` and reports what it returns.
925 #[cfg(unix)]
926 #[test]
927 fn clean_drop_removal_failure_is_logged_and_never_panics() {
928 let parent = tempfile::tempdir().expect("test can create a temp parent");
929 let dir = tempfile::Builder::new()
930 .prefix("liminal-durability-")
931 .tempdir_in(parent.path())
932 .expect("test can create a guard dir under the parent");
933 let path = dir.path().to_path_buf();
934 let guard = EphemeralGuard {
935 store: Some(()),
936 dir: Some(dir),
937 };
938
939 set_mode(parent.path(), 0o500);
940 let captured = CapturedLog::default();
941 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
942 captured.capturing(|| drop(guard));
943 }));
944 // Restored before the assertions so a failing assertion still leaves a
945 // parent the outer `TempDir` can clean up.
946 set_mode(parent.path(), 0o700);
947
948 assert!(
949 outcome.is_ok(),
950 "a removal failure is reported, never raised as a panic"
951 );
952 let logged = captured.text();
953 assert!(
954 logged.contains("ERROR"),
955 "the removal failure is logged at error level; captured: {logged:?}"
956 );
957 assert!(
958 logged.contains(&path.display().to_string()),
959 "the log names the directory that survived; captured: {logged:?}"
960 );
961 assert!(
962 path.exists(),
963 "the residue is left where the log says it is, not silently claimed removed"
964 );
965 }
966
967 /// Clean-teardown gate (negative control for the capture instrument): a
968 /// removal that SUCCEEDS logs nothing, so the assertion above discriminates
969 /// failure from success rather than matching any teardown at all.
970 #[test]
971 fn clean_drop_that_succeeds_logs_nothing() {
972 let dir = tempfile::tempdir().expect("test can create a temp dir");
973 let path = dir.path().to_path_buf();
974 let guard = EphemeralGuard {
975 store: Some(()),
976 dir: Some(dir),
977 };
978
979 let captured = CapturedLog::default();
980 captured.capturing(|| drop(guard));
981
982 assert!(!path.exists(), "the successful clean drop removed the dir");
983 assert!(
984 captured.text().is_empty(),
985 "a successful removal is silent; captured: {:?}",
986 captured.text()
987 );
988 }
989
990 /// Positive control for the capture instrument: the panic path's sanctioned
991 /// leak line IS captured, path and all, by the same predicate the
992 /// removal-failure gate uses.
993 ///
994 /// Without this, an empty capture would be a measurement of the harness
995 /// rather than of the code under test.
996 #[test]
997 fn panic_path_leak_is_logged_with_its_path() {
998 let dir = tempfile::tempdir().expect("test can create a temp dir");
999 let path = dir.path().to_path_buf();
1000 let guard = EphemeralGuard {
1001 store: Some(PanickingProbeStore),
1002 dir: Some(dir),
1003 };
1004
1005 let captured = CapturedLog::default();
1006 let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1007 captured.capturing(|| drop(guard));
1008 }));
1009
1010 assert!(unwound.is_err(), "the injected store-drop panic propagates");
1011 let logged = captured.text();
1012 assert!(
1013 logged.contains("ERROR"),
1014 "the sanctioned leak is logged at error level; captured: {logged:?}"
1015 );
1016 assert!(
1017 logged.contains(&path.display().to_string()),
1018 "the leak log names the leaked directory; captured: {logged:?}"
1019 );
1020 std::fs::remove_dir_all(&path).expect("test cleans up the deliberately leaked directory");
1021 }
1022}