use std::collections::{HashMap, VecDeque};
use std::sync::Mutex;
use crate::cypher::ast::{Clause, Expr, ExprKind, IntermediateClause, Statement, UnwindBody};
use crate::cypher::ir::LogicalOp;
use crate::types::Result;
const DEFAULT_CAPACITY: usize = 128;
#[derive(Hash, Eq, PartialEq, Clone, Debug)]
struct Key {
cypher: String,
epoch: u64,
}
pub(crate) struct PlanCache {
inner: Mutex<Inner>,
}
struct Inner {
map: HashMap<Key, LogicalOp>,
order: VecDeque<Key>,
capacity: usize,
}
impl PlanCache {
pub(crate) fn new() -> Self {
Self::with_capacity(DEFAULT_CAPACITY)
}
pub(crate) fn with_capacity(capacity: usize) -> Self {
Self {
inner: Mutex::new(Inner {
map: HashMap::with_capacity(capacity),
order: VecDeque::with_capacity(capacity),
capacity,
}),
}
}
pub(crate) fn get_or_plan<F>(&self, cypher: &str, epoch: u64, build: F) -> Result<LogicalOp>
where
F: FnOnce() -> Result<LogicalOp>,
{
let key = Key {
cypher: cypher.to_string(),
epoch,
};
if let Ok(guard) = self.inner.lock() {
if let Some(plan) = guard.map.get(&key) {
return Ok(plan.clone());
}
}
let plan = build()?;
if let Ok(mut guard) = self.inner.lock() {
if !guard.map.contains_key(&key) {
if guard.map.len() >= guard.capacity {
if let Some(oldest) = guard.order.pop_front() {
guard.map.remove(&oldest);
}
}
guard.order.push_back(key.clone());
guard.map.insert(key, plan.clone());
}
}
Ok(plan)
}
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.inner.lock().map(|g| g.map.len()).unwrap_or(0)
}
}
impl Default for PlanCache {
fn default() -> Self {
Self::new()
}
}
pub(crate) fn is_plan_cacheable(stmt: &Statement) -> bool {
match stmt {
Statement::Call { .. } | Statement::CreateIndex { .. } | Statement::DropIndex { .. } => {
false
}
Statement::Explain(inner) => is_plan_cacheable(inner),
Statement::Union { statements, .. } => statements.iter().all(is_plan_cacheable),
Statement::Match(m) => {
!skip_limit_has_param(&m.skip, &m.limit)
&& !intermediates_block_caching(&m.intermediate_clauses)
}
Statement::Create(c) => !skip_limit_has_param(&c.skip, &c.limit),
Statement::MatchCreate(mc) => !skip_limit_has_param(&mc.skip, &mc.limit),
Statement::MatchMerge(mm) => !skip_limit_has_param(&mm.skip, &mm.limit),
Statement::Delete(d) => !skip_limit_has_param(&d.skip, &d.limit),
Statement::Set(s) => {
!skip_limit_has_param(&s.skip, &s.limit)
&& !intermediates_block_caching(&s.intermediate_clauses)
}
Statement::Remove(r) => !skip_limit_has_param(&r.skip, &r.limit),
Statement::Merge(m) => !skip_limit_has_param(&m.skip, &m.limit),
Statement::Unwind(u) => match &u.body {
UnwindBody::Return {
intermediate_clauses,
skip,
limit,
..
} => {
!skip_limit_has_param(skip, limit)
&& !intermediates_block_caching(intermediate_clauses)
}
UnwindBody::Create {
intermediate_clauses,
skip,
limit,
..
} => {
!skip_limit_has_param(skip, limit)
&& !intermediates_block_caching(intermediate_clauses)
}
},
Statement::Return(r) => !skip_limit_has_param(&r.skip, &r.limit),
Statement::MultiClause(mc) => {
!skip_limit_has_param(&mc.skip, &mc.limit)
&& mc.clauses.iter().all(|c| !clause_blocks_caching(c))
}
}
}
fn clause_blocks_caching(clause: &Clause) -> bool {
match clause {
Clause::Call { .. } => true,
Clause::With(w) => skip_limit_has_param(&w.skip, &w.limit),
_ => false,
}
}
fn intermediates_block_caching(clauses: &[IntermediateClause]) -> bool {
clauses.iter().any(|c| match c {
IntermediateClause::With(w) => skip_limit_has_param(&w.skip, &w.limit),
_ => false,
})
}
fn skip_limit_has_param(skip: &Option<Expr>, limit: &Option<Expr>) -> bool {
expr_opt_has_param(skip) || expr_opt_has_param(limit)
}
fn expr_opt_has_param(e: &Option<Expr>) -> bool {
e.as_ref().is_some_and(expr_has_param)
}
fn expr_has_param(expr: &Expr) -> bool {
match &expr.kind {
ExprKind::Parameter(_) => true,
ExprKind::BinaryOp { left, right, .. } => expr_has_param(left) || expr_has_param(right),
ExprKind::Not(e) | ExprKind::IsNull(e) | ExprKind::IsNotNull(e) => expr_has_param(e),
ExprKind::FunctionCall { args, .. } => args.iter().any(expr_has_param),
ExprKind::Case {
operand,
alternatives,
default,
} => {
operand.as_deref().is_some_and(expr_has_param)
|| alternatives
.iter()
.any(|(c, r)| expr_has_param(c) || expr_has_param(r))
|| default.as_deref().is_some_and(expr_has_param)
}
ExprKind::List(items) => items.iter().any(expr_has_param),
ExprKind::ListComprehension {
list_expr,
filter,
map_expr,
..
} => {
expr_has_param(list_expr)
|| filter.as_deref().is_some_and(expr_has_param)
|| map_expr.as_deref().is_some_and(expr_has_param)
}
ExprKind::PatternComprehension {
where_clause,
map_expr,
..
} => where_clause.as_deref().is_some_and(expr_has_param) || expr_has_param(map_expr),
ExprKind::Quantifier {
list_expr,
predicate,
..
} => expr_has_param(list_expr) || expr_has_param(predicate),
ExprKind::Exists { where_clause, .. } => {
where_clause.as_deref().is_some_and(expr_has_param)
}
ExprKind::ExistsSubquery(_) => false, ExprKind::MapLiteral(pairs) => pairs.iter().any(|(_, v)| expr_has_param(v)),
ExprKind::Index { expr: e, index } => expr_has_param(e) || expr_has_param(index),
ExprKind::DotAccess { expr: e, .. } => expr_has_param(e),
ExprKind::Slice {
expr: e,
start,
end,
} => {
expr_has_param(e)
|| start.as_deref().is_some_and(expr_has_param)
|| end.as_deref().is_some_and(expr_has_param)
}
ExprKind::Literal(_)
| ExprKind::Property(_, _)
| ExprKind::Variable(_)
| ExprKind::HasLabel(_, _)
| ExprKind::PatternPredicate(_)
| ExprKind::Star => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cypher::ir::LogicalOp;
use crate::cypher::parser;
fn dummy_plan() -> LogicalOp {
LogicalOp::SingleRow
}
#[test]
fn hit_returns_equal_plan() {
let cache = PlanCache::with_capacity(8);
let q = "MATCH (n:Person) RETURN n";
let a = cache.get_or_plan(q, 0, || Ok(dummy_plan())).unwrap();
let b = cache
.get_or_plan(q, 0, || panic!("should not rebuild on hit"))
.unwrap();
assert_eq!(a, b);
assert_eq!(cache.len(), 1);
}
#[test]
fn epoch_change_misses() {
let cache = PlanCache::with_capacity(8);
let q = "MATCH (n:Person) RETURN n";
cache.get_or_plan(q, 0, || Ok(dummy_plan())).unwrap();
let mut rebuilt = false;
cache
.get_or_plan(q, 1, || {
rebuilt = true;
Ok(dummy_plan())
})
.unwrap();
assert!(rebuilt, "epoch change should miss");
}
#[test]
fn evicts_oldest_at_capacity() {
let cache = PlanCache::with_capacity(2);
cache
.get_or_plan("MATCH (n) RETURN n", 0, || Ok(dummy_plan()))
.unwrap();
cache
.get_or_plan("MATCH (n:A) RETURN n", 0, || Ok(dummy_plan()))
.unwrap();
cache
.get_or_plan("MATCH (n:B) RETURN n", 0, || Ok(dummy_plan()))
.unwrap();
assert_eq!(cache.len(), 2);
}
#[test]
fn build_error_is_not_cached() {
let cache = PlanCache::with_capacity(8);
let q = "MATCH (n) RETURN n";
let err: Result<LogicalOp> =
cache.get_or_plan(q, 0, || Err(crate::types::GraphError::syntax("forced")));
assert!(err.is_err());
assert_eq!(cache.len(), 0);
}
#[test]
fn cacheability_skip_limit_param() {
let stmt = parser::parse("MATCH (n) RETURN n LIMIT $k").unwrap();
assert!(!is_plan_cacheable(&stmt));
let stmt = parser::parse("MATCH (n) RETURN n SKIP $s LIMIT 10").unwrap();
assert!(!is_plan_cacheable(&stmt));
let stmt = parser::parse("MATCH (n) RETURN n LIMIT 10").unwrap();
assert!(is_plan_cacheable(&stmt));
}
#[test]
fn cacheability_with_clause_skip_param() {
let stmt = parser::parse("MATCH (n) WITH n LIMIT $k RETURN n").unwrap();
assert!(!is_plan_cacheable(&stmt));
}
#[test]
fn cacheability_param_in_where_is_ok() {
let stmt = parser::parse("MATCH (n:Person) WHERE n.age = $age RETURN n").unwrap();
assert!(is_plan_cacheable(&stmt));
}
}