Skip to main content

ursa_engine/database/
index_store.rs

1//! The database's stored graph indexes: `club.ursa.d/indexes/`.
2//!
3//! ```text
4//! indexes/
5//!   manifest.json    one entry per stored index
6//!   <hash>.idx       one index per file (see `index_file`)
7//! ```
8//!
9//! The manifest is the source of truth: an index file it does not list is
10//! removed when a writer first uses the store (a leftover of a process that
11//! stopped between writing a file and listing it), and a missing or
12//! unreadable manifest is an empty one. It is rewritten atomically
13//! (temporary file, `fsync`, rename) on every change.
14//!
15//! ```json
16//! {
17//!   "format": "ursa-indexes",
18//!   "version": 1,
19//!   "indexes": [
20//!     {"file": "…idx", "size": 123, "created": 1730000000000,
21//!      "last_used": 1730000000000, "key": {"fingerprint": "…", "files": […]}}
22//!   ]
23//! }
24//! ```
25//!
26//! `created` and `last_used` are milliseconds since the Unix epoch.
27//!
28//! **Lookup** ([`IndexStore::load`]) uses an entry only if its full key
29//! matches, file identities included, and the file reads back whole; a file
30//! that does not (truncated, corrupt, another version) is a miss, and a
31//! writer drops its entry. A hit updates `last_used`.
32//!
33//! **Writes** ([`IndexStore::save`]) run on a background thread, so the query
34//! that built the index is not held up by the disk. [`IndexStore::flush`]
35//! waits for them; the database calls it when it closes (before it releases
36//! its lock), so a short `ursa -c` process does not exit with a write half
37//! done.
38//!
39//! **The in-adjacency** is built after the index, by the first algorithm that
40//! needs it (PageRank, in-degree, `direction => 'both'`), usually after the
41//! background write has started. So it is stored as a chunk of the index
42//! file (see `index_file`): with the index if it exists when the index is
43//! written, and otherwise appended by [`IndexStore::flush`], for every index
44//! this process stored or loaded whose in-adjacency has been built since. A
45//! later process lists the chunk when it loads the index and reads it only
46//! when an algorithm asks for the in-adjacency, so a query that does not
47//! need it does not pay for it. The manifest's `size` counts the chunks, and
48//! a chunk that would take the store past the limit is not appended.
49//!
50//! **Evaluated weights** are stored the same way, one chunk per set, named by
51//! the weights' key (the weighted edge plan's fingerprint and the identity of
52//! the files it reads, which include the weight column's). They are used only
53//! with an index whose full key matched, and only under the same weights key,
54//! so a changed weight expression or source file is never served stale
55//! weights. A later process reads a set when a query asks for it, and reads
56//! no edge rows. They are evicted with their index, as part of its file. A new entry replaces any entry with the same fingerprint (an index of
57//! the same edges over an older version of a file), then entries are evicted,
58//! least recently used first, until the total is within the session's
59//! `ursa.index_storage_limit`. Never evicted: the entry just written, and an
60//! index a running query holds. An index larger than the limit on its own is
61//! not stored.
62//!
63//! A read-only database reads the manifest afresh on each lookup (a writer
64//! may be changing it) and never writes.
65
66use std::collections::{HashMap, HashSet};
67use std::fs::File;
68use std::io::{BufWriter, Write};
69use std::path::{Path, PathBuf};
70use std::sync::{Arc, Mutex, Weak};
71use std::thread::JoinHandle;
72use std::time::{SystemTime, UNIX_EPOCH};
73
74use serde_json::{json, Value};
75
76use super::index_file::{
77    self, Encoded, EncodedChunk, IndexKey, MainPart, CHUNK_INCOMING, CHUNK_WEIGHTS,
78};
79use crate::graph::GraphIndex;
80
81/// The `format` field of the manifest.
82pub const MANIFEST_FORMAT: &str = "ursa-indexes";
83/// The manifest version this build reads and writes.
84pub const MANIFEST_VERSION: u64 = 1;
85const MANIFEST: &str = "manifest.json";
86
87/// One stored index, as the manifest lists it.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct Entry {
90    pub key: IndexKey,
91    /// The file's name in the index directory.
92    pub file: String,
93    pub size: u64,
94    /// Milliseconds since the Unix epoch.
95    pub created: u64,
96    /// Milliseconds since the Unix epoch.
97    pub last_used: u64,
98}
99
100impl Entry {
101    fn to_json(&self) -> Value {
102        json!({
103            "file": self.file,
104            "size": self.size,
105            "created": self.created,
106            "last_used": self.last_used,
107            "key": self.key.to_json(),
108        })
109    }
110
111    fn from_json(value: &Value) -> Option<Self> {
112        let file = value.get("file")?.as_str()?.to_string();
113        // A file name, never a path out of the directory.
114        if file.is_empty() || file.contains(['/', '\\']) || file.starts_with('.') {
115            return None;
116        }
117        Some(Self {
118            key: IndexKey::from_json(value.get("key")?)?,
119            file,
120            size: value.get("size")?.as_u64()?,
121            created: value.get("created")?.as_u64()?,
122            last_used: value.get("last_used")?.as_u64()?,
123        })
124    }
125}
126
127/// The stored indexes of one database.
128#[derive(Debug)]
129pub struct IndexStore {
130    dir: PathBuf,
131    writable: bool,
132    state: Mutex<State>,
133    pending: Mutex<Vec<JoinHandle<()>>>,
134}
135
136#[derive(Debug, Default)]
137struct State {
138    /// Whether `entries` has been read from disk (a writer reads it once).
139    loaded: bool,
140    entries: Vec<Entry>,
141    /// Indexes this process loaded or stored, by file: an entry whose index
142    /// a running query holds is not evicted (see `in_use`), and chunks built
143    /// since are appended to its file (see [`IndexStore::flush`]).
144    live: HashMap<String, Live>,
145}
146
147/// An index this process loaded or stored, and what its file holds.
148#[derive(Debug)]
149struct Live {
150    index: Weak<GraphIndex>,
151    main: MainPart,
152    /// The chunks the file has, by kind and name.
153    chunks: HashSet<(u32, Vec<u8>)>,
154    /// The session's storage limit when it was loaded or stored.
155    limit: u64,
156}
157
158impl IndexStore {
159    /// The store in `dir`. A writable store creates `dir` on its first write.
160    pub fn new(dir: PathBuf, writable: bool) -> Self {
161        Self {
162            dir,
163            writable,
164            state: Mutex::new(State::default()),
165            pending: Mutex::new(Vec::new()),
166        }
167    }
168
169    pub fn dir(&self) -> &Path {
170        &self.dir
171    }
172
173    pub fn is_writable(&self) -> bool {
174        self.writable
175    }
176
177    fn state(&self) -> std::sync::MutexGuard<'_, State> {
178        self.state.lock().unwrap_or_else(|e| e.into_inner())
179    }
180
181    /// The manifest's entries, as this process knows them.
182    pub fn entries(&self) -> Vec<Entry> {
183        if !self.writable {
184            return read_manifest(&self.dir);
185        }
186        let mut state = self.state();
187        self.ensure_loaded(&mut state);
188        state.entries.clone()
189    }
190
191    /// Read the manifest and tidy the directory, once (writers only).
192    fn ensure_loaded(&self, state: &mut State) {
193        if state.loaded {
194            return;
195        }
196        state.loaded = true;
197        let mut entries = read_manifest(&self.dir);
198        let before = entries.len();
199        entries.retain(|e| self.dir.join(&e.file).is_file());
200        // One entry per file and per fingerprint, should a manifest have more.
201        let mut files = HashSet::new();
202        let mut fingerprints = HashSet::new();
203        entries.retain(|e| {
204            files.insert(e.file.clone()) && fingerprints.insert(e.key.fingerprint.clone())
205        });
206        let changed = entries.len() != before;
207        // Remove what the manifest does not list: index files written but
208        // never listed, and temporary files of interrupted writes.
209        if let Ok(listing) = std::fs::read_dir(&self.dir) {
210            for item in listing.flatten() {
211                let name = item.file_name().to_string_lossy().into_owned();
212                let ours = name.ends_with(".idx") || name.ends_with(".tmp");
213                if ours && !files.contains(&name) {
214                    let _ = std::fs::remove_file(item.path());
215                }
216            }
217        }
218        state.entries = entries;
219        if changed {
220            let _ = write_manifest(&self.dir, &state.entries);
221        }
222    }
223
224    /// The stored index for `key`, if there is one whose full key matches
225    /// and whose file reads back whole. Blocking: it reads the file. `limit`
226    /// is the session's storage limit, for chunks appended to it later.
227    pub fn load(&self, key: &IndexKey, limit: u64) -> Option<Arc<GraphIndex>> {
228        let file = if self.writable {
229            let mut state = self.state();
230            self.ensure_loaded(&mut state);
231            state.entries.iter().find(|e| e.key == *key)?.file.clone()
232        } else {
233            read_manifest(&self.dir)
234                .into_iter()
235                .find(|e| e.key == *key)?
236                .file
237        };
238        let read = index_file::read_file(&self.dir.join(&file), key);
239        let mut state = self.state();
240        match read {
241            Ok(stored) => {
242                let chunks = stored
243                    .chunks
244                    .iter()
245                    .map(|c| (c.kind, c.name.clone()))
246                    .collect();
247                let weights: Vec<index_file::ChunkInfo> = stored
248                    .chunks
249                    .into_iter()
250                    .filter(|c| c.kind == CHUNK_WEIGHTS)
251                    .collect();
252                let keys = weights
253                    .iter()
254                    .filter_map(|c| String::from_utf8(c.name.clone()).ok())
255                    .collect();
256                let (path, main) = (self.dir.join(&file), stored.main);
257                let n_edges = stored.topology.n_edges();
258                let read = Box::new(move |key: &str| {
259                    let chunk = weights.iter().find(|c| c.name == key.as_bytes())?;
260                    index_file::read_weights(&path, main, chunk, n_edges).ok()
261                });
262                let index = Arc::new(
263                    GraphIndex::new(Arc::new(stored.topology), Arc::new(stored.ids))
264                        .with_stored_weights(keys, read),
265                );
266                state.live.insert(
267                    file.clone(),
268                    Live {
269                        index: Arc::downgrade(&index),
270                        main: stored.main,
271                        chunks,
272                        limit,
273                    },
274                );
275                if self.writable {
276                    let now = tick(&state.entries);
277                    if let Some(entry) = state.entries.iter_mut().find(|e| e.file == file) {
278                        entry.last_used = now;
279                        let _ = write_manifest(&self.dir, &state.entries);
280                    }
281                }
282                Some(index)
283            }
284            Err(_) => {
285                if self.writable {
286                    let before = state.entries.len();
287                    state.entries.retain(|e| e.file != file);
288                    if state.entries.len() != before {
289                        let _ = std::fs::remove_file(self.dir.join(&file));
290                        let _ = write_manifest(&self.dir, &state.entries);
291                    }
292                }
293                None
294            }
295        }
296    }
297
298    /// Store `index` under `key` on a background thread, keeping the store
299    /// within `limit` bytes. Nothing happens in a read-only store.
300    pub fn save(self: &Arc<Self>, key: IndexKey, index: Arc<GraphIndex>, limit: u64) {
301        if !self.writable {
302            return;
303        }
304        // Tidy the directory now, never while a write's files are not yet
305        // listed in the manifest.
306        self.ensure_loaded(&mut self.state());
307        let store = Arc::clone(self);
308        let spawned = std::thread::Builder::new()
309            .name("ursa-index-write".into())
310            .spawn(move || {
311                // A failed write leaves the store as it was; the index is
312                // built again by a later process.
313                let _ = store.write(&key, &index, limit);
314            });
315        if let Ok(handle) = spawned {
316            let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner());
317            pending.retain(|h| !h.is_finished());
318            pending.push(handle);
319        }
320    }
321
322    /// Wait for every write [`save`](Self::save) started, then append to
323    /// each index file this process stored or loaded the parts of its index
324    /// built since (the in-adjacency, evaluated weights), within the storage
325    /// limit.
326    pub fn flush(&self) {
327        let handles = std::mem::take(&mut *self.pending.lock().unwrap_or_else(|e| e.into_inner()));
328        for handle in handles {
329            let _ = handle.join();
330        }
331        if self.writable {
332            self.append_chunks();
333        }
334    }
335
336    /// Append to every live index file the in-adjacency and the weight sets
337    /// its index has built since and the file lacks. A failed append leaves
338    /// the file as it was (less any partial chunk); what it held is built
339    /// again by the next process that needs it.
340    fn append_chunks(&self) {
341        let mut state = self.state();
342        self.ensure_loaded(&mut state);
343        let files: Vec<String> = state.live.keys().cloned().collect();
344        let mut appended = Vec::new();
345        for file in files {
346            let live = &state.live[&file];
347            let Some(index) = live.index.upgrade() else {
348                continue;
349            };
350            let (main, limit) = (live.main, live.limit);
351            let mut todo: Vec<(u32, Vec<u8>)> = Vec::new();
352            if index.topology.incoming_if_built().is_some() {
353                todo.push((CHUNK_INCOMING, Vec::new()));
354            }
355            let weight_sets = index.weight_sets();
356            todo.extend(
357                weight_sets
358                    .iter()
359                    .map(|(key, _)| (CHUNK_WEIGHTS, key.as_bytes().to_vec())),
360            );
361            todo.retain(|chunk| !live.chunks.contains(chunk));
362            for (kind, name) in todo {
363                let Some(entry) = state.entries.iter().position(|e| e.file == file) else {
364                    break;
365                };
366                let columns: Vec<&[f64]> = match kind {
367                    CHUNK_WEIGHTS => weight_sets
368                        .iter()
369                        .find(|(key, _)| key.as_bytes() == name.as_slice())
370                        .map(|(_, w)| w.iter().map(|c| c.as_slice()).collect())
371                        .unwrap_or_default(),
372                    _ => Vec::new(),
373                };
374                let encode = |start| match kind {
375                    CHUNK_WEIGHTS => EncodedChunk::weights(
376                        std::str::from_utf8(&name).unwrap_or_default(),
377                        &columns,
378                        start,
379                    ),
380                    _ => EncodedChunk::incoming(
381                        index.topology.incoming_if_built().expect("checked above"),
382                        start,
383                    ),
384                };
385                // Not past the limit on its own, as for a whole index.
386                let size = state.entries[entry].size;
387                if size + encode(size).size() > limit {
388                    continue;
389                }
390                let result =
391                    index_file::append_chunk(&self.dir.join(&file), main, kind, &name, encode);
392                if let Ok(appended_len) = result {
393                    if let Some(live) = state.live.get_mut(&file) {
394                        live.chunks.insert((kind, name));
395                    }
396                    if let Some(len) = appended_len {
397                        state.entries[entry].size = len;
398                        appended.push(file.clone());
399                    }
400                }
401            }
402        }
403        if !appended.is_empty() {
404            let limit = state
405                .live
406                .values()
407                .map(|l| l.limit)
408                .min()
409                .unwrap_or(u64::MAX);
410            let removed = evict(&mut state, &appended, limit);
411            let _ = write_manifest(&self.dir, &state.entries);
412            drop(state);
413            for gone in removed {
414                let _ = std::fs::remove_file(self.dir.join(gone));
415            }
416        }
417    }
418
419    /// Write `index` under `key`, then update the manifest and evict. The
420    /// in-adjacency, if it is built by now, is written as a chunk after the
421    /// main part.
422    fn write(&self, key: &IndexKey, index: &Arc<GraphIndex>, limit: u64) -> std::io::Result<()> {
423        let encoded = Encoded::new(key, &index.topology, &index.ids)?;
424        let incoming = index
425            .topology
426            .incoming_if_built()
427            .map(|incoming| EncodedChunk::incoming(incoming, encoded.file_size()))
428            .filter(|chunk| encoded.file_size() + chunk.size() <= limit);
429        let size = incoming
430            .as_ref()
431            .map_or(encoded.file_size(), EncodedChunk::end);
432        if size > limit {
433            return Ok(());
434        }
435        std::fs::create_dir_all(&self.dir)?;
436        let file = format!("{:016x}.idx", key.hash());
437        let path = self.dir.join(&file);
438        let temp = self.dir.join(format!("{file}.tmp"));
439        let written = (|| {
440            let out = File::create(&temp)?;
441            let mut writer = BufWriter::with_capacity(1 << 20, out);
442            let main = encoded.write_to(&mut writer)?;
443            if let Some(chunk) = &incoming {
444                chunk.write_to(&mut writer)?;
445            }
446            let out = writer.into_inner().map_err(|e| e.into_error())?;
447            out.sync_all()?;
448            std::fs::rename(&temp, &path)?;
449            Ok(main)
450        })();
451        let main = match written {
452            Ok(main) => main,
453            Err(e) => {
454                let _ = std::fs::remove_file(&temp);
455                return Err(e);
456            }
457        };
458
459        let mut state = self.state();
460        self.ensure_loaded(&mut state);
461        // Replace what this index supersedes: the same fingerprint (the same
462        // edges over files that have changed since), or the same file name.
463        let mut removed = Vec::new();
464        state.entries.retain(|e| {
465            let superseded = e.key.fingerprint == key.fingerprint || e.file == file;
466            if superseded && e.file != file {
467                removed.push(e.file.clone());
468            }
469            !superseded
470        });
471        let now = tick(&state.entries);
472        state.entries.push(Entry {
473            key: key.clone(),
474            file: file.clone(),
475            size,
476            created: now,
477            last_used: now,
478        });
479        let chunks = incoming
480            .iter()
481            .map(|_| (CHUNK_INCOMING, Vec::new()))
482            .collect();
483        state.live.insert(
484            file.clone(),
485            Live {
486                index: Arc::downgrade(index),
487                main,
488                chunks,
489                limit,
490            },
491        );
492        removed.extend(evict(&mut state, std::slice::from_ref(&file), limit));
493        state.live.retain(|_, live| live.index.strong_count() > 0);
494        let saved = write_manifest(&self.dir, &state.entries);
495        drop(state);
496        for gone in removed {
497            let _ = std::fs::remove_file(self.dir.join(gone));
498        }
499        sync_dir(&self.dir);
500        saved
501    }
502}
503
504impl Drop for IndexStore {
505    fn drop(&mut self) {
506        self.flush();
507    }
508}
509
510/// Remove entries, least recently used first, until their total is at most
511/// `limit`, keeping `keep` and any index a running query holds. Returns the
512/// files of the entries removed.
513fn evict(state: &mut State, keep: &[String], limit: u64) -> Vec<String> {
514    let mut removed = Vec::new();
515    let mut total: u64 = state.entries.iter().map(|e| e.size).sum();
516    while total > limit {
517        let candidate = state
518            .entries
519            .iter()
520            .enumerate()
521            .filter(|(_, e)| !keep.contains(&e.file) && !in_use(&state.live, &e.file))
522            .min_by_key(|(_, e)| (e.last_used, e.created))
523            .map(|(i, _)| i);
524        let Some(i) = candidate else { break };
525        let entry = state.entries.remove(i);
526        total -= entry.size;
527        removed.push(entry.file);
528    }
529    removed
530}
531
532/// Whether a running query holds the index stored in `file`: the session's
533/// index cache holds one reference to an index it keeps, and a query using it
534/// holds another.
535fn in_use(live: &HashMap<String, Live>, file: &str) -> bool {
536    live.get(file)
537        .is_some_and(|live| live.index.strong_count() > 1)
538}
539
540/// Now, in milliseconds since the Unix epoch, and after every time in
541/// `entries`, so the order of uses is the order of their times.
542fn tick(entries: &[Entry]) -> u64 {
543    let now = SystemTime::now()
544        .duration_since(UNIX_EPOCH)
545        .map(|d| d.as_millis() as u64)
546        .unwrap_or(0);
547    let latest = entries
548        .iter()
549        .map(|e| e.last_used.max(e.created))
550        .max()
551        .map_or(0, |t| t + 1);
552    now.max(latest)
553}
554
555/// The manifest in `dir`: empty if it is missing or not a manifest this
556/// build reads. Entries it cannot read are skipped.
557pub fn read_manifest(dir: &Path) -> Vec<Entry> {
558    let Ok(text) = std::fs::read_to_string(dir.join(MANIFEST)) else {
559        return Vec::new();
560    };
561    let Ok(value) = serde_json::from_str::<Value>(&text) else {
562        return Vec::new();
563    };
564    if value.get("format").and_then(Value::as_str) != Some(MANIFEST_FORMAT)
565        || value.get("version").and_then(Value::as_u64) != Some(MANIFEST_VERSION)
566    {
567        return Vec::new();
568    }
569    value
570        .get("indexes")
571        .and_then(Value::as_array)
572        .map(|entries| entries.iter().filter_map(Entry::from_json).collect())
573        .unwrap_or_default()
574}
575
576/// Rewrite the manifest in `dir` atomically.
577fn write_manifest(dir: &Path, entries: &[Entry]) -> std::io::Result<()> {
578    std::fs::create_dir_all(dir)?;
579    let value = json!({
580        "format": MANIFEST_FORMAT,
581        "version": MANIFEST_VERSION,
582        "indexes": entries.iter().map(Entry::to_json).collect::<Vec<_>>(),
583    });
584    let mut text = serde_json::to_string_pretty(&value).map_err(std::io::Error::other)?;
585    text.push('\n');
586    let temp = dir.join(format!("{MANIFEST}.tmp"));
587    {
588        let mut file = File::create(&temp)?;
589        file.write_all(text.as_bytes())?;
590        file.sync_all()?;
591    }
592    std::fs::rename(&temp, dir.join(MANIFEST))?;
593    sync_dir(dir);
594    Ok(())
595}
596
597/// `fsync` a directory so a rename in it is durable. Best effort.
598fn sync_dir(dir: &Path) {
599    #[cfg(unix)]
600    if let Ok(dir) = File::open(dir) {
601        let _ = dir.sync_all();
602    }
603    #[cfg(not(unix))]
604    let _ = dir;
605}