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_dropped(stored.dropped.n_dropped, stored.dropped.kept)
265                        .with_stored_weights(keys, read),
266                );
267                state.live.insert(
268                    file.clone(),
269                    Live {
270                        index: Arc::downgrade(&index),
271                        main: stored.main,
272                        chunks,
273                        limit,
274                    },
275                );
276                if self.writable {
277                    let now = tick(&state.entries);
278                    if let Some(entry) = state.entries.iter_mut().find(|e| e.file == file) {
279                        entry.last_used = now;
280                        let _ = write_manifest(&self.dir, &state.entries);
281                    }
282                }
283                Some(index)
284            }
285            Err(_) => {
286                if self.writable {
287                    let before = state.entries.len();
288                    state.entries.retain(|e| e.file != file);
289                    if state.entries.len() != before {
290                        let _ = std::fs::remove_file(self.dir.join(&file));
291                        let _ = write_manifest(&self.dir, &state.entries);
292                    }
293                }
294                None
295            }
296        }
297    }
298
299    /// Store `index` under `key` on a background thread, keeping the store
300    /// within `limit` bytes. Nothing happens in a read-only store.
301    pub fn save(self: &Arc<Self>, key: IndexKey, index: Arc<GraphIndex>, limit: u64) {
302        if !self.writable {
303            return;
304        }
305        // Tidy the directory now, never while a write's files are not yet
306        // listed in the manifest.
307        self.ensure_loaded(&mut self.state());
308        let store = Arc::clone(self);
309        let spawned = std::thread::Builder::new()
310            .name("ursa-index-write".into())
311            .spawn(move || {
312                // A failed write leaves the store as it was; the index is
313                // built again by a later process.
314                let _ = store.write(&key, &index, limit);
315            });
316        if let Ok(handle) = spawned {
317            let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner());
318            pending.retain(|h| !h.is_finished());
319            pending.push(handle);
320        }
321    }
322
323    /// Wait for every write [`save`](Self::save) started, then append to
324    /// each index file this process stored or loaded the parts of its index
325    /// built since (the in-adjacency, evaluated weights), within the storage
326    /// limit.
327    pub fn flush(&self) {
328        let handles = std::mem::take(&mut *self.pending.lock().unwrap_or_else(|e| e.into_inner()));
329        for handle in handles {
330            let _ = handle.join();
331        }
332        if self.writable {
333            self.append_chunks();
334        }
335    }
336
337    /// Append to every live index file the in-adjacency and the weight sets
338    /// its index has built since and the file lacks. A failed append leaves
339    /// the file as it was (less any partial chunk); what it held is built
340    /// again by the next process that needs it.
341    fn append_chunks(&self) {
342        let mut state = self.state();
343        self.ensure_loaded(&mut state);
344        let files: Vec<String> = state.live.keys().cloned().collect();
345        let mut appended = Vec::new();
346        for file in files {
347            let live = &state.live[&file];
348            let Some(index) = live.index.upgrade() else {
349                continue;
350            };
351            let (main, limit) = (live.main, live.limit);
352            let mut todo: Vec<(u32, Vec<u8>)> = Vec::new();
353            if index.topology.incoming_if_built().is_some() {
354                todo.push((CHUNK_INCOMING, Vec::new()));
355            }
356            let weight_sets = index.weight_sets();
357            todo.extend(
358                weight_sets
359                    .iter()
360                    .map(|(key, _)| (CHUNK_WEIGHTS, key.as_bytes().to_vec())),
361            );
362            todo.retain(|chunk| !live.chunks.contains(chunk));
363            for (kind, name) in todo {
364                let Some(entry) = state.entries.iter().position(|e| e.file == file) else {
365                    break;
366                };
367                let columns: Vec<&[f64]> = match kind {
368                    CHUNK_WEIGHTS => weight_sets
369                        .iter()
370                        .find(|(key, _)| key.as_bytes() == name.as_slice())
371                        .map(|(_, w)| w.iter().map(|c| c.as_slice()).collect())
372                        .unwrap_or_default(),
373                    _ => Vec::new(),
374                };
375                let encode = |start| match kind {
376                    CHUNK_WEIGHTS => EncodedChunk::weights(
377                        std::str::from_utf8(&name).unwrap_or_default(),
378                        &columns,
379                        start,
380                    ),
381                    _ => EncodedChunk::incoming(
382                        index.topology.incoming_if_built().expect("checked above"),
383                        start,
384                    ),
385                };
386                // Not past the limit on its own, as for a whole index.
387                let size = state.entries[entry].size;
388                if size + encode(size).size() > limit {
389                    continue;
390                }
391                let result =
392                    index_file::append_chunk(&self.dir.join(&file), main, kind, &name, encode);
393                if let Ok(appended_len) = result {
394                    if let Some(live) = state.live.get_mut(&file) {
395                        live.chunks.insert((kind, name));
396                    }
397                    if let Some(len) = appended_len {
398                        state.entries[entry].size = len;
399                        appended.push(file.clone());
400                    }
401                }
402            }
403        }
404        if !appended.is_empty() {
405            let limit = state
406                .live
407                .values()
408                .map(|l| l.limit)
409                .min()
410                .unwrap_or(u64::MAX);
411            let removed = evict(&mut state, &appended, limit);
412            let _ = write_manifest(&self.dir, &state.entries);
413            drop(state);
414            for gone in removed {
415                let _ = std::fs::remove_file(self.dir.join(gone));
416            }
417        }
418    }
419
420    /// Write `index` under `key`, then update the manifest and evict. The
421    /// in-adjacency, if it is built by now, is written as a chunk after the
422    /// main part.
423    fn write(&self, key: &IndexKey, index: &Arc<GraphIndex>, limit: u64) -> std::io::Result<()> {
424        let encoded = Encoded::with_dropped(
425            key,
426            &index.topology,
427            &index.ids,
428            index.n_dropped(),
429            index.kept_rows(),
430        )?;
431        let incoming = index
432            .topology
433            .incoming_if_built()
434            .map(|incoming| EncodedChunk::incoming(incoming, encoded.file_size()))
435            .filter(|chunk| encoded.file_size() + chunk.size() <= limit);
436        let size = incoming
437            .as_ref()
438            .map_or(encoded.file_size(), EncodedChunk::end);
439        if size > limit {
440            return Ok(());
441        }
442        std::fs::create_dir_all(&self.dir)?;
443        let file = format!("{:016x}.idx", key.hash());
444        let path = self.dir.join(&file);
445        let temp = self.dir.join(format!("{file}.tmp"));
446        let written = (|| {
447            let out = File::create(&temp)?;
448            let mut writer = BufWriter::with_capacity(1 << 20, out);
449            let main = encoded.write_to(&mut writer)?;
450            if let Some(chunk) = &incoming {
451                chunk.write_to(&mut writer)?;
452            }
453            let out = writer.into_inner().map_err(|e| e.into_error())?;
454            out.sync_all()?;
455            std::fs::rename(&temp, &path)?;
456            Ok(main)
457        })();
458        let main = match written {
459            Ok(main) => main,
460            Err(e) => {
461                let _ = std::fs::remove_file(&temp);
462                return Err(e);
463            }
464        };
465
466        let mut state = self.state();
467        self.ensure_loaded(&mut state);
468        // Replace what this index supersedes: the same fingerprint (the same
469        // edges over files that have changed since), or the same file name.
470        let mut removed = Vec::new();
471        state.entries.retain(|e| {
472            let superseded = e.key.fingerprint == key.fingerprint || e.file == file;
473            if superseded && e.file != file {
474                removed.push(e.file.clone());
475            }
476            !superseded
477        });
478        let now = tick(&state.entries);
479        state.entries.push(Entry {
480            key: key.clone(),
481            file: file.clone(),
482            size,
483            created: now,
484            last_used: now,
485        });
486        let chunks = incoming
487            .iter()
488            .map(|_| (CHUNK_INCOMING, Vec::new()))
489            .collect();
490        state.live.insert(
491            file.clone(),
492            Live {
493                index: Arc::downgrade(index),
494                main,
495                chunks,
496                limit,
497            },
498        );
499        removed.extend(evict(&mut state, std::slice::from_ref(&file), limit));
500        state.live.retain(|_, live| live.index.strong_count() > 0);
501        let saved = write_manifest(&self.dir, &state.entries);
502        drop(state);
503        for gone in removed {
504            let _ = std::fs::remove_file(self.dir.join(gone));
505        }
506        sync_dir(&self.dir);
507        saved
508    }
509}
510
511impl Drop for IndexStore {
512    fn drop(&mut self) {
513        self.flush();
514    }
515}
516
517/// Remove entries, least recently used first, until their total is at most
518/// `limit`, keeping `keep` and any index a running query holds. Returns the
519/// files of the entries removed.
520fn evict(state: &mut State, keep: &[String], limit: u64) -> Vec<String> {
521    let mut removed = Vec::new();
522    let mut total: u64 = state.entries.iter().map(|e| e.size).sum();
523    while total > limit {
524        let candidate = state
525            .entries
526            .iter()
527            .enumerate()
528            .filter(|(_, e)| !keep.contains(&e.file) && !in_use(&state.live, &e.file))
529            .min_by_key(|(_, e)| (e.last_used, e.created))
530            .map(|(i, _)| i);
531        let Some(i) = candidate else { break };
532        let entry = state.entries.remove(i);
533        total -= entry.size;
534        removed.push(entry.file);
535    }
536    removed
537}
538
539/// Whether a running query holds the index stored in `file`: the session's
540/// index cache holds one reference to an index it keeps, and a query using it
541/// holds another.
542fn in_use(live: &HashMap<String, Live>, file: &str) -> bool {
543    live.get(file)
544        .is_some_and(|live| live.index.strong_count() > 1)
545}
546
547/// Now, in milliseconds since the Unix epoch, and after every time in
548/// `entries`, so the order of uses is the order of their times.
549fn tick(entries: &[Entry]) -> u64 {
550    let now = SystemTime::now()
551        .duration_since(UNIX_EPOCH)
552        .map(|d| d.as_millis() as u64)
553        .unwrap_or(0);
554    let latest = entries
555        .iter()
556        .map(|e| e.last_used.max(e.created))
557        .max()
558        .map_or(0, |t| t + 1);
559    now.max(latest)
560}
561
562/// The manifest in `dir`: empty if it is missing or not a manifest this
563/// build reads. Entries it cannot read are skipped.
564pub fn read_manifest(dir: &Path) -> Vec<Entry> {
565    let Ok(text) = std::fs::read_to_string(dir.join(MANIFEST)) else {
566        return Vec::new();
567    };
568    let Ok(value) = serde_json::from_str::<Value>(&text) else {
569        return Vec::new();
570    };
571    if value.get("format").and_then(Value::as_str) != Some(MANIFEST_FORMAT)
572        || value.get("version").and_then(Value::as_u64) != Some(MANIFEST_VERSION)
573    {
574        return Vec::new();
575    }
576    value
577        .get("indexes")
578        .and_then(Value::as_array)
579        .map(|entries| entries.iter().filter_map(Entry::from_json).collect())
580        .unwrap_or_default()
581}
582
583/// Rewrite the manifest in `dir` atomically.
584fn write_manifest(dir: &Path, entries: &[Entry]) -> std::io::Result<()> {
585    std::fs::create_dir_all(dir)?;
586    let value = json!({
587        "format": MANIFEST_FORMAT,
588        "version": MANIFEST_VERSION,
589        "indexes": entries.iter().map(Entry::to_json).collect::<Vec<_>>(),
590    });
591    let mut text = serde_json::to_string_pretty(&value).map_err(std::io::Error::other)?;
592    text.push('\n');
593    let temp = dir.join(format!("{MANIFEST}.tmp"));
594    {
595        let mut file = File::create(&temp)?;
596        file.write_all(text.as_bytes())?;
597        file.sync_all()?;
598    }
599    std::fs::rename(&temp, dir.join(MANIFEST))?;
600    sync_dir(dir);
601    Ok(())
602}
603
604/// `fsync` a directory so a rename in it is durable. Best effort.
605fn sync_dir(dir: &Path) {
606    #[cfg(unix)]
607    if let Ok(dir) = File::open(dir) {
608        let _ = dir.sync_all();
609    }
610    #[cfg(not(unix))]
611    let _ = dir;
612}