use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use pensieve_graph::{GraphExportPage, GraphRelationship, PositionedNode};
pub const PAGE_SIZE_DEFAULT: usize = 10_000;
pub const SYNC_COMPUTE_MAX_NODES: usize = 20_000;
const MAX_ENTRIES: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CacheKey {
pub database: String,
pub graph: String,
pub realm: Option<String>,
pub algorithm: pensieve_graph::LayoutAlgorithm,
}
#[derive(Debug)]
pub struct LaidOutGraph {
pub layout_id: String,
pub fingerprint: (usize, usize),
pub nodes: Vec<PositionedNode>,
pub edges: Vec<GraphRelationship>,
}
#[derive(Debug, Clone)]
pub enum CacheState {
Computing,
Ready(Arc<LaidOutGraph>),
}
#[derive(Default)]
struct CacheInner {
map: HashMap<CacheKey, CacheState>,
lru: Vec<CacheKey>,
by_id: HashMap<String, Arc<LaidOutGraph>>,
}
#[derive(Default)]
pub struct LayoutCache {
inner: Mutex<CacheInner>,
}
impl LayoutCache {
pub fn new() -> Self {
Self::default()
}
pub fn layout_id(key: &CacheKey, fingerprint: (usize, usize)) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
key.hash(&mut h);
fingerprint.hash(&mut h);
format!("L{:016x}", h.finish())
}
pub fn get_fresh(&self, key: &CacheKey, fingerprint: (usize, usize)) -> Option<CacheState> {
let mut g = self.inner.lock().unwrap();
let inner = &mut *g;
match inner.map.get(key) {
Some(CacheState::Computing) => Some(CacheState::Computing),
Some(CacheState::Ready(l)) if l.fingerprint == fingerprint => {
inner.lru.retain(|k| k != key);
inner.lru.push(key.clone());
Some(CacheState::Ready(l.clone()))
}
Some(CacheState::Ready(l)) => {
inner.by_id.remove(&l.layout_id);
inner.map.remove(key);
inner.lru.retain(|k| k != key);
None
}
None => None,
}
}
pub fn begin_compute(&self, key: &CacheKey) -> bool {
let mut g = self.inner.lock().unwrap();
let inner = &mut *g;
if matches!(inner.map.get(key), Some(CacheState::Computing)) {
return false;
}
inner.map.insert(key.clone(), CacheState::Computing);
true
}
pub fn insert_ready(&self, key: CacheKey, laid: LaidOutGraph) -> Arc<LaidOutGraph> {
let laid = Arc::new(laid);
let mut g = self.inner.lock().unwrap();
let inner = &mut *g;
inner.by_id.insert(laid.layout_id.clone(), laid.clone());
inner.map.insert(key.clone(), CacheState::Ready(laid.clone()));
inner.lru.retain(|k| k != &key);
inner.lru.push(key);
while inner.lru.len() > MAX_ENTRIES {
let evict = inner.lru.remove(0);
if let Some(CacheState::Ready(l)) = inner.map.remove(&evict) {
inner.by_id.remove(&l.layout_id);
}
}
laid
}
pub fn abort_compute(&self, key: &CacheKey) {
let mut g = self.inner.lock().unwrap();
let inner = &mut *g;
if matches!(inner.map.get(key), Some(CacheState::Computing)) {
inner.map.remove(key);
}
}
pub fn by_layout_id(&self, layout_id: &str) -> Option<Arc<LaidOutGraph>> {
self.inner.lock().unwrap().by_id.get(layout_id).cloned()
}
pub fn compute_guard(self: &Arc<Self>, key: CacheKey) -> ComputeGuard {
ComputeGuard { cache: Arc::clone(self), key: Some(key) }
}
}
pub struct ComputeGuard {
cache: Arc<LayoutCache>,
key: Option<CacheKey>,
}
impl ComputeGuard {
pub fn disarm(&mut self) {
self.key = None;
}
}
impl Drop for ComputeGuard {
fn drop(&mut self) {
if let Some(ref key) = self.key {
self.cache.abort_compute(key);
}
}
}
pub fn parse_cursor(cursor: &str) -> Option<(String, char, usize)> {
let mut parts = cursor.rsplitn(3, ':');
let offset: usize = parts.next()?.parse().ok()?;
let kind = parts.next()?.chars().next().filter(|c| *c == 'n' || *c == 'e')?;
let id = parts.next()?.to_string();
if id.is_empty() {
return None;
}
Some((id, kind, offset))
}
pub fn slice_page(g: &LaidOutGraph, kind: char, offset: usize, page_size: usize) -> GraphExportPage {
let mut page = GraphExportPage {
layout_status: "ready".into(),
layout_id: g.layout_id.clone(),
total_nodes: g.nodes.len(),
total_edges: g.edges.len(),
nodes: Vec::new(),
edges: Vec::new(),
next_cursor: None,
};
if kind == 'n' {
let start = offset.min(g.nodes.len());
let end = offset.saturating_add(page_size).min(g.nodes.len()).max(start);
page.nodes = g.nodes[start..end].to_vec();
page.next_cursor = if end < g.nodes.len() {
Some(format!("{}:n:{}", g.layout_id, end))
} else if !g.edges.is_empty() {
Some(format!("{}:e:0", g.layout_id))
} else {
None
};
} else {
let start = offset.min(g.edges.len());
let end = offset.saturating_add(page_size).min(g.edges.len()).max(start);
page.edges = g.edges[start..end].to_vec();
page.next_cursor =
(end < g.edges.len()).then(|| format!("{}:e:{}", g.layout_id, end));
}
page
}
#[cfg(test)]
mod tests {
use super::*;
use pensieve_graph::{GraphNode, NodeMetadata};
fn laid(n: usize, e: usize) -> LaidOutGraph {
let nodes = (0..n)
.map(|i| PositionedNode {
node: GraphNode {
id: format!("n{i}"),
labels: vec!["Table".into()],
properties: Default::default(),
metadata: NodeMetadata {
created_at: String::new(),
updated_at: String::new(),
source_type: None,
source_id: None,
realm: "default".into(),
},
},
x: i as f64,
y: 0.0,
})
.collect();
let edges = (0..e)
.map(|i| GraphRelationship {
id: format!("e{i}"),
source_id: format!("n{}", i % n.max(1)),
target_id: format!("n{}", (i + 1) % n.max(1)),
relationship_type: "REFERENCES".into(),
properties: Default::default(),
})
.collect();
LaidOutGraph { layout_id: "Labc".into(), fingerprint: (n, e), nodes, edges }
}
#[test]
fn pages_walk_nodes_then_edges_to_completion() {
let g = laid(25, 7);
let p1 = slice_page(&g, 'n', 0, 10);
assert_eq!(p1.nodes.len(), 10);
assert_eq!(p1.next_cursor.as_deref(), Some("Labc:n:10"));
let p3 = slice_page(&g, 'n', 20, 10);
assert_eq!(p3.nodes.len(), 5);
assert_eq!(p3.next_cursor.as_deref(), Some("Labc:e:0"));
let p4 = slice_page(&g, 'e', 0, 10);
assert_eq!(p4.edges.len(), 7);
assert_eq!(p4.next_cursor, None);
assert_eq!(p4.total_nodes, 25);
assert_eq!(p4.total_edges, 7);
}
#[test]
fn cursor_roundtrip_and_garbage() {
assert_eq!(parse_cursor("Labc:n:10"), Some(("Labc".into(), 'n', 10)));
assert_eq!(parse_cursor("Labc:e:0"), Some(("Labc".into(), 'e', 0)));
assert_eq!(parse_cursor("nonsense"), None);
assert_eq!(parse_cursor("x:q:5"), None);
assert_eq!(parse_cursor(":n:5"), None);
}
#[test]
fn stale_fingerprint_evicts() {
let cache = LayoutCache::new();
let key = CacheKey {
database: "db".into(),
graph: "g".into(),
realm: None,
algorithm: pensieve_graph::LayoutAlgorithm::Force,
};
cache.insert_ready(key.clone(), laid(5, 2));
assert!(matches!(cache.get_fresh(&key, (5, 2)), Some(CacheState::Ready(_))));
assert!(cache.get_fresh(&key, (6, 2)).is_none(), "stale entry must evict");
assert!(cache.by_layout_id("Labc").is_none(), "by_id index must evict too");
}
#[test]
fn begin_compute_is_exclusive() {
let cache = LayoutCache::new();
let key = CacheKey {
database: "db".into(),
graph: "g".into(),
realm: None,
algorithm: pensieve_graph::LayoutAlgorithm::Force,
};
assert!(cache.begin_compute(&key));
assert!(!cache.begin_compute(&key));
cache.abort_compute(&key);
assert!(cache.begin_compute(&key));
}
#[test]
fn slice_page_survives_huge_offset() {
let g = laid(5, 2);
let p = slice_page(&g, 'n', usize::MAX - 5, 10_000);
assert!(p.nodes.is_empty(), "nodes must be empty for huge offset");
assert_eq!(
p.next_cursor.as_deref(),
Some("Labc:e:0"),
"should chain to edge page since edges exist"
);
let p2 = slice_page(&g, 'e', usize::MAX - 5, 10_000);
assert!(p2.edges.is_empty(), "edges must be empty for huge offset");
assert_eq!(p2.next_cursor, None, "no next cursor past last edge page");
}
#[test]
fn compute_guard_aborts_on_drop() {
let cache = Arc::new(LayoutCache::new());
let key = CacheKey {
database: "db".into(),
graph: "g".into(),
realm: None,
algorithm: pensieve_graph::LayoutAlgorithm::Force,
};
{
assert!(cache.begin_compute(&key));
let _guard = cache.compute_guard(key.clone());
}
assert!(cache.begin_compute(&key), "Computing marker must have been removed by guard drop");
cache.abort_compute(&key);
assert!(cache.begin_compute(&key));
let mut guard = cache.compute_guard(key.clone());
cache.insert_ready(key.clone(), laid(3, 1));
guard.disarm();
drop(guard);
assert!(
matches!(cache.get_fresh(&key, (3, 1)), Some(CacheState::Ready(_))),
"disarmed guard must not evict the Ready entry"
);
}
}