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": 2,
19//!   "indexes": [
20//!     {"file": "…idx", "graph": "g", "size": 123, "created": 1730000000000,
21//!      "last_used": 1730000000000,
22//!      "key": {"version": 2, "fingerprint": "…", "files": […]}}
23//!   ]
24//! }
25//! ```
26//!
27//! `created` and `last_used` are milliseconds since the Unix epoch; `graph`
28//! names the graph the index was built for, for notices. **Version 2**
29//! (#327) has the portable key (`crate::graph::index`); a manifest of another
30//! version is an empty one, so a writer removes the files it lists, once, and
31//! says so (a notice), and every index is built again when first used. An
32//! index is never read under a key it was not written for.
33//!
34//! **Lookup** ([`IndexStore::load`]) uses an entry only if its full key
35//! matches, file identities included, and the file reads back whole; a file
36//! that does not (truncated, corrupt, another version) is a miss, and a
37//! writer drops its entry. A hit updates `last_used`.
38//!
39//! **The limit** is the database's: its stored `ursa.index_storage_limit`,
40//! or the default ([`IndexStore::set_limit`]), never a session's own `SET`,
41//! so a read-only or in-memory session cannot shrink what a database keeps.
42//!
43//! **Storing** ([`IndexStore::save`]) decides at once, in the query that
44//! built the index, and says what it decided ([`Saved`]):
45//!
46//! 1. A read-only store stores nothing.
47//! 2. An index larger than the limit on its own is not stored.
48//! 3. Otherwise it replaces any entry with the same fingerprint (an index of
49//!    the same edges over an older version of a file), and evicts entries,
50//!    least recently used first, only as many as it takes to fit within the
51//!    limit beside the indexes being written. Never evicted: an index a
52//!    running query holds. If the indexes in use leave no room, nothing is
53//!    evicted and the index is not stored.
54//!
55//! Evictions happen before the write, so the room is there on disk too, and
56//! each is a notice. The write itself runs on a background thread, so the
57//! query is not held up by the disk; [`IndexStore::flush`] waits for it. The
58//! database calls it when it closes (before it releases its lock), so a short
59//! `ursa -c` process does not exit with a write half done. A failed write is
60//! a notice, and leaves the store as it was less what it evicted.
61//!
62//! **The in-adjacency** is built after the index, by the first algorithm that
63//! needs it (PageRank, in-degree, `direction => 'both'`), usually after the
64//! index was stored. So it is stored as a chunk of the index file (see
65//! `index_file`): with the index if it exists when the index is stored, and
66//! otherwise appended by [`IndexStore::flush`], for every index this process
67//! stored or loaded whose in-adjacency has been built since. A later process
68//! lists the chunk when it loads the index and reads it only when an
69//! algorithm asks for the in-adjacency, so a query that does not need it does
70//! not pay for it. The manifest's `size` counts the chunks, and a chunk makes
71//! room for itself as an index does (evicting other indexes, never its own),
72//! or is not appended (a notice).
73//!
74//! **Evaluated weights** are stored the same way, one chunk per set, named by
75//! the weights' key (the weighted edge plan's fingerprint and the identity of
76//! the files it reads, which include the weight column's). They are used only
77//! with an index whose full key matched, and only under the same weights key,
78//! so a changed weight expression or source file is never served stale
79//! weights. A later process reads a set when a query asks for it, and reads
80//! no edge rows. They are evicted with their index, as part of its file.
81//!
82//! A read-only database reads the manifest afresh on each lookup (a writer
83//! may be changing it) and never writes.
84
85use std::collections::{HashMap, HashSet};
86use std::fs::File;
87use std::io::{BufWriter, Write};
88use std::path::{Path, PathBuf};
89use std::sync::atomic::{AtomicU64, Ordering};
90use std::sync::{Arc, Mutex, Weak};
91use std::thread::JoinHandle;
92use std::time::{SystemTime, UNIX_EPOCH};
93
94use serde_json::{json, Value};
95
96use super::index_file::{
97    self, Encoded, EncodedChunk, IndexKey, MainPart, CHUNK_INCOMING, CHUNK_WEIGHTS,
98};
99use crate::graph::GraphIndex;
100use crate::notices::{self, NoticeLevel, Notifier};
101use crate::options::ByteSize;
102
103/// The `format` field of the manifest.
104pub const MANIFEST_FORMAT: &str = "ursa-indexes";
105/// The manifest version this build reads and writes.
106pub const MANIFEST_VERSION: u64 = 2;
107const MANIFEST: &str = "manifest.json";
108/// How notices name an index whose entry names no graph.
109const UNNAMED: &str = "a graph";
110
111/// One stored index, as the manifest lists it.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Entry {
114    pub key: IndexKey,
115    /// The file's name in the index directory.
116    pub file: String,
117    /// The graph it was built for, as notices name it.
118    pub graph: String,
119    pub size: u64,
120    /// Milliseconds since the Unix epoch.
121    pub created: u64,
122    /// Milliseconds since the Unix epoch.
123    pub last_used: u64,
124}
125
126impl Entry {
127    fn to_json(&self) -> Value {
128        json!({
129            "file": self.file,
130            "graph": self.graph,
131            "size": self.size,
132            "created": self.created,
133            "last_used": self.last_used,
134            "key": self.key.to_json(),
135        })
136    }
137
138    fn from_json(value: &Value) -> Option<Self> {
139        let file = value.get("file")?.as_str()?.to_string();
140        // A file name, never a path out of the directory.
141        if file.is_empty() || file.contains(['/', '\\']) || file.starts_with('.') {
142            return None;
143        }
144        Some(Self {
145            key: IndexKey::from_json(value.get("key")?)?,
146            file,
147            graph: value
148                .get("graph")
149                .and_then(Value::as_str)
150                .unwrap_or(UNNAMED)
151                .to_string(),
152            size: value.get("size")?.as_u64()?,
153            created: value.get("created")?.as_u64()?,
154            last_used: value.get("last_used")?.as_u64()?,
155        })
156    }
157}
158
159/// What [`IndexStore::save`] decided for an index.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum Saved {
162    /// Being written, in `size` bytes.
163    Stored { size: u64 },
164    /// The database is open read-only.
165    ReadOnly,
166    /// `size` bytes is more than the limit on its own.
167    TooLarge { size: u64, limit: u64 },
168    /// The indexes running queries hold leave less than `size` bytes of the
169    /// limit.
170    NoRoom { size: u64, limit: u64 },
171    /// It cannot be stored (node ids of a type the file cannot hold).
172    Unstorable(String),
173}
174
175impl Saved {
176    /// Whether this loses work a later process will do again, so it is said
177    /// however quick the build was.
178    pub fn loses_work(&self) -> bool {
179        matches!(
180            self,
181            Saved::TooLarge { .. } | Saved::NoRoom { .. } | Saved::Unstorable(_)
182        )
183    }
184
185    /// What became of the index, as a notice says it, for the database
186    /// named `database`.
187    pub fn describe(&self, database: &str) -> String {
188        match self {
189            Saved::Stored { size } => {
190                format!("stored in {database} ({})", notices::size(*size))
191            }
192            Saved::ReadOnly => format!("not stored: {database} is open read-only"),
193            Saved::TooLarge { size, limit } => format!(
194                "not stored: it takes {}, more than ursa.index_storage_limit ({}); SET \
195                 ursa.index_storage_limit = '{}' to keep it",
196                notices::size(*size),
197                ByteSize(*limit),
198                ByteSize(suggested_limit(*size))
199            ),
200            Saved::NoRoom { size, limit } => format!(
201                "not stored: the stored indexes running queries use leave less than its {} of \
202                 ursa.index_storage_limit ({})",
203                notices::size(*size),
204                ByteSize(*limit)
205            ),
206            Saved::Unstorable(why) => format!("not stored: {why}"),
207        }
208    }
209}
210
211/// A limit to suggest for an index of `size` bytes: room for it and as much
212/// again (its in-adjacency, which algorithms such as PageRank build, is about
213/// as large), rounded up to a power of two.
214pub fn suggested_limit(size: u64) -> u64 {
215    size.saturating_mul(2)
216        .checked_next_power_of_two()
217        .unwrap_or(u64::MAX)
218        .max(1 << 20)
219}
220
221/// The stored indexes of one database.
222#[derive(Debug)]
223pub struct IndexStore {
224    dir: PathBuf,
225    writable: bool,
226    /// The database as notices name it.
227    database: String,
228    /// The most bytes the store keeps: the database's limit.
229    limit: AtomicU64,
230    state: Mutex<State>,
231    pending: Mutex<Vec<JoinHandle<()>>>,
232    /// The notifier of the session that last used the store: what notices
233    /// decided when the database closes are said with.
234    notifier: Mutex<Notifier>,
235}
236
237#[derive(Debug, Default)]
238struct State {
239    /// Whether `entries` has been read from disk (a writer reads it once).
240    loaded: bool,
241    entries: Vec<Entry>,
242    /// Bytes of the indexes being written, which `entries` does not list yet.
243    reserved: u64,
244    /// Indexes this process loaded or stored, by file: an entry whose index
245    /// a running query holds is not evicted (see `in_use`), and chunks built
246    /// since are appended to its file (see [`IndexStore::flush`]).
247    live: HashMap<String, Live>,
248}
249
250/// An index this process loaded or stored, and what its file holds.
251#[derive(Debug)]
252struct Live {
253    index: Weak<GraphIndex>,
254    main: MainPart,
255    /// The chunks the file has, by kind and name.
256    chunks: HashSet<(u32, Vec<u8>)>,
257    /// Chunks not appended for want of room: said once, not tried again.
258    declined: HashSet<(u32, Vec<u8>)>,
259    graph: String,
260}
261
262impl IndexStore {
263    /// The store in `dir`, of the database `database` (as notices name it),
264    /// keeping at most `limit` bytes. A writable store creates `dir` on its
265    /// first write.
266    pub fn new(dir: PathBuf, writable: bool, database: String, limit: u64) -> Self {
267        Self {
268            dir,
269            writable,
270            database,
271            limit: AtomicU64::new(limit),
272            state: Mutex::new(State::default()),
273            pending: Mutex::new(Vec::new()),
274            notifier: Mutex::new(Notifier::silent()),
275        }
276    }
277
278    pub fn dir(&self) -> &Path {
279        &self.dir
280    }
281
282    pub fn is_writable(&self) -> bool {
283        self.writable
284    }
285
286    /// The database as notices name it.
287    pub fn database(&self) -> &str {
288        &self.database
289    }
290
291    /// The most bytes of indexes the store keeps.
292    pub fn limit(&self) -> u64 {
293        self.limit.load(Ordering::Relaxed)
294    }
295
296    /// Keep at most `limit` bytes from now on: the next index stored makes
297    /// room within it. Nothing is evicted now.
298    pub fn set_limit(&self, limit: u64) {
299        self.limit.store(limit, Ordering::Relaxed);
300    }
301
302    fn state(&self) -> std::sync::MutexGuard<'_, State> {
303        self.state.lock().unwrap_or_else(|e| e.into_inner())
304    }
305
306    fn notifier(&self) -> Notifier {
307        self.notifier
308            .lock()
309            .unwrap_or_else(|e| e.into_inner())
310            .clone()
311    }
312
313    fn use_notifier(&self, notifier: &Notifier) {
314        *self.notifier.lock().unwrap_or_else(|e| e.into_inner()) = notifier.clone();
315    }
316
317    /// The manifest's entries, as this process knows them.
318    pub fn entries(&self) -> Vec<Entry> {
319        if !self.writable {
320            return read_manifest(&self.dir);
321        }
322        let mut state = self.state();
323        self.ensure_loaded(&mut state);
324        state.entries.clone()
325    }
326
327    /// Read the manifest and tidy the directory, once (writers only).
328    fn ensure_loaded(&self, state: &mut State) {
329        if state.loaded {
330            return;
331        }
332        state.loaded = true;
333        let manifest = read_manifest_checked(&self.dir);
334        let mut entries = manifest.entries;
335        let before = entries.len();
336        entries.retain(|e| self.dir.join(&e.file).is_file());
337        // One entry per file and per fingerprint, should a manifest have more.
338        let mut files = HashSet::new();
339        let mut fingerprints = HashSet::new();
340        entries.retain(|e| {
341            files.insert(e.file.clone()) && fingerprints.insert(e.key.fingerprint.clone())
342        });
343        let changed = entries.len() != before || manifest.foreign.is_some();
344        // Remove what the manifest does not list: index files written but
345        // never listed, and temporary files of interrupted writes.
346        let mut freed = 0;
347        if let Ok(listing) = std::fs::read_dir(&self.dir) {
348            for item in listing.flatten() {
349                let name = item.file_name().to_string_lossy().into_owned();
350                let ours = name.ends_with(".idx") || name.ends_with(".tmp");
351                if ours && !files.contains(&name) {
352                    freed += item.metadata().map_or(0, |m| m.len());
353                    let _ = std::fs::remove_file(item.path());
354                }
355            }
356        }
357        if let Some(n) = manifest.foreign.filter(|n| *n > 0) {
358            self.notifier().say(NoticeLevel::On, || {
359                format!(
360                    "removed {n} stored {} ({}) from {} written by another version of ursa; \
361                     each is built again when it is first used",
362                    if n == 1 { "index" } else { "indexes" },
363                    notices::size(freed),
364                    self.database
365                )
366            });
367        }
368        state.entries = entries;
369        if changed {
370            let _ = write_manifest(&self.dir, &state.entries);
371        }
372    }
373
374    /// The stored index for `key`, if there is one whose full key matches
375    /// and whose file reads back whole, and the size of its file. Blocking:
376    /// it reads the file.
377    pub fn load(&self, key: &IndexKey, notifier: &Notifier) -> Option<(Arc<GraphIndex>, u64)> {
378        let entry = if self.writable {
379            self.use_notifier(notifier);
380            let mut state = self.state();
381            self.ensure_loaded(&mut state);
382            state.entries.iter().find(|e| e.key == *key)?.clone()
383        } else {
384            read_manifest(&self.dir)
385                .into_iter()
386                .find(|e| e.key == *key)?
387        };
388        let file = entry.file.clone();
389        let read = index_file::read_file(&self.dir.join(&file), key);
390        let mut state = self.state();
391        match read {
392            Ok(stored) => {
393                let chunks = stored
394                    .chunks
395                    .iter()
396                    .map(|c| (c.kind, c.name.clone()))
397                    .collect();
398                let weights: Vec<index_file::ChunkInfo> = stored
399                    .chunks
400                    .into_iter()
401                    .filter(|c| c.kind == CHUNK_WEIGHTS)
402                    .collect();
403                let keys = weights
404                    .iter()
405                    .filter_map(|c| String::from_utf8(c.name.clone()).ok())
406                    .collect();
407                let (path, main) = (self.dir.join(&file), stored.main);
408                let n_edges = stored.topology.n_edges();
409                let read = Box::new(move |key: &str| {
410                    let chunk = weights.iter().find(|c| c.name == key.as_bytes())?;
411                    index_file::read_weights(&path, main, chunk, n_edges).ok()
412                });
413                let index = Arc::new(
414                    GraphIndex::new(Arc::new(stored.topology), Arc::new(stored.ids))
415                        .with_dropped(stored.dropped.n_dropped, stored.dropped.kept)
416                        .with_stored_weights(keys, read),
417                );
418                state.live.insert(
419                    file.clone(),
420                    Live {
421                        index: Arc::downgrade(&index),
422                        main: stored.main,
423                        chunks,
424                        declined: HashSet::new(),
425                        graph: entry.graph.clone(),
426                    },
427                );
428                if self.writable {
429                    let now = tick(&state.entries);
430                    if let Some(entry) = state.entries.iter_mut().find(|e| e.file == file) {
431                        entry.last_used = now;
432                        let _ = write_manifest(&self.dir, &state.entries);
433                    }
434                }
435                Some((index, entry.size))
436            }
437            Err(_) => {
438                if self.writable {
439                    let before = state.entries.len();
440                    state.entries.retain(|e| e.file != file);
441                    if state.entries.len() != before {
442                        let _ = std::fs::remove_file(self.dir.join(&file));
443                        let _ = write_manifest(&self.dir, &state.entries);
444                    }
445                }
446                None
447            }
448        }
449    }
450
451    /// Decide whether to store `index`, built for the graph `graph`, under
452    /// `key` (see the module docs), evict what that takes, and start the
453    /// write on a background thread. Evictions and a failed write are said
454    /// with `notifier`; what was decided is returned for the caller to say.
455    pub fn save(
456        self: &Arc<Self>,
457        key: IndexKey,
458        index: Arc<GraphIndex>,
459        graph: &str,
460        notifier: &Notifier,
461    ) -> Saved {
462        if !self.writable {
463            return Saved::ReadOnly;
464        }
465        self.use_notifier(notifier);
466        let limit = self.limit();
467        let encoded = match Encoded::with_dropped(
468            &key,
469            &index.topology,
470            &index.ids,
471            index.n_dropped(),
472            index.kept_rows(),
473        ) {
474            Ok(encoded) => encoded,
475            Err(e) => return Saved::Unstorable(e.to_string()),
476        };
477        let main = encoded.file_size();
478        let size = if main > limit {
479            main
480        } else {
481            // The in-adjacency, if it is built by now and fits.
482            index
483                .topology
484                .incoming_if_built()
485                .map(|incoming| EncodedChunk::incoming(incoming, main).end())
486                .filter(|end| *end <= limit)
487                .unwrap_or(main)
488        };
489        drop(encoded);
490        if size > limit {
491            return Saved::TooLarge { size, limit };
492        }
493        let with_incoming = size > main;
494        let file = format!("{:016x}.idx", key.hash());
495
496        let mut state = self.state();
497        self.ensure_loaded(&mut state);
498        // Replaced: the same fingerprint (the same edges over files that have
499        // changed since), or the same file name.
500        let replaced: Vec<String> = state
501            .entries
502            .iter()
503            .filter(|e| e.key.fingerprint == key.fingerprint || e.file == file)
504            .map(|e| e.file.clone())
505            .collect();
506        let Some(evicted) = room_for(&state, size, &replaced, &[], limit) else {
507            return Saved::NoRoom { size, limit };
508        };
509        let gone = self.remove(&mut state, &replaced, &evicted, notifier, limit);
510        state.reserved += size;
511        drop(state);
512        for file in gone {
513            let _ = std::fs::remove_file(self.dir.join(file));
514        }
515
516        let store = Arc::clone(self);
517        let (graph, notifier) = (graph.to_string(), notifier.clone());
518        let spawned = std::thread::Builder::new()
519            .name("ursa-index-write".into())
520            .spawn(move || {
521                let written = store.write(&key, &index, &file, size, with_incoming, &graph);
522                if let Err(e) = written {
523                    store.state().reserved -= size;
524                    notifier.say(NoticeLevel::On, || {
525                        format!(
526                            "could not store the index for {graph} in {}: {e}; it is built \
527                             again when next needed",
528                            store.database
529                        )
530                    });
531                }
532            });
533        match spawned {
534            Ok(handle) => {
535                let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner());
536                pending.retain(|h| !h.is_finished());
537                pending.push(handle);
538                Saved::Stored { size }
539            }
540            Err(e) => {
541                self.state().reserved -= size;
542                Saved::Unstorable(format!("cannot start its write: {e}"))
543            }
544        }
545    }
546
547    /// Remove the entries of `replaced` and `evicted` from the manifest,
548    /// saying each eviction. Returns the files to delete.
549    fn remove(
550        &self,
551        state: &mut State,
552        replaced: &[String],
553        evicted: &[String],
554        notifier: &Notifier,
555        limit: u64,
556    ) -> Vec<String> {
557        if replaced.is_empty() && evicted.is_empty() {
558            return Vec::new();
559        }
560        let mut gone = Vec::new();
561        state.entries.retain(|e| {
562            if evicted.contains(&e.file) {
563                notifier.say(NoticeLevel::On, || {
564                    format!(
565                        "evicted the stored index for {} ({}, the least recently used) from {} \
566                         to stay within ursa.index_storage_limit ({})",
567                        e.graph,
568                        notices::size(e.size),
569                        self.database,
570                        ByteSize(limit)
571                    )
572                });
573            }
574            let keep = !replaced.contains(&e.file) && !evicted.contains(&e.file);
575            if !keep {
576                gone.push(e.file.clone());
577            }
578            keep
579        });
580        let _ = write_manifest(&self.dir, &state.entries);
581        gone
582    }
583
584    /// Wait for every write [`save`](Self::save) started, then append to
585    /// each index file this process stored or loaded the parts of its index
586    /// built since (the in-adjacency, evaluated weights), within the storage
587    /// limit.
588    pub fn flush(&self) {
589        let handles = std::mem::take(&mut *self.pending.lock().unwrap_or_else(|e| e.into_inner()));
590        for handle in handles {
591            let _ = handle.join();
592        }
593        if self.writable {
594            self.append_chunks();
595        }
596    }
597
598    /// Append to every live index file the in-adjacency and the weight sets
599    /// its index has built since and the file lacks, each making room for
600    /// itself as an index does. A failed append leaves the file as it was
601    /// (less any partial chunk); what it held is built again by the next
602    /// process that needs it.
603    fn append_chunks(&self) {
604        let notifier = self.notifier();
605        let limit = self.limit();
606        let mut state = self.state();
607        self.ensure_loaded(&mut state);
608        let files: Vec<String> = state.live.keys().cloned().collect();
609        let mut gone = Vec::new();
610        for file in files {
611            let live = &state.live[&file];
612            let Some(index) = live.index.upgrade() else {
613                continue;
614            };
615            let (main, graph) = (live.main, live.graph.clone());
616            let mut todo: Vec<(u32, Vec<u8>)> = Vec::new();
617            if index.topology.incoming_if_built().is_some() {
618                todo.push((CHUNK_INCOMING, Vec::new()));
619            }
620            let weight_sets = index.weight_sets();
621            todo.extend(
622                weight_sets
623                    .iter()
624                    .map(|(key, _)| (CHUNK_WEIGHTS, key.as_bytes().to_vec())),
625            );
626            todo.retain(|chunk| !live.chunks.contains(chunk) && !live.declined.contains(chunk));
627            for (kind, name) in todo {
628                let Some(entry) = state.entries.iter().position(|e| e.file == file) else {
629                    break;
630                };
631                let columns: Vec<&[f64]> = match kind {
632                    CHUNK_WEIGHTS => weight_sets
633                        .iter()
634                        .find(|(key, _)| key.as_bytes() == name.as_slice())
635                        .map(|(_, w)| w.iter().map(|c| c.as_slice()).collect())
636                        .unwrap_or_default(),
637                    _ => Vec::new(),
638                };
639                let encode = |start| match kind {
640                    CHUNK_WEIGHTS => EncodedChunk::weights(
641                        std::str::from_utf8(&name).unwrap_or_default(),
642                        &columns,
643                        start,
644                    ),
645                    _ => EncodedChunk::incoming(
646                        index.topology.incoming_if_built().expect("checked above"),
647                        start,
648                    ),
649                };
650                let size = state.entries[entry].size;
651                let need = encode(size).size();
652                let what = match kind {
653                    CHUNK_WEIGHTS => "evaluated weights",
654                    _ => "in-adjacency",
655                };
656                let Some(evicted) = room_for(&state, need, &[], std::slice::from_ref(&file), limit)
657                else {
658                    notifier.say(NoticeLevel::On, || {
659                        format!(
660                            "did not store the {what} of the index for {graph} ({}) in {}: it \
661                             would take the stored indexes past ursa.index_storage_limit ({})",
662                            notices::size(need),
663                            self.database,
664                            ByteSize(limit)
665                        )
666                    });
667                    if let Some(live) = state.live.get_mut(&file) {
668                        live.declined.insert((kind, name));
669                    }
670                    continue;
671                };
672                gone.extend(self.remove(&mut state, &[], &evicted, &notifier, limit));
673                let Some(entry) = state.entries.iter().position(|e| e.file == file) else {
674                    break;
675                };
676                let result =
677                    index_file::append_chunk(&self.dir.join(&file), main, kind, &name, encode);
678                if let Ok(appended_len) = result {
679                    if let Some(live) = state.live.get_mut(&file) {
680                        live.chunks.insert((kind, name));
681                    }
682                    if let Some(len) = appended_len {
683                        state.entries[entry].size = len;
684                        let _ = write_manifest(&self.dir, &state.entries);
685                    }
686                }
687            }
688        }
689        drop(state);
690        for file in gone {
691            let _ = std::fs::remove_file(self.dir.join(file));
692        }
693    }
694
695    /// Write `index` under `key` to `file`, in `planned` bytes (with its
696    /// in-adjacency as a chunk if `with_incoming`), then list it. Room was
697    /// made by [`Self::save`].
698    fn write(
699        &self,
700        key: &IndexKey,
701        index: &Arc<GraphIndex>,
702        file: &str,
703        planned: u64,
704        with_incoming: bool,
705        graph: &str,
706    ) -> std::io::Result<()> {
707        let encoded = Encoded::with_dropped(
708            key,
709            &index.topology,
710            &index.ids,
711            index.n_dropped(),
712            index.kept_rows(),
713        )?;
714        let incoming = index
715            .topology
716            .incoming_if_built()
717            .filter(|_| with_incoming)
718            .map(|incoming| EncodedChunk::incoming(incoming, encoded.file_size()));
719        let size = incoming
720            .as_ref()
721            .map_or(encoded.file_size(), EncodedChunk::end);
722        std::fs::create_dir_all(&self.dir)?;
723        let path = self.dir.join(file);
724        let temp = self.dir.join(format!("{file}.tmp"));
725        let written = (|| {
726            let out = File::create(&temp)?;
727            let mut writer = BufWriter::with_capacity(1 << 20, out);
728            let main = encoded.write_to(&mut writer)?;
729            if let Some(chunk) = &incoming {
730                chunk.write_to(&mut writer)?;
731            }
732            let out = writer.into_inner().map_err(|e| e.into_error())?;
733            out.sync_all()?;
734            std::fs::rename(&temp, &path)?;
735            Ok(main)
736        })();
737        let main = match written {
738            Ok(main) => main,
739            Err(e) => {
740                let _ = std::fs::remove_file(&temp);
741                return Err(e);
742            }
743        };
744
745        let mut state = self.state();
746        state.reserved -= planned;
747        state.entries.retain(|e| e.file != file);
748        let now = tick(&state.entries);
749        state.entries.push(Entry {
750            key: key.clone(),
751            file: file.to_string(),
752            graph: graph.to_string(),
753            size,
754            created: now,
755            last_used: now,
756        });
757        let chunks = incoming
758            .iter()
759            .map(|_| (CHUNK_INCOMING, Vec::new()))
760            .collect();
761        state.live.insert(
762            file.to_string(),
763            Live {
764                index: Arc::downgrade(index),
765                main,
766                chunks,
767                declined: HashSet::new(),
768                graph: graph.to_string(),
769            },
770        );
771        state.live.retain(|_, live| live.index.strong_count() > 0);
772        let saved = write_manifest(&self.dir, &state.entries);
773        drop(state);
774        sync_dir(&self.dir);
775        saved
776    }
777}
778
779impl Drop for IndexStore {
780    fn drop(&mut self) {
781        self.flush();
782    }
783}
784
785/// The entries to evict, least recently used first, to fit `need` more bytes
786/// within `limit`, beside the indexes being written and every entry but
787/// those of `dropping` (which are going anyway). Entries of `keep` and any
788/// index a running query holds are never chosen. `None` if no choice makes
789/// room; nothing is evicted then.
790fn room_for(
791    state: &State,
792    need: u64,
793    dropping: &[String],
794    keep: &[String],
795    limit: u64,
796) -> Option<Vec<String>> {
797    let staying = |e: &&Entry| !dropping.contains(&e.file);
798    let mut total: u64 = state.reserved
799        + state
800            .entries
801            .iter()
802            .filter(staying)
803            .map(|e| e.size)
804            .sum::<u64>();
805    let mut candidates: Vec<&Entry> = state
806        .entries
807        .iter()
808        .filter(staying)
809        .filter(|e| !keep.contains(&e.file) && !in_use(&state.live, &e.file))
810        .collect();
811    candidates.sort_by_key(|e| (e.last_used, e.created));
812    let mut evicted = Vec::new();
813    let mut candidates = candidates.into_iter();
814    while total.saturating_add(need) > limit {
815        let entry = candidates.next()?;
816        total -= entry.size;
817        evicted.push(entry.file.clone());
818    }
819    Some(evicted)
820}
821
822/// Whether a running query holds the index stored in `file`: the session's
823/// index cache holds one reference to an index it keeps, and a query using it
824/// holds another.
825fn in_use(live: &HashMap<String, Live>, file: &str) -> bool {
826    live.get(file)
827        .is_some_and(|live| live.index.strong_count() > 1)
828}
829
830/// Now, in milliseconds since the Unix epoch, and after every time in
831/// `entries`, so the order of uses is the order of their times.
832fn tick(entries: &[Entry]) -> u64 {
833    let now = SystemTime::now()
834        .duration_since(UNIX_EPOCH)
835        .map(|d| d.as_millis() as u64)
836        .unwrap_or(0);
837    let latest = entries
838        .iter()
839        .map(|e| e.last_used.max(e.created))
840        .max()
841        .map_or(0, |t| t + 1);
842    now.max(latest)
843}
844
845/// A manifest as read: its entries, and, for a manifest of another version,
846/// how many indexes it lists (they are not read).
847struct Manifest {
848    entries: Vec<Entry>,
849    foreign: Option<usize>,
850}
851
852/// The manifest in `dir`: empty if it is missing or not a manifest this
853/// build reads. Entries it cannot read are skipped.
854pub fn read_manifest(dir: &Path) -> Vec<Entry> {
855    read_manifest_checked(dir).entries
856}
857
858fn read_manifest_checked(dir: &Path) -> Manifest {
859    let empty = |foreign| Manifest {
860        entries: Vec::new(),
861        foreign,
862    };
863    let Ok(text) = std::fs::read_to_string(dir.join(MANIFEST)) else {
864        return empty(None);
865    };
866    let Ok(value) = serde_json::from_str::<Value>(&text) else {
867        return empty(None);
868    };
869    if value.get("format").and_then(Value::as_str) != Some(MANIFEST_FORMAT) {
870        return empty(None);
871    }
872    let listed = value.get("indexes").and_then(Value::as_array);
873    if value.get("version").and_then(Value::as_u64) != Some(MANIFEST_VERSION) {
874        return empty(Some(listed.map_or(0, Vec::len)));
875    }
876    Manifest {
877        entries: listed
878            .map(|entries| entries.iter().filter_map(Entry::from_json).collect())
879            .unwrap_or_default(),
880        foreign: None,
881    }
882}
883
884/// Rewrite the manifest in `dir` atomically.
885fn write_manifest(dir: &Path, entries: &[Entry]) -> std::io::Result<()> {
886    std::fs::create_dir_all(dir)?;
887    let value = json!({
888        "format": MANIFEST_FORMAT,
889        "version": MANIFEST_VERSION,
890        "indexes": entries.iter().map(Entry::to_json).collect::<Vec<_>>(),
891    });
892    let mut text = serde_json::to_string_pretty(&value).map_err(std::io::Error::other)?;
893    text.push('\n');
894    let temp = dir.join(format!("{MANIFEST}.tmp"));
895    {
896        let mut file = File::create(&temp)?;
897        file.write_all(text.as_bytes())?;
898        file.sync_all()?;
899    }
900    std::fs::rename(&temp, dir.join(MANIFEST))?;
901    sync_dir(dir);
902    Ok(())
903}
904
905/// `fsync` a directory so a rename in it is durable. Best effort.
906fn sync_dir(dir: &Path) {
907    #[cfg(unix)]
908    if let Ok(dir) = File::open(dir) {
909        let _ = dir.sync_all();
910    }
911    #[cfg(not(unix))]
912    let _ = dir;
913}
914
915#[cfg(test)]
916mod tests {
917    use super::*;
918
919    #[test]
920    fn the_suggested_limit_leaves_room_for_the_in_adjacency() {
921        let gib = 1u64 << 30;
922        assert_eq!(suggested_limit(21 * gib), 64 * gib);
923        assert_eq!(suggested_limit(1_503_238_554), 4 * gib);
924        assert_eq!(suggested_limit(3000), 1 << 20);
925        assert_eq!(ByteSize(suggested_limit(13 * gib)).to_string(), "32GiB");
926    }
927}