1use 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
103pub const MANIFEST_FORMAT: &str = "ursa-indexes";
105pub const MANIFEST_VERSION: u64 = 2;
107const MANIFEST: &str = "manifest.json";
108const UNNAMED: &str = "a graph";
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Entry {
114 pub key: IndexKey,
115 pub file: String,
117 pub graph: String,
119 pub size: u64,
120 pub created: u64,
122 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 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#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum Saved {
162 Stored { size: u64 },
164 ReadOnly,
166 TooLarge { size: u64, limit: u64 },
168 NoRoom { size: u64, limit: u64 },
171 Unstorable(String),
173}
174
175impl Saved {
176 pub fn loses_work(&self) -> bool {
179 matches!(
180 self,
181 Saved::TooLarge { .. } | Saved::NoRoom { .. } | Saved::Unstorable(_)
182 )
183 }
184
185 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
211pub 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#[derive(Debug)]
223pub struct IndexStore {
224 dir: PathBuf,
225 writable: bool,
226 database: String,
228 limit: AtomicU64,
230 state: Mutex<State>,
231 pending: Mutex<Vec<JoinHandle<()>>>,
232 notifier: Mutex<Notifier>,
235}
236
237#[derive(Debug, Default)]
238struct State {
239 loaded: bool,
241 entries: Vec<Entry>,
242 reserved: u64,
244 live: HashMap<String, Live>,
248}
249
250#[derive(Debug)]
252struct Live {
253 index: Weak<GraphIndex>,
254 main: MainPart,
255 chunks: HashSet<(u32, Vec<u8>)>,
257 declined: HashSet<(u32, Vec<u8>)>,
259 graph: String,
260}
261
262impl IndexStore {
263 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 pub fn database(&self) -> &str {
288 &self.database
289 }
290
291 pub fn limit(&self) -> u64 {
293 self.limit.load(Ordering::Relaxed)
294 }
295
296 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 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 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 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 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 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 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 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 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 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 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 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, ¬ifier, 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 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
785fn 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
822fn 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
830fn 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
845struct Manifest {
848 entries: Vec<Entry>,
849 foreign: Option<usize>,
850}
851
852pub 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
884fn 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
905fn 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}