use std::fmt::Debug;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use anyhow::Result;
use async_graphql::dynamic::Schema;
use async_graphql::dynamic::indexmap::IndexMap;
use tokio::sync::RwLock;
use super::error::GraphqlError;
use super::schema::generate_schema;
use crate::catalog::providers::{AuthorisationProvider, DatabaseProvider, TableProvider};
use crate::catalog::{
DatabaseId, GraphQLConfig, GraphQLFunctionsConfig, GraphQLTablesConfig, NamespaceId,
};
use crate::dbs::Session;
use crate::kvs::{Datastore, Transaction};
type CacheKey = (String, String, GraphQLConfig, u64);
const SCHEMA_CACHE_MAX_ENTRIES: usize = 256;
#[derive(Clone, Default)]
pub struct GraphQLSchemaCache {
ns_db_schema_cache: Arc<RwLock<IndexMap<CacheKey, Schema>>>,
}
impl Debug for GraphQLSchemaCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SchemaCache").field("ns_db_schema_cache", &self.ns_db_schema_cache).finish()
}
}
impl GraphQLSchemaCache {
pub async fn get_schema(
&self,
datastore: &Arc<Datastore>,
session: &Session,
) -> Result<Schema, GraphqlError> {
use crate::kvs::{LockType, TransactionType};
let ns = session.ns.as_ref().ok_or(GraphqlError::UnspecifiedNamespace)?;
let db = session.db.as_ref().ok_or(GraphqlError::UnspecifiedDatabase)?;
let kvs = datastore;
let tx = kvs.transaction(TransactionType::Read, LockType::Optimistic).await?;
let db_def = match tx.get_db_by_name(ns, db, None).await? {
Some(db) => db,
None => return Err(GraphqlError::NotConfigured),
};
let cg = tx
.expect_db_config(db_def.namespace_id, db_def.database_id, "graphql")
.await
.map_err(|e| {
if matches!(e.downcast_ref(), Some(crate::err::Error::CgNotFound { .. })) {
GraphqlError::NotConfigured
} else {
GraphqlError::DbError(e)
}
})?;
let graphql_config = (*cg).clone().try_into_graphql()?;
let fingerprint = compute_schema_fingerprint(
&tx,
db_def.namespace_id,
db_def.database_id,
&graphql_config,
)
.await
.map_err(GraphqlError::DbError)?;
let cache_key = (ns.to_owned(), db.to_owned(), graphql_config.clone(), fingerprint);
{
let guard = self.ns_db_schema_cache.read().await;
if let Some(cand) = guard.get(&cache_key) {
return Ok(cand.clone());
}
};
let schema = match generate_schema(datastore, session, graphql_config).await {
Ok(s) => s,
Err(e) => {
if matches!(e, GraphqlError::DbError(_) | GraphqlError::SchemaError(_)) {
let mut guard = self.ns_db_schema_cache.write().await;
guard.shift_remove(&cache_key);
}
return Err(e);
}
};
{
let mut guard = self.ns_db_schema_cache.write().await;
insert_bounded(&mut guard, cache_key, schema.clone());
}
Ok(schema)
}
}
fn insert_bounded<V>(cache: &mut IndexMap<CacheKey, V>, key: CacheKey, value: V) {
let (ns_key, db_key, cfg_key, _) = &key;
cache.retain(|(n, d, c, _), _| !(n == ns_key && d == db_key && c == cfg_key));
while cache.len() >= SCHEMA_CACHE_MAX_ENTRIES {
cache.shift_remove_index(0);
}
cache.insert(key, value);
}
async fn compute_schema_fingerprint(
tx: &Transaction,
ns: NamespaceId,
db: DatabaseId,
graphql_config: &GraphQLConfig,
) -> Result<u64> {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
let tbs = tx.all_tb(ns, db, None).await?;
let mut tables_to_hash: Vec<&crate::catalog::TableDefinition> = match &graphql_config.tables {
GraphQLTablesConfig::None => Vec::new(),
GraphQLTablesConfig::Auto => tbs.iter().collect(),
GraphQLTablesConfig::Include(inc) => tbs.iter().filter(|t| inc.contains(&t.name)).collect(),
GraphQLTablesConfig::Exclude(exc) => {
tbs.iter().filter(|t| !exc.contains(&t.name)).collect()
}
};
tables_to_hash.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
for tb in &tables_to_hash {
tb.hash(&mut hasher);
}
let fns = tx.all_db_functions(ns, db, None).await?;
let mut fns_to_hash: Vec<&crate::catalog::FunctionDefinition> = match &graphql_config.functions
{
GraphQLFunctionsConfig::None => Vec::new(),
GraphQLFunctionsConfig::Auto => fns.iter().collect(),
GraphQLFunctionsConfig::Include(inc) => {
fns.iter().filter(|f| inc.iter().any(|n| n.as_str() == f.name.as_str())).collect()
}
GraphQLFunctionsConfig::Exclude(exc) => {
fns.iter().filter(|f| !exc.iter().any(|n| n.as_str() == f.name.as_str())).collect()
}
};
fns_to_hash.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
for f in &fns_to_hash {
f.hash(&mut hasher);
}
let accesses = tx.all_db_accesses(ns, db, None).await?;
let mut accesses_sorted: Vec<&crate::catalog::AccessDefinition> = accesses.iter().collect();
accesses_sorted.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
for a in &accesses_sorted {
a.hash(&mut hasher);
}
Ok(hasher.finish())
}
#[cfg(test)]
mod tests {
use super::*;
fn key(ns: &str, db: &str, fingerprint: u64) -> CacheKey {
(ns.to_owned(), db.to_owned(), GraphQLConfig::default(), fingerprint)
}
#[test]
fn insert_bounded_evicts_oldest_first() {
let mut cache: IndexMap<CacheKey, usize> = IndexMap::new();
for i in 0..SCHEMA_CACHE_MAX_ENTRIES {
insert_bounded(&mut cache, key("ns", &format!("db{i}"), 0), i);
}
assert_eq!(cache.len(), SCHEMA_CACHE_MAX_ENTRIES);
insert_bounded(&mut cache, key("ns", "db-new", 0), 9999);
assert_eq!(cache.len(), SCHEMA_CACHE_MAX_ENTRIES);
assert!(!cache.contains_key(&key("ns", "db0", 0)), "oldest entry should be evicted");
assert!(cache.contains_key(&key("ns", "db1", 0)), "second-oldest entry should survive");
assert!(cache.contains_key(&key("ns", "db-new", 0)), "newest entry must be retained");
}
#[test]
fn insert_bounded_replaces_stale_fingerprint_for_same_prefix() {
let mut cache: IndexMap<CacheKey, usize> = IndexMap::new();
insert_bounded(&mut cache, key("ns", "db", 1), 1);
insert_bounded(&mut cache, key("ns", "db", 2), 2);
assert_eq!(cache.len(), 1);
assert!(!cache.contains_key(&key("ns", "db", 1)));
assert_eq!(cache.get(&key("ns", "db", 2)), Some(&2));
}
#[test]
fn insert_bounded_keeps_distinct_prefixes() {
let mut cache: IndexMap<CacheKey, usize> = IndexMap::new();
insert_bounded(&mut cache, key("ns", "db_a", 1), 1);
insert_bounded(&mut cache, key("ns", "db_b", 1), 2);
assert_eq!(cache.len(), 2);
assert_eq!(cache.get(&key("ns", "db_a", 1)), Some(&1));
assert_eq!(cache.get(&key("ns", "db_b", 1)), Some(&2));
}
}