nedb_engine/index.rs
1//! Index store for NEDB v2.
2//!
3//! Two index types:
4//!
5//! 1. **ID index** (`indexes/{coll}/id/{doc_id}` → object hash)
6//! Atomic file-per-document. Reading is a single `fs::read_to_string`.
7//! Writing is atomic (write .tmp → rename). Parallel reads are lock-free.
8//!
9//! 2. **Sorted index** (`indexes/{coll}/{field}.sorted` → in-memory BTreeMap)
10//! Rebuilt from object store on startup. Persisted as a compact binary
11//! file for fast cold start. Used for ORDER BY field ASC/DESC LIMIT n.
12
13use std::collections::BTreeMap;
14use std::fs;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17use anyhow::Result;
18use dashmap::DashMap;
19use serde_json::Value;
20
21/// Ordered JSON value for BTree indexes (null < bool < number < string < array < object).
22#[derive(Debug, Clone, PartialEq)]
23pub enum OrderedValue {
24 Null,
25 Bool(bool),
26 Number(f64), // NaN-safe comparison via total_cmp
27 Str(String),
28 Array(Vec<OrderedValue>),
29 Object, // objects are all equal in ordering (sort by insertion order falls back to hash)
30}
31
32impl Eq for OrderedValue {}
33
34impl PartialOrd for OrderedValue {
35 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
36 Some(self.cmp(other))
37 }
38}
39
40impl Ord for OrderedValue {
41 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
42 use OrderedValue::*;
43 use std::cmp::Ordering::*;
44 match (self, other) {
45 (Null, Null) => Equal,
46 (Null, _) => Less,
47 (_, Null) => Greater,
48 (Bool(a), Bool(b)) => a.cmp(b),
49 (Bool(_), _) => Less,
50 (_, Bool(_)) => Greater,
51 (Number(a), Number(b)) => a.total_cmp(b),
52 (Number(_), _) => Less,
53 (_, Number(_)) => Greater,
54 (Str(a), Str(b)) => a.cmp(b),
55 (Str(_), _) => Less,
56 (_, Str(_)) => Greater,
57 (Array(a), Array(b)) => a.cmp(b),
58 (Array(_), _) => Less,
59 (_, Array(_)) => Greater,
60 (Object, Object) => Equal,
61 }
62 }
63}
64
65impl From<&Value> for OrderedValue {
66 fn from(v: &Value) -> Self {
67 match v {
68 Value::Null => OrderedValue::Null,
69 Value::Bool(b) => OrderedValue::Bool(*b),
70 Value::Number(n) => OrderedValue::Number(n.as_f64().unwrap_or(f64::NAN)),
71 Value::String(s) => OrderedValue::Str(s.clone()),
72 Value::Array(a) => OrderedValue::Array(a.iter().map(|x| x.into()).collect()),
73 Value::Object(_) => OrderedValue::Object,
74 }
75 }
76}
77
78/// Compute a 2-char hex shard prefix from a document id.
79/// Distributes files across 256 subdirectories to avoid flat-directory
80/// slowdown on ext4/xfs when a collection has >50k documents.
81fn id_shard(id: &str) -> String {
82 // FNV-1a 32-bit — fast, no crypto needed, deterministic
83 let mut hash: u32 = 2166136261;
84 for b in id.bytes() {
85 hash ^= b as u32;
86 hash = hash.wrapping_mul(16777619);
87 }
88 format!("{:02x}", hash & 0xff)
89}
90
91/// Encode a document id into a filesystem-safe leaf filename.
92///
93/// The id-index stores one file per document, and the id is the filename. Raw
94/// ids work on case-sensitive POSIX filesystems, but ids containing bytes that
95/// are illegal in Windows filenames (`: | / \ < > " ? *`, control chars) — most
96/// notably link ids like `driver:d1|handles|trip:t1` — cannot be written there,
97/// so the write silently fails and the entry is lost on reopen.
98///
99/// We percent-escape every byte that isn't unreserved (`A-Z a-z 0-9 - _ .`).
100/// `%` itself is escaped so decoding is unambiguous. Safe ids (block heights,
101/// hex hashes, utxo keys) are all-unreserved and return UNCHANGED, so existing
102/// chainstate paths are byte-for-byte identical and the hot path is unaffected.
103fn encode_id(id: &str) -> String {
104 fn is_unreserved(b: u8) -> bool {
105 b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')
106 }
107 if id.bytes().all(is_unreserved) {
108 return id.to_string();
109 }
110 let mut out = String::with_capacity(id.len() + 8);
111 for &b in id.as_bytes() {
112 if is_unreserved(b) {
113 out.push(b as char);
114 } else {
115 out.push_str(&format!("%{:02X}", b));
116 }
117 }
118 out
119}
120
121/// Inverse of `encode_id`. A name with no `%` (a safe id, or a legacy raw id
122/// written by an older version on a POSIX filesystem) is returned unchanged, so
123/// `list_ids` recovers the right id for both new and pre-upgrade files.
124fn decode_id(name: &str) -> String {
125 if !name.contains('%') {
126 return name.to_string();
127 }
128 fn hexval(b: u8) -> Option<u8> {
129 match b {
130 b'0'..=b'9' => Some(b - b'0'),
131 b'A'..=b'F' => Some(b - b'A' + 10),
132 b'a'..=b'f' => Some(b - b'a' + 10),
133 _ => None,
134 }
135 }
136 let bytes = name.as_bytes();
137 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
138 let mut i = 0;
139 while i < bytes.len() {
140 if bytes[i] == b'%' && i + 2 < bytes.len() {
141 if let (Some(hi), Some(lo)) = (hexval(bytes[i + 1]), hexval(bytes[i + 2])) {
142 out.push(hi * 16 + lo);
143 i += 3;
144 continue;
145 }
146 }
147 out.push(bytes[i]);
148 i += 1;
149 }
150 String::from_utf8_lossy(&out).into_owned()
151}
152
153/// Per-document ID index — atomic file-per-doc, sharded across 256 subdirs.
154///
155/// Write path: updates go to `write_buf` (DashMap, zero I/O, lock-free).
156/// Background ticker calls `flush_write_buf()` every 1s — Rayon-parallel disk writes.
157/// Read path: `write_buf` checked first (latest value), then disk.
158/// This eliminates per-PUT `fs::rename` from the hot path, fixing concurrent write contention.
159pub struct IdIndex {
160 root: PathBuf,
161 /// In-memory store: (coll, id) → hash. None = disk-backed (normal mode).
162 mem: Option<Arc<dashmap::DashMap<(String, String), String>>>,
163 /// WAL write buffer — disk-backed mode buffers here, flushed to disk periodically.
164 write_buf: Arc<dashmap::DashMap<(String, String), Option<String>>>, // None = tombstone
165}
166
167impl IdIndex {
168 pub fn new(db_root: &Path) -> Result<Self> {
169 let root = db_root.join("indexes");
170 fs::create_dir_all(&root)?;
171 Ok(Self { root, mem: None, write_buf: Arc::new(dashmap::DashMap::new()) })
172 }
173
174 /// Create a pure in-memory id index — no disk I/O.
175 pub fn in_memory() -> Self {
176 Self {
177 root: PathBuf::from(":memory:"),
178 mem: Some(Arc::new(dashmap::DashMap::new())),
179 write_buf: Arc::new(dashmap::DashMap::new()),
180 }
181 }
182
183 /// Flush the WAL write buffer to disk in parallel. Called by the background ticker.
184 /// No-op for in-memory databases. Safe to call concurrently with writes.
185 /// Flush the in-memory WAL to disk, reporting I/O failure to the caller.
186 /// Every entry is attempted; the first error is returned after the pass.
187 ///
188 /// DURABILITY INVARIANT: an entry is dropped from `write_buf` ONLY when its
189 /// disk write actually succeeded. A failed write (ENOSPC, EIO, EROFS) leaves
190 /// the entry buffered so the next tick retries it.
191 ///
192 /// Before 2.8.6 this cleared the buffer unconditionally, which silently and
193 /// permanently discarded acknowledged writes whenever a flush hit a full
194 /// disk: `put()` had already returned `Ok` and the content-addressed object
195 /// was durable (so `verify()` still counted it), but no id-index entry ever
196 /// reached disk — so the row was simply absent on reopen, with no error
197 /// anywhere. Reproduced on a full 22 MiB filesystem: 30 rows acknowledged,
198 /// `verify()` reported 30 healthy objects, `list()` returned 0.
199 pub fn try_flush_write_buf(&self) -> std::io::Result<()> {
200 if self.mem.is_some() || self.write_buf.is_empty() { return Ok(()); }
201 use rayon::prelude::*;
202 // Drain all pending entries and write them in parallel
203 let entries: Vec<((String, String), Option<String>)> = self.write_buf
204 .iter()
205 .map(|e| (e.key().clone(), e.value().clone()))
206 .collect();
207 let results: Vec<std::io::Result<()>> = entries.par_iter()
208 .map(|((coll, id), hash_opt)| -> std::io::Result<()> {
209 match hash_opt {
210 Some(hash) => {
211 // Write/update: tmp → rename
212 let path = self.path(coll, id);
213 if let Some(parent) = path.parent() {
214 fs::create_dir_all(parent)?;
215 }
216 let tmp = path.with_extension("tmp");
217 if let Err(e) = fs::write(&tmp, hash) {
218 // A partial/empty tmp must not be left behind on a
219 // full disk — it consumes the very space needed to
220 // retry, and it is not a valid index leaf.
221 let _ = fs::remove_file(&tmp);
222 return Err(e);
223 }
224 if let Err(e) = fs::rename(&tmp, &path) {
225 let _ = fs::remove_file(&tmp);
226 return Err(e);
227 }
228 Ok(())
229 }
230 None => {
231 // Tombstone: remove the file (encoded leaf + legacy raw if distinct).
232 // Already-absent is success — the desired end state holds.
233 let path = self.path(coll, id);
234 match fs::remove_file(&path) {
235 Ok(()) => {}
236 Err(ref e) if e.kind() == std::io::ErrorKind::NotFound => {}
237 Err(e) => return Err(e),
238 }
239 let raw = self.raw_path(coll, id);
240 if raw != path {
241 match fs::remove_file(&raw) {
242 Ok(()) => {}
243 Err(ref e) if e.kind() == std::io::ErrorKind::NotFound => {}
244 Err(e) => return Err(e),
245 }
246 }
247 Ok(())
248 }
249 }
250 })
251 .collect();
252
253 // Clear flushed entries — but ONLY when the write SUCCEEDED, and only
254 // when the buffered value is still the exact value we flushed. An
255 // unconditional remove() here would delete a NEWER value written between
256 // the snapshot above and this point: that write would never reach disk
257 // (the file holds the stale hash we just wrote) and get() would serve the
258 // old version once the buffer check misses — a silent lost update.
259 // remove_if closes the race; a newer value simply stays buffered and
260 // flushes on the next tick.
261 let mut first_err: Option<std::io::Error> = None;
262 for ((key, flushed_val), result) in entries.iter().zip(results.iter()) {
263 match result {
264 Ok(()) => {
265 self.write_buf.remove_if(key, |_, current| current == flushed_val);
266 }
267 Err(e) => {
268 if first_err.is_none() {
269 first_err = Some(std::io::Error::new(
270 e.kind(),
271 format!("id-index leaf {}/{}: {}", key.0, key.1, e),
272 ));
273 }
274 }
275 }
276 }
277 match first_err {
278 Some(e) => Err(e),
279 None => Ok(()),
280 }
281 }
282
283 /// Back-compat wrapper around [`try_flush_write_buf`]: flushes and logs.
284 /// Prefer the `try_` form — a swallowed flush error is a lost write.
285 pub fn flush_write_buf(&self) {
286 if let Err(e) = self.try_flush_write_buf() {
287 eprintln!(
288 "nedb: id-index flush failed ({}) — affected writes are RETAINED in the WAL and will be retried on the next flush",
289 e
290 );
291 }
292 }
293
294 fn path(&self, coll: &str, id: &str) -> PathBuf {
295 // Shard across 256 subdirectories using first 2 hex chars of a simple
296 // hash of the id. Prevents flat-directory slowdown (ext4 htree degrades
297 // past ~50k files per directory) for large collections like kv.
298 // Format: indexes/{coll}/id/{shard}/{encode_id(id)}
299 // Shard on the RAW id (stable across versions); only the leaf filename
300 // is encoded so it is legal on every filesystem (incl. Windows).
301 let shard = id_shard(id);
302 self.root.join(coll).join("id").join(&shard).join(encode_id(id))
303 }
304
305 /// Legacy path: the raw id as the leaf filename (pre-`encode_id`). Used only
306 /// as a read/cleanup fallback so id-index entries written by older versions
307 /// on POSIX filesystems stay readable after upgrade. On Windows a raw path
308 /// with illegal chars simply fails to open (→ treated as absent).
309 fn raw_path(&self, coll: &str, id: &str) -> PathBuf {
310 let shard = id_shard(id);
311 self.root.join(coll).join("id").join(&shard).join(id)
312 }
313
314 /// Get the current object hash for a document.
315 /// Checks WAL write buffer first (most recent), then disk.
316 pub fn get(&self, coll: &str, id: &str) -> Option<String> {
317 if let Some(ref mem) = self.mem {
318 return mem.get(&(coll.to_string(), id.to_string())).map(|v| v.clone());
319 }
320 // Check WAL buffer first — may have an unflushed write or tombstone
321 let key = (coll.to_string(), id.to_string());
322 if let Some(entry) = self.write_buf.get(&key) {
323 return entry.value().clone(); // None = tombstoned
324 }
325 // Fall through to disk: encoded filename first, then the legacy raw
326 // filename (pre-upgrade data). For safe ids the two paths are identical,
327 // so this is a single read on the hot path.
328 let p = self.path(coll, id);
329 let content = match fs::read_to_string(&p) {
330 Ok(c) => c,
331 Err(_) => {
332 let raw = self.raw_path(coll, id);
333 if raw == p { return None; }
334 fs::read_to_string(&raw).ok()?
335 }
336 };
337 let h = content.trim().to_string();
338 if h.is_empty() { None } else { Some(h) }
339 }
340
341 /// Set the current object hash for a document.
342 /// Disk mode: writes to WAL buffer only (zero I/O on hot path).
343 /// Background ticker flushes WAL to disk every 1s via Rayon.
344 pub fn set(&self, coll: &str, id: &str, hash: &str) -> Result<()> {
345 if let Some(ref mem) = self.mem {
346 mem.insert((coll.to_string(), id.to_string()), hash.to_string());
347 return Ok(());
348 }
349 // WAL: buffer the update, no disk I/O here
350 self.write_buf.insert(
351 (coll.to_string(), id.to_string()),
352 Some(hash.to_string()),
353 );
354 Ok(())
355 }
356
357 /// List all doc IDs in a collection (memory map or disk + WAL merge).
358 pub fn list_ids(&self, coll: &str) -> Vec<String> {
359 if let Some(ref mem) = self.mem {
360 // DashMap iteration order is also unspecified — sort here too, so
361 // memory mode and disk mode agree.
362 let mut ids: Vec<String> = mem.iter()
363 .filter(|e| e.key().0 == coll)
364 .map(|e| e.key().1.clone())
365 .collect();
366 ids.sort_unstable();
367 return ids;
368 }
369 // Read from disk then overlay WAL (adds buffered writes, removes tombstones)
370 let id_root = self.root.join(coll).join("id");
371 // Each entry in id_root is a 2-char hex shard dir
372 let mut ids: Vec<String> = fs::read_dir(&id_root)
373 .into_iter()
374 .flatten()
375 .filter_map(|e| e.ok())
376 .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
377 .flat_map(|shard_dir| {
378 fs::read_dir(shard_dir.path())
379 .into_iter()
380 .flatten()
381 .filter_map(|e| e.ok())
382 .filter_map(|e| {
383 let name = e.file_name().to_string_lossy().to_string();
384 if name.ends_with(".tmp") { return None; }
385 // Decode the on-disk filename back to the document id
386 // (encoded for new files; identity for legacy/safe ids).
387 Some(decode_id(&name))
388 })
389 .collect::<Vec<_>>()
390 })
391 .collect::<std::collections::HashSet<_>>()
392 .into_iter()
393 // Overlay WAL: add buffered writes, remove tombstones
394 .chain(
395 self.write_buf.iter()
396 .filter(|e| e.key().0 == coll && e.value().is_some())
397 .map(|e| e.key().1.clone())
398 )
399 .collect::<std::collections::HashSet<_>>()
400 .into_iter()
401 .filter(|id| {
402 // Exclude WAL tombstones
403 self.write_buf.get(&(coll.to_string(), id.clone()))
404 .map(|v| v.is_some())
405 .unwrap_or(true)
406 })
407 .collect::<Vec<_>>();
408
409 // Deterministic order. The dedup above runs through HashSet, and Rust
410 // seeds its hasher randomly PER PROCESS — so without this the same query
411 // over unchanged data returns rows in a different order on every restart:
412 //
413 // run 1: o5 o8 o7 o4 o6 ...
414 // run 2: o7 o5 o2 o6 o8 ...
415 //
416 // Cosmetic for a full scan, but not for `LIMIT 5` with no ORDER BY,
417 // which then returns an arbitrary 5 of 8 and calls it an answer. It also
418 // makes any snapshot/diff test flaky for reasons that look like data
419 // corruption.
420 //
421 // Sorted by id, which for the common case of sequential ids is also
422 // insertion order. Callers that want a different order say ORDER BY.
423 ids.sort_unstable();
424 ids
425 }
426
427 /// Remove the id index entry for a document (tombstone / delete).
428 /// Disk mode: writes a tombstone to the WAL buffer; flushed to disk on next ticker.
429 pub fn remove(&self, coll: &str, id: &str) -> Result<()> {
430 if let Some(ref mem) = self.mem {
431 mem.remove(&(coll.to_string(), id.to_string()));
432 return Ok(());
433 }
434 // WAL tombstone: None value means "delete this file on flush"
435 self.write_buf.insert((coll.to_string(), id.to_string()), None);
436 Ok(())
437 }
438
439 /// List all known collections.
440 ///
441 /// Overlays the WAL, exactly as `ids()` does. A collection whose first write
442 /// is still sitting in `write_buf` has no directory on disk yet, so a
443 /// read_dir-only implementation reports it as absent for up to a full flush
444 /// tick.
445 ///
446 /// That was a real bug, and a nasty one because it was invisible to a human
447 /// at a terminal: type a PUT, type a query, and the 1s ticker has already
448 /// fired in between. Only an automated caller — one that writes and reads in
449 /// the same millisecond — ever sees the empty list. It surfaced through
450 /// `/cast`, which checks the generated collection against this list and
451 /// returned "collection does not exist" for a collection that had just been
452 /// written successfully.
453 ///
454 /// Tombstoned entries are excluded, but only when the collection has no
455 /// surviving documents anywhere — a delete of one document must not hide the
456 /// whole collection.
457 pub fn collections(&self) -> Vec<String> {
458 if let Some(ref mem) = self.mem {
459 let mut colls: Vec<String> = mem.iter()
460 .map(|e| e.key().0.clone())
461 .collect::<std::collections::HashSet<_>>()
462 .into_iter().collect();
463 colls.sort();
464 return colls;
465 }
466
467 let mut set: std::collections::HashSet<String> = fs::read_dir(&self.root)
468 .into_iter()
469 .flatten()
470 .filter_map(|e| e.ok())
471 .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
472 .map(|e| e.file_name().to_string_lossy().to_string())
473 .collect();
474
475 // Overlay WAL: a buffered live write makes its collection visible now.
476 for e in self.write_buf.iter() {
477 if e.value().is_some() {
478 set.insert(e.key().0.clone());
479 }
480 }
481
482 let mut colls: Vec<String> = set.into_iter().collect();
483 colls.sort();
484 colls
485 }
486}
487
488/// In-memory sorted index per (collection, field).
489/// Rebuilt from object store on startup. O(log n) ORDER BY queries.
490pub struct SortedIndexes {
491 /// (coll, field) → BTreeMap<value, Vec<hash>>
492 inner: DashMap<(String, String), BTreeMap<OrderedValue, Vec<String>>>,
493}
494
495impl SortedIndexes {
496 pub fn new() -> Self {
497 Self { inner: DashMap::new() }
498 }
499
500 /// Register a field as sorted-indexed for a collection.
501 /// Must be called before any puts for that field to be indexed.
502 pub fn ensure(&self, coll: &str, field: &str) {
503 self.inner
504 .entry((coll.to_string(), field.to_string()))
505 .or_default();
506 }
507
508 /// Insert (or update) a value → hash mapping.
509 pub fn insert(&self, coll: &str, field: &str, value: &Value, hash: &str) {
510 let key = (coll.to_string(), field.to_string());
511 if let Some(mut idx) = self.inner.get_mut(&key) {
512 let ov = OrderedValue::from(value);
513 idx.entry(ov)
514 .or_default()
515 .push(hash.to_string());
516 }
517 }
518
519 /// Remove a hash from the index (on overwrite/delete of a doc version).
520 pub fn remove(&self, coll: &str, field: &str, value: &Value, hash: &str) {
521 let key = (coll.to_string(), field.to_string());
522 if let Some(mut idx) = self.inner.get_mut(&key) {
523 let ov = OrderedValue::from(value);
524 if let Some(hashes) = idx.get_mut(&ov) {
525 hashes.retain(|h| h != hash);
526 if hashes.is_empty() { idx.remove(&ov); }
527 }
528 }
529 }
530
531 /// Return the top-k hashes ordered by field ASC.
532 pub fn top_k_asc(&self, coll: &str, field: &str, k: usize) -> Vec<String> {
533 let key = (coll.to_string(), field.to_string());
534 self.inner.get(&key).map(|idx| {
535 idx.values().flat_map(|v| v.iter().cloned()).take(k).collect()
536 }).unwrap_or_default()
537 }
538
539 /// Return the top-k hashes ordered by field DESC.
540 pub fn top_k_desc(&self, coll: &str, field: &str, k: usize) -> Vec<String> {
541 let key = (coll.to_string(), field.to_string());
542 self.inner.get(&key).map(|idx| {
543 idx.values().rev().flat_map(|v| v.iter().cloned()).take(k).collect()
544 }).unwrap_or_default()
545 }
546
547 /// Hashes whose indexed value falls within the given bounds.
548 ///
549 /// The BTreeMap already orders by value, so a bounded predicate is a
550 /// range walk rather than a full collection scan. `None` for either bound
551 /// means unbounded on that side, which serves a one-sided inequality
552 /// (`fee > 10`) as well as a two-sided `BETWEEN`.
553 ///
554 /// Documents where the field is ABSENT are not in this index at all, and
555 /// are therefore not returned. That is correct for every predicate this
556 /// serves: a missing field compares as null, which satisfies no ordering
557 /// comparison.
558 pub fn range(
559 &self,
560 coll: &str,
561 field: &str,
562 low: Option<&Value>,
563 high: Option<&Value>,
564 low_incl: bool,
565 high_incl: bool,
566 ) -> Vec<String> {
567 use std::ops::Bound;
568 let key = (coll.to_string(), field.to_string());
569 self.inner.get(&key).map(|idx| {
570 let lo = match low {
571 None => Bound::Unbounded,
572 Some(v) => {
573 let ov = OrderedValue::from(v);
574 if low_incl { Bound::Included(ov) } else { Bound::Excluded(ov) }
575 }
576 };
577 let hi = match high {
578 None => Bound::Unbounded,
579 Some(v) => {
580 let ov = OrderedValue::from(v);
581 if high_incl { Bound::Included(ov) } else { Bound::Excluded(ov) }
582 }
583 };
584 idx.range((lo, hi)).flat_map(|(_, v)| v.iter().cloned()).collect()
585 }).unwrap_or_default()
586 }
587
588 /// Hashes whose indexed value equals `value` — an O(log n) point lookup,
589 /// used for `=` and for each arm of an `IN (...)` list.
590 pub fn exact(&self, coll: &str, field: &str, value: &Value) -> Vec<String> {
591 let key = (coll.to_string(), field.to_string());
592 self.inner.get(&key).map(|idx| {
593 idx.get(&OrderedValue::from(value)).cloned().unwrap_or_default()
594 }).unwrap_or_default()
595 }
596
597 /// How many hashes a range covers, without materialising them.
598 ///
599 /// Lets the planner compare two candidate indexes and pick the more
600 /// selective one, rather than committing to whichever field it saw first.
601 pub fn range_len(
602 &self,
603 coll: &str,
604 field: &str,
605 low: Option<&Value>,
606 high: Option<&Value>,
607 low_incl: bool,
608 high_incl: bool,
609 ) -> usize {
610 use std::ops::Bound;
611 let key = (coll.to_string(), field.to_string());
612 self.inner.get(&key).map(|idx| {
613 let lo = match low {
614 None => Bound::Unbounded,
615 Some(v) => {
616 let ov = OrderedValue::from(v);
617 if low_incl { Bound::Included(ov) } else { Bound::Excluded(ov) }
618 }
619 };
620 let hi = match high {
621 None => Bound::Unbounded,
622 Some(v) => {
623 let ov = OrderedValue::from(v);
624 if high_incl { Bound::Included(ov) } else { Bound::Excluded(ov) }
625 }
626 };
627 idx.range((lo, hi)).map(|(_, v)| v.len()).sum()
628 }).unwrap_or(0)
629 }
630
631 /// Check if a sorted index exists for a (coll, field) pair.
632 pub fn has(&self, coll: &str, field: &str) -> bool {
633 self.inner.contains_key(&(coll.to_string(), field.to_string()))
634 }
635
636 /// True if no sorted indexes have been registered yet.
637 pub fn is_empty(&self) -> bool {
638 self.inner.is_empty()
639 }
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645 use tempfile::tempdir;
646
647 /// A failed flush must RETAIN the entry for retry, never discard it.
648 ///
649 /// Regression for the 2.8.5 silent-loss bug: `flush_write_buf` cleared every
650 /// snapshotted key regardless of whether its disk write succeeded, so a
651 /// flush that hit ENOSPC/EACCES permanently dropped acknowledged writes.
652 /// The fault is injected by planting a regular FILE where the collection
653 /// directory must go, so `create_dir_all` fails with NotADirectory. That is
654 /// deliberately independent of file permissions: containers frequently run
655 /// as root or hold `cap_dac_override`, where a chmod-0555 fixture is
656 /// silently writable and the test would pass without exercising anything.
657 #[test]
658 fn failed_flush_retains_entries_for_retry() {
659 let dir = tempdir().unwrap();
660 let idx = IdIndex::new(dir.path()).unwrap();
661
662 idx.set("rows", "a", "hash_a").unwrap();
663 idx.set("rows", "b", "hash_b").unwrap();
664
665 // Block the leaf path: `indexes/rows` is a file, so the index cannot
666 // create `indexes/rows/id/<shard>/` beneath it.
667 let blocker = dir.path().join("indexes").join("rows");
668 fs::write(&blocker, b"not a directory").unwrap();
669
670 let result = idx.try_flush_write_buf();
671 assert!(
672 result.is_err(),
673 "flush must report the I/O failure, got Ok — callers cannot detect lost writes"
674 );
675
676 // THE INVARIANT: the entries are still buffered, so a later flush retries.
677 assert_eq!(
678 idx.write_buf.len(),
679 2,
680 "failed flush discarded buffered writes — acknowledged data would be lost"
681 );
682
683 // Clear the fault and retry: the writes must now land.
684 fs::remove_file(&blocker).unwrap();
685 idx.try_flush_write_buf()
686 .expect("retry after the fault clears must succeed");
687 assert!(idx.write_buf.is_empty(), "successful flush must drain the WAL");
688
689 // Reopen from disk only — proves the retry actually persisted.
690 let idx2 = IdIndex::new(dir.path()).unwrap();
691 assert_eq!(idx2.get("rows", "a").as_deref(), Some("hash_a"));
692 assert_eq!(idx2.get("rows", "b").as_deref(), Some("hash_b"));
693 }
694
695 /// A successful flush still drains the buffer and reports Ok.
696 /// Guards the false-positive direction: the new error path must not make
697 /// healthy flushes look like failures.
698 #[test]
699 fn successful_flush_reports_ok_and_drains() {
700 let dir = tempdir().unwrap();
701 let idx = IdIndex::new(dir.path()).unwrap();
702 for i in 0..64 {
703 idx.set("rows", &format!("id{}", i), &format!("h{}", i)).unwrap();
704 }
705 idx.try_flush_write_buf().expect("healthy flush must be Ok");
706 assert!(idx.write_buf.is_empty());
707 let idx2 = IdIndex::new(dir.path()).unwrap();
708 assert_eq!(idx2.get("rows", "id63").as_deref(), Some("h63"));
709 }
710
711 #[test]
712 fn id_index_roundtrip() {
713 let dir = tempdir().unwrap();
714 let idx = IdIndex::new(dir.path()).unwrap();
715 idx.set("blocks", "618000", "abcdef1234").unwrap();
716 assert_eq!(idx.get("blocks", "618000"), Some("abcdef1234".to_string()));
717 }
718
719 #[test]
720 fn encode_decode_id_bijective() {
721 // Safe ids pass through unchanged (chainstate paths stay identical).
722 for safe in ["618000", "utxo-000000042", "abc_DEF.123", "deadBEEF"] {
723 assert_eq!(encode_id(safe), safe, "safe id must be identity");
724 assert_eq!(decode_id(&encode_id(safe)), safe);
725 }
726 // FS-unsafe ids (link ids, paths) round-trip and contain no illegal
727 // Windows filename chars once encoded.
728 for weird in ["driver:d1|handles|trip:t1", "a/b\\c", "x<y>z?\"*", "100%done"] {
729 let enc = encode_id(weird);
730 assert!(
731 !enc.chars().any(|c| matches!(c,
732 ':' | '|' | '/' | '\\' | '<' | '>' | '?' | '"' | '*')),
733 "encoded leaf must be filesystem-safe: {}", enc);
734 assert_eq!(decode_id(&enc), weird, "encode/decode must round-trip");
735 }
736 }
737
738 #[test]
739 fn id_index_fs_unsafe_id_survives_disk_roundtrip() {
740 // Regression: link ids contain ':' and '|', illegal in Windows filenames.
741 // They must persist to the on-disk id-index and read back after reopen.
742 let dir = tempdir().unwrap();
743 let weird = "driver:d1|handles|trip:t1";
744 {
745 let idx = IdIndex::new(dir.path()).unwrap();
746 idx.set("__links__", weird, "deadbeefcafe").unwrap();
747 idx.flush_write_buf(); // persist WAL → disk (encoded leaf filename)
748 }
749 // Cold reopen: nothing in the WAL, must come from disk.
750 let idx2 = IdIndex::new(dir.path()).unwrap();
751 assert_eq!(idx2.get("__links__", weird), Some("deadbeefcafe".to_string()),
752 "FS-unsafe id must be readable from disk after reopen");
753 assert_eq!(idx2.list_ids("__links__"), vec![weird.to_string()],
754 "list_ids must decode the on-disk filename back to the id");
755 }
756
757 #[test]
758 fn ordered_value_ordering() {
759 use OrderedValue::*;
760 assert!(Null < Bool(false));
761 assert!(Bool(false) < Bool(true));
762 assert!(Bool(true) < Number(0.0));
763 assert!(Number(1.0) < Number(2.0));
764 assert!(Number(2.0) < Str("a".to_string()));
765 assert!(Str("a".to_string()) < Str("b".to_string()));
766 }
767
768 #[test]
769 fn sorted_index_top_k() {
770 let idx = SortedIndexes::new();
771 idx.ensure("blocks", "height");
772 idx.insert("blocks", "height", &serde_json::json!(3), "hash3");
773 idx.insert("blocks", "height", &serde_json::json!(1), "hash1");
774 idx.insert("blocks", "height", &serde_json::json!(2), "hash2");
775 let asc = idx.top_k_asc("blocks", "height", 2);
776 assert_eq!(asc, vec!["hash1", "hash2"]);
777 let desc = idx.top_k_desc("blocks", "height", 2);
778 assert_eq!(desc, vec!["hash3", "hash2"]);
779 }
780
781 /// Regression stress test for the flush_write_buf lost-update race.
782 ///
783 /// Old behavior: flush snapshotted the buffer, wrote files in parallel, then
784 /// UNCONDITIONALLY removed each snapshotted key. A set() landing between the
785 /// snapshot and the remove was deleted from the buffer without ever being
786 /// flushed — disk kept the stale hash and (with no later write to re-insert
787 /// the key) the newer value was lost forever.
788 ///
789 /// Shape: every key is written exactly twice (v1 then v2) while a flusher
790 /// thread spins. Under the old code, keys whose v1 was snapshotted and whose
791 /// v2 arrived during the parallel disk-write phase get their v2 dropped by
792 /// the unconditional remove — the final assert catches them on disk at v1.
793 /// With remove_if, a superseded snapshot entry leaves the newer value
794 /// buffered for the next flush, so every key must read v2 at the end.
795 /// (Probabilistic by nature, but the race window — thousands of parallel
796 /// file writes — is wide; with 2000 keys the old code fails reliably.)
797 #[test]
798 fn flush_never_drops_a_concurrent_newer_write() {
799 use std::sync::Arc;
800 use std::sync::atomic::{AtomicBool, Ordering};
801
802 let dir = tempdir().unwrap();
803 let idx = Arc::new(IdIndex::new(dir.path()).unwrap());
804 let stop = Arc::new(AtomicBool::new(false));
805 const N: usize = 2000;
806
807 let flusher = {
808 let idx = Arc::clone(&idx);
809 let stop = Arc::clone(&stop);
810 std::thread::spawn(move || {
811 while !stop.load(Ordering::Relaxed) {
812 idx.flush_write_buf();
813 }
814 })
815 };
816
817 // v1 for every key, then v2 for every key — the flusher races both passes.
818 for i in 0..N {
819 idx.set("c", &format!("k{}", i), "v1").unwrap();
820 }
821 for i in 0..N {
822 idx.set("c", &format!("k{}", i), "v2").unwrap();
823 }
824
825 stop.store(true, Ordering::Relaxed);
826 flusher.join().unwrap();
827 // Drain anything still buffered (remove_if leaves superseded entries in).
828 idx.flush_write_buf();
829 idx.flush_write_buf();
830
831 // Every key must be v2 — from this handle AND from a cold reopen (disk).
832 for i in 0..N {
833 let k = format!("k{}", i);
834 assert_eq!(idx.get("c", &k), Some("v2".to_string()),
835 "key {} lost its newer write (buffer path)", k);
836 }
837 let cold = IdIndex::new(dir.path()).unwrap();
838 for i in 0..N {
839 let k = format!("k{}", i);
840 assert_eq!(cold.get("c", &k), Some("v2".to_string()),
841 "key {} lost its newer write (disk path)", k);
842 }
843 }
844
845 /// Regression: a collection must be visible the instant it is written, not
846 /// one flush tick later.
847 ///
848 /// Old behavior: `collections()` did a bare `read_dir` of the object root. A
849 /// brand-new collection lives only in `write_buf` until the 1s ticker fires,
850 /// so it was reported as ABSENT for up to a full second after a successful
851 /// write. Every other read path (`get`, `list_ids`) already overlaid the WAL;
852 /// this one silently did not.
853 ///
854 /// Why it hid for so long: a human at a terminal cannot reproduce it. Typing
855 /// a PUT and then a query leaves hundreds of milliseconds in between, and the
856 /// ticker has already run. Only a caller that writes and reads within the
857 /// same millisecond sees the empty list — which is exactly what an automated
858 /// test does. It surfaced through `/cast`, which validates the model's chosen
859 /// collection against this list and rejected a collection that had just been
860 /// written.
861 ///
862 /// NOTE the deliberate absence of any flush below. Calling flush_write_buf()
863 /// here would make this test pass against the OLD code and assert nothing.
864 #[test]
865 fn collections_are_visible_before_flush() {
866 let dir = tempdir().unwrap();
867 let idx = IdIndex::new(dir.path()).unwrap();
868
869 idx.set("orders", "o1", "hash1").unwrap();
870 let colls = idx.collections();
871 assert!(
872 colls.contains(&"orders".to_string()),
873 "collection invisible before flush: {colls:?}"
874 );
875
876 // Still correct once it does reach disk — no duplicates from the overlay.
877 idx.flush_write_buf();
878 let after = idx.collections();
879 assert_eq!(after, vec!["orders".to_string()], "after flush: {after:?}");
880
881 // Second collection, same story, and the first must not vanish.
882 idx.set("stylists", "s1", "hash2").unwrap();
883 let both = idx.collections();
884 assert_eq!(both, vec!["orders".to_string(), "stylists".to_string()],
885 "expected both collections, got {both:?}");
886 }
887
888 /// Regression: `list_ids` must return a STABLE order.
889 ///
890 /// The dedup path runs through `HashSet`, and Rust seeds its hasher randomly
891 /// per process. Observed on a real daemon — same query, same data, three
892 /// consecutive runs:
893 ///
894 /// ```text
895 /// o5 o8 o7 o4 o6 ...
896 /// o7 o5 o2 o6 o8 ...
897 /// o6 o1 o5 o4 o7 ...
898 /// ```
899 ///
900 /// Cosmetic on a full scan. NOT cosmetic for `LIMIT 5` with no `ORDER BY`,
901 /// which then hands back an arbitrary 5 of 8 as though it were an answer.
902 ///
903 /// NOTE the id set below. Sequential ids (`o1`..`o8`) can land in a
904 /// consistent order by chance, which would let this pass against the old
905 /// code. These are deliberately hash-scattered strings, and 24 of them, so a
906 /// single unsorted run being accidentally sorted is vanishingly unlikely.
907 #[test]
908 fn list_ids_order_is_stable() {
909 let dir = tempdir().unwrap();
910 let idx = IdIndex::new(dir.path()).unwrap();
911
912 let ids: Vec<String> = (0..24).map(|i| format!("zq{:x}-{}", i * 7919, i)).collect();
913 for id in &ids {
914 idx.set("orders", id, "h").unwrap();
915 }
916
917 // Buffered (pre-flush) and on-disk (post-flush) must BOTH be sorted, and
918 // must agree with each other — a flush is not a reordering event.
919 let mut want = ids.clone();
920 want.sort_unstable();
921
922 let before = idx.list_ids("orders");
923 assert_eq!(before, want, "unsorted before flush");
924
925 idx.flush_write_buf();
926 let after = idx.list_ids("orders");
927 assert_eq!(after, want, "unsorted after flush");
928 assert_eq!(before, after, "flush changed the order");
929
930 // Repeat reads within a process must not drift either.
931 for _ in 0..5 {
932 assert_eq!(idx.list_ids("orders"), want, "order varied between reads");
933 }
934 }
935
936 /// A tombstoned document must not resurrect its collection.
937 #[test]
938 fn collections_excludes_tombstone_only_writes() {
939 let dir = tempdir().unwrap();
940 let idx = IdIndex::new(dir.path()).unwrap();
941 idx.remove("ghosts", "g1").unwrap();
942 let colls = idx.collections();
943 assert!(!colls.contains(&"ghosts".to_string()),
944 "a tombstone conjured a collection: {colls:?}");
945 }
946
947}