use sparrowdb_catalog::catalog::Catalog;
use sparrowdb_common::{col_id_of, Error};
use sparrowdb_storage::csr::CsrForward;
use sparrowdb_storage::edge_store::{EdgeStore, RelTableId};
use sparrowdb_storage::node_store::{NodeStore, Value};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
pub fn fnv1a_col_id(key: &str) -> u32 {
col_id_of(key)
}
pub fn cypher_escape_string(s: &str) -> String {
s.replace('\\', "\\\\").replace('\'', "\\'")
}
pub(crate) fn literal_to_value(lit: &sparrowdb_cypher::ast::Literal) -> Value {
use sparrowdb_cypher::ast::Literal;
match lit {
Literal::Int(n) => Value::Int64(*n),
Literal::Float(f) => Value::Float(*f),
Literal::Bool(b) => Value::Int64(if *b { 1 } else { 0 }),
Literal::String(s) => Value::Bytes(s.as_bytes().to_vec()),
Literal::Null | Literal::Param(_) => Value::Int64(0),
}
}
pub(crate) fn expr_to_value(expr: &sparrowdb_cypher::ast::Expr) -> Value {
use sparrowdb_cypher::ast::Expr;
match expr {
Expr::Literal(lit) => literal_to_value(lit),
_ => Value::Int64(0),
}
}
pub(crate) fn literal_to_value_with_params(
lit: &sparrowdb_cypher::ast::Literal,
params: &HashMap<String, sparrowdb_execution::Value>,
) -> crate::Result<Value> {
use sparrowdb_cypher::ast::Literal;
match lit {
Literal::Int(n) => Ok(Value::Int64(*n)),
Literal::Float(f) => Ok(Value::Float(*f)),
Literal::Bool(b) => Ok(Value::Int64(if *b { 1 } else { 0 })),
Literal::String(s) => Ok(Value::Bytes(s.as_bytes().to_vec())),
Literal::Null => Ok(Value::Int64(0)),
Literal::Param(p) => match params.get(p.as_str()) {
Some(v) => Ok(exec_value_to_storage(v)),
None => Err(sparrowdb_common::Error::InvalidArgument(format!(
"parameter ${p} was referenced in the query but not supplied"
))),
},
}
}
pub(crate) fn expr_to_value_with_params(
expr: &sparrowdb_cypher::ast::Expr,
params: &HashMap<String, sparrowdb_execution::Value>,
) -> crate::Result<Value> {
use sparrowdb_cypher::ast::Expr;
match expr {
Expr::Literal(lit) => literal_to_value_with_params(lit, params),
_ => Err(sparrowdb_common::Error::InvalidArgument(
"property value must be a literal or $parameter".into(),
)),
}
}
pub(crate) fn resolve_create_prop_value(
key: &str,
expr: &sparrowdb_cypher::ast::Expr,
params: Option<&HashMap<String, sparrowdb_execution::Value>>,
) -> crate::Result<Value> {
use sparrowdb_cypher::ast::{Expr, Literal};
use sparrowdb_execution::Value as EV;
match expr {
Expr::Literal(Literal::Null) => Err(Error::InvalidArgument(format!(
"CREATE property '{key}' is null; use a concrete value"
))),
Expr::Literal(Literal::Param(p)) => {
let Some(params) = params else {
return Err(Error::InvalidArgument(format!(
"CREATE property '{key}' references parameter ${p}; use \
GraphDb::execute_with_params to bind runtime parameters"
)));
};
match params.get(p.as_str()) {
None => Err(Error::InvalidArgument(format!(
"parameter ${p} was referenced in the query but not supplied"
))),
Some(EV::Null) => Err(Error::InvalidArgument(format!(
"CREATE property '{key}' is bound to parameter ${p}, which is null; \
use a concrete value"
))),
Some(EV::Int64(n)) => Ok(Value::Int64(*n)),
Some(EV::Float64(f)) => Ok(Value::Float(*f)),
Some(EV::Bool(b)) => Ok(Value::Int64(if *b { 1 } else { 0 })),
Some(EV::String(s)) => Ok(Value::Bytes(s.as_bytes().to_vec())),
Some(other) => Err(Error::InvalidArgument(format!(
"CREATE property '{key}' is bound to parameter ${p}, whose value ({other:?}) \
is a {} and cannot be stored as a scalar property; only int, float, bool, \
and string parameters may be used in CREATE",
exec_value_kind(other),
))),
}
}
Expr::Literal(lit) => Ok(literal_to_value(lit)),
_ => Err(Error::InvalidArgument(format!(
"CREATE property '{key}' must be a literal value or $parameter"
))),
}
}
fn exec_value_kind(v: &sparrowdb_execution::Value) -> &'static str {
use sparrowdb_execution::Value as EV;
match v {
EV::Null => "null",
EV::Int64(_) => "int",
EV::Float64(_) => "float",
EV::Bool(_) => "bool",
EV::String(_) => "string",
EV::NodeRef(_) => "node reference",
EV::EdgeRef(_) => "edge reference",
EV::List(_) => "list",
EV::Map(_) => "map",
EV::Vector(_) => "vector",
}
}
pub(crate) fn exec_value_to_storage(v: &sparrowdb_execution::Value) -> Value {
use sparrowdb_execution::Value as EV;
match v {
EV::Int64(n) => Value::Int64(*n),
EV::Float64(f) => Value::Float(*f),
EV::Bool(b) => Value::Int64(if *b { 1 } else { 0 }),
EV::String(s) => Value::Bytes(s.as_bytes().to_vec()),
_ => Value::Int64(0),
}
}
pub(crate) fn storage_value_to_exec(val: &Value) -> sparrowdb_execution::Value {
match val {
Value::Int64(n) => sparrowdb_execution::Value::Int64(*n),
Value::Bytes(b) => {
sparrowdb_execution::Value::String(String::from_utf8_lossy(b).into_owned())
}
Value::Float(f) => sparrowdb_execution::Value::Float64(*f),
}
}
pub(crate) fn eval_expr_merge(
expr: &sparrowdb_cypher::ast::Expr,
vals: &HashMap<String, sparrowdb_execution::Value>,
) -> sparrowdb_execution::Value {
use sparrowdb_cypher::ast::{Expr, Literal};
match expr {
Expr::PropAccess { var, prop } => {
let key = format!("{var}.{prop}");
vals.get(&key)
.cloned()
.unwrap_or(sparrowdb_execution::Value::Null)
}
Expr::Literal(lit) => match lit {
Literal::Int(n) => sparrowdb_execution::Value::Int64(*n),
Literal::Float(f) => sparrowdb_execution::Value::Float64(*f),
Literal::Bool(b) => sparrowdb_execution::Value::Bool(*b),
Literal::String(s) => sparrowdb_execution::Value::String(s.clone()),
Literal::Null | Literal::Param(_) => sparrowdb_execution::Value::Null,
},
Expr::Var(v) => vals
.get(v.as_str())
.cloned()
.unwrap_or(sparrowdb_execution::Value::Null),
_ => sparrowdb_execution::Value::Null,
}
}
pub(crate) fn is_edge_delete_mutation(mm: &sparrowdb_cypher::ast::MatchMutateStatement) -> bool {
if mm.mutations.len() != 1 {
return false;
}
let sparrowdb_cypher::ast::Mutation::Delete { var, .. } = &mm.mutations[0] else {
return false;
};
mm.match_patterns
.iter()
.any(|p| p.rels.iter().any(|r| !r.var.is_empty() && &r.var == var))
}
#[inline]
pub(crate) fn is_reserved_label(label: &str) -> bool {
label.starts_with("__SO_")
}
pub(crate) const CONSTRAINTS_FILE: &str = "constraints.bin";
pub(crate) fn save_constraints(
db_root: &Path,
constraints: &HashSet<(u32, u32)>,
) -> crate::Result<()> {
use std::io::Write;
let path = db_root.join(CONSTRAINTS_FILE);
let mut buf = Vec::with_capacity(4 + constraints.len() * 8);
buf.extend_from_slice(&(constraints.len() as u32).to_le_bytes());
for &(label_id, col_id) in constraints {
buf.extend_from_slice(&label_id.to_le_bytes());
buf.extend_from_slice(&col_id.to_le_bytes());
}
let tmp_path = db_root.join("constraints.bin.tmp");
let mut f = std::fs::File::create(&tmp_path)?;
f.write_all(&buf)?;
f.sync_all()?;
std::fs::rename(&tmp_path, &path)?;
Ok(())
}
pub(crate) fn load_constraints(db_root: &Path) -> HashSet<(u32, u32)> {
let path = db_root.join(CONSTRAINTS_FILE);
let data = match std::fs::read(&path) {
Ok(d) => d,
Err(_) => return HashSet::new(),
};
if data.len() < 4 {
return HashSet::new();
}
let count = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
let expected_len = 4 + count * 8;
if data.len() < expected_len {
return HashSet::new();
}
let mut set = HashSet::with_capacity(count);
for i in 0..count {
let off = 4 + i * 8;
let label_id = u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]);
let col_id =
u32::from_le_bytes([data[off + 4], data[off + 5], data[off + 6], data[off + 7]]);
set.insert((label_id, col_id));
}
set
}
pub(crate) fn build_label_row_counts_from_disk(
catalog: &Catalog,
db_root: &Path,
) -> HashMap<sparrowdb_catalog::catalog::LabelId, usize> {
let store = match NodeStore::open(db_root) {
Ok(s) => s,
Err(_) => return HashMap::new(),
};
catalog
.list_labels()
.unwrap_or_default()
.into_iter()
.filter_map(|(lid, _name)| {
let live = store.live_count_for_label(lid as u32).unwrap_or(0);
if live > 0 {
Some((lid, live as usize))
} else {
None
}
})
.collect()
}
pub(crate) fn open_csr_map(path: &Path) -> HashMap<u32, CsrForward> {
let catalog = match Catalog::open(path) {
Ok(c) => c,
Err(_) => return HashMap::new(),
};
let mut map = HashMap::new();
let mut rel_ids: Vec<u32> = catalog
.list_rel_table_ids()
.into_iter()
.map(|(id, _, _, _)| id as u32)
.collect();
if !rel_ids.contains(&0u32) {
rel_ids.push(0u32);
}
for rid in rel_ids {
if let Ok(store) = EdgeStore::open(path, RelTableId(rid)) {
if let Ok(csr) = store.open_fwd() {
map.insert(rid, csr);
}
}
}
map
}
pub(crate) fn try_open_csr_map(path: &Path) -> crate::Result<HashMap<u32, CsrForward>> {
let catalog = Catalog::open(path)?;
let mut map = HashMap::new();
let mut rel_ids: Vec<u32> = catalog
.list_rel_table_ids()
.into_iter()
.map(|(id, _, _, _)| id as u32)
.collect();
if !rel_ids.contains(&0u32) {
rel_ids.push(0u32);
}
for rid in rel_ids {
if let Ok(store) = EdgeStore::open(path, RelTableId(rid)) {
if let Ok(csr) = store.open_fwd() {
map.insert(rid, csr);
}
}
}
Ok(map)
}
fn parse_index_stem(stem: &str) -> Option<(String, String)> {
let rest = stem.strip_prefix("hnsw_")?;
let sep = rest.rfind('_')?;
let (label, prop) = (&rest[..sep], &rest[sep + 1..]);
if label.is_empty() || prop.is_empty() {
return None;
}
Some((label.to_string(), prop.to_string()))
}
enum IndexFile {
Live { label: String, prop: String },
Quarantined {
label: String,
prop: String,
path: PathBuf,
},
}
fn scan_vector_index_dir(dir: &Path) -> std::result::Result<Vec<IndexFile>, String> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e)
if e.kind() == std::io::ErrorKind::NotFound
&& std::fs::symlink_metadata(dir).is_err() =>
{
return Ok(Vec::new())
}
Err(e) => {
return Err(format!(
"{} could not be listed ({e}); the contents of this database's vector \
index directory are unknown, so the absence of reported damage means \
nothing was observed, not that nothing is wrong",
dir.display()
))
}
};
let mut found = Vec::new();
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(e) => {
return Err(format!(
"an entry in {} could not be read ({e}); the listing is incomplete, \
so no conclusion about damage can be drawn from it",
dir.display()
))
}
};
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if let Some(stem) = name.strip_suffix(".bin") {
if let Some((label, prop)) = parse_index_stem(stem) {
found.push(IndexFile::Live { label, prop });
}
} else if let Some((head, _stamp)) = name.split_once(".bin.corrupt.") {
if let Some((label, prop)) = parse_index_stem(head) {
found.push(IndexFile::Quarantined {
label,
prop,
path: entry.path(),
});
}
}
}
found.sort_by(|a, b| sort_key(a).cmp(&sort_key(b)));
Ok(found)
}
fn sort_key(f: &IndexFile) -> (&str, &str, &Path) {
match f {
IndexFile::Live { label, prop } => (label, prop, Path::new("")),
IndexFile::Quarantined { label, prop, path } => (label, prop, path.as_path()),
}
}
pub(crate) fn load_vector_indexes(db_root: &Path) -> crate::Result<crate::types::VectorIndexMap> {
let dir = db_root.join("vector_indexes");
let mut map: crate::types::VectorIndexMap = HashMap::new();
let mut failures: Vec<String> = Vec::new();
let entries = scan_vector_index_dir(&dir).map_err(Error::Corruption)?;
for (label, prop) in entries.into_iter().filter_map(|f| match f {
IndexFile::Live { label, prop } => Some((label, prop)),
IndexFile::Quarantined { .. } => None,
}) {
match sparrowdb_storage::VectorIndex::load_and_quarantine(&dir, &label, &prop) {
Ok(Some(idx)) => {
map.insert((label, prop), Arc::new(RwLock::new(idx)));
}
Ok(None) => continue,
Err(e) => failures.push(format!(
"({label}, {prop}) at {}: {e}",
dir.join(format!("hnsw_{label}_{prop}.bin")).display(),
)),
}
}
if !failures.is_empty() {
return Err(Error::Corruption(format!(
"{} vector index file(s) exist but could not be loaded: {}. \
Refusing to open: continuing would silently drop every vector write for these \
indexes and return no results for every search. Move the files aside and re-open \
to run without them, then rebuild the indexes.",
failures.len(),
failures.join("; "),
)));
}
Ok(map)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VectorIndexFailure {
pub label: String,
pub prop: String,
pub path: PathBuf,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct VectorIndexHealth {
pub unscannable: Option<String>,
pub active: Vec<VectorIndexFailure>,
pub historical: Vec<VectorIndexFailure>,
}
impl VectorIndexHealth {
pub fn is_healthy(&self) -> bool {
self.unscannable.is_none() && self.active.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Depth {
Names,
Contents,
}
pub(crate) fn vector_index_health(db_root: &Path) -> VectorIndexHealth {
health(db_root, Depth::Names)
}
pub(crate) fn vector_index_load_failures(db_root: &Path) -> VectorIndexHealth {
health(db_root, Depth::Contents)
}
fn health(db_root: &Path, depth: Depth) -> VectorIndexHealth {
let dir = db_root.join("vector_indexes");
let entries = match scan_vector_index_dir(&dir) {
Ok(entries) => entries,
Err(reason) => {
return VectorIndexHealth {
unscannable: Some(reason),
..Default::default()
}
}
};
let mut serving: HashSet<(String, String)> = HashSet::new();
let mut active: Vec<VectorIndexFailure> = Vec::new();
for entry in &entries {
let IndexFile::Live { label, prop } = entry else {
continue;
};
let path = dir.join(format!("hnsw_{label}_{prop}.bin"));
if !path.exists() {
active.push(VectorIndexFailure {
label: label.clone(),
prop: prop.clone(),
path,
reason: "a directory entry with this name exists but does not resolve to a \
readable file — most likely a symbolic link whose target has been \
removed. It is not an absent index: the pair is configured, and \
treating it as unconfigured would silently drop every vector write \
for it."
.to_owned(),
});
continue;
}
match depth {
Depth::Names => {
serving.insert((label.clone(), prop.clone()));
}
Depth::Contents => match sparrowdb_storage::VectorIndex::load(&dir, label, prop) {
Ok(Some(_)) => {
serving.insert((label.clone(), prop.clone()));
}
Ok(None) => {}
Err(e) => active.push(VectorIndexFailure {
label: label.clone(),
prop: prop.clone(),
path,
reason: e.to_string(),
}),
},
}
}
let mut historical: Vec<VectorIndexFailure> = Vec::new();
for entry in entries {
let IndexFile::Quarantined { label, prop, path } = entry else {
continue;
};
let superseded = serving.contains(&(label.clone(), prop.clone()));
let reason = if superseded {
format!(
"index file was rejected by a previous load attempt and preserved as {} \
(#442 quarantine). A working index now serves this (label, prop), so the \
pair is back in service and these bytes are debris — kept because they are \
the only surviving evidence that the incident happened. Whether the \
rebuilt index holds everything these bytes held cannot be determined: they \
do not decode. Remove the artifact when you are done with it.",
path.display(),
)
} else {
format!(
"index file was rejected by a previous load attempt and preserved as {} \
(#442 quarantine). The vectors it held are not in service and cannot be \
rebuilt from column data; the original decode failure is not recorded on \
disk. Recover from these bytes or rebuild the index deliberately, then \
remove the artifact to clear this report.",
path.display(),
)
};
let failure = VectorIndexFailure {
label,
prop,
path,
reason,
};
if superseded {
historical.push(failure);
} else {
active.push(failure);
}
}
let by_name = |a: &VectorIndexFailure, b: &VectorIndexFailure| {
(&a.label, &a.prop, &a.path).cmp(&(&b.label, &b.prop, &b.path))
};
active.sort_by(by_name);
historical.sort_by(by_name);
VectorIndexHealth {
unscannable: None,
active,
historical,
}
}
pub(crate) fn dir_size_bytes(dir: &Path) -> u64 {
let mut total: u64 = 0;
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
total += dir_size_bytes(&p);
} else if let Ok(m) = std::fs::metadata(&p) {
total += m.len();
}
}
total
}
pub(crate) fn collect_maintenance_params(
catalog: &Catalog,
node_store: &NodeStore,
db_root: &Path,
) -> Vec<(u32, u64)> {
let rel_table_entries = catalog.list_rel_table_ids();
let mut rel_triples: Vec<(u32, Option<u16>, Option<u16>)> = rel_table_entries
.iter()
.map(|(id, src, dst, _)| (*id as u32, Some(*src), Some(*dst)))
.collect();
if !rel_triples.iter().any(|(id, _, _)| *id == 0u32) {
rel_triples.push((0u32, None, None));
}
let global_max_hwm: u64 = catalog
.list_labels()
.unwrap_or_default()
.iter()
.map(|(label_id, _name)| node_store.hwm_for_label(*label_id as u32).unwrap_or(0))
.max()
.unwrap_or(0);
rel_triples
.iter()
.map(|&(rel_id, src_label, dst_label)| {
let hwm_n_nodes = match (src_label, dst_label) {
(Some(src), Some(dst)) => {
let src_hwm = node_store.hwm_for_label(src as u32).unwrap_or(0);
let dst_hwm = node_store.hwm_for_label(dst as u32).unwrap_or(0);
src_hwm.max(dst_hwm)
}
_ => global_max_hwm,
};
let delta_max: u64 = EdgeStore::open(db_root, RelTableId(rel_id))
.ok()
.and_then(|s| s.read_delta().ok())
.map(|records| {
records
.iter()
.flat_map(|r| {
let src_slot = r.src.0 & 0xFFFF_FFFF;
let dst_slot = r.dst.0 & 0xFFFF_FFFF;
[src_slot, dst_slot].into_iter()
})
.max()
.map(|max_slot| max_slot + 1)
.unwrap_or(0)
})
.unwrap_or(0);
let n_nodes = hwm_n_nodes.max(delta_max).max(1);
(rel_id, n_nodes)
})
.collect()
}