use std::collections::BTreeMap;
use std::collections::HashSet;
use std::fs;
use std::io::Write;
use std::path::Path;
use sparrowdb_common::{NodeId, Result};
use crate::node_store::NodeStore;
#[allow(unused_imports)]
use tracing;
const TOMBSTONE: u64 = u64::MAX;
const ABSENT: u64 = 0;
const INT64_TAG: u64 = 0x00;
#[inline]
pub fn sort_key(raw: u64) -> u64 {
if (raw >> 56) == INT64_TAG {
raw ^ 0x0080_0000_0000_0000
} else {
raw
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct IndexKey {
pub label_id: u32,
pub col_id: u32,
}
#[derive(Default, Clone)]
pub struct PropertyIndex {
index: std::collections::HashMap<IndexKey, std::sync::Arc<BTreeMap<u64, Vec<u32>>>>,
loaded: HashSet<IndexKey>,
pub generation: u64,
persisted_key_count: usize,
}
impl PropertyIndex {
pub fn new() -> Self {
PropertyIndex::default()
}
pub fn clear(&mut self) {
self.index.clear();
self.loaded.clear();
self.generation = self.generation.wrapping_add(1);
self.persisted_key_count = 0;
}
pub fn merge_from(&mut self, other: &PropertyIndex) {
for key in &other.loaded {
if self.loaded.insert(*key) {
if let Some(btree) = other.index.get(key) {
self.index.insert(*key, btree.clone());
}
}
}
}
pub fn build_for(&mut self, store: &NodeStore, label_id: u32, col_id: u32) -> Result<()> {
if col_id == 0 {
return Ok(());
}
let key = IndexKey { label_id, col_id };
if self.loaded.contains(&key) {
return Ok(());
}
self.loaded.insert(key);
let tombstone_slots: HashSet<u32> = match store.read_col_all(label_id, 0) {
Ok(col0) => col0
.iter()
.enumerate()
.filter_map(|(slot, &raw)| {
if raw == TOMBSTONE {
Some(slot as u32)
} else {
None
}
})
.collect(),
Err(_) => HashSet::new(),
};
let raw_vals = match store.read_col_all(label_id, col_id) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
label_id = label_id,
col_id = col_id,
error = ?e,
"PropertyIndex::build_for: failed to read column; index disabled for this pair"
);
return Ok(());
}
};
let null_bitmap = store.read_null_bitmap_all(label_id, col_id).unwrap_or(None);
let mut btree: BTreeMap<u64, Vec<u32>> = BTreeMap::new();
for (slot, raw) in raw_vals.into_iter().enumerate() {
let slot = slot as u32;
let is_present = match &null_bitmap {
Some(bits) => bits.get(slot as usize).copied().unwrap_or(false),
None => raw != ABSENT,
};
if !is_present || tombstone_slots.contains(&slot) {
continue;
}
btree.entry(sort_key(raw)).or_default().push(slot);
}
self.index.insert(key, std::sync::Arc::new(btree));
Ok(())
}
pub fn build(store: &NodeStore, label_ids: &[(u32, String)]) -> Self {
let mut idx = PropertyIndex::new();
for &(label_id, ref _label_name) in label_ids {
let col_ids = match store.col_ids_for_label(label_id) {
Ok(ids) => ids,
Err(e) => {
eprintln!(
"WARN: PropertyIndex::build: failed to list col_ids for label {label_id}: {e}; skipping"
);
continue;
}
};
let tombstone_slots: std::collections::HashSet<u32> =
match store.read_col_all(label_id, 0) {
Ok(col0) => col0
.iter()
.enumerate()
.filter_map(|(slot, &raw)| {
if raw == TOMBSTONE {
Some(slot as u32)
} else {
None
}
})
.collect(),
Err(_) => std::collections::HashSet::new(),
};
for col_id in col_ids {
let key = IndexKey { label_id, col_id };
let raw_vals = match store.read_col_all(label_id, col_id) {
Ok(v) => v,
Err(e) => {
eprintln!(
"WARN: PropertyIndex::build: failed to read col_{col_id} for label {label_id}: {e}; skipping"
);
continue;
}
};
let null_bitmap = store.read_null_bitmap_all(label_id, col_id).unwrap_or(None);
let mut btree: BTreeMap<u64, Vec<u32>> = BTreeMap::new();
for (slot, raw) in raw_vals.into_iter().enumerate() {
let slot = slot as u32;
let is_present = match &null_bitmap {
Some(bits) => bits.get(slot as usize).copied().unwrap_or(false),
None => raw != ABSENT,
};
if !is_present || tombstone_slots.contains(&slot) {
continue;
}
btree.entry(sort_key(raw)).or_default().push(slot);
}
idx.index.insert(key, std::sync::Arc::new(btree));
idx.loaded.insert(key);
}
}
idx
}
pub fn lookup(&self, label_id: u32, col_id: u32, raw_value: u64) -> &[u32] {
let key = IndexKey { label_id, col_id };
match self.index.get(&key) {
Some(btree) => btree
.get(&sort_key(raw_value))
.map(|v| v.as_slice())
.unwrap_or(&[]),
None => &[],
}
}
pub fn lookup_range(
&self,
label_id: u32,
col_id: u32,
lo: Option<(u64, bool)>,
hi: Option<(u64, bool)>,
) -> Vec<u32> {
use std::ops::Bound;
let key = IndexKey { label_id, col_id };
let btree = match self.index.get(&key) {
Some(bt) => bt,
None => return vec![],
};
let lo_bound = match lo {
None => Bound::Unbounded,
Some((v, true)) => Bound::Included(v),
Some((v, false)) => Bound::Excluded(v),
};
let hi_bound = match hi {
None => Bound::Unbounded,
Some((v, true)) => Bound::Included(v),
Some((v, false)) => Bound::Excluded(v),
};
btree
.range((lo_bound, hi_bound))
.flat_map(|(_k, slots)| slots.iter().copied())
.collect()
}
pub fn is_indexed(&self, label_id: u32, col_id: u32) -> bool {
self.index.contains_key(&IndexKey { label_id, col_id })
}
pub fn insert(&mut self, label_id: u32, col_id: u32, slot: u32, raw_value: u64) {
if raw_value == ABSENT {
return;
}
let key = IndexKey { label_id, col_id };
let btree = std::sync::Arc::make_mut(
self.index
.entry(key)
.or_insert_with(|| std::sync::Arc::new(BTreeMap::new())),
);
btree.entry(sort_key(raw_value)).or_default().push(slot);
}
pub fn update(&mut self, label_id: u32, col_id: u32, slot: u32, old_raw: u64, new_raw: u64) {
let key = IndexKey { label_id, col_id };
let btree = std::sync::Arc::make_mut(
self.index
.entry(key)
.or_insert_with(|| std::sync::Arc::new(BTreeMap::new())),
);
if old_raw != ABSENT {
let sk_old = sort_key(old_raw);
if let Some(slots) = btree.get_mut(&sk_old) {
slots.retain(|&s| s != slot);
if slots.is_empty() {
btree.remove(&sk_old);
}
}
}
if new_raw != ABSENT {
btree.entry(sort_key(new_raw)).or_default().push(slot);
}
}
pub fn remove(&mut self, label_id: u32, col_id: u32, slot: u32, raw_value: u64) {
if raw_value == ABSENT {
return;
}
let sk = sort_key(raw_value);
let key = IndexKey { label_id, col_id };
if let Some(arc_btree) = self.index.get_mut(&key) {
let btree = std::sync::Arc::make_mut(arc_btree);
if let Some(slots) = btree.get_mut(&sk) {
slots.retain(|&s| s != slot);
if slots.is_empty() {
btree.remove(&sk);
}
}
}
}
pub fn n_distinct(&self, label_id: u32, col_id: u32) -> usize {
let key = IndexKey { label_id, col_id };
match self.index.get(&key) {
Some(btree) => btree.len(),
None => 0,
}
}
#[inline]
pub fn node_id_to_slot(node_id: NodeId) -> u32 {
(node_id.0 & 0xFFFF_FFFF) as u32
}
const INDEX_FILE: &'static str = "prop_index.bin";
pub fn save_to_dir(&mut self, db_root: &Path) {
if self.index.is_empty() {
let _ = fs::remove_file(db_root.join(Self::INDEX_FILE));
self.persisted_key_count = 0;
return;
}
if let Err(e) = self.save_to_dir_inner(db_root) {
tracing::warn!(error = ?e, "PropertyIndex::save_to_dir: failed to persist index");
} else {
self.persisted_key_count = self.index.len();
}
}
pub fn persist_if_grew(&mut self, db_root: &Path) {
if self.index.len() > self.persisted_key_count {
self.save_to_dir(db_root);
}
}
fn save_to_dir_inner(&self, db_root: &Path) -> std::io::Result<()> {
let path = db_root.join(Self::INDEX_FILE);
let tmp_path = db_root.join("prop_index.bin.tmp");
let mut buf: Vec<u8> = Vec::new();
buf.write_all(&0x50524F50u32.to_le_bytes())?;
buf.write_all(&1u32.to_le_bytes())?;
buf.write_all(&(self.index.len() as u32).to_le_bytes())?;
for (key, btree) in &self.index {
buf.write_all(&key.label_id.to_le_bytes())?;
buf.write_all(&key.col_id.to_le_bytes())?;
buf.write_all(&(btree.len() as u32).to_le_bytes())?;
for (&sort_key_val, slots) in btree.as_ref() {
buf.write_all(&sort_key_val.to_le_bytes())?;
buf.write_all(&(slots.len() as u32).to_le_bytes())?;
for &slot in slots {
buf.write_all(&slot.to_le_bytes())?;
}
}
}
fs::write(&tmp_path, &buf)?;
fs::rename(&tmp_path, &path)?;
Ok(())
}
pub fn load_from_dir(db_root: &Path) -> Option<PropertyIndex> {
let path = db_root.join(Self::INDEX_FILE);
let data = match fs::read(&path) {
Ok(d) => d,
Err(_) => return None,
};
Self::decode(&data)
}
fn decode(data: &[u8]) -> Option<PropertyIndex> {
let mut cursor = 0usize;
let read_bytes = |cursor: &mut usize, n: usize| -> Option<&[u8]> {
if *cursor + n > data.len() {
return None;
}
let slice = &data[*cursor..*cursor + n];
*cursor += n;
Some(slice)
};
let read_u32 = |cursor: &mut usize| -> Option<u32> {
let b = read_bytes(cursor, 4)?;
Some(u32::from_le_bytes(b.try_into().ok()?))
};
let read_u64 = |cursor: &mut usize| -> Option<u64> {
let b = read_bytes(cursor, 8)?;
Some(u64::from_le_bytes(b.try_into().ok()?))
};
let magic = read_u32(&mut cursor)?;
if magic != 0x50524F50 {
return None;
}
let version = read_u32(&mut cursor)?;
if version != 1 {
return None;
}
let num_keys = read_u32(&mut cursor)? as usize;
let mut index = std::collections::HashMap::with_capacity(num_keys);
let mut loaded = HashSet::with_capacity(num_keys);
for _ in 0..num_keys {
let label_id = read_u32(&mut cursor)?;
let col_id = read_u32(&mut cursor)?;
let num_entries = read_u32(&mut cursor)? as usize;
let mut btree = BTreeMap::new();
for _ in 0..num_entries {
let sk = read_u64(&mut cursor)?;
let num_slots = read_u32(&mut cursor)? as usize;
let mut slots = Vec::with_capacity(num_slots);
for _ in 0..num_slots {
slots.push(read_u32(&mut cursor)?);
}
btree.insert(sk, slots);
}
let key = IndexKey { label_id, col_id };
index.insert(key, std::sync::Arc::new(btree));
loaded.insert(key);
}
if cursor != data.len() {
return None;
}
let persisted_key_count = index.len();
Some(PropertyIndex {
index,
loaded,
generation: 0,
persisted_key_count,
})
}
pub fn remove_persisted(db_root: &Path) {
let _ = fs::remove_file(db_root.join(Self::INDEX_FILE));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn insert_and_lookup() {
let mut idx = PropertyIndex::new();
idx.insert(1, 0, 0, 42u64);
idx.insert(1, 0, 1, 42u64);
idx.insert(1, 0, 2, 99u64);
let slots = idx.lookup(1, 0, 42u64);
assert_eq!(slots, &[0u32, 1]);
let slots = idx.lookup(1, 0, 99u64);
assert_eq!(slots, &[2u32]);
let slots = idx.lookup(1, 0, 777u64);
assert!(slots.is_empty());
}
#[test]
fn update_moves_slot() {
let mut idx = PropertyIndex::new();
idx.insert(1, 5, 0, 10u64);
idx.update(1, 5, 0, 10u64, 20u64);
assert!(idx.lookup(1, 5, 10u64).is_empty());
assert_eq!(idx.lookup(1, 5, 20u64), &[0u32]);
}
#[test]
fn remove_cleans_slot() {
let mut idx = PropertyIndex::new();
idx.insert(2, 3, 7, 55u64);
idx.remove(2, 3, 7, 55u64);
assert!(idx.lookup(2, 3, 55u64).is_empty());
}
#[test]
fn absent_sentinel_not_indexed() {
let mut idx = PropertyIndex::new();
idx.insert(1, 0, 0, 0u64); assert!(idx.lookup(1, 0, 0u64).is_empty());
}
#[test]
fn is_indexed_tracks_presence() {
let mut idx = PropertyIndex::new();
assert!(!idx.is_indexed(1, 0));
idx.insert(1, 0, 0, 42u64);
assert!(idx.is_indexed(1, 0));
}
#[test]
fn save_and_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let mut idx = PropertyIndex::new();
idx.insert(1, 2, 0, 42u64);
idx.insert(1, 2, 1, 42u64);
idx.insert(1, 2, 2, 99u64);
idx.insert(3, 5, 10, 7u64);
idx.save_to_dir(dir.path());
let loaded = PropertyIndex::load_from_dir(dir.path()).expect("should load persisted index");
assert_eq!(loaded.lookup(1, 2, 42u64), &[0u32, 1]);
assert_eq!(loaded.lookup(1, 2, 99u64), &[2u32]);
assert_eq!(loaded.lookup(3, 5, 7u64), &[10u32]);
assert!(loaded.is_indexed(1, 2));
assert!(loaded.is_indexed(3, 5));
}
#[test]
fn load_returns_none_when_no_file() {
let dir = tempfile::tempdir().unwrap();
assert!(PropertyIndex::load_from_dir(dir.path()).is_none());
}
#[test]
fn load_returns_none_on_corrupt_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("prop_index.bin"), b"garbage").unwrap();
assert!(PropertyIndex::load_from_dir(dir.path()).is_none());
}
#[test]
fn remove_persisted_deletes_file() {
let dir = tempfile::tempdir().unwrap();
let mut idx = PropertyIndex::new();
idx.insert(1, 1, 0, 1u64);
idx.save_to_dir(dir.path());
assert!(dir.path().join("prop_index.bin").exists());
PropertyIndex::remove_persisted(dir.path());
assert!(!dir.path().join("prop_index.bin").exists());
}
#[test]
fn empty_index_removes_file_on_save() {
let dir = tempfile::tempdir().unwrap();
let mut idx = PropertyIndex::new();
idx.insert(1, 1, 0, 1u64);
idx.save_to_dir(dir.path());
assert!(dir.path().join("prop_index.bin").exists());
let mut empty = PropertyIndex::new();
empty.save_to_dir(dir.path());
assert!(!dir.path().join("prop_index.bin").exists());
}
#[test]
fn range_lookup_survives_persistence() {
let dir = tempfile::tempdir().unwrap();
let mut idx = PropertyIndex::new();
for i in 0u64..10 {
let raw = i; idx.insert(1, 1, i as u32, raw);
}
idx.save_to_dir(dir.path());
let loaded = PropertyIndex::load_from_dir(dir.path()).unwrap();
let results =
loaded.lookup_range(1, 1, Some((sort_key(3), true)), Some((sort_key(7), true)));
assert_eq!(results.len(), 5);
for s in [3u32, 4, 5, 6, 7] {
assert!(results.contains(&s), "expected slot {s} in range results");
}
}
}