Skip to main content

nedb_engine/
exit.rs

1// SPDX-FileCopyrightText: 2026 INTERCHAINED LLC
2// SPDX-License-Identifier: BUSL-1.1
3// NEDB · © 2026 INTERCHAINED LLC × Eth-Interchained × Vex (Claude Opus 5)
4
5//! Durable-mode auto-flush-on-exit.
6//!
7//! A durable [`Db`] stages id-index updates in an in-memory WAL (`write_buf`) and
8//! only makes them durable in `flush_all()` — normally driven by the manifest
9//! ticker or by `Drop`. But `Drop` "only fires once every owning handle is gone",
10//! and a ticker thread (or a server that blocks forever in `serve()`) holds an
11//! `Arc<Db>` for the whole process lifetime — so on a hard exit (`Ctrl+C`,
12//! `SIGTERM` from an orchestrator, `kill`) `Drop` NEVER runs and the writes staged
13//! since the last tick are lost. That is the gap this module closes.
14//!
15//! [`Db::install_exit_flush`] registers a durable database to be flushed when the
16//! process receives `SIGINT` or `SIGTERM`. It flushes exactly once, on the way
17//! out — NOT on every put — so the hot write path stays hot.
18//!
19//! # Design
20//!
21//! - **Opt-in.** A *library* that unilaterally seizes signal handlers would
22//!   trample a host application's own shutdown logic, so the core never installs
23//!   this implicitly. The napi (`nedb-node`) and pyo3 (`nedb-py`) `open()` paths
24//!   call it for durable databases — so Node and Python embedders get
25//!   flush-on-exit for free — and Rust applications call it explicitly. `nedbd`
26//!   keeps its own tokio graceful-shutdown handler and does not use this.
27//!
28//! - **Async-signal-safe.** The installed handler does exactly one thing: a
29//!   non-blocking `write(2)` of the signal number to a self-pipe (`write` is on
30//!   the POSIX async-signal-safe list). A dedicated reader thread blocks on the
31//!   read end; when woken it runs `flush_all()` on every registered database from
32//!   a *normal* thread context (locks, allocation and file I/O are all safe
33//!   there), restores the signal's default disposition, and re-raises it so the
34//!   process terminates with the correct `128 + signum` status.
35//!
36//! - **Idempotent.** The handler and reader thread are installed once per
37//!   process; each subsequent call just registers another database. In-memory
38//!   (`:memory:`) databases are ignored — there is nothing to flush.
39//!
40//! - **Weak references.** The registry holds `Weak<Db>`, so a registered database
41//!   that is otherwise dropped is not kept alive (no leak) and is pruned on the
42//!   next flush pass.
43
44use std::sync::{Arc, Mutex, OnceLock, Weak};
45#[cfg(unix)]
46use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
47
48use crate::db::Db;
49
50/// Registered durable databases to flush on exit. Weak so we never keep a `Db`
51/// alive past its owner.
52static REGISTRY: OnceLock<Mutex<Vec<Weak<Db>>>> = OnceLock::new();
53
54fn registry() -> &'static Mutex<Vec<Weak<Db>>> {
55    REGISTRY.get_or_init(|| Mutex::new(Vec::new()))
56}
57
58/// Flush every still-live registered database. Runs on the reader thread (normal
59/// context) — safe to take locks and do I/O. Prunes dead weak refs as it goes.
60fn flush_all_registered() {
61    if let Some(reg) = REGISTRY.get() {
62        let mut guard = match reg.lock() {
63            Ok(g) => g,
64            Err(poisoned) => poisoned.into_inner(), // a panicked writer must not stop the flush
65        };
66        guard.retain(|w| w.strong_count() > 0);
67        for w in guard.iter() {
68            if let Some(db) = w.upgrade() {
69                db.flush_all();
70            }
71        }
72    }
73}
74
75impl Db {
76    /// Flush this durable database's buffered state on `SIGINT`/`SIGTERM`
77    /// (`Ctrl+C`, `kill`, orchestrator shutdown) — the flush-on-close contract
78    /// extended to hard exits that never run `Drop`.
79    ///
80    /// Call once, after the database is wrapped in an `Arc` (the registry holds a
81    /// `Weak`, so this never keeps the `Db` alive). Idempotent; safe to call from
82    /// multiple databases. A no-op for in-memory (`:memory:`) databases.
83    ///
84    /// ```no_run
85    /// # use std::sync::Arc;
86    /// # use nedb_engine::Db;
87    /// let db = Arc::new(Db::open(std::path::Path::new("/data/mydb"), None)?);
88    /// Db::install_exit_flush(Arc::clone(&db));   // durable across Ctrl+C / SIGTERM
89    /// # Ok::<(), anyhow::Error>(())
90    /// ```
91    pub fn install_exit_flush(self_arc: Arc<Db>) {
92        // Nothing to flush for an in-memory database.
93        if self_arc.root == std::path::PathBuf::from(":memory:") {
94            return;
95        }
96        // Register (dedup by pointer identity so repeated calls don't stack).
97        {
98            let mut reg = match registry().lock() {
99                Ok(g) => g,
100                Err(poisoned) => poisoned.into_inner(),
101            };
102            let already = reg
103                .iter()
104                .any(|w| w.upgrade().is_some_and(|a| Arc::ptr_eq(&a, &self_arc)));
105            if !already {
106                reg.push(Arc::downgrade(&self_arc));
107            }
108        }
109        install_signal_handler_once();
110    }
111}
112
113// ── Unix: self-pipe + sigaction, dependency-free (libc). ─────────────────────
114
115#[cfg(unix)]
116static INSTALLED: AtomicBool = AtomicBool::new(false);
117/// Write end of the self-pipe, read by the signal handler. `-1` until installed.
118#[cfg(unix)]
119static PIPE_WRITE_FD: AtomicI32 = AtomicI32::new(-1);
120
121/// The signal handler. MUST be async-signal-safe: it does nothing but write the
122/// signal number to the self-pipe (non-blocking, so it can never stall the
123/// interrupted thread). All real work happens on the reader thread.
124#[cfg(unix)]
125extern "C" fn handler(sig: libc::c_int) {
126    let fd = PIPE_WRITE_FD.load(Ordering::SeqCst);
127    if fd >= 0 {
128        let byte = [sig as u8];
129        // write(2) is async-signal-safe; ignore the result (EAGAIN if the pipe is
130        // already full means a signal is already pending — which is all we need).
131        unsafe {
132            let _ = libc::write(fd, byte.as_ptr() as *const libc::c_void, 1);
133        }
134    }
135}
136
137#[cfg(unix)]
138fn install_signal_handler_once() {
139    // Exactly one handler + reader thread per process.
140    if INSTALLED.swap(true, Ordering::SeqCst) {
141        return;
142    }
143    unsafe {
144        // Self-pipe. write end non-blocking so the handler never blocks.
145        let mut fds = [0i32; 2];
146        if libc::pipe(fds.as_mut_ptr()) != 0 {
147            INSTALLED.store(false, Ordering::SeqCst); // let a later call retry
148            return;
149        }
150        let (read_fd, write_fd) = (fds[0], fds[1]);
151        let flags = libc::fcntl(write_fd, libc::F_GETFL);
152        if flags != -1 {
153            libc::fcntl(write_fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
154        }
155        PIPE_WRITE_FD.store(write_fd, Ordering::SeqCst);
156
157        // Install the handler for SIGINT (Ctrl+C) and SIGTERM (kill / orchestrator).
158        let mut sa: libc::sigaction = std::mem::zeroed();
159        sa.sa_sigaction = handler as extern "C" fn(libc::c_int) as libc::sighandler_t;
160        libc::sigemptyset(&mut sa.sa_mask);
161        sa.sa_flags = libc::SA_RESTART;
162        libc::sigaction(libc::SIGINT, &sa, std::ptr::null_mut());
163        libc::sigaction(libc::SIGTERM, &sa, std::ptr::null_mut());
164
165        // Reader thread: block on the pipe, flush, restore default, re-raise.
166        std::thread::Builder::new()
167            .name("nedb-exit-flush".into())
168            .spawn(move || {
169                reader_loop(read_fd);
170            })
171            .ok();
172    }
173}
174
175/// Block on the self-pipe. On the first signal: flush every registered database,
176/// restore that signal's default disposition, and re-raise so the process
177/// terminates with the correct status. Never returns.
178#[cfg(unix)]
179fn reader_loop(read_fd: i32) -> ! {
180    let mut buf = [0u8; 1];
181    loop {
182        let n = unsafe { libc::read(read_fd, buf.as_mut_ptr() as *mut libc::c_void, 1) };
183        if n <= 0 {
184            continue; // EINTR / spurious wakeup — keep waiting
185        }
186        let sig = buf[0] as libc::c_int;
187
188        flush_all_registered();
189
190        // Restore default disposition and re-raise: preserves 128+signum exit
191        // status and lets the OS terminate us the way the sender intended.
192        unsafe {
193            let mut sa: libc::sigaction = std::mem::zeroed();
194            sa.sa_sigaction = libc::SIG_DFL;
195            libc::sigemptyset(&mut sa.sa_mask);
196            libc::sigaction(sig, &sa, std::ptr::null_mut());
197            libc::raise(sig);
198        }
199        // If raise somehow returns, fall back to a clean exit after flushing.
200        std::process::exit(128 + sig);
201    }
202}
203
204// ── Non-Unix: no POSIX signals. Durability on exit relies on `Drop` / an
205//    explicit `flush()`. Documented, honest no-op. ────────────────────────────
206
207#[cfg(not(unix))]
208fn install_signal_handler_once() {
209    // Windows has no SIGTERM; SIGINT semantics differ. Embedders on non-Unix
210    // should flush explicitly on shutdown (or rely on `Drop` for short-lived
211    // handles). Left as a no-op rather than pretending to install a handler.
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    /// In-memory databases have nothing to flush → must never be registered.
219    /// Checks this specific handle (not the registry length), so it stays correct
220    /// under parallel test runs that share the process-global registry.
221    #[test]
222    fn in_memory_is_not_registered() {
223        let db = Arc::new(Db::in_memory());
224        Db::install_exit_flush(Arc::clone(&db));
225        let found = registry()
226            .lock()
227            .unwrap()
228            .iter()
229            .any(|w| w.upgrade().is_some_and(|a| Arc::ptr_eq(&a, &db)));
230        assert!(!found, ":memory: db must not be registered");
231    }
232
233    /// Registering the same durable db twice adds exactly one registry entry.
234    #[test]
235    fn durable_registration_is_idempotent() {
236        let dir = tempfile::tempdir().unwrap();
237        let db = Arc::new(Db::open(dir.path(), None).unwrap());
238        let count = || {
239            registry()
240                .lock()
241                .unwrap()
242                .iter()
243                .filter(|w| w.upgrade().is_some_and(|a| Arc::ptr_eq(&a, &db)))
244                .count()
245        };
246        Db::install_exit_flush(Arc::clone(&db));
247        assert_eq!(count(), 1, "first install registers exactly once");
248        Db::install_exit_flush(Arc::clone(&db));
249        assert_eq!(count(), 1, "second install does not duplicate");
250    }
251
252    /// The flush path the reader thread runs makes staged writes durable: write,
253    /// flush via the registry, reopen from disk, and the doc is present. Exercises
254    /// everything the signal path does except the raise-and-die tail (which cannot
255    /// be asserted in-process — see tests/exit_flush_signal.rs for the
256    /// child-process end-to-end proof).
257    #[test]
258    fn registered_flush_makes_writes_durable_on_reopen() {
259        let dir = tempfile::tempdir().unwrap();
260        let path = dir.path().to_path_buf();
261        {
262            let db = Arc::new(Db::open(&path, None).unwrap());
263            Db::install_exit_flush(Arc::clone(&db));
264            db.put("k", "v1", serde_json::json!({ "n": 1 }), vec![], None, None)
265                .unwrap();
266            flush_all_registered(); // what the reader thread does on SIGTERM (no raise)
267        }
268        let reopened = Db::open(&path, None).unwrap();
269        let got = reopened.get("k", "v1");
270        assert!(got.is_some(), "write must survive flush + reopen");
271        assert_eq!(got.unwrap().data.get("n").and_then(|v| v.as_i64()), Some(1));
272    }
273}