use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, Weak};
use std::thread::JoinHandle;
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
use super::index_file::{
self, Encoded, EncodedChunk, IndexKey, MainPart, CHUNK_INCOMING, CHUNK_WEIGHTS,
};
use crate::graph::GraphIndex;
pub const MANIFEST_FORMAT: &str = "ursa-indexes";
pub const MANIFEST_VERSION: u64 = 1;
const MANIFEST: &str = "manifest.json";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub key: IndexKey,
pub file: String,
pub size: u64,
pub created: u64,
pub last_used: u64,
}
impl Entry {
fn to_json(&self) -> Value {
json!({
"file": self.file,
"size": self.size,
"created": self.created,
"last_used": self.last_used,
"key": self.key.to_json(),
})
}
fn from_json(value: &Value) -> Option<Self> {
let file = value.get("file")?.as_str()?.to_string();
if file.is_empty() || file.contains(['/', '\\']) || file.starts_with('.') {
return None;
}
Some(Self {
key: IndexKey::from_json(value.get("key")?)?,
file,
size: value.get("size")?.as_u64()?,
created: value.get("created")?.as_u64()?,
last_used: value.get("last_used")?.as_u64()?,
})
}
}
#[derive(Debug)]
pub struct IndexStore {
dir: PathBuf,
writable: bool,
state: Mutex<State>,
pending: Mutex<Vec<JoinHandle<()>>>,
}
#[derive(Debug, Default)]
struct State {
loaded: bool,
entries: Vec<Entry>,
live: HashMap<String, Live>,
}
#[derive(Debug)]
struct Live {
index: Weak<GraphIndex>,
main: MainPart,
chunks: HashSet<(u32, Vec<u8>)>,
limit: u64,
}
impl IndexStore {
pub fn new(dir: PathBuf, writable: bool) -> Self {
Self {
dir,
writable,
state: Mutex::new(State::default()),
pending: Mutex::new(Vec::new()),
}
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn is_writable(&self) -> bool {
self.writable
}
fn state(&self) -> std::sync::MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn entries(&self) -> Vec<Entry> {
if !self.writable {
return read_manifest(&self.dir);
}
let mut state = self.state();
self.ensure_loaded(&mut state);
state.entries.clone()
}
fn ensure_loaded(&self, state: &mut State) {
if state.loaded {
return;
}
state.loaded = true;
let mut entries = read_manifest(&self.dir);
let before = entries.len();
entries.retain(|e| self.dir.join(&e.file).is_file());
let mut files = HashSet::new();
let mut fingerprints = HashSet::new();
entries.retain(|e| {
files.insert(e.file.clone()) && fingerprints.insert(e.key.fingerprint.clone())
});
let changed = entries.len() != before;
if let Ok(listing) = std::fs::read_dir(&self.dir) {
for item in listing.flatten() {
let name = item.file_name().to_string_lossy().into_owned();
let ours = name.ends_with(".idx") || name.ends_with(".tmp");
if ours && !files.contains(&name) {
let _ = std::fs::remove_file(item.path());
}
}
}
state.entries = entries;
if changed {
let _ = write_manifest(&self.dir, &state.entries);
}
}
pub fn load(&self, key: &IndexKey, limit: u64) -> Option<Arc<GraphIndex>> {
let file = if self.writable {
let mut state = self.state();
self.ensure_loaded(&mut state);
state.entries.iter().find(|e| e.key == *key)?.file.clone()
} else {
read_manifest(&self.dir)
.into_iter()
.find(|e| e.key == *key)?
.file
};
let read = index_file::read_file(&self.dir.join(&file), key);
let mut state = self.state();
match read {
Ok(stored) => {
let chunks = stored
.chunks
.iter()
.map(|c| (c.kind, c.name.clone()))
.collect();
let weights: Vec<index_file::ChunkInfo> = stored
.chunks
.into_iter()
.filter(|c| c.kind == CHUNK_WEIGHTS)
.collect();
let keys = weights
.iter()
.filter_map(|c| String::from_utf8(c.name.clone()).ok())
.collect();
let (path, main) = (self.dir.join(&file), stored.main);
let n_edges = stored.topology.n_edges();
let read = Box::new(move |key: &str| {
let chunk = weights.iter().find(|c| c.name == key.as_bytes())?;
index_file::read_weights(&path, main, chunk, n_edges).ok()
});
let index = Arc::new(
GraphIndex::new(Arc::new(stored.topology), Arc::new(stored.ids))
.with_stored_weights(keys, read),
);
state.live.insert(
file.clone(),
Live {
index: Arc::downgrade(&index),
main: stored.main,
chunks,
limit,
},
);
if self.writable {
let now = tick(&state.entries);
if let Some(entry) = state.entries.iter_mut().find(|e| e.file == file) {
entry.last_used = now;
let _ = write_manifest(&self.dir, &state.entries);
}
}
Some(index)
}
Err(_) => {
if self.writable {
let before = state.entries.len();
state.entries.retain(|e| e.file != file);
if state.entries.len() != before {
let _ = std::fs::remove_file(self.dir.join(&file));
let _ = write_manifest(&self.dir, &state.entries);
}
}
None
}
}
}
pub fn save(self: &Arc<Self>, key: IndexKey, index: Arc<GraphIndex>, limit: u64) {
if !self.writable {
return;
}
self.ensure_loaded(&mut self.state());
let store = Arc::clone(self);
let spawned = std::thread::Builder::new()
.name("ursa-index-write".into())
.spawn(move || {
let _ = store.write(&key, &index, limit);
});
if let Ok(handle) = spawned {
let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner());
pending.retain(|h| !h.is_finished());
pending.push(handle);
}
}
pub fn flush(&self) {
let handles = std::mem::take(&mut *self.pending.lock().unwrap_or_else(|e| e.into_inner()));
for handle in handles {
let _ = handle.join();
}
if self.writable {
self.append_chunks();
}
}
fn append_chunks(&self) {
let mut state = self.state();
self.ensure_loaded(&mut state);
let files: Vec<String> = state.live.keys().cloned().collect();
let mut appended = Vec::new();
for file in files {
let live = &state.live[&file];
let Some(index) = live.index.upgrade() else {
continue;
};
let (main, limit) = (live.main, live.limit);
let mut todo: Vec<(u32, Vec<u8>)> = Vec::new();
if index.topology.incoming_if_built().is_some() {
todo.push((CHUNK_INCOMING, Vec::new()));
}
let weight_sets = index.weight_sets();
todo.extend(
weight_sets
.iter()
.map(|(key, _)| (CHUNK_WEIGHTS, key.as_bytes().to_vec())),
);
todo.retain(|chunk| !live.chunks.contains(chunk));
for (kind, name) in todo {
let Some(entry) = state.entries.iter().position(|e| e.file == file) else {
break;
};
let columns: Vec<&[f64]> = match kind {
CHUNK_WEIGHTS => weight_sets
.iter()
.find(|(key, _)| key.as_bytes() == name.as_slice())
.map(|(_, w)| w.iter().map(|c| c.as_slice()).collect())
.unwrap_or_default(),
_ => Vec::new(),
};
let encode = |start| match kind {
CHUNK_WEIGHTS => EncodedChunk::weights(
std::str::from_utf8(&name).unwrap_or_default(),
&columns,
start,
),
_ => EncodedChunk::incoming(
index.topology.incoming_if_built().expect("checked above"),
start,
),
};
let size = state.entries[entry].size;
if size + encode(size).size() > limit {
continue;
}
let result =
index_file::append_chunk(&self.dir.join(&file), main, kind, &name, encode);
if let Ok(appended_len) = result {
if let Some(live) = state.live.get_mut(&file) {
live.chunks.insert((kind, name));
}
if let Some(len) = appended_len {
state.entries[entry].size = len;
appended.push(file.clone());
}
}
}
}
if !appended.is_empty() {
let limit = state
.live
.values()
.map(|l| l.limit)
.min()
.unwrap_or(u64::MAX);
let removed = evict(&mut state, &appended, limit);
let _ = write_manifest(&self.dir, &state.entries);
drop(state);
for gone in removed {
let _ = std::fs::remove_file(self.dir.join(gone));
}
}
}
fn write(&self, key: &IndexKey, index: &Arc<GraphIndex>, limit: u64) -> std::io::Result<()> {
let encoded = Encoded::new(key, &index.topology, &index.ids)?;
let incoming = index
.topology
.incoming_if_built()
.map(|incoming| EncodedChunk::incoming(incoming, encoded.file_size()))
.filter(|chunk| encoded.file_size() + chunk.size() <= limit);
let size = incoming
.as_ref()
.map_or(encoded.file_size(), EncodedChunk::end);
if size > limit {
return Ok(());
}
std::fs::create_dir_all(&self.dir)?;
let file = format!("{:016x}.idx", key.hash());
let path = self.dir.join(&file);
let temp = self.dir.join(format!("{file}.tmp"));
let written = (|| {
let out = File::create(&temp)?;
let mut writer = BufWriter::with_capacity(1 << 20, out);
let main = encoded.write_to(&mut writer)?;
if let Some(chunk) = &incoming {
chunk.write_to(&mut writer)?;
}
let out = writer.into_inner().map_err(|e| e.into_error())?;
out.sync_all()?;
std::fs::rename(&temp, &path)?;
Ok(main)
})();
let main = match written {
Ok(main) => main,
Err(e) => {
let _ = std::fs::remove_file(&temp);
return Err(e);
}
};
let mut state = self.state();
self.ensure_loaded(&mut state);
let mut removed = Vec::new();
state.entries.retain(|e| {
let superseded = e.key.fingerprint == key.fingerprint || e.file == file;
if superseded && e.file != file {
removed.push(e.file.clone());
}
!superseded
});
let now = tick(&state.entries);
state.entries.push(Entry {
key: key.clone(),
file: file.clone(),
size,
created: now,
last_used: now,
});
let chunks = incoming
.iter()
.map(|_| (CHUNK_INCOMING, Vec::new()))
.collect();
state.live.insert(
file.clone(),
Live {
index: Arc::downgrade(index),
main,
chunks,
limit,
},
);
removed.extend(evict(&mut state, std::slice::from_ref(&file), limit));
state.live.retain(|_, live| live.index.strong_count() > 0);
let saved = write_manifest(&self.dir, &state.entries);
drop(state);
for gone in removed {
let _ = std::fs::remove_file(self.dir.join(gone));
}
sync_dir(&self.dir);
saved
}
}
impl Drop for IndexStore {
fn drop(&mut self) {
self.flush();
}
}
fn evict(state: &mut State, keep: &[String], limit: u64) -> Vec<String> {
let mut removed = Vec::new();
let mut total: u64 = state.entries.iter().map(|e| e.size).sum();
while total > limit {
let candidate = state
.entries
.iter()
.enumerate()
.filter(|(_, e)| !keep.contains(&e.file) && !in_use(&state.live, &e.file))
.min_by_key(|(_, e)| (e.last_used, e.created))
.map(|(i, _)| i);
let Some(i) = candidate else { break };
let entry = state.entries.remove(i);
total -= entry.size;
removed.push(entry.file);
}
removed
}
fn in_use(live: &HashMap<String, Live>, file: &str) -> bool {
live.get(file)
.is_some_and(|live| live.index.strong_count() > 1)
}
fn tick(entries: &[Entry]) -> u64 {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let latest = entries
.iter()
.map(|e| e.last_used.max(e.created))
.max()
.map_or(0, |t| t + 1);
now.max(latest)
}
pub fn read_manifest(dir: &Path) -> Vec<Entry> {
let Ok(text) = std::fs::read_to_string(dir.join(MANIFEST)) else {
return Vec::new();
};
let Ok(value) = serde_json::from_str::<Value>(&text) else {
return Vec::new();
};
if value.get("format").and_then(Value::as_str) != Some(MANIFEST_FORMAT)
|| value.get("version").and_then(Value::as_u64) != Some(MANIFEST_VERSION)
{
return Vec::new();
}
value
.get("indexes")
.and_then(Value::as_array)
.map(|entries| entries.iter().filter_map(Entry::from_json).collect())
.unwrap_or_default()
}
fn write_manifest(dir: &Path, entries: &[Entry]) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let value = json!({
"format": MANIFEST_FORMAT,
"version": MANIFEST_VERSION,
"indexes": entries.iter().map(Entry::to_json).collect::<Vec<_>>(),
});
let mut text = serde_json::to_string_pretty(&value).map_err(std::io::Error::other)?;
text.push('\n');
let temp = dir.join(format!("{MANIFEST}.tmp"));
{
let mut file = File::create(&temp)?;
file.write_all(text.as_bytes())?;
file.sync_all()?;
}
std::fs::rename(&temp, dir.join(MANIFEST))?;
sync_dir(dir);
Ok(())
}
fn sync_dir(dir: &Path) {
#[cfg(unix)]
if let Ok(dir) = File::open(dir) {
let _ = dir.sync_all();
}
#[cfg(not(unix))]
let _ = dir;
}