Skip to main content

mongreldb_core/
core.rs

1//! Shared storage-core plumbing (spec §10.1, S1A-003/S1A-004).
2//!
3//! This module hosts the two cross-cutting pieces of the Stage 1A "one shared
4//! storage core" work that are independent of the [`crate::database`] internals:
5//!
6//! - [`DatabaseFileIdentity`] — the stable, directory-handle-derived identity
7//!   of a database root (S1A-003). The canonical path is kept for diagnostics
8//!   only; identity is the device + inode (Unix) or volume serial + file index
9//!   (Windows) of the pinned directory descriptor, so renames and symlink
10//!   aliases of the same root collapse onto one identity.
11//! - [`LifecycleState`] / [`LifecycleController`] / [`OperationGuard`] — the
12//!   core lifecycle state machine (S1A-004). Every operation on a shared core
13//!   holds an [`OperationGuard`]; `shutdown()` transitions through
14//!   `Draining`/`Closing` to `Closed` while new operations are rejected.
15
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::time::{Duration, Instant};
19
20use parking_lot::{Condvar, Mutex};
21
22use crate::error::{MongrelError, Result};
23
24/// Stable identity of a database root directory (spec §10.1, S1A-003).
25///
26/// Equality and hashing use only the durable directory-handle identity, never
27/// the path text: two paths that resolve to the same directory (symlinks,
28/// `..` segments, renamed parents) map to one identity. `canonical_path` is
29/// retained for diagnostics and error messages.
30#[derive(Clone, Debug)]
31pub struct DatabaseFileIdentity {
32    stable: crate::durable_file::DurableFileIdentity,
33    canonical_path: PathBuf,
34}
35
36impl DatabaseFileIdentity {
37    /// Resolve the stable identity of an existing database root.
38    ///
39    /// The path is canonicalized and pinned through a [`crate::durable_file::DurableRoot`]
40    /// descriptor so the identity is read from the directory handle itself.
41    pub fn for_path(root: impl AsRef<Path>) -> Result<Self> {
42        let root = root.as_ref();
43        let canonical_path = root.canonicalize().map_err(|error| {
44            if error.kind() == std::io::ErrorKind::NotFound {
45                MongrelError::NotFound(format!("database root {}: {error}", root.display()))
46            } else {
47                MongrelError::Io(error)
48            }
49        })?;
50        let durable_root = crate::durable_file::DurableRoot::open(&canonical_path)?;
51        let stable = durable_root.file_identity()?;
52        Ok(Self {
53            stable,
54            canonical_path,
55        })
56    }
57
58    /// Build an identity from an already-pinned durable root.
59    pub fn from_durable_root(root: &crate::durable_file::DurableRoot) -> Result<Self> {
60        Ok(Self {
61            stable: root.file_identity()?,
62            canonical_path: root.canonical_path().to_path_buf(),
63        })
64    }
65
66    /// Canonical path of the root at identity resolution time. Diagnostics
67    /// only — never use this as the identity itself.
68    pub fn canonical_path(&self) -> &Path {
69        &self.canonical_path
70    }
71}
72
73impl PartialEq for DatabaseFileIdentity {
74    fn eq(&self, other: &Self) -> bool {
75        self.stable == other.stable
76    }
77}
78
79impl Eq for DatabaseFileIdentity {}
80
81impl std::hash::Hash for DatabaseFileIdentity {
82    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
83        self.stable.hash(state);
84    }
85}
86
87impl std::fmt::Display for DatabaseFileIdentity {
88    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        write!(formatter, "{}", self.canonical_path.display())
90    }
91}
92
93/// Lifecycle states of one storage core (spec §10.1, S1A-004).
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub enum LifecycleState {
96    /// Recovery, WAL opening, open-generation advancement, and table mounting
97    /// are in progress. No operations are admitted.
98    Opening,
99    /// The core admits operations.
100    Open,
101    /// `shutdown()` has begun: new sessions and writes are rejected while
102    /// in-flight operations drain.
103    Draining,
104    /// Operations have drained; durable state is being synced, workers are
105    /// stopping, and the file lock is being released.
106    Closing,
107    /// The core is fully shut down. Every operation fails.
108    Closed,
109    /// An unrecoverable internal error (e.g. a durability poison) left the
110    /// core unable to continue. Every operation fails.
111    Poisoned,
112}
113
114impl LifecycleState {
115    /// Whether the core currently admits new operations.
116    pub fn admits_operations(self) -> bool {
117        matches!(self, LifecycleState::Open)
118    }
119}
120
121impl std::fmt::Display for LifecycleState {
122    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        let name = match self {
124            LifecycleState::Opening => "opening",
125            LifecycleState::Open => "open",
126            LifecycleState::Draining => "draining",
127            LifecycleState::Closing => "closing",
128            LifecycleState::Closed => "closed",
129            LifecycleState::Poisoned => "poisoned",
130        };
131        formatter.write_str(name)
132    }
133}
134
135/// The lifecycle controller embedded in every `DatabaseCore` (spec §10.1,
136/// S1A-004). It owns the state machine plus the in-flight operation counter
137/// that `shutdown()` drains against.
138///
139/// The controller is shared through an `Arc` so [`OperationGuard`]s can be
140/// `'static` and cross threads freely (a query worker may hold a guard long
141/// after the initiating handle returned).
142#[derive(Debug)]
143pub struct LifecycleController {
144    inner: Mutex<LifecycleInner>,
145    drained: Condvar,
146}
147
148#[derive(Debug)]
149struct LifecycleInner {
150    state: LifecycleState,
151    active_operations: u64,
152}
153
154impl Default for LifecycleController {
155    fn default() -> Self {
156        Self::new()
157    }
158}
159
160impl LifecycleController {
161    /// A controller in the `Opening` state: the core admits no operations
162    /// until [`Self::mark_open`] completes initialization.
163    pub fn new() -> Self {
164        Self {
165            inner: Mutex::new(LifecycleInner {
166                state: LifecycleState::Opening,
167                active_operations: 0,
168            }),
169            drained: Condvar::new(),
170        }
171    }
172
173    /// The current lifecycle state.
174    pub fn state(&self) -> LifecycleState {
175        self.inner.lock().state
176    }
177
178    /// Whether the core currently admits new operations.
179    pub fn is_open(&self) -> bool {
180        self.state().admits_operations()
181    }
182
183    /// `Opening` → `Open`. Called exactly once when core initialization
184    /// finishes (recovery, WAL opening, open-generation advancement, and
185    /// table mounting are done).
186    pub fn mark_open(&self) {
187        let mut inner = self.inner.lock();
188        if matches!(inner.state, LifecycleState::Opening) {
189            inner.state = LifecycleState::Open;
190        }
191    }
192
193    /// `Open` → `Draining`: the first `shutdown()` step. Returns `true` when
194    /// this call initiated the drain; `false` when the core was already past
195    /// `Open` (a concurrent or repeated shutdown), in which case the caller
196    /// must not drive the remaining shutdown steps.
197    pub fn begin_shutdown(&self) -> bool {
198        let mut inner = self.inner.lock();
199        if matches!(inner.state, LifecycleState::Open) {
200            inner.state = LifecycleState::Draining;
201            return true;
202        }
203        false
204    }
205
206    /// Wait until every in-flight operation has drained or `deadline`
207    /// elapses. The core stays in `Draining` either way; a timeout leaves the
208    /// shutdown to the caller to retry or abandon.
209    pub fn wait_drained(&self, deadline: Duration) -> Result<()> {
210        let started = Instant::now();
211        let mut inner = self.inner.lock();
212        loop {
213            if inner.active_operations == 0 {
214                return Ok(());
215            }
216            let elapsed = started.elapsed();
217            if elapsed >= deadline {
218                return Err(MongrelError::DatabaseBusy {
219                    strong_handles: inner.active_operations as usize,
220                });
221            }
222            let remaining = deadline - elapsed;
223            if self
224                .drained
225                .wait_for(&mut inner, remaining.min(Duration::from_millis(50)))
226                .timed_out()
227                && started.elapsed() >= deadline
228            {
229                return Err(MongrelError::DatabaseBusy {
230                    strong_handles: inner.active_operations as usize,
231                });
232            }
233        }
234    }
235
236    /// `Draining` → `Closing`: operations have drained; durable sync, worker
237    /// stop, and file-lock release happen in this state.
238    pub fn mark_closing(&self) {
239        let mut inner = self.inner.lock();
240        if matches!(inner.state, LifecycleState::Draining) {
241            inner.state = LifecycleState::Closing;
242        }
243    }
244
245    /// `Closing` → `Closed`: the final shutdown step.
246    pub fn mark_closed(&self) {
247        let mut inner = self.inner.lock();
248        if matches!(
249            inner.state,
250            LifecycleState::Closing | LifecycleState::Draining
251        ) {
252            inner.state = LifecycleState::Closed;
253        }
254        self.drained.notify_all();
255    }
256
257    /// Any state → `Poisoned`: an unrecoverable internal error. Poison is
258    /// terminal and sticky — a poisoned core never returns to `Open`.
259    pub fn poison(&self) {
260        let mut inner = self.inner.lock();
261        inner.state = LifecycleState::Poisoned;
262        drop(inner);
263        self.drained.notify_all();
264    }
265
266    /// Admit one operation (S1A-004: "every operation holds an
267    /// `OperationGuard`"). New operations are rejected unless the core is
268    /// `Open`; the returned guard releases its slot on drop.
269    pub fn begin_operation(self: &Arc<Self>) -> Result<OperationGuard> {
270        let mut inner = self.inner.lock();
271        if !inner.state.admits_operations() {
272            return Err(MongrelError::Conflict(format!(
273                "database core is not open (lifecycle state: {})",
274                inner.state
275            )));
276        }
277        inner.active_operations = inner
278            .active_operations
279            .checked_add(1)
280            .ok_or_else(|| MongrelError::Full("operation counter exhausted".into()))?;
281        Ok(OperationGuard {
282            lifecycle: Arc::clone(self),
283        })
284    }
285
286    /// Number of operations currently holding a guard.
287    pub fn active_operations(&self) -> u64 {
288        self.inner.lock().active_operations
289    }
290
291    fn end_operation(&self) {
292        let mut inner = self.inner.lock();
293        inner.active_operations = inner.active_operations.saturating_sub(1);
294        if inner.active_operations == 0 {
295            drop(inner);
296            self.drained.notify_all();
297        }
298    }
299}
300
301/// RAII proof that one operation holds the core open (spec §10.1, S1A-004).
302/// Dropping the guard releases the operation slot; `shutdown()` waits for all
303/// outstanding guards before closing.
304#[derive(Debug)]
305pub struct OperationGuard {
306    lifecycle: Arc<LifecycleController>,
307}
308
309impl OperationGuard {
310    /// The lifecycle state the guard was taken under (always
311    /// [`LifecycleState::Open`]).
312    pub fn lifecycle(&self) -> &Arc<LifecycleController> {
313        &self.lifecycle
314    }
315}
316
317impl Drop for OperationGuard {
318    fn drop(&mut self) {
319        self.lifecycle.end_operation();
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn file_identity_ignores_path_spelling() {
329        let dir = std::env::temp_dir().join(format!(
330            "mongreldb-core-identity-{}-{}",
331            std::process::id(),
332            std::time::SystemTime::now()
333                .duration_since(std::time::UNIX_EPOCH)
334                .unwrap()
335                .as_nanos()
336        ));
337        std::fs::create_dir_all(dir.join("sub")).unwrap();
338        let direct = DatabaseFileIdentity::for_path(dir.join("sub")).unwrap();
339        let aliased =
340            DatabaseFileIdentity::for_path(dir.join("sub").join("..").join("sub")).unwrap();
341        assert_eq!(direct, aliased);
342        let other = DatabaseFileIdentity::for_path(&dir).unwrap();
343        assert_ne!(direct, other);
344        std::fs::remove_dir_all(&dir).unwrap();
345    }
346
347    #[test]
348    fn lifecycle_rejects_operations_unless_open() {
349        let lifecycle = Arc::new(LifecycleController::new());
350        assert_eq!(lifecycle.state(), LifecycleState::Opening);
351        assert!(lifecycle.begin_operation().is_err());
352        lifecycle.mark_open();
353        let guard = lifecycle.begin_operation().unwrap();
354        assert_eq!(lifecycle.active_operations(), 1);
355        drop(guard);
356        assert_eq!(lifecycle.active_operations(), 0);
357    }
358
359    #[test]
360    fn shutdown_drains_then_closes() {
361        let lifecycle = Arc::new(LifecycleController::new());
362        lifecycle.mark_open();
363        let guard = lifecycle.begin_operation().unwrap();
364        assert!(lifecycle.begin_shutdown());
365        // New operations are rejected while draining.
366        assert!(lifecycle.begin_operation().is_err());
367        // A second shutdown attempt does not take ownership of the drain.
368        assert!(!lifecycle.begin_shutdown());
369        assert!(
370            lifecycle.wait_drained(Duration::from_millis(10)).is_err(),
371            "drain must time out while a guard is held"
372        );
373        drop(guard);
374        lifecycle.wait_drained(Duration::from_secs(1)).unwrap();
375        lifecycle.mark_closing();
376        assert_eq!(lifecycle.state(), LifecycleState::Closing);
377        lifecycle.mark_closed();
378        assert_eq!(lifecycle.state(), LifecycleState::Closed);
379        assert!(lifecycle.begin_operation().is_err());
380    }
381
382    #[test]
383    fn poison_is_terminal() {
384        let lifecycle = Arc::new(LifecycleController::new());
385        lifecycle.mark_open();
386        lifecycle.poison();
387        assert_eq!(lifecycle.state(), LifecycleState::Poisoned);
388        assert!(lifecycle.begin_operation().is_err());
389        lifecycle.mark_open();
390        assert_eq!(lifecycle.state(), LifecycleState::Poisoned);
391        assert!(!lifecycle.begin_shutdown());
392    }
393}