use parking_lot::RwLock;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::{Duration, Instant};
pub const MAX_CACHED_ROWS: usize = 10_000;
#[derive(Debug, Clone)]
pub struct QueryCacheConfig {
pub max_entries: usize,
pub ttl_secs: u64,
}
impl Default for QueryCacheConfig {
fn default() -> Self {
Self {
max_entries: 1_000,
ttl_secs: 60, }
}
}
pub struct CachedQueryResult {
pub result: Arc<Vec<serde_json::Value>>,
pub cached_at: Instant,
collections: Vec<String>,
}
#[derive(Default)]
struct Inner {
entries: HashMap<String, CachedQueryResult>,
by_collection: HashMap<String, HashSet<String>>,
}
impl Inner {
fn remove_entry(&mut self, key: &str) {
if let Some(entry) = self.entries.remove(key) {
for coll in &entry.collections {
let emptied = match self.by_collection.get_mut(coll) {
Some(set) => {
set.remove(key);
set.is_empty()
}
None => false,
};
if emptied {
self.by_collection.remove(coll);
}
}
}
}
}
pub struct QueryCache {
inner: RwLock<Inner>,
max_entries: usize,
ttl: Duration,
}
impl QueryCache {
pub fn new(max_entries: usize, ttl_secs: u64) -> Self {
Self {
inner: RwLock::new(Inner::default()),
max_entries,
ttl: Duration::from_secs(ttl_secs),
}
}
pub fn with_config(config: &QueryCacheConfig) -> Self {
Self::new(config.max_entries, config.ttl_secs)
}
pub fn get(&self, query_hash: &str) -> Option<Arc<Vec<serde_json::Value>>> {
let inner = self.inner.read();
if let Some(cached) = inner.entries.get(query_hash) {
if cached.cached_at.elapsed() < self.ttl {
return Some(cached.result.clone());
}
}
None
}
pub fn put(&self, query_hash: String, result: Vec<serde_json::Value>) {
self.put_checked(query_hash, result, None);
}
fn put_checked(
&self,
query_hash: String,
result: Vec<serde_json::Value>,
generation: Option<u64>,
) {
let collections = extract_collections_from_key(&query_hash);
let mut inner = self.inner.write();
if generation.is_some_and(|g| g != current_generation()) {
return;
}
if inner.entries.len() >= self.max_entries {
let keys_to_remove: Vec<String> = inner
.entries
.keys()
.take(self.max_entries / 2)
.cloned()
.collect();
for key in &keys_to_remove {
inner.remove_entry(key);
}
}
for coll in &collections {
inner
.by_collection
.entry(coll.clone())
.or_default()
.insert(query_hash.clone());
}
inner.entries.insert(
query_hash,
CachedQueryResult {
result: Arc::new(result),
cached_at: Instant::now(),
collections,
},
);
}
pub fn invalidate_all(&self) {
bump_generation();
let mut inner = self.inner.write();
inner.entries.clear();
inner.by_collection.clear();
}
pub fn invalidate_collection(&self, collection_name: &str) {
bump_generation();
let mut inner = self.inner.write();
let Some(keys_to_remove) = inner.by_collection.remove(collection_name) else {
return;
};
for key in &keys_to_remove {
inner.remove_entry(key);
}
}
pub fn stats(&self) -> QueryCacheStats {
QueryCacheStats {
entries: self.inner.read().entries.len(),
max_entries: self.max_entries,
ttl_secs: self.ttl.as_secs(),
}
}
#[cfg(test)]
fn index_len(&self) -> usize {
self.inner.read().by_collection.len()
}
}
impl Default for QueryCache {
fn default() -> Self {
Self::new(1_000, 60)
}
}
#[derive(Debug, Clone)]
pub struct QueryCacheStats {
pub entries: usize,
pub max_entries: usize,
pub ttl_secs: u64,
}
static QUERY_CACHE: std::sync::OnceLock<QueryCache> = std::sync::OnceLock::new();
pub fn init_query_cache(config: &QueryCacheConfig) {
let _ = QUERY_CACHE.set(QueryCache::with_config(config));
}
pub fn get_query_cache() -> &'static QueryCache {
QUERY_CACHE.get_or_init(QueryCache::default)
}
fn extract_collections_from_key(key: &str) -> Vec<String> {
let Some(slash_pos) = key.find('/') else {
return vec![];
};
let after_slash = &key[slash_pos + 1..];
let Some(colon_pos) = after_slash.rfind(':') else {
return vec![];
};
let colls_str = &after_slash[..colon_pos];
if colls_str.is_empty() {
return vec![];
}
colls_str.split(',').map(|s| s.to_string()).collect()
}
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn bump_generation() {
GENERATION.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
}
pub fn current_generation() -> u64 {
GENERATION.load(std::sync::atomic::Ordering::Acquire)
}
impl QueryCache {
pub fn put_if_current(
&self,
query_hash: String,
result: Vec<serde_json::Value>,
generation: u64,
) {
self.put_checked(query_hash, result, Some(generation));
}
}
fn index_name(collection: &str) -> &str {
let name = collection.rsplit(':').next().unwrap_or(collection);
name.trim_matches('`')
}
pub fn invalidate_collection(db_name: &str, collection: &str) {
let _ = db_name;
get_query_cache().invalidate_collection(index_name(collection));
}
pub fn invalidate_all() {
get_query_cache().invalidate_all();
}
fn hash_principal<H: std::hash::Hasher>(principal: &crate::sdbql::QueryPrincipal, hasher: &mut H) {
use std::hash::Hash;
"principal".hash(hasher);
principal.user.hash(hasher);
let mut roles: Vec<&String> = principal.roles.iter().collect();
roles.sort();
roles.dedup();
roles.hash(hasher);
principal.can_read.hash(hasher);
principal.can_write.hash(hasher);
principal.can_admin.hash(hasher);
}
pub fn hash_query(
db_name: &str,
query: &str,
bind_vars: &std::collections::HashMap<String, serde_json::Value>,
principal: &crate::sdbql::QueryPrincipal,
collections: &[String],
) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
db_name.hash(&mut hasher);
query.hash(&mut hasher);
let mut sorted_vars: Vec<_> = bind_vars.iter().collect();
sorted_vars.sort_by(|a, b| a.0.cmp(b.0));
for (k, v) in sorted_vars {
k.hash(&mut hasher);
v.hash(&mut hasher);
}
hash_principal(principal, &mut hasher);
let mut names: Vec<&str> = collections.iter().map(|c| index_name(c)).collect();
names.sort_unstable();
names.dedup();
format!("{}/{}:{:x}", db_name, names.join(","), hasher.finish())
}
pub fn cache_key_for(
storage: &crate::storage::StorageEngine,
db_name: &str,
query_text: &str,
query: &crate::sdbql::Query,
bind_vars: &std::collections::HashMap<String, serde_json::Value>,
principal: &crate::sdbql::QueryPrincipal,
) -> Option<String> {
let refs = cacheable_collections(query)?;
let exists = |name: &str| {
let full = if name.contains(':') {
name.to_string()
} else {
format!("{}:{}", db_name, name)
};
storage.get_collection(&full).is_ok()
};
let mut collections = Vec::with_capacity(refs.required.len());
for name in refs.required {
if !exists(&name) {
return None;
}
collections.push(name);
}
for name in refs.maybe {
if exists(&name) {
collections.push(name);
}
}
Some(hash_query(
db_name,
query_text,
bind_vars,
principal,
&collections,
))
}
#[derive(Debug, Default, PartialEq)]
pub struct CollectionRefs {
pub required: Vec<String>,
pub maybe: Vec<String>,
}
const UNCACHEABLE_FUNCTIONS: &[&str] = &[
"CURRENT_USER",
"CURRENT_ROLES",
"CAN",
"ROW_POLICY",
"APPLY",
"CALL",
"EMBED",
"EMBED_BATCH",
"EXTRACT",
"LLM",
"CHAT",
"RERANK",
"RAG_PIPELINE",
"NEIGHBORS",
"GRAPH_RAG",
"GRAPH_RAG_SEARCH",
"COMMUNITY_SEARCH",
"PAGERANK",
"DEGREE_CENTRALITY",
"SHORTEST_PATH",
"K_PATHS",
"GRAPH_INFO",
"CREATE_VIEW",
"DROP_VIEW",
"CREATE_GRAPH",
"DROP_GRAPH",
];
const COLLECTION_ARG_FUNCTIONS: &[&str] = &[
"FULLTEXT",
"SAMPLE",
"COLLECTION_COUNT",
"HYBRID_SEARCH",
"VECTOR_SEARCH",
"VECTOR_INDEX_STATS",
"DOC_AS_OF",
"DOC_HISTORY",
"SNAPSHOT_DIFF",
"SEARCH_INDEX",
];
#[derive(Default)]
struct CollectionWalk {
sources: std::collections::BTreeSet<String>,
bound: HashSet<String>,
unknown: bool,
}
impl CollectionWalk {
fn source(&mut self, name: &str) {
let name = name.trim_matches('`');
if name.is_empty() {
return;
}
self.sources.insert(name.to_string());
}
fn query(&mut self, q: &crate::sdbql::Query) {
use crate::sdbql::ast::BodyClause;
if q.create_stream_clause.is_some()
|| q.create_materialized_view_clause.is_some()
|| q.refresh_materialized_view_clause.is_some()
|| q.window_clause.is_some()
{
self.unknown = true;
return;
}
if let Some(with) = &q.with_clause {
for cte in &with.ctes {
self.bound.insert(cte.name.clone());
self.query(&cte.query);
}
}
for l in q.let_clauses.iter().chain(q.post_limit_lets.iter()) {
self.bound.insert(l.variable.clone());
self.expr(&l.expression);
}
for f in &q.for_clauses {
self.for_clause(f);
}
for j in &q.join_clauses {
self.join(j);
}
for f in &q.filter_clauses {
self.expr(&f.expression);
}
if let Some(sort) = &q.sort_clause {
for (e, _) in &sort.fields {
self.expr(e);
}
}
if let Some(limit) = &q.limit_clause {
self.expr(&limit.offset);
if let Some(c) = &limit.count {
self.expr(c);
}
}
if let Some(r) = &q.return_clause {
self.expr(&r.expression);
}
for clause in &q.body_clauses {
match clause {
BodyClause::For(f) => self.for_clause(f),
BodyClause::Let(l) => {
self.bound.insert(l.variable.clone());
self.expr(&l.expression);
}
BodyClause::Filter(f) | BodyClause::Search(f) => self.expr(&f.expression),
BodyClause::Join(j) => self.join(j),
BodyClause::GraphTraversal(_) | BodyClause::ShortestPath(_) => {
self.unknown = true;
}
BodyClause::Collect(c) => {
for (v, e) in &c.group_vars {
self.bound.insert(v.clone());
self.expr(e);
}
for a in &c.aggregates {
self.bound.insert(a.variable.clone());
if let Some(e) = &a.argument {
self.expr(e);
}
}
if let Some(v) = &c.into_var {
self.bound.insert(v.clone());
}
if let Some(v) = &c.count_var {
self.bound.insert(v.clone());
}
}
BodyClause::Insert(i) => {
self.source(&i.collection);
self.expr(&i.document);
}
BodyClause::Update(u) => {
self.source(&u.collection);
self.expr(&u.selector);
self.expr(&u.changes);
}
BodyClause::Upsert(u) => {
self.source(&u.collection);
self.expr(&u.search);
self.expr(&u.insert);
self.expr(&u.update);
}
BodyClause::Remove(r) => {
self.source(&r.collection);
self.expr(&r.selector);
}
BodyClause::Window(_) => self.unknown = true,
}
}
for op in &q.set_operations {
self.query(&op.query);
}
}
fn for_clause(&mut self, f: &crate::sdbql::ast::ForClause) {
use crate::sdbql::ast::ValidTimeSpec;
self.bound.insert(f.variable.clone());
if let Some(e) = &f.source_expression {
self.expr(e);
} else {
self.source(&f.collection);
}
if let Some(e) = &f.system_time {
self.expr(e);
}
match &f.valid_time {
Some(ValidTimeSpec::AsOf(e)) => self.expr(e),
Some(ValidTimeSpec::Range { from, to }) => {
self.expr(from);
self.expr(to);
}
None => {}
}
}
fn join(&mut self, j: &crate::sdbql::ast::JoinClause) {
self.bound.insert(j.variable.clone());
self.source(&j.collection);
self.expr(&j.condition);
if let Some(asof) = &j.asof {
self.expr(&asof.left_time);
self.expr(&asof.right_time);
if let Some(t) = &asof.tolerance {
self.expr(t);
}
}
}
fn expr(&mut self, e: &crate::sdbql::ast::Expression) {
use crate::sdbql::ast::{BinaryOperator, Expression};
if self.unknown {
return;
}
match e {
Expression::Subquery(q) => self.query(q),
Expression::FunctionCall { name, args } => {
self.function(name, args);
for a in args {
self.expr(a);
}
}
Expression::WindowFunctionCall { function, .. } => {
if UNCACHEABLE_FUNCTIONS
.iter()
.any(|f| function.eq_ignore_ascii_case(f))
{
self.unknown = true;
}
e.for_each_child(&mut |c| self.expr(c));
}
Expression::BinaryOp {
op: BinaryOperator::SemanticMatch,
..
} => self.unknown = true,
Expression::Lambda { params, body } => {
self.bound.extend(params.iter().cloned());
self.expr(body);
}
other => other.for_each_child(&mut |c| self.expr(c)),
}
}
fn function(&mut self, name: &str, args: &[crate::sdbql::ast::Expression]) {
use crate::sdbql::ast::Expression;
let upper = name.to_ascii_uppercase();
if UNCACHEABLE_FUNCTIONS.contains(&upper.as_str()) {
self.unknown = true;
return;
}
if COLLECTION_ARG_FUNCTIONS.contains(&upper.as_str()) {
match args.first() {
Some(Expression::Literal(serde_json::Value::String(c))) => self.source(c),
_ => self.unknown = true,
}
return;
}
if upper == "DOCUMENT" {
let id_collection = |v: &serde_json::Value| -> Option<String> {
v.as_str()
.and_then(|s| s.split_once('/'))
.map(|(c, _)| c.to_string())
};
match args {
[Expression::Literal(serde_json::Value::String(c)), _] => self.source(c),
[Expression::Literal(v @ serde_json::Value::String(_))] => match id_collection(v) {
Some(c) => self.source(&c),
None => self.unknown = true,
},
[Expression::Literal(serde_json::Value::Array(ids))] => {
for id in ids {
match id_collection(id) {
Some(c) => self.source(&c),
None => self.unknown = true,
}
}
}
[Expression::Array(items)] => {
for item in items {
match item {
Expression::Literal(v) => match id_collection(v) {
Some(c) => self.source(&c),
None => self.unknown = true,
},
_ => self.unknown = true,
}
}
}
_ => self.unknown = true,
}
}
}
}
pub fn cacheable_collections(query: &crate::sdbql::Query) -> Option<CollectionRefs> {
let mut walk = CollectionWalk::default();
walk.query(query);
if walk.unknown {
return None;
}
let mut refs = CollectionRefs::default();
for name in walk.sources {
if walk.bound.contains(&name) {
refs.maybe.push(name);
} else {
refs.required.push(name);
}
}
Some(refs)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_put_and_get() {
let cache = QueryCache::new(10, 60);
cache.put("db/coll:abc".to_string(), vec![json!({"a": 1})]);
let got = cache.get("db/coll:abc");
assert!(got.is_some());
assert_eq!(got.unwrap().len(), 1);
}
#[test]
fn test_get_missing() {
let cache = QueryCache::new(10, 60);
assert!(cache.get("db/coll:missing").is_none());
}
#[test]
fn test_invalidate_collection() {
let cache = QueryCache::new(10, 60);
cache.put("db/users:1".to_string(), vec![json!({"a": 1})]);
cache.put("db/orders:2".to_string(), vec![json!({"b": 2})]);
cache.put("db/users,orders:3".to_string(), vec![json!({"c": 3})]);
cache.invalidate_collection("users");
assert!(cache.get("db/users:1").is_none());
assert!(cache.get("db/orders:2").is_some());
assert!(cache.get("db/users,orders:3").is_none());
}
#[test]
fn test_invalidate_all() {
let cache = QueryCache::new(10, 60);
cache.put("db/users:1".to_string(), vec![json!({"a": 1})]);
cache.put("db/orders:2".to_string(), vec![json!({"b": 2})]);
cache.invalidate_all();
assert!(cache.get("db/users:1").is_none());
assert!(cache.get("db/orders:2").is_none());
}
#[test]
fn test_extract_collections() {
let mut got = extract_collections_from_key("db/users,orders:abc");
got.sort();
assert_eq!(got, vec!["orders".to_string(), "users".to_string()]);
assert_eq!(
extract_collections_from_key("db/:abc"),
Vec::<String>::new()
);
assert_eq!(
extract_collections_from_key("no_slash"),
Vec::<String>::new()
);
}
#[test]
fn test_eviction() {
let cache = QueryCache::new(2, 60);
cache.put("db/a:1".to_string(), vec![json!({"a": 1})]);
cache.put("db/b:2".to_string(), vec![json!({"b": 2})]);
cache.put("db/c:3".to_string(), vec![json!({"c": 3})]);
let stats = cache.stats();
assert!(stats.entries <= 2);
assert!(cache.index_len() <= stats.entries);
}
#[test]
fn test_invalidate_prunes_index() {
let cache = QueryCache::new(10, 60);
cache.put("db/users:1".to_string(), vec![json!({"a": 1})]);
cache.put("db/users,orders:2".to_string(), vec![json!({"b": 2})]);
assert_eq!(cache.index_len(), 2);
cache.invalidate_collection("users");
assert_eq!(cache.index_len(), 0);
assert_eq!(cache.stats().entries, 0);
}
fn refs(q: &str) -> Option<CollectionRefs> {
cacheable_collections(&crate::sdbql::parse(q).unwrap())
}
fn required(q: &str) -> Vec<String> {
refs(q).expect("cacheable").required
}
#[test]
fn test_hash_query_format() {
let p = crate::sdbql::QueryPrincipal::from_roles("alice", vec!["viewer".into()]);
let key = hash_query(
"mydb",
"FOR doc IN users RETURN doc",
&std::collections::HashMap::new(),
&p,
&["users".to_string()],
);
assert!(key.starts_with("mydb/users:"));
assert_eq!(
extract_collections_from_key(&key),
vec!["users".to_string()]
);
}
#[test]
fn test_key_differs_per_principal() {
let q = "FOR o IN orders RETURN o";
let vars = std::collections::HashMap::new();
let colls = ["orders".to_string()];
let admin = crate::sdbql::QueryPrincipal::from_roles("root", vec!["admin".into()]);
let viewer = crate::sdbql::QueryPrincipal::from_roles("bob", vec!["viewer".into()]);
let viewer2 = crate::sdbql::QueryPrincipal::from_roles("carol", vec!["viewer".into()]);
let k_admin = hash_query("db", q, &vars, &admin, &colls);
let k_viewer = hash_query("db", q, &vars, &viewer, &colls);
let k_viewer2 = hash_query("db", q, &vars, &viewer2, &colls);
assert_ne!(k_admin, k_viewer);
assert_ne!(k_viewer, k_viewer2);
let a = crate::sdbql::QueryPrincipal::from_roles("u", vec!["a".into(), "b".into()]);
let b = crate::sdbql::QueryPrincipal::from_roles("u", vec!["b".into(), "a".into()]);
assert_eq!(
hash_query("db", q, &vars, &a, &colls),
hash_query("db", q, &vars, &b, &colls)
);
}
#[test]
fn test_collections_from_ast() {
assert_eq!(required("FOR doc IN users RETURN doc"), vec!["users"]);
assert_eq!(required("FOR doc\nIN\tusers\nRETURN doc"), vec!["users"]);
let mut got = required(
"FOR u IN users LET os = (FOR o IN orders FILTER o.u == u._key RETURN o) RETURN os",
);
got.sort();
assert_eq!(got, vec!["orders", "users"]);
let mut got = required("FOR u IN users RETURN DOCUMENT(\"teams/t1\")");
got.sort();
assert_eq!(got, vec!["teams", "users"]);
}
#[test]
fn test_uncacheable_queries() {
assert!(refs("RETURN CURRENT_USER()").is_none());
assert!(refs("FOR d IN docs FILTER CAN(\"read\", d) RETURN d").is_none());
assert!(refs("RETURN CURRENT_ROLES()").is_none());
assert!(refs("FOR d IN docs RETURN DOCUMENT(d.ref)").is_none());
assert!(refs("RETURN COLLECTION_COUNT(@c)").is_none());
assert!(refs("FOR v IN 1..2 OUTBOUND \"users/a\" follows RETURN v").is_none());
}
#[test]
fn test_let_variable_is_not_required_collection() {
let r = refs("LET xs = [1, 2] FOR x IN xs RETURN x").expect("cacheable");
assert!(r.required.is_empty());
assert_eq!(r.maybe, vec!["xs".to_string()]);
}
#[test]
fn test_put_if_current_skips_after_invalidation() {
let cache = QueryCache::new(10, 60);
let gen = current_generation();
cache.invalidate_collection("users");
cache.put_if_current("db/users:1".to_string(), vec![json!(1)], gen);
assert!(cache.get("db/users:1").is_none());
let gen = current_generation();
cache.put_if_current("db/users:1".to_string(), vec![json!(1)], gen);
let _ = cache.get("db/users:1");
}
#[test]
fn test_index_name_normalises() {
assert_eq!(index_name("mydb:users"), "users");
assert_eq!(index_name("users"), "users");
assert_eq!(index_name("`users`"), "users");
}
}