use super::action::{DagAction, DagActionHash, DagPayload, TripleInsertPayload};
use super::backend::DagBackend;
use super::pruning::{PruneResult, RetentionPolicy};
use super::tips::DagTipSet;
use crate::NodeId;
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::RwLock;
const ACTION_PREFIX: &[u8] = b"a:";
const TIPS_KEY: &[u8] = b"_tips";
const VERSION_KEY: &[u8] = b"_ver";
const SCHEMA_VERSION: u8 = 1;
fn action_key(hash: &[u8; 32]) -> Vec<u8> {
let mut key = Vec::with_capacity(34);
key.extend_from_slice(ACTION_PREFIX);
key.extend_from_slice(hash);
key
}
fn serialize_tips(tips: &[[u8; 32]]) -> Vec<u8> {
tips.iter().flat_map(|h| h.iter().copied()).collect()
}
fn deserialize_tips(bytes: &[u8]) -> Vec<[u8; 32]> {
bytes
.chunks_exact(32)
.map(|c| {
let mut h = [0u8; 32];
h.copy_from_slice(c);
h
})
.collect()
}
pub struct DagStore {
backend: Box<dyn DagBackend>,
author_index: RwLock<HashMap<(String, u64), [u8; 32]>>,
affected_index: RwLock<HashMap<[u8; 32], Vec<[u8; 32]>>>,
subject_index: RwLock<HashMap<[u8; 32], Vec<[u8; 32]>>>,
tips: RwLock<DagTipSet>,
count: RwLock<usize>,
}
impl DagStore {
pub fn new() -> Self {
Self::with_backend(Box::new(super::backend::MemoryDagBackend::new()))
.expect("MemoryDagBackend should never fail")
}
pub fn with_backend(backend: Box<dyn DagBackend>) -> crate::Result<Self> {
let store = Self {
backend,
author_index: RwLock::new(HashMap::new()),
affected_index: RwLock::new(HashMap::new()),
subject_index: RwLock::new(HashMap::new()),
tips: RwLock::new(DagTipSet::new()),
count: RwLock::new(0),
};
store.rebuild_indexes()?;
Ok(store)
}
fn rebuild_indexes(&self) -> crate::Result<()> {
if let Some(ver_bytes) = self.backend.get(VERSION_KEY)? {
let stored_version = ver_bytes.first().copied().unwrap_or(0);
if stored_version > SCHEMA_VERSION {
return Err(crate::Error::Storage(format!(
"DAG backend schema version {} is newer than this binary supports ({}). \
Upgrade the aingle binary before opening this database.",
stored_version, SCHEMA_VERSION
)));
}
if stored_version < SCHEMA_VERSION {
self.backend.put(VERSION_KEY, &[SCHEMA_VERSION])?;
}
}
let entries = self.backend.scan_prefix(ACTION_PREFIX)?;
if entries.is_empty() {
if self.backend.get(VERSION_KEY)?.is_none() {
self.backend.put(VERSION_KEY, &[SCHEMA_VERSION])?;
}
return Ok(());
}
let mut author_idx = self
.author_index
.write()
.map_err(|_| crate::Error::Storage("DagStore author index lock poisoned".into()))?;
let mut affected_idx = self
.affected_index
.write()
.map_err(|_| crate::Error::Storage("DagStore affected index lock poisoned".into()))?;
let mut subject_idx = self
.subject_index
.write()
.map_err(|_| crate::Error::Storage("DagStore subject index lock poisoned".into()))?;
let mut count = self
.count
.write()
.map_err(|_| crate::Error::Storage("DagStore count lock poisoned".into()))?;
author_idx.clear();
affected_idx.clear();
subject_idx.clear();
let mut action_count = 0usize;
for (key, value) in &entries {
if key.len() != 34 || !key.starts_with(ACTION_PREFIX) {
continue;
}
let mut hash_bytes = [0u8; 32];
hash_bytes.copy_from_slice(&key[2..]);
if let Some(action) = DagAction::from_bytes(value) {
let author_key = format!("{}", action.author);
author_idx.insert((author_key, action.seq), hash_bytes);
for triple_id in extract_affected_triple_ids(&action.payload) {
affected_idx.entry(triple_id).or_default().push(hash_bytes);
}
for subject_hash in extract_subject_hashes(&action.payload) {
subject_idx
.entry(subject_hash)
.or_default()
.push(hash_bytes);
}
action_count += 1;
} else {
let hex: String = hash_bytes.iter().map(|b| format!("{:02x}", b)).collect();
log::warn!(
"Skipping corrupted DAG action {} ({} bytes) during index rebuild",
&hex[..12],
value.len()
);
}
}
*count = action_count;
drop(author_idx);
drop(affected_idx);
drop(subject_idx);
drop(count);
if let Some(tips_bytes) = self.backend.get(TIPS_KEY)? {
let raw = deserialize_tips(&tips_bytes);
let mut tips = self
.tips
.write()
.map_err(|_| crate::Error::Storage("DagStore tips lock poisoned".into()))?;
*tips = DagTipSet::from_raw(raw);
}
if self.backend.get(VERSION_KEY)?.is_none() {
self.backend.put(VERSION_KEY, &[SCHEMA_VERSION])?;
}
Ok(())
}
fn persist_tips(&self, tips: &DagTipSet) -> crate::Result<()> {
let raw = tips.to_raw();
let bytes = serialize_tips(&raw);
self.backend.put(TIPS_KEY, &bytes)?;
Ok(())
}
pub fn flush(&self) -> crate::Result<()> {
self.backend.flush()
}
pub fn put(&self, action: &DagAction) -> crate::Result<DagActionHash> {
for parent in &action.parents {
if self.backend.get(&action_key(&parent.0))?.is_none() {
return Err(crate::Error::Storage(format!(
"DAG action references non-existent parent {}",
parent.to_hex()
)));
}
}
let hash = action.compute_hash();
let bytes = action.to_bytes();
self.backend.put(&action_key(&hash.0), &bytes)?;
{
let mut idx = self
.author_index
.write()
.map_err(|_| crate::Error::Storage("DagStore author index lock poisoned".into()))?;
let author_key = format!("{}", action.author);
idx.insert((author_key, action.seq), hash.0);
}
{
let mut idx = self.affected_index.write().map_err(|_| {
crate::Error::Storage("DagStore affected index lock poisoned".into())
})?;
for triple_id in extract_affected_triple_ids(&action.payload) {
idx.entry(triple_id).or_default().push(hash.0);
}
}
{
let mut idx = self.subject_index.write().map_err(|_| {
crate::Error::Storage("DagStore subject index lock poisoned".into())
})?;
for subject_hash in extract_subject_hashes(&action.payload) {
idx.entry(subject_hash).or_default().push(hash.0);
}
}
{
let mut tips = self
.tips
.write()
.map_err(|_| crate::Error::Storage("DagStore tips lock poisoned".into()))?;
tips.advance(hash, &action.parents);
self.persist_tips(&tips)?;
}
{
let mut c = self
.count
.write()
.map_err(|_| crate::Error::Storage("DagStore count lock poisoned".into()))?;
*c += 1;
}
Ok(hash)
}
pub fn get(&self, hash: &DagActionHash) -> crate::Result<Option<DagAction>> {
match self.backend.get(&action_key(&hash.0))? {
Some(bytes) => Ok(DagAction::from_bytes(&bytes)),
None => Ok(None),
}
}
pub fn contains(&self, hash: &DagActionHash) -> crate::Result<bool> {
Ok(self.backend.get(&action_key(&hash.0))?.is_some())
}
pub fn tips(&self) -> crate::Result<Vec<DagActionHash>> {
let tips = self
.tips
.read()
.map_err(|_| crate::Error::Storage("DagStore tips lock poisoned".into()))?;
Ok(tips.current())
}
pub fn tip_count(&self) -> crate::Result<usize> {
let tips = self
.tips
.read()
.map_err(|_| crate::Error::Storage("DagStore tips lock poisoned".into()))?;
Ok(tips.len())
}
pub fn tips_raw(&self) -> crate::Result<Vec<[u8; 32]>> {
let tips = self
.tips
.read()
.map_err(|_| crate::Error::Storage("DagStore tips lock poisoned".into()))?;
Ok(tips.to_raw())
}
pub fn restore_tips(&self, raw: Vec<[u8; 32]>) -> crate::Result<()> {
let mut tips = self
.tips
.write()
.map_err(|_| crate::Error::Storage("DagStore tips lock poisoned".into()))?;
*tips = DagTipSet::from_raw(raw);
self.persist_tips(&tips)?;
Ok(())
}
pub fn chain(&self, author: &NodeId, limit: usize) -> crate::Result<Vec<DagAction>> {
let author_key = format!("{}", author);
let idx = self
.author_index
.read()
.map_err(|_| crate::Error::Storage("DagStore lock poisoned".into()))?;
let mut entries: Vec<(u64, [u8; 32])> = idx
.iter()
.filter(|((a, _), _)| a == &author_key)
.map(|((_, seq), hash)| (*seq, *hash))
.collect();
entries.sort_by(|a, b| b.0.cmp(&a.0));
entries.truncate(limit);
drop(idx);
let mut result = Vec::new();
for (_, hash) in &entries {
if let Some(bytes) = self.backend.get(&action_key(hash))? {
if let Some(action) = DagAction::from_bytes(&bytes) {
result.push(action);
}
}
}
Ok(result)
}
pub fn history(&self, triple_id: &[u8; 32], limit: usize) -> crate::Result<Vec<DagAction>> {
let idx = self
.affected_index
.read()
.map_err(|_| crate::Error::Storage("DagStore lock poisoned".into()))?;
let hashes = match idx.get(triple_id) {
Some(h) => h.clone(),
None => return Ok(vec![]),
};
drop(idx);
let mut result: Vec<DagAction> = Vec::new();
for hash in hashes.iter().rev().take(limit) {
if let Some(bytes) = self.backend.get(&action_key(hash))? {
if let Some(action) = DagAction::from_bytes(&bytes) {
result.push(action);
}
}
}
result.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
result.truncate(limit);
Ok(result)
}
pub fn history_by_subject(&self, subject: &str, limit: usize) -> crate::Result<Vec<DagAction>> {
let subject_hash = *blake3::hash(subject.as_bytes()).as_bytes();
let idx = self
.subject_index
.read()
.map_err(|_| crate::Error::Storage("DagStore lock poisoned".into()))?;
let hashes = match idx.get(&subject_hash) {
Some(h) => h.clone(),
None => return Ok(vec![]),
};
drop(idx);
let mut result: Vec<DagAction> = Vec::new();
for hash in hashes.iter().rev().take(limit) {
if let Some(bytes) = self.backend.get(&action_key(hash))? {
if let Some(action) = DagAction::from_bytes(&bytes) {
result.push(action);
}
}
}
result.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
result.truncate(limit);
Ok(result)
}
pub fn action_count(&self) -> usize {
self.count.read().map(|c| *c).unwrap_or(0)
}
pub fn init_or_migrate(&self, triple_count: usize) -> crate::Result<DagActionHash> {
let count = self.action_count();
if count > 0 {
let tips = self.tips()?;
return Ok(tips.into_iter().next().unwrap_or(DagActionHash([0; 32])));
}
let genesis = DagAction {
parents: vec![],
author: NodeId::named("aingle:system"),
seq: 0,
timestamp: chrono::DateTime::from_timestamp(0, 0)
.expect("unix epoch is a valid timestamp"),
payload: DagPayload::Genesis {
triple_count,
description: "Migration from v0.5.0".into(),
},
signature: None,
};
self.put(&genesis)
}
pub fn export_graph(&self) -> crate::Result<super::export::DagGraph> {
let entries = self.backend.scan_prefix(ACTION_PREFIX)?;
let mut all_actions: Vec<DagAction> = entries
.iter()
.filter_map(|(_, value)| DagAction::from_bytes(value))
.collect();
all_actions.sort_by_key(|a| a.timestamp);
let tips = self.tips()?;
Ok(super::export::DagGraph::from_actions(&all_actions, &tips))
}
pub fn ingest(&self, action: &DagAction) -> crate::Result<DagActionHash> {
let hash = action.compute_hash();
if self.backend.get(&action_key(&hash.0))?.is_some() {
return Ok(hash);
}
self.backend.put(&action_key(&hash.0), &action.to_bytes())?;
{
let mut idx = self
.author_index
.write()
.map_err(|_| crate::Error::Storage("DagStore author index lock poisoned".into()))?;
let author_key = format!("{}", action.author);
idx.insert((author_key, action.seq), hash.0);
}
{
let mut idx = self.affected_index.write().map_err(|_| {
crate::Error::Storage("DagStore affected index lock poisoned".into())
})?;
for triple_id in extract_affected_triple_ids(&action.payload) {
idx.entry(triple_id).or_default().push(hash.0);
}
}
{
let mut idx = self.subject_index.write().map_err(|_| {
crate::Error::Storage("DagStore subject index lock poisoned".into())
})?;
for subject_hash in extract_subject_hashes(&action.payload) {
idx.entry(subject_hash).or_default().push(hash.0);
}
}
{
let mut c = self
.count
.write()
.map_err(|_| crate::Error::Storage("DagStore count lock poisoned".into()))?;
*c += 1;
}
Ok(hash)
}
pub fn compute_missing(&self, remote_tips: &[DagActionHash]) -> crate::Result<Vec<DagAction>> {
let our_tips = self.tips()?;
let mut our_ancestors: HashSet<[u8; 32]> = HashSet::new();
for tip in &our_tips {
let set = self.ancestor_set(tip)?;
our_ancestors.extend(set);
}
let mut remote_ancestors: HashSet<[u8; 32]> = HashSet::new();
for tip in remote_tips {
if self.contains(tip)? {
let set = self.ancestor_set(tip)?;
remote_ancestors.extend(set);
}
}
let missing_hashes: HashSet<[u8; 32]> = our_ancestors
.difference(&remote_ancestors)
.copied()
.collect();
if missing_hashes.is_empty() {
return Ok(vec![]);
}
let mut collected: HashMap<[u8; 32], DagAction> = HashMap::new();
for hash in &missing_hashes {
if let Some(bytes) = self.backend.get(&action_key(hash))? {
if let Some(action) = DagAction::from_bytes(&bytes) {
collected.insert(*hash, action);
}
}
}
topo_sort(collected)
}
pub fn ancestors(&self, target: &DagActionHash) -> crate::Result<Vec<DagAction>> {
let mut visited: HashSet<[u8; 32]> = HashSet::new();
let mut queue: VecDeque<[u8; 32]> = VecDeque::new();
let mut collected: HashMap<[u8; 32], DagAction> = HashMap::new();
queue.push_back(target.0);
visited.insert(target.0);
while let Some(hash) = queue.pop_front() {
if let Some(bytes) = self.backend.get(&action_key(&hash))? {
if let Some(action) = DagAction::from_bytes(&bytes) {
for parent in &action.parents {
if visited.insert(parent.0) {
queue.push_back(parent.0);
}
}
collected.insert(hash, action);
}
}
}
topo_sort(collected)
}
pub fn ancestor_set(&self, target: &DagActionHash) -> crate::Result<HashSet<[u8; 32]>> {
let mut visited: HashSet<[u8; 32]> = HashSet::new();
let mut queue: VecDeque<[u8; 32]> = VecDeque::new();
queue.push_back(target.0);
visited.insert(target.0);
while let Some(hash) = queue.pop_front() {
if let Some(bytes) = self.backend.get(&action_key(&hash))? {
if let Some(action) = DagAction::from_bytes(&bytes) {
for parent in &action.parents {
if visited.insert(parent.0) {
queue.push_back(parent.0);
}
}
}
}
}
Ok(visited)
}
pub fn actions_between(
&self,
from: &DagActionHash,
to: &DagActionHash,
) -> crate::Result<Vec<DagAction>> {
let from_set = self.ancestor_set(from)?;
let to_ancestors = self.ancestors(to)?;
Ok(to_ancestors
.into_iter()
.filter(|a| {
let h = a.compute_hash();
!from_set.contains(&h.0)
})
.collect())
}
pub fn action_at_or_before(
&self,
ts: &chrono::DateTime<chrono::Utc>,
) -> crate::Result<Option<DagActionHash>> {
let entries = self.backend.scan_prefix(ACTION_PREFIX)?;
let mut best: Option<(DagActionHash, chrono::DateTime<chrono::Utc>)> = None;
for (key, value) in &entries {
if key.len() != 34 || !key.starts_with(ACTION_PREFIX) {
continue;
}
let mut hash = [0u8; 32];
hash.copy_from_slice(&key[2..]);
if let Some(action) = DagAction::from_bytes(value) {
if action.timestamp <= *ts {
if best.as_ref().map_or(true, |(_, t)| action.timestamp > *t) {
best = Some((DagActionHash(hash), action.timestamp));
}
}
}
}
Ok(best.map(|(h, _)| h))
}
pub fn prune(
&self,
policy: &RetentionPolicy,
create_checkpoint: bool,
) -> crate::Result<PruneResult> {
let to_remove = match policy {
RetentionPolicy::KeepAll => {
return Ok(PruneResult {
pruned_count: 0,
retained_count: self.action_count(),
checkpoint_hash: None,
});
}
RetentionPolicy::KeepSince { seconds } => self.collect_older_than(*seconds)?,
RetentionPolicy::KeepLast(n) => self.collect_excess(*n)?,
RetentionPolicy::KeepDepth(d) => self.collect_beyond_depth(*d)?,
};
if to_remove.is_empty() {
return Ok(PruneResult {
pruned_count: 0,
retained_count: self.action_count(),
checkpoint_hash: None,
});
}
let pruned_count = self.remove_actions(&to_remove)?;
let retained_count = self.action_count();
let checkpoint_hash = if create_checkpoint {
let tips = self.tips()?;
let action = DagAction {
parents: tips,
author: NodeId::named("aingle:system"),
seq: 0,
timestamp: chrono::Utc::now(),
payload: DagPayload::Compact {
pruned_count,
retained_count,
policy: format!("{:?}", policy),
},
signature: None,
};
Some(self.put(&action)?)
} else {
None
};
Ok(PruneResult {
pruned_count,
retained_count: self.action_count(),
checkpoint_hash,
})
}
pub fn depth_map(&self) -> crate::Result<HashMap<[u8; 32], usize>> {
let entries = self.backend.scan_prefix(ACTION_PREFIX)?;
let tips = self
.tips
.read()
.map_err(|_| crate::Error::Storage("DagStore tips lock poisoned".into()))?;
let mut actions_map: HashMap<[u8; 32], DagAction> = HashMap::new();
for (key, value) in &entries {
if key.len() != 34 || !key.starts_with(ACTION_PREFIX) {
continue;
}
let mut hash = [0u8; 32];
hash.copy_from_slice(&key[2..]);
if let Some(action) = DagAction::from_bytes(value) {
actions_map.insert(hash, action);
}
}
let mut depths: HashMap<[u8; 32], usize> = HashMap::new();
let mut queue: VecDeque<([u8; 32], usize)> = VecDeque::new();
for tip in tips.current() {
depths.insert(tip.0, 0);
queue.push_back((tip.0, 0));
}
while let Some((hash, depth)) = queue.pop_front() {
if let Some(action) = actions_map.get(&hash) {
for parent in &action.parents {
let parent_depth = depth + 1;
let entry = depths.entry(parent.0).or_insert(usize::MAX);
if parent_depth < *entry {
*entry = parent_depth;
queue.push_back((parent.0, parent_depth));
}
}
}
}
for hash in actions_map.keys() {
depths.entry(*hash).or_insert(usize::MAX);
}
Ok(depths)
}
fn remove_actions(&self, to_remove: &HashSet<[u8; 32]>) -> crate::Result<usize> {
let mut removed = 0;
for hash in to_remove {
if self.backend.delete(&action_key(hash))? {
removed += 1;
}
}
let mut author_idx = self
.author_index
.write()
.map_err(|_| crate::Error::Storage("DagStore author index lock poisoned".into()))?;
author_idx.retain(|_, h| !to_remove.contains(h));
let mut affected_idx = self
.affected_index
.write()
.map_err(|_| crate::Error::Storage("DagStore affected index lock poisoned".into()))?;
affected_idx.retain(|_, hashes| {
hashes.retain(|h| !to_remove.contains(h));
!hashes.is_empty()
});
let mut subject_idx = self
.subject_index
.write()
.map_err(|_| crate::Error::Storage("DagStore subject index lock poisoned".into()))?;
subject_idx.retain(|_, hashes| {
hashes.retain(|h| !to_remove.contains(h));
!hashes.is_empty()
});
let mut count = self
.count
.write()
.map_err(|_| crate::Error::Storage("DagStore count lock poisoned".into()))?;
*count = count.saturating_sub(removed);
Ok(removed)
}
fn collect_older_than(&self, seconds: u64) -> crate::Result<HashSet<[u8; 32]>> {
let cutoff = chrono::Utc::now() - chrono::Duration::seconds(seconds as i64);
let entries = self.backend.scan_prefix(ACTION_PREFIX)?;
let tips = self
.tips
.read()
.map_err(|_| crate::Error::Storage("DagStore tips lock poisoned".into()))?;
let tip_set: HashSet<[u8; 32]> = tips.current().iter().map(|h| h.0).collect();
let mut result = HashSet::new();
for (key, value) in &entries {
if key.len() != 34 || !key.starts_with(ACTION_PREFIX) {
continue;
}
let mut hash = [0u8; 32];
hash.copy_from_slice(&key[2..]);
if tip_set.contains(&hash) {
continue;
}
if let Some(action) = DagAction::from_bytes(value) {
if action.timestamp < cutoff {
result.insert(hash);
}
}
}
Ok(result)
}
fn collect_excess(&self, keep: usize) -> crate::Result<HashSet<[u8; 32]>> {
let entries = self.backend.scan_prefix(ACTION_PREFIX)?;
let tips = self
.tips
.read()
.map_err(|_| crate::Error::Storage("DagStore tips lock poisoned".into()))?;
let total = entries.len();
if total <= keep {
return Ok(HashSet::new());
}
let tip_set: HashSet<[u8; 32]> = tips.current().iter().map(|h| h.0).collect();
let mut candidates: Vec<([u8; 32], chrono::DateTime<chrono::Utc>)> = Vec::new();
for (key, value) in &entries {
if key.len() != 34 || !key.starts_with(ACTION_PREFIX) {
continue;
}
let mut hash = [0u8; 32];
hash.copy_from_slice(&key[2..]);
if tip_set.contains(&hash) {
continue;
}
if let Some(action) = DagAction::from_bytes(value) {
candidates.push((hash, action.timestamp));
}
}
candidates.sort_by_key(|(_, ts)| *ts);
let to_prune = total.saturating_sub(keep);
Ok(candidates
.into_iter()
.take(to_prune)
.map(|(hash, _)| hash)
.collect())
}
fn collect_beyond_depth(&self, max_depth: usize) -> crate::Result<HashSet<[u8; 32]>> {
let depths = self.depth_map()?;
let tips = self
.tips
.read()
.map_err(|_| crate::Error::Storage("DagStore tips lock poisoned".into()))?;
let tip_set: HashSet<[u8; 32]> = tips.current().iter().map(|h| h.0).collect();
Ok(depths
.into_iter()
.filter(|(hash, depth)| *depth > max_depth && !tip_set.contains(hash))
.map(|(hash, _)| hash)
.collect())
}
}
impl Default for DagStore {
fn default() -> Self {
Self::new()
}
}
fn topo_sort(mut collected: HashMap<[u8; 32], DagAction>) -> crate::Result<Vec<DagAction>> {
let mut in_degree: HashMap<[u8; 32], usize> = HashMap::new();
let mut children: HashMap<[u8; 32], Vec<[u8; 32]>> = HashMap::new();
for (hash, action) in &collected {
in_degree.entry(*hash).or_insert(0);
for parent in &action.parents {
if collected.contains_key(&parent.0) {
children.entry(parent.0).or_default().push(*hash);
*in_degree.entry(*hash).or_insert(0) += 1;
}
}
}
let mut ready: VecDeque<[u8; 32]> = in_degree
.iter()
.filter(|(_, deg)| **deg == 0)
.map(|(hash, _)| *hash)
.collect();
let mut result = Vec::with_capacity(collected.len());
while let Some(hash) = ready.pop_front() {
if let Some(action) = collected.remove(&hash) {
result.push(action);
}
if let Some(kids) = children.get(&hash) {
for kid in kids {
if let Some(deg) = in_degree.get_mut(kid) {
*deg -= 1;
if *deg == 0 {
ready.push_back(*kid);
}
}
}
}
}
Ok(result)
}
fn extract_affected_triple_ids(payload: &DagPayload) -> Vec<[u8; 32]> {
match payload {
DagPayload::TripleInsert { triples } => triples
.iter()
.map(|t| compute_triple_id_from_payload(t))
.collect(),
DagPayload::TripleDelete { triple_ids, .. } => triple_ids.clone(),
DagPayload::Batch { ops } => ops.iter().flat_map(extract_affected_triple_ids).collect(),
_ => vec![],
}
}
fn extract_subject_hashes(payload: &DagPayload) -> Vec<[u8; 32]> {
match payload {
DagPayload::TripleInsert { triples } => triples
.iter()
.map(|t| *blake3::hash(t.subject.as_bytes()).as_bytes())
.collect(),
DagPayload::TripleDelete { subjects, .. } => subjects
.iter()
.map(|s| *blake3::hash(s.as_bytes()).as_bytes())
.collect(),
DagPayload::Custom { subject, .. } => subject
.iter()
.map(|s| *blake3::hash(s.as_bytes()).as_bytes())
.collect(),
DagPayload::Batch { ops } => ops.iter().flat_map(extract_subject_hashes).collect(),
_ => vec![],
}
}
fn compute_triple_id_from_payload(t: &TripleInsertPayload) -> [u8; 32] {
let subject = crate::NodeId::named(&t.subject);
let predicate = crate::Predicate::named(&t.predicate);
let object = json_to_graph_value(&t.object);
let triple = crate::Triple::new(subject, predicate, object);
*crate::TripleId::from_triple(&triple).as_bytes()
}
pub(crate) fn json_to_graph_value(v: &serde_json::Value) -> crate::Value {
match v {
serde_json::Value::String(s) => crate::Value::String(s.clone()),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
crate::Value::Integer(i)
} else if let Some(f) = n.as_f64() {
crate::Value::Float(f)
} else {
crate::Value::String(n.to_string())
}
}
serde_json::Value::Bool(b) => crate::Value::Boolean(*b),
serde_json::Value::Null => crate::Value::Null,
_ => crate::Value::Json(v.clone()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::NodeId;
use chrono::Utc;
fn make_action(seq: u64, parents: Vec<DagActionHash>) -> DagAction {
DagAction {
parents,
author: NodeId::named("node:1"),
seq,
timestamp: Utc::now(),
payload: DagPayload::TripleInsert {
triples: vec![TripleInsertPayload {
subject: "alice".into(),
predicate: "knows".into(),
object: serde_json::json!("bob"),
provenance: None,
}],
},
signature: None,
}
}
#[test]
fn test_put_and_get() {
let store = DagStore::new();
let action = make_action(1, vec![]);
let hash = store.put(&action).unwrap();
let retrieved = store.get(&hash).unwrap().unwrap();
assert_eq!(retrieved.seq, 1);
}
#[test]
fn test_tips_linear_chain() {
let store = DagStore::new();
let a1 = make_action(1, vec![]);
let h1 = store.put(&a1).unwrap();
assert_eq!(store.tip_count().unwrap(), 1);
let a2 = make_action(2, vec![h1]);
let h2 = store.put(&a2).unwrap();
assert_eq!(store.tip_count().unwrap(), 1);
let tips = store.tips().unwrap();
assert_eq!(tips[0], h2);
}
#[test]
fn test_author_chain() {
let store = DagStore::new();
for seq in 0..5 {
let action = make_action(seq, vec![]);
store.put(&action).unwrap();
}
let chain = store.chain(&NodeId::named("node:1"), 10).unwrap();
assert_eq!(chain.len(), 5);
assert_eq!(chain[0].seq, 4);
}
#[test]
fn test_triple_history() {
let store = DagStore::new();
let a1 = make_action(1, vec![]);
store.put(&a1).unwrap();
let a2 = make_action(2, vec![]);
store.put(&a2).unwrap();
let tid = compute_triple_id_from_payload(&TripleInsertPayload {
subject: "alice".into(),
predicate: "knows".into(),
object: serde_json::json!("bob"),
provenance: None,
});
let history = store.history(&tid, 10).unwrap();
assert_eq!(history.len(), 2);
}
#[test]
fn test_init_or_migrate() {
let store = DagStore::new();
let hash = store.init_or_migrate(100).unwrap();
assert_eq!(store.action_count(), 1);
let genesis = store.get(&hash).unwrap().unwrap();
assert!(genesis.is_genesis());
assert!(matches!(
genesis.payload,
DagPayload::Genesis {
triple_count: 100,
..
}
));
let hash2 = store.init_or_migrate(200).unwrap();
assert_eq!(store.action_count(), 1); assert_ne!(hash2, DagActionHash([0; 32]));
}
#[test]
fn test_action_count() {
let store = DagStore::new();
assert_eq!(store.action_count(), 0);
store.put(&make_action(1, vec![])).unwrap();
assert_eq!(store.action_count(), 1);
store.put(&make_action(2, vec![])).unwrap();
assert_eq!(store.action_count(), 2);
}
#[test]
fn test_contains() {
let store = DagStore::new();
let action = make_action(1, vec![]);
let hash = store.put(&action).unwrap();
assert!(store.contains(&hash).unwrap());
assert!(!store.contains(&DagActionHash([0xFF; 32])).unwrap());
}
#[test]
fn test_restore_tips() {
let store = DagStore::new();
let raw = vec![[1u8; 32], [2u8; 32]];
store.restore_tips(raw).unwrap();
assert_eq!(store.tip_count().unwrap(), 2);
}
#[test]
fn test_triple_id_matches_graph_triple_id() {
use crate::{Predicate, Triple, TripleId, Value};
let subject = "user:alice";
let predicate = "knows";
let object_json = serde_json::json!("bob");
let dag_tid = compute_triple_id_from_payload(&TripleInsertPayload {
subject: subject.into(),
predicate: predicate.into(),
object: object_json.clone(),
provenance: None,
});
let triple = Triple::new(
NodeId::named(subject),
Predicate::named(predicate),
Value::String("bob".into()),
);
let graph_tid = *TripleId::from_triple(&triple).as_bytes();
assert_eq!(
dag_tid, graph_tid,
"DagStore triple ID must match TripleId::from_triple()"
);
}
#[test]
fn test_history_matches_real_triple_id() {
use crate::{Predicate, Triple, TripleId, Value};
let store = DagStore::new();
let action = make_action(1, vec![]);
store.put(&action).unwrap();
let triple = Triple::new(
NodeId::named("alice"),
Predicate::named("knows"),
Value::String("bob".into()),
);
let real_tid = *TripleId::from_triple(&triple).as_bytes();
let history = store.history(&real_tid, 10).unwrap();
assert_eq!(
history.len(),
1,
"history lookup using real TripleId must find the DagAction"
);
}
fn make_action_at(
seq: u64,
parents: Vec<DagActionHash>,
ts: chrono::DateTime<Utc>,
) -> DagAction {
DagAction {
parents,
author: NodeId::named("node:1"),
seq,
timestamp: ts,
payload: DagPayload::TripleInsert {
triples: vec![TripleInsertPayload {
subject: format!("s{}", seq),
predicate: "p".into(),
object: serde_json::json!(seq),
provenance: None,
}],
},
signature: None,
}
}
#[test]
fn test_prune_keep_all() {
let store = DagStore::new();
store.put(&make_action(1, vec![])).unwrap();
store.put(&make_action(2, vec![])).unwrap();
let result = store.prune(&RetentionPolicy::KeepAll, false).unwrap();
assert_eq!(result.pruned_count, 0);
assert_eq!(result.retained_count, 2);
}
#[test]
fn test_prune_keep_last() {
let store = DagStore::new();
let now = Utc::now();
let a1 = make_action_at(1, vec![], now - chrono::Duration::seconds(50));
let h1 = store.put(&a1).unwrap();
let a2 = make_action_at(2, vec![h1], now - chrono::Duration::seconds(40));
let h2 = store.put(&a2).unwrap();
let a3 = make_action_at(3, vec![h2], now - chrono::Duration::seconds(30));
let h3 = store.put(&a3).unwrap();
let a4 = make_action_at(4, vec![h3], now - chrono::Duration::seconds(20));
let h4 = store.put(&a4).unwrap();
let a5 = make_action_at(5, vec![h4], now - chrono::Duration::seconds(10));
let h5 = store.put(&a5).unwrap();
assert_eq!(store.action_count(), 5);
let result = store.prune(&RetentionPolicy::KeepLast(3), false).unwrap();
assert_eq!(result.pruned_count, 2);
assert_eq!(result.retained_count, 3);
assert!(store.get(&h1).unwrap().is_none());
assert!(store.get(&h2).unwrap().is_none());
assert!(store.get(&h3).unwrap().is_some());
assert!(store.get(&h4).unwrap().is_some());
assert!(store.get(&h5).unwrap().is_some());
let tips = store.tips().unwrap();
assert_eq!(tips.len(), 1);
assert_eq!(tips[0], h5);
}
#[test]
fn test_prune_keep_since() {
let store = DagStore::new();
let now = Utc::now();
let old1 = make_action_at(1, vec![], now - chrono::Duration::seconds(200));
let h_old1 = store.put(&old1).unwrap();
let old2 = make_action_at(2, vec![h_old1], now - chrono::Duration::seconds(150));
let h_old2 = store.put(&old2).unwrap();
let new1 = make_action_at(3, vec![h_old2], now - chrono::Duration::seconds(50));
let h_new1 = store.put(&new1).unwrap();
let new2 = make_action_at(4, vec![h_new1], now - chrono::Duration::seconds(10));
let h_new2 = store.put(&new2).unwrap();
let result = store
.prune(&RetentionPolicy::KeepSince { seconds: 100 }, false)
.unwrap();
assert_eq!(result.pruned_count, 2);
assert_eq!(result.retained_count, 2);
assert!(store.get(&h_old1).unwrap().is_none());
assert!(store.get(&h_old2).unwrap().is_none());
assert!(store.get(&h_new1).unwrap().is_some());
assert!(store.get(&h_new2).unwrap().is_some());
}
#[test]
fn test_prune_keep_depth() {
let store = DagStore::new();
let a1 = make_action(1, vec![]);
let h1 = store.put(&a1).unwrap();
let a2 = make_action(2, vec![h1]);
let h2 = store.put(&a2).unwrap();
let a3 = make_action(3, vec![h2]);
let h3 = store.put(&a3).unwrap();
let a4 = make_action(4, vec![h3]);
let h4 = store.put(&a4).unwrap();
let result = store.prune(&RetentionPolicy::KeepDepth(1), false).unwrap();
assert_eq!(result.pruned_count, 2);
assert!(store.get(&h1).unwrap().is_none());
assert!(store.get(&h2).unwrap().is_none());
assert!(store.get(&h3).unwrap().is_some());
assert!(store.get(&h4).unwrap().is_some());
}
#[test]
fn test_prune_never_removes_tips() {
let store = DagStore::new();
let a1 = make_action_at(1, vec![], Utc::now() - chrono::Duration::seconds(1000));
let h1 = store.put(&a1).unwrap();
let a2 = make_action_at(2, vec![], Utc::now() - chrono::Duration::seconds(1000));
let h2 = store.put(&a2).unwrap();
let result = store.prune(&RetentionPolicy::KeepLast(1), false).unwrap();
assert_eq!(result.pruned_count, 0);
assert!(store.get(&h1).unwrap().is_some());
assert!(store.get(&h2).unwrap().is_some());
}
#[test]
fn test_prune_with_checkpoint() {
let store = DagStore::new();
let now = Utc::now();
let a1 = make_action_at(1, vec![], now - chrono::Duration::seconds(200));
let h1 = store.put(&a1).unwrap();
let a2 = make_action_at(2, vec![h1], now - chrono::Duration::seconds(10));
store.put(&a2).unwrap();
assert_eq!(store.action_count(), 2);
let result = store
.prune(&RetentionPolicy::KeepSince { seconds: 100 }, true)
.unwrap();
assert_eq!(result.pruned_count, 1);
assert!(result.checkpoint_hash.is_some());
let cp = store
.get(&result.checkpoint_hash.unwrap())
.unwrap()
.unwrap();
assert!(matches!(cp.payload, DagPayload::Compact { .. }));
assert_eq!(store.action_count(), 2); }
#[test]
fn test_prune_cleans_indexes() {
let store = DagStore::new();
let now = Utc::now();
let a1 = make_action_at(1, vec![], now - chrono::Duration::seconds(200));
let h1 = store.put(&a1).unwrap();
let a2 = make_action_at(2, vec![h1], now - chrono::Duration::seconds(10));
store.put(&a2).unwrap();
assert_eq!(store.chain(&NodeId::named("node:1"), 10).unwrap().len(), 2);
store
.prune(&RetentionPolicy::KeepSince { seconds: 100 }, false)
.unwrap();
assert_eq!(store.chain(&NodeId::named("node:1"), 10).unwrap().len(), 1);
}
#[test]
fn test_depth_map() {
let store = DagStore::new();
let a1 = make_action(1, vec![]);
let h1 = store.put(&a1).unwrap();
let a2 = make_action(2, vec![h1]);
let h2 = store.put(&a2).unwrap();
let a3 = make_action(3, vec![h2]);
let h3 = store.put(&a3).unwrap();
let depths = store.depth_map().unwrap();
assert_eq!(depths[&h3.0], 0); assert_eq!(depths[&h2.0], 1);
assert_eq!(depths[&h1.0], 2);
}
#[test]
fn test_with_backend_rebuilds_indexes() {
use super::super::backend::MemoryDagBackend;
use std::sync::Arc;
let _backend = Arc::new(MemoryDagBackend::new());
let store = DagStore::new();
let a1 = make_action(1, vec![]);
let h1 = store.put(&a1).unwrap();
let a2 = make_action(2, vec![h1]);
let h2 = store.put(&a2).unwrap();
assert_eq!(store.action_count(), 2);
assert_eq!(store.tip_count().unwrap(), 1);
assert_eq!(store.tips().unwrap()[0], h2);
let chain = store.chain(&NodeId::named("node:1"), 10).unwrap();
assert_eq!(chain.len(), 2);
}
#[test]
fn test_tips_persisted_to_backend() {
let store = DagStore::new();
let a1 = make_action(1, vec![]);
store.put(&a1).unwrap();
let tips_bytes = store.backend.get(TIPS_KEY).unwrap();
assert!(tips_bytes.is_some());
let raw = deserialize_tips(&tips_bytes.unwrap());
assert_eq!(raw.len(), 1);
}
#[cfg(feature = "sled-backend")]
#[test]
fn test_sled_persistence_end_to_end() {
use super::super::backend::SledDagBackend;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_str().unwrap();
let h1;
let h3;
{
let backend = SledDagBackend::open(path).unwrap();
let store = DagStore::with_backend(Box::new(backend)).unwrap();
let a1 = make_action(1, vec![]);
h1 = store.put(&a1).unwrap();
let a2 = make_action(2, vec![h1]);
let h2 = store.put(&a2).unwrap();
let a3 = make_action(3, vec![h2]);
h3 = store.put(&a3).unwrap();
assert_eq!(store.action_count(), 3);
assert_eq!(store.tip_count().unwrap(), 1);
assert_eq!(store.tips().unwrap()[0], h3);
store.flush().unwrap();
}
{
let backend = SledDagBackend::open(path).unwrap();
let store = DagStore::with_backend(Box::new(backend)).unwrap();
assert_eq!(store.action_count(), 3);
assert_eq!(store.tip_count().unwrap(), 1);
assert_eq!(store.tips().unwrap()[0], h3);
let a1 = store.get(&h1).unwrap().unwrap();
assert_eq!(a1.seq, 1);
let a3 = store.get(&h3).unwrap().unwrap();
assert_eq!(a3.seq, 3);
let chain = store.chain(&NodeId::named("node:1"), 10).unwrap();
assert_eq!(chain.len(), 3);
assert_eq!(chain[0].seq, 3);
let tid = compute_triple_id_from_payload(&TripleInsertPayload {
subject: "alice".into(),
predicate: "knows".into(),
object: serde_json::json!("bob"),
provenance: None,
});
let history = store.history(&tid, 10).unwrap();
assert_eq!(history.len(), 3);
let a4 = make_action(4, vec![h3]);
let h4 = store.put(&a4).unwrap();
assert_eq!(store.action_count(), 4);
assert_eq!(store.tips().unwrap()[0], h4);
}
{
let backend = SledDagBackend::open(path).unwrap();
let store = DagStore::with_backend(Box::new(backend)).unwrap();
assert_eq!(store.action_count(), 4);
}
}
#[cfg(feature = "sled-backend")]
#[test]
fn test_sled_persistence_with_pruning() {
use super::super::backend::SledDagBackend;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_str().unwrap();
{
let backend = SledDagBackend::open(path).unwrap();
let store = DagStore::with_backend(Box::new(backend)).unwrap();
let now = chrono::Utc::now();
let a1 = make_action_at(1, vec![], now - chrono::Duration::seconds(200));
let h1 = store.put(&a1).unwrap();
let a2 = make_action_at(2, vec![h1], now - chrono::Duration::seconds(10));
store.put(&a2).unwrap();
let result = store
.prune(&RetentionPolicy::KeepSince { seconds: 100 }, true)
.unwrap();
assert_eq!(result.pruned_count, 1);
store.flush().unwrap();
}
{
let backend = SledDagBackend::open(path).unwrap();
let store = DagStore::with_backend(Box::new(backend)).unwrap();
assert_eq!(store.action_count(), 2);
let chain = store.chain(&NodeId::named("node:1"), 10).unwrap();
assert_eq!(chain.len(), 1);
assert_eq!(chain[0].seq, 2);
}
}
#[cfg(feature = "sled-backend")]
#[test]
fn test_sled_persistence_genesis_migration() {
use super::super::backend::SledDagBackend;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_str().unwrap();
let genesis_hash;
{
let backend = SledDagBackend::open(path).unwrap();
let store = DagStore::with_backend(Box::new(backend)).unwrap();
genesis_hash = store.init_or_migrate(42).unwrap();
assert_eq!(store.action_count(), 1);
store.flush().unwrap();
}
{
let backend = SledDagBackend::open(path).unwrap();
let store = DagStore::with_backend(Box::new(backend)).unwrap();
assert_eq!(store.action_count(), 1);
let hash2 = store.init_or_migrate(999).unwrap();
assert_eq!(store.action_count(), 1); assert_ne!(hash2, DagActionHash([0; 32]));
let genesis = store.get(&genesis_hash).unwrap().unwrap();
assert!(genesis.is_genesis());
assert!(matches!(
genesis.payload,
DagPayload::Genesis {
triple_count: 42,
..
}
));
}
}
#[test]
fn test_schema_version_reject_future() {
use super::super::backend::MemoryDagBackend;
let backend = MemoryDagBackend::new();
backend.put(VERSION_KEY, &[99u8]).unwrap();
let result = DagStore::with_backend(Box::new(backend));
assert!(result.is_err());
let err_msg = format!("{}", result.err().unwrap());
assert!(
err_msg.contains("newer than this binary"),
"error must explain version mismatch: {err_msg}"
);
}
#[test]
fn test_schema_version_written_on_first_use() {
use super::super::backend::MemoryDagBackend;
let backend = MemoryDagBackend::new();
assert!(backend.get(VERSION_KEY).unwrap().is_none());
let _store = DagStore::with_backend(Box::new(backend)).unwrap();
}
#[cfg(feature = "sled-backend")]
#[test]
fn test_sled_schema_version_persists() {
use super::super::backend::SledDagBackend;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_str().unwrap();
{
let backend = SledDagBackend::open(path).unwrap();
let _store = DagStore::with_backend(Box::new(backend)).unwrap();
}
{
let backend = SledDagBackend::open(path).unwrap();
let ver = backend.get(VERSION_KEY).unwrap().unwrap();
assert_eq!(ver, vec![SCHEMA_VERSION]);
}
}
#[cfg(feature = "sled-backend")]
#[test]
fn enable_dag_persistent_shares_sled_db_no_lock() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("g.sled");
let p = path.to_str().unwrap();
let mut graph = crate::GraphDB::sled(p).unwrap();
graph
.enable_dag_persistent(p)
.expect("enable_dag_persistent must succeed on persistent sled");
assert!(graph.dag_store().is_some());
let genesis = graph.dag_store().unwrap().init_or_migrate(0).unwrap();
let fetched = graph.dag_store().unwrap().get(&genesis).unwrap();
assert!(fetched.is_some(), "genesis action must be readable back");
}
}