nedb_engine/db.rs
1//! Main DAG database — coordinates ObjectStore, IdIndex, SortedIndexes, GraphStore.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
7use anyhow::Result;
8use dashmap::DashMap;
9use serde_json::Value;
10use parking_lot::RwLock;
11
12use crate::store::{Dek, Node, ObjectStore};
13use crate::index::{IdIndex, OrderedValue, SortedIndexes};
14use crate::graph::GraphStore;
15use crate::migrate;
16
17/// MANIFEST: cached {seq, head} written atomically after every write.
18/// On startup, if MANIFEST exists and no sorted indexes need rebuilding,
19/// startup is O(1) — just read this one file instead of scanning all objects.
20#[derive(serde::Serialize, serde::Deserialize)]
21struct Manifest {
22 seq: u64,
23 head: String,
24 /// Object hash of the highest-seq node at flush time. Lets `tip()` resolve the
25 /// last write O(1) on a warm boot — before any scan repopulates the in-memory
26 /// seq index. `#[serde(default)]` so pre-2.5.43 MANIFESTs (no field) still parse.
27 #[serde(default)]
28 tip_hash: String,
29 /// Per-collection tip: `coll -> object hash of the highest-seq node in that
30 /// collection`. Lets `tip_collection()` resolve O(1) on a warm boot, same
31 /// contract as `tip_hash` for the global head. `#[serde(default)]` so
32 /// pre-this-field MANIFESTs still parse (empty map — self-heals on next write
33 /// or cold scan).
34 #[serde(default)]
35 coll_tips: std::collections::HashMap<String, String>,
36}
37
38/// Default cap for `since()` when the caller passes `limit == 0`. Bounds the
39/// engine primitive itself so a stale/offline consumer can never force an
40/// unbounded materialization — the safety lives in the core, not the HTTP layer.
41pub const DEFAULT_SINCE_LIMIT: usize = 10_000;
42
43/// One page of the changefeed returned by `since()`. The replication contract:
44/// apply `nodes` in ascending seq order, advance your cursor to `to_seq`, and keep
45/// paging while `has_more` is true; then attach to the live `subscribe` edge.
46/// `head_seq` tells the consumer how far the log currently extends (how far behind
47/// it is).
48#[derive(Debug, Clone, serde::Serialize)]
49pub struct SinceBatch {
50 /// Writes in (`from_seq`, `to_seq`], ascending by seq.
51 pub nodes: Vec<Node>,
52 /// The exclusive cursor this page started from (echoes the request).
53 pub from_seq: u64,
54 /// Seq of the last node in this page — the consumer's next cursor.
55 pub to_seq: u64,
56 /// Current head seq of the log (latest committed write).
57 pub head_seq: u64,
58 /// True when more writes remain past `to_seq` (the page hit `limit`).
59 pub has_more: bool,
60}
61
62/// Replication readiness snapshot. `scan_complete` is the correctness gate: until
63/// the cold-scan finishes rebuilding the seq index, an old cursor passed to
64/// `since()` can return a PARTIAL page and look (wrongly) like "caught up". A
65/// correctness-critical consumer MUST wait for `scan_complete == true` before
66/// trusting historical catch-up. `indexed_seq_min/max` report the currently
67/// resolvable seq range; `tip_seq` is the log head.
68#[derive(Debug, Clone, serde::Serialize)]
69pub struct ScanStatus {
70 /// Cold-scan finished — historical seqs fully resolvable; catch-up is safe.
71 pub scan_complete: bool,
72 /// Head seq of the log (latest committed write).
73 pub tip_seq: u64,
74 /// Lowest seq currently in the seq index (0 if empty).
75 pub indexed_seq_min: u64,
76 /// Highest seq currently in the seq index.
77 pub indexed_seq_max: u64,
78 /// Number of seqs currently resolvable via the index.
79 pub indexed_count: usize,
80 /// True when the seq index actually covers the log — i.e. `since()` can
81 /// resolve historical seqs. DISTINCT from `scan_complete`: a warm boot is
82 /// "startup complete" in O(1) precisely because it SKIPS the scan, so
83 /// `scan_complete` is true while this is false and `since()` resolves
84 /// nothing. Replication consumers must gate on this field, not on
85 /// `scan_complete`; call `rebuild_id_index()`/`repair()` to populate it.
86 pub seq_index_ready: bool,
87}
88
89pub struct Db {
90 pub objects: ObjectStore,
91 pub id_index: IdIndex,
92 pub sorted_indexes: SortedIndexes,
93 pub graph: GraphStore,
94 pub root: PathBuf,
95 /// Advisory exclusive lock on the data directory (`LOCK` file), held for
96 /// the Db's lifetime. One process owns a durable store at a time — a
97 /// second opener gets a loud refusal instead of silent split-brain (two
98 /// engines with independent in-memory state on one dir: cross-process
99 /// writes invisible, CAS races — the 2026-07-20 aias multi-worker session
100 /// bug, caught live). Released automatically on drop AND on any process
101 /// death including SIGKILL, because the flock dies with the fd. `None`
102 /// for in-memory databases and under NEDB_SHARED_OPEN=1 (operator
103 /// override for tooling that accepts the risk).
104 _dir_lock: Option<std::fs::File>,
105 /// Dirty flag — set true when head changes, cleared after manifest flush.
106 /// Decouples flush_manifest from the hot write path so concurrent writes
107 /// don't serialise on 2× file I/O per PUT.
108 manifest_dirty: Arc<AtomicBool>,
109 pub seq: AtomicU64,
110 /// Cached Merkle head — updated incrementally on every write (O(1)).
111 head: RwLock<String>,
112 /// `(seq, object hash)` of the most recent write (highest seq). Mirrors `head`
113 /// but holds the tip's content hash, so `tip()` can resolve the last node O(1)
114 /// on a warm boot when the in-memory `seq_index` is still cold. The seq rides
115 /// along so concurrent writers can settle the tip by HIGHEST SEQ rather than
116 /// arrival order (a slow older put must never clobber a newer tip). Only the
117 /// hash is persisted in MANIFEST — format unchanged.
118 tip_hash: RwLock<(u64, String)>,
119 /// Per-collection tip: `coll -> (seq, object hash)` of the highest-seq node in
120 /// that collection. Kept current on every write (`update_head`, seq-guarded),
121 /// restored from MANIFEST on warm boot, rebuilt by the cold scan — so
122 /// `tip_collection()` is O(1) and durable across restarts in every startup
123 /// regime, by construction.
124 coll_tip_hash: Arc<DashMap<String, (u64, String)>>,
125 /// True once startup is fully ready (MANIFEST loaded or cold scan complete).
126 /// Warm starts set this true before returning from open().
127 /// Cold starts set this true in the background thread when scan completes.
128 /// Writes are held with 503 until this is true; reads always proceed.
129 pub startup_ready: Arc<AtomicBool>,
130 /// Seq → hash lookup for v1 compatibility. Populated by put(), put_batch(),
131 /// and the cold-scan background pass. Only covers nodes from the current
132 /// process session + cold-scan; older seqs not in this map cannot be resolved.
133 seq_index: Arc<DashMap<u64, String>>,
134}
135
136impl Db {
137 /// Create a pure in-memory database — no disk I/O, no migration, instant startup.
138 /// Perfect for tests, hot-cache layers, and ephemeral sessions.
139 /// All data is lost when the Db is dropped.
140 pub fn in_memory() -> Self {
141 Self {
142 objects: ObjectStore::in_memory(),
143 id_index: IdIndex::in_memory(),
144 sorted_indexes: SortedIndexes::new(),
145 graph: GraphStore::in_memory(),
146 root: std::path::PathBuf::from(":memory:"),
147 _dir_lock: None,
148 seq: AtomicU64::new(0),
149 head: RwLock::new(String::new()),
150 tip_hash: RwLock::new((0, String::new())),
151 coll_tip_hash: Arc::new(DashMap::new()),
152 startup_ready: Arc::new(AtomicBool::new(true)), // always ready
153 manifest_dirty: Arc::new(AtomicBool::new(false)),
154 seq_index: Arc::new(DashMap::new()),
155 }
156 }
157
158 /// Acquire the exclusive advisory lock on a durable data directory.
159 /// Refuses (with the holder's pid when known) rather than allowing a
160 /// second live engine on the same files. NEDB_SHARED_OPEN=1 skips the
161 /// guard entirely — for tooling that knowingly accepts split-brain risk.
162 fn acquire_dir_lock(db_root: &Path) -> Result<Option<std::fs::File>> {
163 if std::env::var("NEDB_SHARED_OPEN").map(|v| v.trim() == "1").unwrap_or(false) {
164 return Ok(None);
165 }
166 use fs2::FileExt as _;
167 use std::io::Write as _;
168 let lock_path = db_root.join("LOCK");
169 let lock_file = std::fs::OpenOptions::new()
170 .create(true).read(true).write(true).open(&lock_path)?;
171 if lock_file.try_lock_exclusive().is_err() {
172 let holder = std::fs::read_to_string(&lock_path).unwrap_or_default();
173 let holder = holder.trim();
174 anyhow::bail!(
175 "data directory {:?} is locked by another process{} — refusing a \
176 split-brain open: a second engine on the same files cannot see this \
177 process's writes (invisible sessions, CAS races). Stop the other \
178 process, or set NEDB_SHARED_OPEN=1 only if you accept that risk.",
179 db_root,
180 if holder.is_empty() { String::new() } else { format!(" (pid {holder})") }
181 );
182 }
183 // Best-effort: record our pid for the next contender's error message.
184 let _ = lock_file.set_len(0);
185 let _ = writeln!(&lock_file, "{}", std::process::id());
186 let _ = lock_file.sync_all();
187 Ok(Some(lock_file))
188 }
189
190 /// Open (or create) a database. Runs v1→v2 migration automatically if log.aof is present.
191 pub fn open(db_root: &Path, dek: Option<Dek>) -> Result<Self> {
192 std::fs::create_dir_all(db_root)?;
193
194 // Split-brain guard FIRST — refuse before touching any store state.
195 let dir_lock = Self::acquire_dir_lock(db_root)?;
196
197 let objects = ObjectStore::new(db_root, dek.clone())?;
198 let id_index = IdIndex::new(db_root)?;
199 let sorted_indexes = SortedIndexes::new();
200 let graph = GraphStore::new(db_root)?;
201
202 let mut db = Self {
203 objects,
204 id_index,
205 sorted_indexes,
206 graph,
207 root: db_root.to_path_buf(),
208 _dir_lock: dir_lock,
209 seq: AtomicU64::new(0),
210 head: RwLock::new(String::new()),
211 tip_hash: RwLock::new((0, String::new())),
212 coll_tip_hash: Arc::new(DashMap::new()),
213 startup_ready: Arc::new(AtomicBool::new(false)),
214 manifest_dirty: Arc::new(AtomicBool::new(false)),
215 seq_index: Arc::new(DashMap::new()),
216 };
217
218 // Auto-migrate v1 → v2 if needed (pass DEK so encrypted AOFs convert correctly)
219 migrate::migrate_if_needed(
220 db_root,
221 &db.objects,
222 &db.id_index,
223 &db.sorted_indexes,
224 &db.graph,
225 dek.as_ref(),
226 )?;
227
228 // Fast startup: load seq+head from MANIFEST if no sorted indexes need rebuilding.
229 // Falls back to full object scan only when necessary (first open, or post-migration).
230 db.startup_rebuild()?;
231
232 Ok(db)
233 }
234
235 /// Smart startup:
236 /// - Warm (MANIFEST exists): O(1) load → startup_ready = true immediately.
237 /// - Cold (no MANIFEST): start server immediately, run scan in background thread.
238 /// Writes return 503 until scan completes; reads always proceed.
239 fn startup_rebuild(&mut self) -> Result<()> {
240 let manifest_path = self.root.join("MANIFEST");
241 let needs_index_rebuild = !self.sorted_indexes.is_empty();
242
243 // Warm path: MANIFEST + no sorted indexes to rebuild → instant start
244 if manifest_path.exists() && !needs_index_rebuild {
245 if let Some(m) = fs::read_to_string(&manifest_path)
246 .ok()
247 .and_then(|s| serde_json::from_str::<Manifest>(&s).ok())
248 {
249 // Self-heal: MANIFEST with an empty or short head is corrupt/stale.
250 // Fall through to cold scan so the head is rebuilt correctly from objects.
251 if m.head.len() < 8 {
252 eprintln!(" [nedbd] MANIFEST head invalid (len={}), self-healing via cold scan", m.head.len());
253 } else {
254 // Pre-2.5.43 MANIFEST (no persisted tip): warm-boot ANYWAY.
255 //
256 // The old policy forced a full cold scan "once to upgrade" —
257 // on multi-million-object embedded stores (itcd -dagv3:
258 // 1.7M+ objects per database) that scan is hours of random
259 // reads on seek-bound media, it races the host's own boot
260 // I/O, and if the process exits before it completes the
261 // NEXT boot pays it again — a permanent boot tax for
262 // exactly the deployments that can least afford it. And it
263 // buys nothing that can't heal lazily: seq + head in the
264 // old MANIFEST are perfectly valid, and flush_manifest
265 // writes tip_hash + coll_tips from live state, so the very
266 // first write + flush after boot upgrades the MANIFEST
267 // organically. Until then tip()/tip_collection() simply
268 // return None on this boot — exactly their documented
269 // behavior for an unresolvable tip — and every other read
270 // and write path is unaffected.
271 if m.tip_hash.is_empty() {
272 eprintln!(" [nedbd] MANIFEST predates durable tip() — warm boot; tip()/tip_collection() heal on first flush (no forced scan)");
273 }
274 self.seq.store(m.seq, Ordering::SeqCst); // m.seq is already the next-to-assign counter
275 *self.head.write() = m.head.clone();
276 // The tip's seq is the last ASSIGNED seq (m.seq is next-to-assign).
277 *self.tip_hash.write() = (m.seq.saturating_sub(1), m.tip_hash.clone());
278 for (coll, hash) in &m.coll_tips {
279 // Per-coll seqs aren't persisted (MANIFEST format unchanged);
280 // seed 0 — every future write has seq >= m.seq > 0 and wins,
281 // and nothing older than the persisted tip can ever arrive
282 // because the seq counter resumes at m.seq.
283 self.coll_tip_hash.insert(coll.clone(), (0, hash.clone()));
284 }
285 self.startup_ready.store(true, Ordering::SeqCst);
286 println!(" [nedbd] warm start — seq={} head={}... tip={}...",
287 m.seq, &m.head[..8],
288 if m.tip_hash.is_empty() { "(pre-2.5.43, heals on flush)" }
289 else { &m.tip_hash[..8.min(m.tip_hash.len())] });
290 return Ok(());
291 }
292 } else {
293 eprintln!(" [nedbd] MANIFEST corrupt or missing, falling back to cold scan");
294 }
295 }
296
297 // Cold path: mark as not ready, return immediately.
298 // The actual background scan is started by Db::start_cold_scan(arc)
299 // which is called from Manager::open_all() AFTER Arc::new(db) — when
300 // the Db is heap-allocated and its field addresses are permanently stable.
301 // Capturing field addresses here would cause UB: Db moves on return.
302 println!(" [nedbd] cold start — background scan will start after heap allocation");
303 Ok(())
304 }
305
306 /// Call this from Manager::open_all() after Arc::new(db).
307 /// Spawns the cold scan background thread with stable heap addresses.
308 /// No-op if startup is already complete (warm start).
309 pub fn start_cold_scan(self_arc: Arc<Self>) {
310 if self_arc.startup_ready.load(Ordering::SeqCst) {
311 return; // warm start — already ready
312 }
313 // Fast path: if the database is empty (new or just created), skip the
314 // background thread entirely. No objects to scan = instant startup.
315 if self_arc.objects.all_hashes().next().is_none() {
316 self_arc.startup_ready.store(true, Ordering::SeqCst);
317 return;
318 }
319 println!(" [nedbd] cold start — background scan starting, server accepting reads now");
320 std::thread::spawn(move || {
321 let db = self_arc;
322 cold_scan_background_arc(db);
323 });
324 }
325
326 /// Rebuild the id index from the object store, synchronously.
327 ///
328 /// Every object carries its own `coll`, `id` and `seq`, so the id index is
329 /// fully derivable: for each (coll, id) the highest seq wins. Use this to
330 /// recover a database whose id-index WAL never reached disk — the objects
331 /// are intact and verify, but `list()`/`get()` return nothing.
332 ///
333 /// Idempotent, and safe on a healthy store (it rewrites the same winners).
334 /// Returns the number of entries written. Flushes before returning.
335 pub fn rebuild_id_index(&self) -> Result<usize> {
336 let hashes: Vec<String> = self.objects.all_hashes().collect();
337 let mut nodes: Vec<Node> = Vec::with_capacity(hashes.len());
338 for h in &hashes {
339 if let Ok(node) = self.objects.read(h) {
340 self.seq_index.insert(node.seq, node.hash.clone());
341 nodes.push(node);
342 }
343 }
344 let written = rebuild_id_index_from_nodes(self, &nodes);
345
346 // Per-collection tips, so tip_collection() resolves after a repair.
347 let mut coll_max: std::collections::HashMap<String, (u64, String)> =
348 std::collections::HashMap::new();
349 for node in &nodes {
350 coll_max
351 .entry(node.coll.clone())
352 .and_modify(|cur| {
353 if node.seq > cur.0 {
354 *cur = (node.seq, node.hash.clone());
355 }
356 })
357 .or_insert((node.seq, node.hash.clone()));
358 }
359 for (coll, (seq, hash)) in coll_max {
360 self.coll_tip_hash.insert(coll, (seq, hash));
361 }
362
363 let max_seq = nodes.iter().map(|n| n.seq).max().unwrap_or(0);
364 // Keep the seq counter ahead of everything we just found, so the next
365 // write cannot reuse a seq that already exists in the log.
366 let next = max_seq + 1;
367 if !nodes.is_empty() && self.seq.load(Ordering::SeqCst) < next {
368 self.seq.store(next, Ordering::SeqCst);
369 }
370
371 // Recompute head + tip through the shared implementation, so a repaired
372 // database reopens WARM with a valid MANIFEST instead of coming back up
373 // cold with an empty head (which reads as corruption to the next boot).
374 if !nodes.is_empty() {
375 recompute_head_and_tip(self, hashes, max_seq);
376 }
377
378 self.try_flush_all()?;
379 Ok(written)
380 }
381
382 /// Full repair: rebuild the seq index and the id index from objects, even on
383 /// a WARM store, then flush.
384 ///
385 /// [`start_cold_scan`] deliberately no-ops when startup is already complete,
386 /// which meant the documented repair path ("idempotent — a no-op on a warm
387 /// store, a full self-heal on a stale MANIFEST") could never repair a
388 /// database that had a valid MANIFEST and a damaged id index. This is the
389 /// forcing entry point; `start_cold_scan` keeps its O(1) warm-boot contract.
390 pub fn repair(&self) -> Result<usize> {
391 self.rebuild_id_index()
392 }
393
394 /// Write a document. Returns the new node with its content hash set.
395 pub fn put(
396 &self,
397 coll: &str,
398 id: &str,
399 data: Value,
400 caused_by: Vec<String>,
401 valid_from: Option<String>,
402 valid_to: Option<String>,
403 ) -> Result<Node> {
404 let seq = self.seq.fetch_add(1, Ordering::SeqCst);
405 let prev = self.id_index.get(coll, id);
406
407 // Remove old node from sorted indexes (it's being superseded).
408 // Skip the old-object disk read entirely when no sorted index exists —
409 // the read (open + BLAKE2b verify + optional AES-GCM decrypt + JSON
410 // parse) was pure waste in the common unindexed case, ~2x read
411 // amplification on every update (the itcd chainstate shape).
412 if !self.sorted_indexes.is_empty() {
413 if let Some(old_hash) = &prev {
414 if let Ok(old_node) = self.objects.read(old_hash) {
415 if let Value::Object(ref obj) = old_node.data {
416 for (field, value) in obj {
417 self.sorted_indexes.remove(coll, field, value, old_hash);
418 }
419 }
420 }
421 }
422 }
423
424 let mut node = Node {
425 id: id.to_string(),
426 coll: coll.to_string(),
427 seq,
428 data: data.clone(),
429 prev,
430 caused_by: caused_by.clone(),
431 ts: now(),
432 valid_from,
433 valid_to,
434 hash: String::new(),
435 };
436
437 // Write to object store (atomic, content-addressed)
438 let hash = self.objects.write(&mut node)?;
439 self.seq_index.insert(seq, hash.clone());
440
441 // Update id index (atomic file)
442 self.id_index.set(coll, id, &hash)?;
443
444 // Update sorted indexes
445 if let Value::Object(ref obj) = data {
446 for (field, value) in obj {
447 if self.sorted_indexes.has(coll, field) {
448 self.sorted_indexes.insert(coll, field, value, &hash);
449 }
450 }
451 }
452
453 // Write causal graph edges
454 for cause in &caused_by {
455 self.graph.add_edge(&hash, "caused_by", cause)?;
456 self.graph.add_edge(cause, "caused_by_rev", &hash)?;
457 }
458
459 // Update running Merkle head: O(1) chain, no full recompute.
460 // new_head = BLAKE2b(prev_head || seq_bytes || new_object_hash)
461 self.update_head(coll, seq, &hash);
462
463 Ok(node)
464 }
465
466 /// Batch put: write N documents in parallel, preserving monotonic seq ordering.
467 /// Pre-allocates N seq numbers atomically, then parallelises object writes and
468 /// id-index updates via Rayon. Each op is independent — safe to parallelise.
469 /// Returns nodes in input order with assigned seq numbers.
470 pub fn put_batch(
471 &self,
472 ops: Vec<(String, String, Value, Vec<String>, Option<String>, Option<String>)>,
473 // (coll, id, data, caused_by, valid_from, valid_to)
474 ) -> Result<Vec<Node>> {
475 use rayon::prelude::*;
476
477 if ops.is_empty() { return Ok(vec![]); }
478 let n = ops.len() as u64;
479
480 // Pre-allocate N consecutive seq numbers — preserves ordering under concurrency
481 let base_seq = self.seq.fetch_add(n, Ordering::SeqCst);
482 let ts = now();
483
484 // Build nodes with assigned seq numbers
485 let index_live = !self.sorted_indexes.is_empty();
486 let mut nodes: Vec<Node> = ops.into_iter().enumerate().map(|(i, (coll, id, data, caused_by, valid_from, valid_to))| {
487 let prev = self.id_index.get(&coll, &id);
488 // Parity with put(): drop the superseded version's values from any
489 // sorted indexes, so top-k never returns stale hashes after a batch
490 // update. Without this, batch updates left the old version's index
491 // entries in place — ORDER BY surfaced superseded rows alongside
492 // current ones. Only pay the old-object read when an index exists.
493 if index_live {
494 if let Some(old_hash) = &prev {
495 if let Ok(old_node) = self.objects.read(old_hash) {
496 if let Value::Object(ref obj) = old_node.data {
497 for (field, value) in obj {
498 self.sorted_indexes.remove(&coll, field, value, old_hash);
499 }
500 }
501 }
502 }
503 }
504 Node {
505 id, coll, seq: base_seq + i as u64,
506 data, prev, caused_by,
507 ts, valid_from, valid_to,
508 hash: String::new(),
509 }
510 }).collect();
511
512 // Parallel object writes (content-addressed, idempotent, safe to parallelise)
513 let write_errors: Vec<anyhow::Error> = nodes.par_iter_mut()
514 .filter_map(|node| self.objects.write(node).err())
515 .collect();
516 if let Some(e) = write_errors.into_iter().next() { return Err(e); }
517
518 // Parallel id-index updates
519 let index_errors: Vec<anyhow::Error> = nodes.par_iter()
520 .filter_map(|node| self.id_index.set(&node.coll, &node.id, &node.hash).err())
521 .collect();
522 if let Some(e) = index_errors.into_iter().next() { return Err(e); }
523
524 // Sorted indexes + causal graph (sequential — small overhead, usually no indexes)
525 for node in &nodes {
526 self.seq_index.insert(node.seq, node.hash.clone());
527 if let Value::Object(ref obj) = node.data {
528 for (field, value) in obj {
529 if self.sorted_indexes.has(&node.coll, field) {
530 self.sorted_indexes.insert(&node.coll, field, value, &node.hash);
531 }
532 }
533 }
534 for cause in &node.caused_by {
535 self.graph.add_edge(&node.hash, "caused_by", cause).ok();
536 self.graph.add_edge(cause, "caused_by_rev", &node.hash).ok();
537 }
538 }
539
540 // Single Merkle head update for the whole batch (chain all hashes)
541 for node in &nodes {
542 self.update_head(&node.coll, node.seq, &node.hash);
543 }
544
545 Ok(nodes)
546 }
547
548 /// Update the running Merkle head with a new write. O(1); no file I/O — the
549 /// background ticker flushes MANIFEST.
550 ///
551 /// Concurrency contract (this function is reached by parallel `put()`s —
552 /// the server runs puts on blocking threads):
553 /// - The head chain is extended under ONE write lock held across the whole
554 /// read-modify-write. The old read-then-write shape let two concurrent
555 /// writers both read the same prev head; one contribution was silently
556 /// dropped from the chain — a corrupted tamper-evidence primitive. The
557 /// chain is arrival-ordered under concurrency (a seq-ordered canonical
558 /// head is tracked as follow-up work); what this lock guarantees is that
559 /// EVERY write is committed into the chain exactly once.
560 /// - Tip pointers settle by HIGHEST SEQ, not arrival order: concurrent
561 /// puts can reach here out of seq order, and "last call wins" could
562 /// persist a stale tip into MANIFEST for the next warm boot.
563 fn update_head(&self, coll: &str, seq: u64, new_hash: &str) {
564 use blake2::{Blake2b512, Digest};
565 {
566 let mut head = self.head.write();
567 let mut h = Blake2b512::new();
568 h.update(head.as_bytes());
569 h.update(seq.to_le_bytes());
570 h.update(new_hash.as_bytes());
571 *head = hex::encode(&h.finalize()[..32]);
572 }
573 {
574 let mut tip = self.tip_hash.write();
575 if seq >= tip.0 {
576 *tip = (seq, new_hash.to_string());
577 }
578 }
579 self.coll_tip_hash
580 .entry(coll.to_string())
581 .and_modify(|t| {
582 if seq >= t.0 {
583 *t = (seq, new_hash.to_string());
584 }
585 })
586 .or_insert_with(|| (seq, new_hash.to_string()));
587 // Mark dirty — background ticker will flush to MANIFEST (no I/O on write path)
588 self.manifest_dirty.store(true, Ordering::Release);
589 }
590
591 /// Flush both the id-index WAL and MANIFEST, REPORTING failure.
592 ///
593 /// This is the durability boundary: until it returns `Ok(())`, writes that
594 /// `put()` acknowledged may not be on disk. Callers that must not lose data
595 /// — anything about to take a destructive or externally-visible action on
596 /// the strength of a persisted record — should use this, not [`flush_all`].
597 ///
598 /// Every stage is attempted even if an earlier one fails (a MANIFEST flush
599 /// is still worth doing when one index leaf failed), and the first error is
600 /// returned. Failed id-index entries stay in the WAL for retry.
601 pub fn try_flush_all(&self) -> Result<()> {
602 let index_result = self.id_index.try_flush_write_buf();
603 // v3: fsync the active segment (no-op for loose/in-memory stores).
604 // One durability point per batch instead of one fsync per object.
605 let sync_result = self.objects.sync();
606 let manifest_result = self.try_flush_manifest();
607
608 index_result.map_err(|e| anyhow::anyhow!("id-index WAL flush failed: {}", e))?;
609 sync_result.map_err(|e| anyhow::anyhow!("object segment sync failed: {}", e))?;
610 manifest_result.map_err(|e| anyhow::anyhow!("MANIFEST flush failed: {}", e))?;
611 Ok(())
612 }
613
614 /// Flush both the id-index WAL and MANIFEST. Used on graceful shutdown.
615 ///
616 /// Errors are logged, not returned — kept for back-compat and for the
617 /// ticker/`Drop` paths that have nowhere to propagate. Prefer
618 /// [`try_flush_all`] whenever the outcome matters.
619 pub fn flush_all(&self) {
620 if let Err(e) = self.try_flush_all() {
621 eprintln!("nedb: flush_all failed: {}", e);
622 }
623 }
624
625 /// Compact the v3 packed object store: keep the CURRENT version of every
626 /// document (from the id-index) and reclaim everything else. No-op unless
627 /// running with the v3 segment substrate (`--dag-v3` / NEDB_DAG_V3).
628 ///
629 /// This is a PRUNING operation: superseded/historical object versions are
630 /// dropped, so AS OF / TRACE over pruned versions is discarded — that is
631 /// what reclaims the space. Flushes first so all data is durable on disk
632 /// before the old segments are deleted.
633 pub fn compact(&self) -> Result<crate::segment::CompactStats> {
634 self.flush_all();
635 let mut live: std::collections::HashSet<String> = std::collections::HashSet::new();
636 for coll in self.id_index.collections() {
637 for id in self.id_index.list_ids(&coll) {
638 if let Some(h) = self.id_index.get(&coll, &id) {
639 live.insert(h);
640 }
641 }
642 }
643 self.objects.compact(&live)
644 }
645
646 /// Flush MANIFEST to disk if dirty. No-op for in-memory databases.
647 pub fn flush_manifest_if_dirty(&self) {
648 if self.root == std::path::PathBuf::from(":memory:") { return; }
649 if self.manifest_dirty.compare_exchange(
650 true, false, Ordering::AcqRel, Ordering::Relaxed
651 ).is_ok() {
652 self.flush_manifest();
653 }
654 }
655
656 /// Atomically persist current seq+head to MANIFEST, reporting failure.
657 /// No-op (`Ok`) for in-memory databases.
658 ///
659 /// A silently failed MANIFEST write is not data loss — the startup
660 /// self-heal rescans — but it IS a warm-boot regression and, on a full
661 /// disk, the first symptom that persistence is failing. Callers deserve
662 /// to know.
663 pub fn try_flush_manifest(&self) -> std::io::Result<()> {
664 if self.root == std::path::PathBuf::from(":memory:") { return Ok(()); }
665 let seq = self.seq.load(Ordering::SeqCst);
666 let head = self.head.read().clone();
667 let tip_hash = self.tip_hash.read().1.clone();
668 let coll_tips: std::collections::HashMap<String, String> = self.coll_tip_hash
669 .iter()
670 .map(|kv| (kv.key().clone(), kv.value().1.clone()))
671 .collect();
672 let m = Manifest { seq, head, tip_hash, coll_tips };
673 let json = serde_json::to_string(&m)
674 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
675 let path = self.root.join("MANIFEST");
676 let tmp = self.root.join("MANIFEST.tmp");
677 // fsync the tmp file BEFORE the rename: rename-without-fsync can
678 // leave a zero-length/partial MANIFEST at the final path after
679 // power loss (ext4 delayed allocation). The startup self-heal
680 // (invalid head -> cold scan) catches that, but a full rescan is
681 // exactly the cost MANIFEST exists to avoid. One fsync per flush,
682 // and flushes are already off the hot write path (ticker-driven).
683 let wrote = (|| -> std::io::Result<()> {
684 use std::io::Write;
685 let mut f = fs::File::create(&tmp)?;
686 f.write_all(json.as_bytes())?;
687 f.sync_all()
688 })();
689 if let Err(e) = wrote {
690 let _ = fs::remove_file(&tmp);
691 return Err(e);
692 }
693 fs::rename(&tmp, &path)?;
694 // Make the rename itself durable (directory entry). Unix-only;
695 // on Windows directory handles don't support this and the
696 // rename is already journaled by NTFS.
697 #[cfg(unix)]
698 if let Ok(dir) = fs::File::open(&self.root) {
699 let _ = dir.sync_all();
700 }
701 Ok(())
702 }
703
704 /// Atomically persist current seq+head to MANIFEST. No-op for in-memory databases.
705 /// Errors are logged; prefer [`try_flush_manifest`] when the outcome matters.
706 pub fn flush_manifest(&self) {
707 if let Err(e) = self.try_flush_manifest() {
708 eprintln!("nedb: MANIFEST flush failed: {}", e);
709 }
710 }
711
712
713 /// Start a background thread that flushes both the id-index WAL and MANIFEST
714 /// every `interval_ms` milliseconds.
715 /// Call this after Arc::new(db) — the Arc keeps Db alive for the thread's lifetime.
716 /// Flush cadence for EMBEDDED durable handles (the napi and pyo3 `open()` paths).
717 ///
718 /// `nedbd` has always run the manifest ticker at 1 s, so a server flushes the id-index WAL and
719 /// MANIFEST every second and a hard kill loses at most a second of acknowledged writes. The
720 /// embedded bindings did not start a ticker at all: their WAL was flushed only by the exit hooks
721 /// (SIGINT/SIGTERM/atexit) — so an embedded app killed with SIGKILL, OOM-killed, or cut by power
722 /// lost EVERY write since open, with no bound. Found by CHALK / Sports-Rater on 2026-09-04
723 /// (acknowledged fan writes gone after `kill -9`). Since 2.8.5 the bindings start the ticker on
724 /// durable open with this cadence — parity with nedbd.
725 ///
726 /// `NEDB_FLUSH_MS` overrides: an integer of milliseconds (min 50), or `0` / `off` to disable
727 /// (only for hosts that own their own flush cadence). Unset → 1000.
728 pub fn embedded_flush_interval_ms() -> Option<u64> {
729 match std::env::var("NEDB_FLUSH_MS") {
730 Err(_) => Some(1000),
731 Ok(v) => {
732 let v = v.trim().to_ascii_lowercase();
733 if v.is_empty() { return Some(1000); }
734 if v == "0" || v == "off" || v == "false" || v == "no" { return None; }
735 match v.parse::<u64>() {
736 Ok(ms) => Some(ms.max(50)),
737 Err(_) => { eprintln!("nedb: NEDB_FLUSH_MS={:?} is not a number — using 1000", v); Some(1000) }
738 }
739 }
740 }
741 }
742
743 /// Spawn the background flush ticker.
744 ///
745 /// The ticker holds a **`Weak<Db>`** and exits the first time the upgrade
746 /// fails — i.e. as soon as the last real owner drops the database. The
747 /// caller must therefore keep its own `Arc` alive for as long as it wants
748 /// ticking; every current caller already does (nedbd stores it in its
749 /// database map, the napi and pyo3 handles own theirs).
750 ///
751 /// It used to hold a strong `Arc` inside an unconditional `loop`, which
752 /// meant the thread never exited and the `Db` was never dropped. Three
753 /// consequences, all of them live since 2.8.5:
754 ///
755 /// * The exclusive data-dir `LOCK` taken in `Db::open` was never released,
756 /// so reopening the same path **in the same process** failed with
757 /// "locked by another process (pid N)" where N was the caller's own pid.
758 /// * Every `open()` leaked a thread and the entire `Db` — indexes, caches,
759 /// segment handles — for the lifetime of the process.
760 /// * `Drop for Db` (flush-on-close) could never fire for embedded users,
761 /// exactly as its own doc comment warned: it "only fires once every
762 /// owning handle is gone", and an immortal thread always held one.
763 ///
764 /// nedbd's `drop_db` was hit by the same thing: removing a database from
765 /// the map did not free it, and an orphaned ticker went on fsyncing it.
766 ///
767 /// The `Arc` is upgraded inside the loop and dropped before the next
768 /// sleep, so the ticker never extends the database's life across a tick.
769 /// No final flush is needed here — the owner's `Drop` does it.
770 pub fn start_manifest_ticker(self_arc: Arc<Self>, interval_ms: u64) {
771 let weak = Arc::downgrade(&self_arc);
772 // Do not let this function's own argument keep the database alive.
773 drop(self_arc);
774 std::thread::spawn(move || {
775 loop {
776 std::thread::sleep(std::time::Duration::from_millis(interval_ms));
777 // Last owner gone: stop ticking and let the thread die.
778 let db = match weak.upgrade() {
779 Some(db) => db,
780 None => break,
781 };
782 // Flush id-index WAL to disk (parallel Rayon writes)
783 db.id_index.flush_write_buf();
784 // Segment bytes must be durable BEFORE a MANIFEST that
785 // references them: otherwise power loss can leave MANIFEST
786 // pointing at a tip whose object bytes were still in the page
787 // cache — the torn tail is truncated on reopen and the warm
788 // boot resolves a tip that no longer exists, with the seq
789 // counter ahead of durable data. Order: sync segments, then
790 // MANIFEST. Gated on the dirty flag so an idle database pays
791 // no per-tick fsync. (flush_all already used this order; the
792 // ticker now matches it.)
793 if db.manifest_dirty.load(Ordering::Acquire) {
794 if let Err(e) = db.objects.sync() {
795 eprintln!("nedb: segment sync failed: {}", e);
796 }
797 db.flush_manifest_if_dirty();
798 }
799 }
800 });
801 }
802
803 /// Return the current Merkle head string. O(1) — read from cache.
804 pub fn head(&self) -> String {
805 self.head.read().clone()
806 }
807
808 /// Delete a document — writes a tombstone node and removes the id from the index.
809 /// The object history is preserved in the DAG; only the live id pointer is cleared.
810 pub fn delete(&self, coll: &str, id: &str) -> Result<bool> {
811 let prev = match self.id_index.get(coll, id) {
812 None => return Ok(false), // already gone
813 Some(h) => h,
814 };
815 let seq = self.seq.fetch_add(1, Ordering::SeqCst);
816 let mut tombstone = Node {
817 id: format!("_del_{}", id),
818 coll: coll.to_string(),
819 seq,
820 data: serde_json::json!({"_deleted": id, "_prev": prev}),
821 prev: Some(prev),
822 caused_by: vec![],
823 ts: now(),
824 valid_from: None,
825 valid_to: None,
826 hash: String::new(),
827 };
828 let hash = self.objects.write(&mut tombstone)?;
829 self.update_head(coll, seq, &hash);
830 // Remove the live id pointer — doc is now invisible to queries and list()
831 self.id_index.remove(coll, id)?;
832 Ok(true)
833 }
834
835 /// Get the current version of a document by id.
836 pub fn get(&self, coll: &str, id: &str) -> Option<Node> {
837 let hash = self.id_index.get(coll, id)?;
838 self.objects.read(&hash).ok()
839 }
840
841 /// Get a specific version of a document by object hash.
842 pub fn get_by_hash(&self, hash: &str) -> Option<Node> {
843 self.objects.read(hash).ok()
844 }
845
846 /// Get a document AS OF a specific sequence number.
847 /// Walks the version chain (prev links) backward until seq <= target.
848 pub fn get_as_of(&self, coll: &str, id: &str, target_seq: u64) -> Option<Node> {
849 let hash = self.id_index.get(coll, id)?;
850 let mut current = self.objects.read(&hash).ok()?;
851 loop {
852 if current.seq <= target_seq {
853 return Some(current);
854 }
855 let prev_hash = current.prev.as_deref()?;
856 current = self.objects.read(prev_hash).ok()?;
857 }
858 }
859
860 /// List all documents in a collection, returning current versions.
861 pub fn list(&self, coll: &str) -> Vec<Node> {
862 self.id_index
863 .list_ids(coll)
864 .into_iter()
865 .filter_map(|id| self.get(coll, &id))
866 .collect()
867 }
868
869 /// Candidate nodes whose `field` falls in the given range, via the sorted
870 /// index. `None` when no index covers (coll, field) — the caller must then
871 /// fall back to a scan.
872 ///
873 /// Returns CURRENT versions only (the index drops a superseded hash on
874 /// overwrite), so this must not be used to serve an `AS OF` query.
875 pub fn range_scan(
876 &self,
877 coll: &str,
878 field: &str,
879 low: Option<&Value>,
880 high: Option<&Value>,
881 low_incl: bool,
882 high_incl: bool,
883 ) -> Option<Vec<Node>> {
884 if !self.sorted_indexes.has(coll, field) {
885 return None;
886 }
887 Some(
888 self.sorted_indexes
889 .range(coll, field, low, high, low_incl, high_incl)
890 .into_iter()
891 .filter_map(|h| self.objects.read(&h).ok())
892 .collect(),
893 )
894 }
895
896 /// Candidate nodes whose `field` equals any of `values` — the indexed path
897 /// for `=` and for `IN (...)`. `None` when no index covers the field.
898 pub fn index_lookup(&self, coll: &str, field: &str, values: &[Value]) -> Option<Vec<Node>> {
899 if !self.sorted_indexes.has(coll, field) {
900 return None;
901 }
902 // A value may legitimately appear in several arms of an IN list, and a
903 // hash must not be returned twice.
904 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
905 let mut out = vec![];
906 for v in values {
907 for h in self.sorted_indexes.exact(coll, field, v) {
908 if seen.insert(h.clone()) {
909 if let Ok(node) = self.objects.read(&h) {
910 out.push(node);
911 }
912 }
913 }
914 }
915 Some(out)
916 }
917
918 /// How many rows an indexed range covers, without reading any of them.
919 /// `None` when no index covers the field.
920 pub fn range_cardinality(
921 &self,
922 coll: &str,
923 field: &str,
924 low: Option<&Value>,
925 high: Option<&Value>,
926 low_incl: bool,
927 high_incl: bool,
928 ) -> Option<usize> {
929 if !self.sorted_indexes.has(coll, field) {
930 return None;
931 }
932 Some(self.sorted_indexes.range_len(coll, field, low, high, low_incl, high_incl))
933 }
934
935 /// True when a sorted index covers (coll, field).
936 pub fn has_sorted_index(&self, coll: &str, field: &str) -> bool {
937 self.sorted_indexes.has(coll, field)
938 }
939
940 /// ORDER BY field ASC LIMIT n — uses sorted index if available, else falls back to full scan.
941 pub fn order_by_asc(&self, coll: &str, field: &str, limit: usize) -> Vec<Node> {
942 if self.sorted_indexes.has(coll, field) {
943 self.sorted_indexes
944 .top_k_asc(coll, field, limit)
945 .into_iter()
946 .filter_map(|h| self.objects.read(&h).ok())
947 .collect()
948 } else {
949 let mut docs = self.list(coll);
950 docs.sort_by(|a, b| {
951 let av = a.data.get(field).map(OrderedValue::from).unwrap_or(OrderedValue::Null);
952 let bv = b.data.get(field).map(OrderedValue::from).unwrap_or(OrderedValue::Null);
953 av.cmp(&bv)
954 });
955 docs.truncate(limit);
956 docs
957 }
958 }
959
960 /// ORDER BY field DESC LIMIT n
961 pub fn order_by_desc(&self, coll: &str, field: &str, limit: usize) -> Vec<Node> {
962 if self.sorted_indexes.has(coll, field) {
963 self.sorted_indexes
964 .top_k_desc(coll, field, limit)
965 .into_iter()
966 .filter_map(|h| self.objects.read(&h).ok())
967 .collect()
968 } else {
969 let mut docs = self.list(coll);
970 docs.sort_by(|a, b| {
971 let av = a.data.get(field).map(OrderedValue::from).unwrap_or(OrderedValue::Null);
972 let bv = b.data.get(field).map(OrderedValue::from).unwrap_or(OrderedValue::Null);
973 bv.cmp(&av)
974 });
975 docs.truncate(limit);
976 docs
977 }
978 }
979
980 /// TRACE caused_by — walk causal graph from a node.
981 pub fn trace(&self, hash: &str, reverse: bool, limit: usize) -> Vec<Node> {
982 self.graph
983 .trace(hash, "caused_by", reverse, limit)
984 .into_iter()
985 .filter_map(|h| self.objects.read(&h).ok())
986 .collect()
987 }
988
989 /// Verify tamper-evidence of all objects.
990 pub fn verify(&self) -> (usize, Vec<String>) {
991 self.objects.verify_all()
992 }
993
994 /// Create a sorted index for a (coll, field) pair.
995 pub fn create_sorted_index(&self, coll: &str, field: &str) {
996 self.sorted_indexes.ensure(coll, field);
997 // Backfill from existing objects
998 for id in self.id_index.list_ids(coll) {
999 if let Some(node) = self.get(coll, &id) {
1000 if let Value::Object(ref obj) = node.data {
1001 if let Some(value) = obj.get(field) {
1002 self.sorted_indexes.insert(coll, field, value, &node.hash);
1003 }
1004 }
1005 }
1006 }
1007 }
1008
1009 /// Resolve a sequence number to its content hash (v1 compatibility).
1010 /// Only covers nodes written in the current process session + cold-scan nodes.
1011 pub fn get_hash_by_seq(&self, seq: u64) -> Option<String> {
1012 self.seq_index.get(&seq).map(|r| r.clone())
1013 }
1014
1015 /// The tip — the most recently written node (highest seq), or `None` if the
1016 /// database is empty. O(1): `self.seq` is the next-to-assign counter, so the
1017 /// latest write sits at `seq - 1`; we resolve it through the same
1018 /// seq_index → object-store path a normal read uses, so the returned Node is
1019 /// byte-identical to one fetched by id or hash (it carries its own seq, hash,
1020 /// causal links, and valid-time). This is the cheap "give me the latest write"
1021 /// primitive — the head of the log, not an aggregate.
1022 pub fn tip(&self) -> Option<Node> {
1023 let next = self.seq.load(Ordering::SeqCst);
1024 if next == 0 {
1025 return None; // nothing written yet
1026 }
1027 // Fast path: resolve the head seq through the in-memory seq index
1028 // (populated by this session's writes or by the cold scan).
1029 if let Some(hash) = self.get_hash_by_seq(next - 1) {
1030 return self.get_by_hash(&hash);
1031 }
1032 // Warm-boot fallback: the seq index is still cold (warm start skips the
1033 // scan), but the tip's object hash was persisted in MANIFEST and restored
1034 // on open. O(1), no scan — this is what makes tip() survive a restart.
1035 let th = self.tip_hash.read().1.clone();
1036 if !th.is_empty() {
1037 return self.get_by_hash(&th);
1038 }
1039 None
1040 }
1041
1042 /// The collection-local tip — the most recent write into `coll` (highest seq in
1043 /// that collection), or `None` if the collection has no writes. O(1): resolves
1044 /// through `coll_tip_hash`, a dedicated per-collection map kept current on every
1045 /// write (`update_head`), restored from MANIFEST on warm boot, and rebuilt by the
1046 /// cold scan — durable across restarts by construction, same contract as `tip()`
1047 /// for the global head. Conceptually a different index than the global `tip()`
1048 /// (global head vs collection head), kept as a separate method so each is
1049 /// explicit — parity with the Python reference's `tip(coll)`. Lets a consumer
1050 /// resume one chain (e.g. blocks / tx / utxo) without pulling global tip and
1051 /// filtering.
1052 pub fn tip_collection(&self, coll: &str) -> Option<Node> {
1053 let hash = self.coll_tip_hash.get(coll)?.1.clone();
1054 self.get_by_hash(&hash)
1055 }
1056
1057 /// Changefeed page: up to `limit` nodes written AFTER `after_seq` (EXCLUSIVE),
1058 /// ascending by seq, wrapped in a `SinceBatch` cursor envelope. `after_seq` is
1059 /// the cursor you last applied (a prior `tip()` seq or `to_seq`). `limit` bounds
1060 /// the page — `0` means DEFAULT_SINCE_LIMIT, so the engine primitive can never
1061 /// materialize an unbounded batch even when embedders call it directly (the
1062 /// safety is here, not only in the HTTP layer). Drain by paging while
1063 /// `has_more`, advancing your cursor to `to_seq`, then hand off to the live
1064 /// `subscribe` edge. The append-only log IS the changefeed, so this is an
1065 /// O(page) walk; unresolved seqs (outside seq_index coverage — see
1066 /// `scan_status()`) are skipped rather than faked.
1067 pub fn since(&self, after_seq: u64, limit: usize) -> SinceBatch {
1068 let next = self.seq.load(Ordering::SeqCst); // head + 1
1069 let head_seq = next.saturating_sub(1);
1070 let cap = if limit == 0 { DEFAULT_SINCE_LIMIT } else { limit };
1071 let mut nodes: Vec<Node> = Vec::new();
1072 let mut to_seq = after_seq;
1073 let mut hit_limit = false;
1074 let mut s = after_seq.saturating_add(1);
1075 while s < next {
1076 if nodes.len() >= cap { hit_limit = true; break; }
1077 if let Some(hash) = self.get_hash_by_seq(s) {
1078 if let Some(node) = self.get_by_hash(&hash) {
1079 to_seq = node.seq;
1080 nodes.push(node);
1081 }
1082 }
1083 s += 1;
1084 }
1085 // `has_more` must never say "caught up" while the cursor is behind the
1086 // log head. Before 2.8.6 this was `hit_limit` alone, so any page whose
1087 // seqs could not be resolved (the whole range, on a warm boot: the warm
1088 // path skips the scan, leaving seq_index empty) returned zero nodes with
1089 // has_more=false — indistinguishable from genuinely up to date. A
1090 // consumer following the documented drain loop stopped forever, one call
1091 // in, on a database with every record unread.
1092 let has_more = hit_limit || to_seq < head_seq;
1093 SinceBatch { nodes, from_seq: after_seq, to_seq, head_seq, has_more }
1094 }
1095
1096 /// Replication readiness — see `ScanStatus`. `scan_complete` gates safe
1097 /// historical catch-up: a consumer pulling an old cursor right after a cold
1098 /// start must wait for it, or `since()` may hand back a partial page that looks
1099 /// like "caught up". Computes the indexed range by scanning the in-memory seq
1100 /// index (O(index)) — intended for periodic status polls, not the per-write
1101 /// hot path.
1102 pub fn scan_status(&self) -> ScanStatus {
1103 let next = self.seq.load(Ordering::SeqCst);
1104 let mut min = u64::MAX;
1105 let mut max = 0u64;
1106 let mut count = 0usize;
1107 for kv in self.seq_index.iter() {
1108 let s = *kv.key();
1109 if s < min { min = s; }
1110 if s > max { max = s; }
1111 count += 1;
1112 }
1113 if count == 0 { min = 0; }
1114 ScanStatus {
1115 scan_complete: self.startup_ready.load(Ordering::SeqCst),
1116 tip_seq: next.saturating_sub(1),
1117 indexed_seq_min: min,
1118 indexed_seq_max: max,
1119 indexed_count: count,
1120 // The seq index covers the log when it resolves as many seqs as the
1121 // log has entries. On a warm boot it is empty while the log is not.
1122 seq_index_ready: count > 0 && (count as u64) >= next.saturating_sub(1),
1123 }
1124 }
1125
1126 /// Add an explicit named relation edge between two documents.
1127 /// Add an explicit named relation between two "coll:id" nodes.
1128 /// Relations stored as __links__ documents — NQL-queryable, time-travelable,
1129 /// consistent with the PyO3 binding which uses the same __links__ convention.
1130 pub fn link(&self, frm: &str, rel: &str, to: &str) -> Result<()> {
1131 let (frm_coll, frm_id) = frm.split_once(':')
1132 .ok_or_else(|| anyhow::anyhow!("link frm must be 'coll:id', got: {}", frm))?;
1133 let (to_coll, to_id) = to.split_once(':')
1134 .ok_or_else(|| anyhow::anyhow!("link to must be 'coll:id', got: {}", to))?;
1135 if self.id_index.get(frm_coll, frm_id).is_none() {
1136 anyhow::bail!("link: frm not found: {}", frm);
1137 }
1138 if self.id_index.get(to_coll, to_id).is_none() {
1139 anyhow::bail!("link: to not found: {}", to);
1140 }
1141 let link_id = format!("{}|{}|{}", frm, rel, to);
1142 let doc = serde_json::json!({"_from": frm, "_rel": rel, "_to": to});
1143 self.put("__links__", &link_id, doc, vec![], None, None)?;
1144 Ok(())
1145 }
1146
1147 /// Remove a named relation (deletes the __links__ document).
1148 pub fn unlink(&self, frm: &str, rel: &str, to: &str) -> Result<bool> {
1149 let link_id = format!("{}|{}|{}", frm, rel, to);
1150 self.delete("__links__", &link_id)
1151 }
1152
1153 /// Get neighbor nodes via a named relation.
1154 /// Queries __links__ — consistent with the PyO3 binding.
1155 pub fn neighbors(&self, frm: &str, rel: &str) -> Vec<Node> {
1156 self.id_index
1157 .list_ids("__links__")
1158 .into_iter()
1159 .filter_map(|id| self.get("__links__", &id))
1160 .filter(|node| {
1161 node.data.get("_from").and_then(|v| v.as_str()) == Some(frm)
1162 && node.data.get("_rel").and_then(|v| v.as_str()) == Some(rel)
1163 })
1164 .filter_map(|node| {
1165 let to = node.data.get("_to")?.as_str()?;
1166 let (to_coll, to_id) = to.split_once(':')?;
1167 self.get(to_coll, to_id)
1168 })
1169 .collect()
1170 }
1171}
1172
1173impl Drop for Db {
1174 /// Flush buffered state when the database is closed so a write-then-drop
1175 /// sequence is durable without an explicit `flush_all()`.
1176 ///
1177 /// `IdIndex::set` only stages updates in the in-memory WAL `write_buf`;
1178 /// disk persistence happens in `flush_write_buf()`, normally driven by the
1179 /// manifest ticker. A short-lived `Db` (a library user's `{ let db =
1180 /// Db::open(p)?; db.put(..)?; }` block, or a test) has no ticker, so without
1181 /// this its writes would be silently lost on reopen. Flushing on drop
1182 /// mirrors the flush-on-close contract of other embedded stores (sled,
1183 /// RocksDB).
1184 ///
1185 /// In production this is a harmless safety net, not the primary durability
1186 /// path: the manifest ticker thread holds an `Arc<Db>` for the process
1187 /// lifetime, so `Drop` only fires once every owning handle is gone. No-op
1188 /// for in-memory databases (`flush_all` short-circuits on `:memory:`).
1189 fn drop(&mut self) {
1190 self.flush_all();
1191 }
1192}
1193
1194/// Background cold-scan worker. Takes Arc<Db> — safe, Db is on the heap.
1195fn cold_scan_background_arc(db: Arc<Db>) {
1196 use rayon::prelude::*;
1197
1198 let objects = &db.objects;
1199 let seq_atomic = &db.seq;
1200 let sorted_indexes = &db.sorted_indexes;
1201 let seq_index = &db.seq_index;
1202 let ready_flag = Arc::clone(&db.startup_ready);
1203
1204 let hashes: Vec<String> = objects.all_hashes().collect();
1205 let total = hashes.len();
1206
1207 if total == 0 {
1208 ready_flag.store(true, Ordering::SeqCst);
1209 return;
1210 }
1211
1212 println!(" [nedbd] background scan — {} objects...", total);
1213 let t0 = std::time::Instant::now();
1214 let step = (total / 10).max(1000);
1215
1216 // Populate the seq index AS objects are read here, not in a second pass
1217 // afterward: this loop is the slow, disk-I/O-bound phase (verifying and
1218 // parsing every object), and it can run for minutes on a multi-million
1219 // object store. `scan_status().indexed_count` reads `seq_index`'s size, so
1220 // inserting here — not after `.collect()` — is what makes that a real, live
1221 // progress signal through the phase that actually takes the time, instead
1222 // of reporting a flat 0 until this whole pass finishes. Safe: DashMap
1223 // supports concurrent inserts, and every parallel worker here inserts a
1224 // disjoint key (each object has its own seq).
1225 let nodes: Vec<Node> = hashes.par_iter()
1226 .enumerate()
1227 .filter_map(|(i, h)| {
1228 if i > 0 && i % step == 0 {
1229 let pct = i * 100 / total;
1230 let elapsed = t0.elapsed().as_secs_f32();
1231 let rate = i as f32 / elapsed;
1232 let eta = (total - i) as f32 / rate;
1233 eprint!("\r [nedbd] {:>3}% {:>8} / {:>8} ({:>8.0}/s eta {:.0}s) ",
1234 pct, i, total, rate, eta);
1235 }
1236 let node = objects.read(h).ok()?;
1237 seq_index.insert(node.seq, node.hash.clone());
1238 Some(node)
1239 })
1240 .collect();
1241
1242 eprintln!("\r [nedbd] 100% {:>8} / {:>8} ({:.1}s) ",
1243 total, total, t0.elapsed().as_secs_f32());
1244
1245 let max_seq = nodes.iter().map(|n| n.seq).max().unwrap_or(0);
1246 seq_atomic.store(max_seq + 1, Ordering::SeqCst);
1247
1248 // Per-collection tip: highest-seq node's hash, per coll. `nodes` is NOT
1249 // seq-ordered here (it comes from an unordered object-hash scan), so this
1250 // must track the max explicitly — unlike the live write path's "last call
1251 // wins" (which relies on ascending call order that a scan doesn't have).
1252 let mut coll_max: std::collections::HashMap<String, (u64, String)> = std::collections::HashMap::new();
1253
1254 for node in &nodes {
1255 // seq_index was already populated above, during the read pass.
1256 coll_max.entry(node.coll.clone())
1257 .and_modify(|(s, h)| if node.seq > *s { *s = node.seq; *h = node.hash.clone(); })
1258 .or_insert_with(|| (node.seq, node.hash.clone()));
1259 if let Value::Object(ref obj) = node.data {
1260 for (field, value) in obj {
1261 if sorted_indexes.has(&node.coll, field) {
1262 sorted_indexes.insert(&node.coll, field, value, &node.hash);
1263 }
1264 }
1265 }
1266 }
1267
1268 for (coll, (seq, hash)) in coll_max {
1269 db.coll_tip_hash.insert(coll, (seq, hash));
1270 }
1271
1272 // Rebuild the id index when it has no collections at all — the lost-WAL
1273 // case. Until 2.8.6 the cold scan restored seq_index, coll_tips, head and
1274 // MANIFEST but NEVER the id index, so a database whose id-index WAL never
1275 // reached disk came back with every object present and verifying while
1276 // `list()` and `get()` returned nothing — and `nedb-cli repair`, whose whole
1277 // job is this, reported success without fixing it.
1278 //
1279 // Gated on "no collections" so a normal cold boot of a healthy store (itcd:
1280 // millions of objects) does not pay N extra index writes. A partially lost
1281 // index is repaired by the explicit `rebuild_id_index()` path.
1282 if db.id_index.collections().is_empty() && !nodes.is_empty() {
1283 let restored = rebuild_id_index_from_nodes(&db, &nodes);
1284 println!(" [nedbd] id index was empty — rebuilt {} entries from objects", restored);
1285 }
1286
1287 // Merkle head + tip, through the one shared implementation so the cold scan
1288 // and the explicit repair path can never drift apart.
1289 recompute_head_and_tip(&db, hashes, max_seq);
1290
1291 // Write MANIFEST through the one canonical writer. The hand-rolled write
1292 // this replaces stored `seq: max_seq` (the last USED seq) — but the warm
1293 // boot loads `m.seq` as the NEXT-TO-ASSIGN counter, so a restart right
1294 // after a quiet cold scan handed the next write the tip's seq: a duplicate
1295 // seq in the log (seq_index overwrite, wrong since() page). flush_manifest
1296 // reads the live counter (already max_seq + 1) — correct by construction.
1297 db.flush_manifest();
1298
1299 // Signal server: writes can now proceed
1300 ready_flag.store(true, Ordering::SeqCst);
1301 println!(" [nedbd] background scan complete — seq={} objects={} MANIFEST written", max_seq, total);
1302}
1303
1304/// Recompute the Merkle head and the tip hash from the full object-hash set.
1305///
1306/// Shared by the cold scan and by `repair()` so the two can never disagree
1307/// about what the head of a rebuilt database is. `hashes` must be every object
1308/// hash in the store; `max_seq` the highest seq observed.
1309fn recompute_head_and_tip(db: &Db, hashes: Vec<String>, max_seq: u64) {
1310 use blake2::{Blake2b512, Digest};
1311 let mut sorted_hashes = hashes;
1312 sorted_hashes.sort();
1313 let mut h = Blake2b512::new();
1314 h.update(max_seq.to_le_bytes());
1315 for hash_str in &sorted_hashes {
1316 h.update(hash_str.as_bytes());
1317 }
1318 *db.head.write() = hex::encode(&h.finalize()[..32]);
1319
1320 // Tip = the highest-seq object indexed. Persisting its hash lets tip()
1321 // resolve O(1) on the next warm boot, before any scan repopulates seq_index.
1322 let tip_hash = db.seq_index.iter()
1323 .max_by_key(|kv| *kv.key())
1324 .map(|kv| kv.value().clone())
1325 .unwrap_or_default();
1326 *db.tip_hash.write() = (max_seq, tip_hash);
1327}
1328
1329/// Reconstruct id-index entries from already-read nodes: for every (coll, id),
1330/// the winner is the HIGHEST seq, which is exactly what `put()` would have left
1331/// behind. Returns the number of entries written.
1332///
1333/// The id index is fully derivable from the object store because every object
1334/// carries its own `coll`, `id` and `seq` — so a lost WAL is recoverable, and
1335/// nothing here invents data.
1336fn rebuild_id_index_from_nodes(db: &Db, nodes: &[Node]) -> usize {
1337 let mut winner: std::collections::HashMap<(String, String), (u64, String)> =
1338 std::collections::HashMap::new();
1339 for node in nodes {
1340 let key = (node.coll.clone(), node.id.clone());
1341 winner
1342 .entry(key)
1343 .and_modify(|cur| {
1344 if node.seq > cur.0 {
1345 *cur = (node.seq, node.hash.clone());
1346 }
1347 })
1348 .or_insert((node.seq, node.hash.clone()));
1349 }
1350 let mut written = 0usize;
1351 for ((coll, id), (_seq, hash)) in &winner {
1352 if db.id_index.set(coll, id, hash).is_ok() {
1353 written += 1;
1354 }
1355 }
1356 // Persist immediately: a rebuild that only lands in the WAL would be lost
1357 // again by the very crash class this recovers from.
1358 if let Err(e) = db.id_index.try_flush_write_buf() {
1359 eprintln!("nedb: id-index rebuild flush failed: {}", e);
1360 }
1361 written
1362}
1363
1364fn now() -> f64 {
1365 std::time::SystemTime::now()
1366 .duration_since(std::time::UNIX_EPOCH)
1367 .map(|d| d.as_secs_f64())
1368 .unwrap_or(0.0)
1369}
1370
1371#[cfg(test)]
1372mod tests {
1373 use super::*;
1374 use tempfile::tempdir;
1375
1376 #[test]
1377 fn put_and_get() {
1378 let dir = tempdir().unwrap();
1379 let db = Db::open(dir.path(), None).unwrap();
1380 db.put(
1381 "blocks", "618000",
1382 serde_json::json!({"height": 618000, "hash": "0000abc"}),
1383 vec![], None, None,
1384 ).unwrap();
1385 let node = db.get("blocks", "618000").unwrap();
1386 assert_eq!(node.id, "618000");
1387 assert_eq!(node.data["height"], 618000);
1388 }
1389
1390 #[test]
1391 fn order_by_with_sorted_index() {
1392 let dir = tempdir().unwrap();
1393 let db = Db::open(dir.path(), None).unwrap();
1394 db.create_sorted_index("blocks", "height");
1395 for h in [3u64, 1, 5, 2, 4] {
1396 db.put("blocks", &h.to_string(),
1397 serde_json::json!({"height": h}),
1398 vec![], None, None).unwrap();
1399 }
1400 let asc = db.order_by_asc("blocks", "height", 3);
1401 let heights: Vec<u64> = asc.iter()
1402 .filter_map(|n| n.data["height"].as_u64())
1403 .collect();
1404 assert_eq!(heights, vec![1, 2, 3]);
1405 }
1406
1407 #[test]
1408 fn causal_trace() {
1409 let dir = tempdir().unwrap();
1410 let db = Db::open(dir.path(), None).unwrap();
1411 let a = db.put("ops", "a", serde_json::json!({"op": "create"}), vec![], None, None).unwrap();
1412 let b = db.put("ops", "b", serde_json::json!({"op": "transfer"}), vec![a.hash.clone()], None, None).unwrap();
1413 let c = db.put("ops", "c", serde_json::json!({"op": "burn"}), vec![b.hash.clone()], None, None).unwrap();
1414
1415 let trace = db.trace(&c.hash, false, 10);
1416 assert_eq!(trace.len(), 3); // c → b → a
1417 }
1418
1419 #[test]
1420 fn as_of() {
1421 let dir = tempdir().unwrap();
1422 let db = Db::open(dir.path(), None).unwrap();
1423 let v1 = db.put("docs", "x", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
1424 let _v2 = db.put("docs", "x", serde_json::json!({"v": 2}), vec![], None, None).unwrap();
1425
1426 let at_v1 = db.get_as_of("docs", "x", v1.seq).unwrap();
1427 assert_eq!(at_v1.data["v"], 1);
1428 let current = db.get("docs", "x").unwrap();
1429 assert_eq!(current.data["v"], 2);
1430 }
1431}
1432
1433#[cfg(test)]
1434mod tests_v2 {
1435 use super::*;
1436 use tempfile::tempdir;
1437
1438 #[test]
1439 fn seq_index_populated_on_put() {
1440 let db = Db::in_memory();
1441 let a = db.put("item", "a", serde_json::json!({"x": 1}), vec![], None, None).unwrap();
1442 let b = db.put("item", "b", serde_json::json!({"x": 2}), vec![], None, None).unwrap();
1443 assert_eq!(db.get_hash_by_seq(a.seq), Some(a.hash.clone()));
1444 assert_eq!(db.get_hash_by_seq(b.seq), Some(b.hash.clone()));
1445 assert_eq!(db.get_hash_by_seq(9999), None);
1446 }
1447
1448 #[test]
1449 fn tip_and_since() {
1450 let db = Db::in_memory();
1451 // Empty db: no tip, empty changefeed.
1452 assert!(db.tip().is_none());
1453 assert!(db.since(0, 0).nodes.is_empty());
1454
1455 let a = db.put("item", "a", serde_json::json!({"x": 1}), vec![], None, None).unwrap();
1456 let b = db.put("item", "b", serde_json::json!({"x": 2}), vec![], None, None).unwrap();
1457
1458 // tip() = the most recent write (highest seq), returned as a full node.
1459 let t = db.tip().expect("tip after writes");
1460 assert_eq!(t.seq, b.seq);
1461 assert_eq!(t.id, "b");
1462 assert_eq!(t.hash, b.hash);
1463
1464 // since(after_seq, limit) — EXCLUSIVE cursor, bounded page + envelope.
1465 let after_a = db.since(a.seq, 0);
1466 assert_eq!(after_a.nodes.len(), 1);
1467 assert_eq!(after_a.nodes[0].id, "b");
1468 assert_eq!(after_a.from_seq, a.seq);
1469 assert_eq!(after_a.to_seq, b.seq);
1470 assert_eq!(after_a.head_seq, b.seq);
1471 assert!(!after_a.has_more);
1472
1473 // Nothing written after the tip.
1474 assert!(db.since(b.seq, 0).nodes.is_empty());
1475
1476 // `limit` bounds the page and sets has_more; resume from to_seq.
1477 let c = db.put("item", "c", serde_json::json!({"x": 3}), vec![], None, None).unwrap();
1478 let page = db.since(a.seq, 1); // (a..] capped at 1 -> [b], more pending
1479 assert_eq!(page.nodes.len(), 1);
1480 assert_eq!(page.nodes[0].id, "b");
1481 assert_eq!(page.to_seq, b.seq);
1482 assert!(page.has_more);
1483 let page2 = db.since(page.to_seq, 1); // resume from b -> [c], done
1484 assert_eq!(page2.nodes.len(), 1);
1485 assert_eq!(page2.nodes[0].id, "c");
1486 assert_eq!(page2.to_seq, c.seq);
1487 assert!(!page2.has_more);
1488 }
1489
1490 #[test]
1491 fn tip_collection_per_chain() {
1492 // The ITC sync-client case: separate chains in separate collections; a
1493 // consumer resumes ONE without pulling global tip and filtering.
1494 let db = Db::in_memory();
1495 assert!(db.tip_collection("blocks").is_none());
1496
1497 db.put("blocks", "b0", serde_json::json!({"h": 0}), vec![], None, None).unwrap();
1498 db.put("tx", "t0", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
1499 let b1 = db.put("blocks", "b1", serde_json::json!({"h": 1}), vec![], None, None).unwrap();
1500 let t1 = db.put("tx", "t1", serde_json::json!({"v": 2}), vec![], None, None).unwrap();
1501
1502 // global tip = latest write overall (t1)
1503 assert_eq!(db.tip().unwrap().id, "t1");
1504 // collection-local tips = latest write in each collection
1505 let bt = db.tip_collection("blocks").expect("blocks tip");
1506 assert_eq!(bt.id, "b1");
1507 assert_eq!(bt.seq, b1.seq);
1508 assert_eq!(db.tip_collection("tx").unwrap().seq, t1.seq);
1509 assert!(db.tip_collection("absent").is_none());
1510 }
1511
1512 #[test]
1513 fn seq_index_survives_batch() {
1514 let db = Db::in_memory();
1515 let nodes = db.put_batch(vec![
1516 ("item".into(), "x".into(), serde_json::json!({"v": 1}), vec![], None, None),
1517 ("item".into(), "y".into(), serde_json::json!({"v": 2}), vec![], None, None),
1518 ]).unwrap();
1519 for node in &nodes {
1520 assert_eq!(db.get_hash_by_seq(node.seq), Some(node.hash.clone()));
1521 }
1522 }
1523
1524 /// Regression: put_batch must remove the superseded version's sorted-index
1525 /// entries, exactly like put() does. Old behavior left the old hashes in
1526 /// the BTree — ORDER BY returned superseded rows alongside current ones
1527 /// (they resolve fine through the content-addressed store, which made the
1528 /// stale rows look legitimate).
1529 #[test]
1530 fn put_batch_removes_superseded_sorted_index_entries() {
1531 let db = Db::in_memory();
1532 db.create_sorted_index("blocks", "height");
1533 db.put("blocks", "x", serde_json::json!({"height": 1}), vec![], None, None).unwrap();
1534 db.put_batch(vec![
1535 ("blocks".into(), "x".into(), serde_json::json!({"height": 99}), vec![], None, None),
1536 ]).unwrap();
1537
1538 let asc = db.order_by_asc("blocks", "height", 10);
1539 assert_eq!(asc.len(), 1, "stale index entry for the superseded version must be gone");
1540 assert_eq!(asc[0].data["height"], 99);
1541 assert_eq!(asc[0].id, "x");
1542 }
1543
1544 /// Updates without any sorted index must keep full version-chain semantics
1545 /// (guards the new skip-old-object-read fast path in put()).
1546 #[test]
1547 fn update_without_indexes_preserves_chain() {
1548 let db = Db::in_memory();
1549 let v1 = db.put("docs", "x", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
1550 let v2 = db.put("docs", "x", serde_json::json!({"v": 2}), vec![], None, None).unwrap();
1551 assert_eq!(v2.prev.as_deref(), Some(v1.hash.as_str()), "prev chain must survive the fast path");
1552 assert_eq!(db.get("docs", "x").unwrap().data["v"], 2);
1553 assert_eq!(db.get_as_of("docs", "x", v1.seq).unwrap().data["v"], 1);
1554 }
1555
1556 #[test]
1557 fn link_and_neighbors() {
1558 let db = Db::in_memory();
1559 db.put("driver", "d1", serde_json::json!({"name": "Bob"}), vec![], None, None).unwrap();
1560 db.put("driver", "d2", serde_json::json!({"name": "Carol"}), vec![], None, None).unwrap();
1561 db.put("trip", "t1", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
1562 db.put("trip", "t2", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
1563
1564 db.link("driver:d1", "handles", "trip:t1").unwrap();
1565 db.link("driver:d1", "handles", "trip:t2").unwrap();
1566 db.link("driver:d2", "handles", "trip:t1").unwrap();
1567
1568 let d1_trips = db.neighbors("driver:d1", "handles");
1569 assert_eq!(d1_trips.len(), 2);
1570 let ids: std::collections::HashSet<&str> = d1_trips.iter().map(|n| n.id.as_str()).collect();
1571 assert!(ids.contains("t1") && ids.contains("t2"));
1572
1573 let d2_trips = db.neighbors("driver:d2", "handles");
1574 assert_eq!(d2_trips.len(), 1);
1575 assert_eq!(d2_trips[0].id, "t1");
1576 }
1577
1578 #[test]
1579 fn link_stored_in_links_collection() {
1580 // Links are stored as __links__ documents, not as graph edges.
1581 // The __links__ collection is NQL-queryable and consistent with the PyO3 binding.
1582 let db = Db::in_memory();
1583 db.put("driver", "d1", serde_json::json!({"name": "Bob"}), vec![], None, None).unwrap();
1584 db.put("trip", "t1", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
1585 db.link("driver:d1", "handles", "trip:t1").unwrap();
1586 // Verify the __links__ document was created
1587 let link_doc = db.get("__links__", "driver:d1|handles|trip:t1");
1588 assert!(link_doc.is_some(), "__links__ doc should exist");
1589 let doc = link_doc.unwrap();
1590 assert_eq!(doc.data["_from"], "driver:d1");
1591 assert_eq!(doc.data["_rel"], "handles");
1592 assert_eq!(doc.data["_to"], "trip:t1");
1593 // neighbors() resolves to the target node
1594 let nb = db.neighbors("driver:d1", "handles");
1595 assert_eq!(nb.len(), 1);
1596 assert_eq!(nb[0].id, "t1");
1597 }
1598
1599 /// A lost id-index WAL must be recoverable: the objects carry coll/id/seq,
1600 /// so `repair()` can reconstruct every row, and the repaired database must
1601 /// reopen WARM with a valid head.
1602 ///
1603 /// Regression for 2.8.5, where the cold scan rebuilt seq_index, coll_tips,
1604 /// head and MANIFEST but never the id index — so a database in this state
1605 /// returned 0 rows from `list()` while `verify()` reported every object
1606 /// healthy, and `nedb-cli repair` printed success without fixing anything.
1607 #[test]
1608 fn repair_rebuilds_id_index_after_lost_wal() {
1609 let dir = tempdir().unwrap();
1610 {
1611 let db = Db::open(dir.path(), None).unwrap();
1612 for i in 0..25 {
1613 db.put("rows", &format!("r{}", i), serde_json::json!({"i": i}), vec![], None, None)
1614 .unwrap();
1615 }
1616 db.put("rows", "r0", serde_json::json!({"i": 0, "v": 2}), vec![], None, None).unwrap();
1617 db.try_flush_all().unwrap();
1618 }
1619
1620 // Simulate the lost WAL: objects survive, the id index does not.
1621 std::fs::remove_dir_all(dir.path().join("indexes")).unwrap();
1622
1623 {
1624 let db = Db::open(dir.path(), None).unwrap();
1625 assert_eq!(db.list("rows").len(), 0, "precondition: rows unreachable");
1626 let (ok, bad) = db.verify();
1627 assert!(ok > 0 && bad.is_empty(), "objects must still be intact and verifying");
1628
1629 let written = db.repair().unwrap();
1630 assert_eq!(written, 25, "one entry per distinct (coll, id)");
1631 assert_eq!(db.list("rows").len(), 25, "every row must come back");
1632
1633 // The winner for a re-put id is the HIGHEST seq, matching put().
1634 let r0 = db.get("rows", "r0").expect("r0 present");
1635 assert_eq!(r0.data.get("v").and_then(|v| v.as_i64()), Some(2),
1636 "repair must restore the latest version, not an older one");
1637 }
1638
1639 // A repaired database must reopen warm with a real head.
1640 let db3 = Db::open(dir.path(), None).unwrap();
1641 assert_eq!(db3.list("rows").len(), 25);
1642 assert!(!db3.head().is_empty(), "repair must leave a valid MANIFEST head");
1643 assert!(db3.tip_collection("rows").is_some(), "tip_collection must resolve after repair");
1644 }
1645
1646 /// `since()` must never report "caught up" while the cursor is behind head.
1647 ///
1648 /// Regression for 2.8.5: on a warm boot the seq index is empty by design
1649 /// (the warm path skips the scan), so every seq lookup missed and `since()`
1650 /// returned zero nodes with `has_more = false` — identical to genuinely up
1651 /// to date. A consumer following the documented drain loop stopped one call
1652 /// in, on a database with every record unread.
1653 #[test]
1654 fn since_never_reports_caught_up_while_behind_head() {
1655 let dir = tempdir().unwrap();
1656 {
1657 let db = Db::open(dir.path(), None).unwrap();
1658 for i in 0..10 {
1659 db.put("rows", &format!("r{}", i), serde_json::json!({"i": i}), vec![], None, None)
1660 .unwrap();
1661 }
1662 db.try_flush_all().unwrap();
1663 }
1664
1665 // Warm reopen: startup is "complete" in O(1) because the scan is skipped.
1666 let db2 = Db::open(dir.path(), None).unwrap();
1667 let st = db2.scan_status();
1668 assert!(st.tip_seq > 0, "log has entries");
1669 assert!(
1670 !st.seq_index_ready,
1671 "warm boot leaves the seq index cold — that is the honest signal"
1672 );
1673
1674 let batch = db2.since(0, 100);
1675 assert!(
1676 batch.to_seq < batch.head_seq,
1677 "cursor is behind the log head in this state"
1678 );
1679 assert!(
1680 batch.has_more,
1681 "has_more must be true while the cursor is behind head — otherwise the \
1682 consumer reads 'caught up' and stops with every record unread"
1683 );
1684
1685 // After a repair the index resolves and the drain actually completes.
1686 db2.repair().unwrap();
1687 assert!(db2.scan_status().seq_index_ready);
1688 let drained = db2.since(0, 100);
1689 assert!(!drained.has_more, "genuinely caught up reports has_more=false");
1690
1691 // KNOWN SHARP EDGE, pinned here deliberately: the cursor is EXCLUSIVE
1692 // and seqs start at 0, so `since(0, _)` returns (0, head] and the very
1693 // first write in a database (seq 0) is not reachable through any cursor
1694 // value. 10 writes therefore drain as 9 records. Changing the cursor
1695 // convention would break existing replication consumers, so this is
1696 // documented rather than silently altered — but a replica seeded from
1697 // since() alone starts one record short.
1698 assert_eq!(
1699 drained.nodes.len(),
1700 9,
1701 "since(0) is exclusive of seq 0 — see the sharp edge noted above"
1702 );
1703 assert!(
1704 drained.nodes.iter().all(|n| n.seq >= 1),
1705 "seq 0 is unreachable via since()"
1706 );
1707 }
1708
1709 #[test]
1710 fn link_missing_node_errors() {
1711 let db = Db::in_memory();
1712 db.put("driver", "d1", serde_json::json!({}), vec![], None, None).unwrap();
1713 assert!(db.link("driver:d1", "handles", "trip:ghost").is_err());
1714 }
1715
1716 #[test]
1717 fn link_durable_survives_reopen() {
1718 let dir = tempdir().unwrap();
1719 {
1720 let db = Db::open(dir.path(), None).unwrap();
1721 db.put("driver", "d1", serde_json::json!({"name": "Bob"}), vec![], None, None).unwrap();
1722 db.put("trip", "t1", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
1723 db.link("driver:d1", "handles", "trip:t1").unwrap();
1724 }
1725 let db2 = Db::open(dir.path(), None).unwrap();
1726 db2.startup_ready.store(true, std::sync::atomic::Ordering::SeqCst);
1727 let trips = db2.neighbors("driver:d1", "handles");
1728 assert_eq!(trips.len(), 1);
1729 assert_eq!(trips[0].id, "t1");
1730 }
1731
1732 #[test]
1733 fn tip_survives_warm_restart() {
1734 // v2.5.43: tip() returns the last written object AND survives a warm restart.
1735 // On reopen the seq_index is cold (warm start skips the scan), so tip() must
1736 // resolve the last write via the MANIFEST tip_hash fallback — no scan.
1737 let dir = tempdir().unwrap();
1738 {
1739 let db = Db::open(dir.path(), None).unwrap();
1740 db.put("blocks", "b1", serde_json::json!({"h": 1}), vec![], None, None).unwrap();
1741 db.put("blocks", "b2", serde_json::json!({"h": 2}), vec![], None, None).unwrap();
1742 db.flush_all(); // persists MANIFEST incl. tip_hash
1743 assert_eq!(db.tip().expect("tip in-session").id, "b2");
1744 }
1745 // Warm reopen: MANIFEST present -> no cold scan -> seq_index cold.
1746 let db2 = Db::open(dir.path(), None).unwrap();
1747 assert!(db2.get_hash_by_seq(1).is_none(), "seq_index is cold on a warm boot");
1748 let tip = db2.tip().expect("tip() must survive a warm restart");
1749 assert_eq!(tip.id, "b2");
1750 assert_eq!(tip.data.get("h").and_then(|v| v.as_i64()), Some(2));
1751 }
1752
1753 #[test]
1754 fn tip_collection_survives_warm_restart() {
1755 // Same contract as tip(), per collection: itc-node-rs resumes headers /
1756 // blocks / l2_receipts independently, so each must be its own durable
1757 // resume point — not just the global tip.
1758 let dir = tempdir().unwrap();
1759 {
1760 let db = Db::open(dir.path(), None).unwrap();
1761 db.put("blocks", "b1", serde_json::json!({"h": 1}), vec![], None, None).unwrap();
1762 db.put("tx", "t1", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
1763 let b2 = db.put("blocks", "b2", serde_json::json!({"h": 2}), vec![], None, None).unwrap();
1764 db.flush_all(); // persists MANIFEST incl. coll_tips
1765 assert_eq!(db.tip_collection("blocks").unwrap().id, "b2");
1766 assert_eq!(db.tip_collection("blocks").unwrap().seq, b2.seq);
1767 }
1768 // Warm reopen: MANIFEST present -> no cold scan -> seq_index cold.
1769 let db2 = Db::open(dir.path(), None).unwrap();
1770 assert!(db2.get_hash_by_seq(0).is_none(), "seq_index is cold on a warm boot");
1771 let blocks_tip = db2.tip_collection("blocks").expect("tip_collection must survive a warm restart");
1772 assert_eq!(blocks_tip.id, "b2");
1773 assert_eq!(blocks_tip.data.get("h").and_then(|v| v.as_i64()), Some(2));
1774 let tx_tip = db2.tip_collection("tx").expect("tx tip must also survive");
1775 assert_eq!(tx_tip.id, "t1");
1776 assert!(db2.tip_collection("absent").is_none());
1777 }
1778
1779 #[test]
1780 fn cold_scan_indexes_every_object_and_reports_completion() {
1781 // Regression guard for the cold-scan refactor: seq_index is now populated
1782 // DURING the parallel read pass (for live scan_status().indexed_count
1783 // progress — see cold_scan_background_arc), not in a second pass
1784 // afterward. This asserts the end state is unchanged: every written
1785 // object is indexed, tip()/tip_collection() are correct, and
1786 // scan_complete eventually reports true.
1787 let dir = tempdir().unwrap();
1788 let n = 25u64;
1789 {
1790 let db = Db::open(dir.path(), None).unwrap();
1791 for i in 0..n {
1792 db.put("things", &i.to_string(), serde_json::json!({"i": i}), vec![], None, None).unwrap();
1793 }
1794 db.flush_all();
1795 }
1796 // Force a COLD start regardless of the MANIFEST nedb-v2 itself would
1797 // have written: delete it so startup_rebuild() takes the cold path and
1798 // start_cold_scan() actually spawns the background scan this test needs
1799 // to exercise.
1800 std::fs::remove_file(dir.path().join("MANIFEST")).unwrap();
1801
1802 let db = Db::open(dir.path(), None).unwrap();
1803 assert!(!db.scan_status().scan_complete, "should be cold immediately after open");
1804 let db = std::sync::Arc::new(db);
1805 Db::start_cold_scan(std::sync::Arc::clone(&db));
1806
1807 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1808 while !db.scan_status().scan_complete {
1809 assert!(std::time::Instant::now() < deadline, "cold scan did not complete in time");
1810 std::thread::sleep(std::time::Duration::from_millis(5));
1811 }
1812
1813 let status = db.scan_status();
1814 assert_eq!(status.indexed_count, n as usize, "every written object must be indexed");
1815 assert!(status.scan_complete);
1816
1817 let tip = db.tip().expect("tip resolves after cold scan");
1818 assert_eq!(tip.data.get("i").and_then(|v| v.as_u64()), Some(n - 1));
1819 let coll_tip = db.tip_collection("things").expect("tip_collection resolves after cold scan");
1820 assert_eq!(coll_tip.id, tip.id);
1821 }
1822
1823 /// Concurrent writers must settle the tip at the HIGHEST SEQ, and that tip
1824 /// must survive a warm restart. Before the seq-guarded tip fix, update_head
1825 /// was "last call wins": a slower thread carrying an OLDER seq could
1826 /// overwrite tip_hash after a newer write, and MANIFEST then persisted the
1827 /// stale tip for the next warm boot (flaky by nature — this pins the
1828 /// contract deterministically for the fixed code).
1829 #[test]
1830 fn concurrent_puts_tip_resolves_to_highest_seq_after_warm_restart() {
1831 let dir = tempdir().unwrap();
1832 let total: u64 = 100;
1833 {
1834 let db = std::sync::Arc::new(Db::open(dir.path(), None).unwrap());
1835 let mut handles = vec![];
1836 for t in 0..4u64 {
1837 let db2 = std::sync::Arc::clone(&db);
1838 handles.push(std::thread::spawn(move || {
1839 for i in 0..25u64 {
1840 db2.put("c", &format!("{}-{}", t, i),
1841 serde_json::json!({"t": t, "i": i}),
1842 vec![], None, None).unwrap();
1843 }
1844 }));
1845 }
1846 for h in handles { h.join().unwrap(); }
1847 // In-session: tip must be the highest assigned seq.
1848 let expected = db.seq.load(std::sync::atomic::Ordering::SeqCst) - 1;
1849 assert_eq!(expected, total - 1, "exactly {} writes expected", total);
1850 assert_eq!(db.tip().expect("in-session tip").seq, expected);
1851 db.flush_all(); // persist MANIFEST incl. tip_hash
1852 }
1853 // Warm reopen: seq_index cold; tip() resolves via MANIFEST tip_hash.
1854 let db2 = Db::open(dir.path(), None).unwrap();
1855 let tip = db2.tip().expect("tip must survive warm restart after concurrent writes");
1856 assert_eq!(tip.seq, total - 1, "warm-boot tip must be the highest-seq write");
1857 // Per-collection tip: same contract.
1858 let ct = db2.tip_collection("c").expect("coll tip survives");
1859 assert_eq!(ct.seq, total - 1);
1860 }
1861
1862 /// Pre-2.5.43 MANIFESTs (no tip_hash) must warm-boot, NOT force a cold
1863 /// scan. The old "cold scan once to upgrade" policy was hours of random
1864 /// reads on multi-million-object seek-bound stores (itcd -dagv3), re-paid
1865 /// on every boot if the process exited before the scan finished. seq+head
1866 /// in the old MANIFEST are valid; tip()/tip_collection() return None until
1867 /// the first write+flush organically rewrites MANIFEST with a tip.
1868 #[test]
1869 fn pre_durable_tip_manifest_warm_boots_and_heals_lazily() {
1870 let dir = tempdir().unwrap();
1871 {
1872 let db = Db::open(dir.path(), None).unwrap();
1873 for i in 0..5u64 {
1874 db.put("things", &i.to_string(), serde_json::json!({"i": i}), vec![], None, None).unwrap();
1875 }
1876 db.flush_all();
1877 }
1878 // Rewrite MANIFEST in the pre-2.5.43 shape: seq + head only.
1879 let manifest_path = dir.path().join("MANIFEST");
1880 let m: serde_json::Value =
1881 serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
1882 let old_format = serde_json::json!({ "seq": m["seq"], "head": m["head"] });
1883 std::fs::write(&manifest_path, serde_json::to_string(&old_format).unwrap()).unwrap();
1884
1885 // Reopen: must be WARM (startup_ready immediately — no cold scan gate).
1886 let db2 = Db::open(dir.path(), None).unwrap();
1887 assert!(db2.startup_ready.load(std::sync::atomic::Ordering::SeqCst),
1888 "pre-2.5.43 MANIFEST must warm-boot, not fall to a cold scan");
1889 // tip() unresolvable this boot — documented None, not a panic or scan.
1890 assert!(db2.tip().is_none(), "tip() is None until the manifest heals");
1891 // seq continuity: a new write gets a FRESH seq (no reuse).
1892 let n = db2.put("things", "next", serde_json::json!({"fresh": true}), vec![], None, None).unwrap();
1893 assert_eq!(n.seq, m["seq"].as_u64().unwrap(), "next write takes the persisted next-to-assign seq");
1894 db2.flush_all(); // organic upgrade: MANIFEST now carries tip_hash
1895 drop(db2);
1896
1897 // Healed: next boot is warm AND tip() resolves.
1898 let db3 = Db::open(dir.path(), None).unwrap();
1899 assert!(db3.startup_ready.load(std::sync::atomic::Ordering::SeqCst));
1900 let tip = db3.tip().expect("tip() must resolve after the organic upgrade");
1901 assert_eq!(tip.id, "next");
1902 }
1903
1904 /// Regression for the cold-scan MANIFEST seq off-by-one. The scan's old
1905 /// hand-rolled MANIFEST stored `seq: max_seq` (the last USED seq), but the
1906 /// warm boot loads `m.seq` as the NEXT-TO-ASSIGN counter — so a restart
1907 /// right after a quiet cold scan handed the next write the tip's seq:
1908 /// a DUPLICATE seq in the log (seq_index overwrite, wrong since() page).
1909 /// The scan now writes MANIFEST via flush_manifest(), which reads the live
1910 /// counter (max_seq + 1).
1911 #[test]
1912 fn manifest_after_cold_scan_does_not_reuse_tip_seq() {
1913 let dir = tempdir().unwrap();
1914 let old_tip_seq;
1915 {
1916 let db = Db::open(dir.path(), None).unwrap();
1917 for i in 0..5u64 {
1918 db.put("things", &i.to_string(), serde_json::json!({"i": i}), vec![], None, None).unwrap();
1919 }
1920 db.flush_all();
1921 old_tip_seq = db.tip().unwrap().seq;
1922 }
1923 // Force a cold start: remove MANIFEST so the background scan runs and
1924 // writes a fresh MANIFEST itself.
1925 std::fs::remove_file(dir.path().join("MANIFEST")).unwrap();
1926 {
1927 let db = std::sync::Arc::new(Db::open(dir.path(), None).unwrap());
1928 Db::start_cold_scan(std::sync::Arc::clone(&db));
1929 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1930 while !db.scan_status().scan_complete {
1931 assert!(std::time::Instant::now() < deadline, "cold scan did not complete");
1932 std::thread::sleep(std::time::Duration::from_millis(5));
1933 }
1934 // No further writes — the scan's own MANIFEST is what the next boot sees.
1935 }
1936 // Warm reopen from the scan-written MANIFEST: the next write must get a
1937 // FRESH seq, never the tip's.
1938 let db3 = Db::open(dir.path(), None).unwrap();
1939 let tip_before = db3.tip().expect("tip survives scan-written MANIFEST");
1940 assert_eq!(tip_before.seq, old_tip_seq, "tip identity preserved across the scan");
1941 let new_node = db3.put("things", "next", serde_json::json!({"fresh": true}),
1942 vec![], None, None).unwrap();
1943 assert!(new_node.seq > old_tip_seq,
1944 "new write reused seq {} (tip was {}) — duplicate seq in the log",
1945 new_node.seq, old_tip_seq);
1946 }
1947
1948 /// Regression: the flush ticker must NOT pin the database.
1949 ///
1950 /// Before this was fixed, `start_manifest_ticker` held a strong `Arc<Db>`
1951 /// in an unconditional `loop`, so the thread never exited, the `Db` was
1952 /// never dropped, and the exclusive data-dir `LOCK` from `Db::open` was
1953 /// never released. Reopening the same path in the SAME PROCESS then failed
1954 /// with "locked by another process (pid N)" — where N was the caller's own
1955 /// pid. Live in every release from 2.8.5 through 3.1.0, and invisible
1956 /// because no CI ran the suite (tests/test_native.py) that hit it.
1957 ///
1958 /// Put the strong `Arc` back in the ticker and this test fails.
1959 #[test]
1960 fn ticker_does_not_pin_the_db_across_a_reopen() {
1961 let dir = tempdir().unwrap();
1962 {
1963 let db = std::sync::Arc::new(Db::open(dir.path(), None).unwrap());
1964 Db::start_manifest_ticker(std::sync::Arc::clone(&db), 25);
1965 db.put("t", "a", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
1966 // Let the ticker run at least a couple of times while the db lives.
1967 std::thread::sleep(std::time::Duration::from_millis(90));
1968 } // last owner dropped here -> Drop flushes -> LOCK released
1969
1970 // The ticker upgrades its Weak for the duration of a tick, so at any
1971 // given instant it may legitimately hold a transient strong reference.
1972 // Release is therefore "eventual, within about one interval", not
1973 // instantaneous -- poll for it.
1974 //
1975 // The first version of this test sampled Arc::strong_count once and
1976 // asserted it was 1. That passed on an idle machine and failed the
1977 // first time it met a loaded CI runner, because the sample landed
1978 // mid-tick. A leak still fails this test deterministically: if the
1979 // ticker holds a strong Arc forever the LOCK is never released and
1980 // the deadline expires.
1981 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1982 let db2 = loop {
1983 match Db::open(dir.path(), None) {
1984 Ok(db) => break db,
1985 Err(e) => {
1986 assert!(std::time::Instant::now() < deadline,
1987 "reopen never succeeded -- the ticker is pinning the Db: {e}");
1988 std::thread::sleep(std::time::Duration::from_millis(25));
1989 }
1990 }
1991 };
1992 assert!(db2.get("t", "a").is_some(), "the write survived close/reopen");
1993 }
1994
1995 /// The ticker thread must actually terminate, not merely stop pinning.
1996 #[test]
1997 fn ticker_thread_exits_when_the_last_owner_drops() {
1998 let dir = tempdir().unwrap();
1999 let weak = {
2000 let db = std::sync::Arc::new(Db::open(dir.path(), None).unwrap());
2001 Db::start_manifest_ticker(std::sync::Arc::clone(&db), 25);
2002 db.put("t", "a", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
2003 std::thread::sleep(std::time::Duration::from_millis(60));
2004 std::sync::Arc::downgrade(&db)
2005 };
2006 // Same reasoning as above: a tick in flight holds a real strong
2007 // reference for a few microseconds, so this is an eventual property.
2008 // A genuine leak never releases and blows the deadline.
2009 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
2010 while weak.upgrade().is_some() {
2011 assert!(std::time::Instant::now() < deadline,
2012 "the Db outlived its last owner — the ticker is leaking it");
2013 std::thread::sleep(std::time::Duration::from_millis(25));
2014 }
2015 }
2016}