#![allow(dead_code)]
use crate::model::key_recipe::Target;
use crate::model::{KeyRecipe, RecipeList};
use std::collections::hash_map::Entry;
use std::collections::{HashMap, VecDeque};
use std::fmt::{self, Debug, Formatter};
use std::mem::take;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
pub(crate) const DEFAULT_QUERY_RECIPE_CACHE_CAPACITY: usize = 2_000;
#[derive(Default)]
pub(crate) struct KeyRecipeCache {
store: RwLock<RecipeStore>,
}
impl Debug for KeyRecipeCache {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
let count = self.len();
formatter
.debug_struct("KeyRecipeCache")
.field("entry_count", &count)
.finish_non_exhaustive()
}
}
impl KeyRecipeCache {
pub(crate) fn new() -> Self {
Self {
store: RwLock::new(RecipeStore::default()),
}
}
pub(crate) fn with_query_capacity(query_capacity: usize) -> Self {
Self {
store: RwLock::new(RecipeStore::with_query_capacity(query_capacity)),
}
}
fn read_store(&self) -> RwLockReadGuard<'_, RecipeStore> {
self.store
.read()
.expect("key recipe cache read lock poisoned")
}
fn write_store(&self) -> RwLockWriteGuard<'_, RecipeStore> {
self.store
.write()
.expect("key recipe cache write lock poisoned")
}
pub(crate) fn get_table_recipe(&self, table_name: &str) -> Option<Arc<KeyRecipe>> {
self.read_store().tables.get(table_name).map(Arc::clone)
}
pub(crate) fn get_index_recipe(&self, index_name: &str) -> Option<Arc<KeyRecipe>> {
self.read_store().indexes.get(index_name).map(Arc::clone)
}
pub(crate) fn get_query_recipe(&self, operation_uid: u64) -> Option<Arc<KeyRecipe>> {
let guard = self.read_store();
let entry = guard.queries.get(&operation_uid)?;
entry.referenced.store(true, Ordering::Relaxed);
Some(Arc::clone(&entry.recipe))
}
pub(crate) fn insert(&self, recipe: KeyRecipe) -> bool {
let Some(target) = recipe.target.clone() else {
return false;
};
let recipe_arc = Arc::new(recipe);
let mut guard = self.write_store();
let _old = match target {
Target::TableName(name) => guard.tables.insert(name, recipe_arc),
Target::IndexName(name) => guard.indexes.insert(name, recipe_arc),
Target::OperationUid(operation_uid) => guard.insert_query(operation_uid, recipe_arc),
};
drop(guard);
true
}
pub(crate) fn insert_batch(&self, recipes: &[KeyRecipe]) {
if recipes.is_empty() {
return;
}
let mut prepared = Vec::with_capacity(recipes.len());
for recipe in recipes {
if let Some(target) = recipe.target.clone() {
prepared.push((target, Arc::new(recipe.clone())));
}
}
if prepared.is_empty() {
return;
}
let mut guard = self.write_store();
for (target, recipe_arc) in prepared {
match target {
Target::TableName(name) => {
guard.tables.insert(name, recipe_arc);
}
Target::IndexName(name) => {
guard.indexes.insert(name, recipe_arc);
}
Target::OperationUid(operation_uid) => {
guard.insert_query(operation_uid, recipe_arc);
}
}
}
}
pub(crate) fn update_from_recipe_list(&self, recipe_list: RecipeList) {
if recipe_list.recipe.is_empty() {
return;
}
let mut prepared = Vec::with_capacity(recipe_list.recipe.len());
for recipe in recipe_list.recipe {
if let Some(target) = recipe.target.clone() {
prepared.push((target, Arc::new(recipe)));
}
}
if prepared.is_empty() {
return;
}
let mut guard = self.write_store();
for (target, recipe_arc) in prepared {
match target {
Target::TableName(name) => {
guard.tables.insert(name, recipe_arc);
}
Target::IndexName(name) => {
guard.indexes.insert(name, recipe_arc);
}
Target::OperationUid(operation_uid) => {
guard.insert_query(operation_uid, recipe_arc);
}
}
}
}
pub(crate) fn clear(&self) {
let (old_tables, old_indexes, old_queries) = {
let mut guard = self.write_store();
let old_tables = take(&mut guard.tables);
let old_indexes = take(&mut guard.indexes);
let old_queries = take(&mut guard.queries);
guard.query_order.clear();
(old_tables, old_indexes, old_queries)
};
drop(old_tables);
drop(old_indexes);
drop(old_queries);
}
pub(crate) fn len(&self) -> usize {
self.read_store().len()
}
pub(crate) fn is_empty(&self) -> bool {
self.len() == 0
}
}
struct QueryRecipeEntry {
recipe: Arc<KeyRecipe>,
referenced: AtomicBool,
}
struct RecipeStore {
tables: HashMap<String, Arc<KeyRecipe>>,
indexes: HashMap<String, Arc<KeyRecipe>>,
queries: HashMap<u64, QueryRecipeEntry>,
query_order: VecDeque<u64>,
query_capacity: usize,
}
impl Default for RecipeStore {
fn default() -> Self {
Self::with_query_capacity(DEFAULT_QUERY_RECIPE_CACHE_CAPACITY)
}
}
impl RecipeStore {
fn with_query_capacity(query_capacity: usize) -> Self {
Self {
tables: HashMap::new(),
indexes: HashMap::new(),
queries: HashMap::new(),
query_order: VecDeque::new(),
query_capacity,
}
}
fn len(&self) -> usize {
self.tables.len() + self.indexes.len() + self.queries.len()
}
fn insert_query(
&mut self,
operation_uid: u64,
recipe: Arc<KeyRecipe>,
) -> Option<Arc<KeyRecipe>> {
if self.query_capacity == 0 {
return None;
}
match self.queries.entry(operation_uid) {
Entry::Occupied(mut occupied) => {
let entry = occupied.get_mut();
let previous_recipe = Arc::clone(&entry.recipe);
entry.recipe = recipe;
entry.referenced.store(true, Ordering::Relaxed);
Some(previous_recipe)
}
Entry::Vacant(vacant) => {
vacant.insert(QueryRecipeEntry {
recipe,
referenced: AtomicBool::new(false),
});
self.query_order.push_back(operation_uid);
self.evict_excess_queries();
None
}
}
}
fn evict_excess_queries(&mut self) {
while self.queries.len() > self.query_capacity {
let Some(candidate_uid) = self.query_order.pop_front() else {
break;
};
let Some(entry) = self.queries.get(&candidate_uid) else {
continue;
};
if entry.referenced.swap(false, Ordering::Relaxed) {
self.query_order.push_back(candidate_uid);
} else {
self.queries.remove(&candidate_uid);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
#[test]
fn key_recipe_cache_implements_send_sync_debug() {
static_assertions::assert_impl_all!(KeyRecipeCache: Send, Sync, Debug);
}
#[test]
fn cache_new_is_empty() {
let cache = KeyRecipeCache::new();
assert!(cache.is_empty(), "new cache must be empty");
assert_eq!(cache.len(), 0, "new cache length must be zero");
}
#[test]
fn insert_without_target_returns_false() {
let cache = KeyRecipeCache::new();
let recipe = KeyRecipe::new();
assert!(
!cache.insert(recipe),
"inserting recipe without target must return false"
);
assert!(cache.is_empty(), "cache must remain empty");
}
#[test]
fn insert_and_get_table_recipe() {
let cache = KeyRecipeCache::new();
let recipe = KeyRecipe::new().set_table_name("Users");
assert!(cache.insert(recipe), "insert must return true");
assert_eq!(cache.len(), 1, "cache length must be 1");
let cached = cache
.get_table_recipe("Users")
.expect("recipe should be cached");
assert_eq!(cached.table_name().expect("table name present"), "Users");
}
#[test]
fn insert_and_get_index_recipe() {
let cache = KeyRecipeCache::new();
let recipe = KeyRecipe::new().set_index_name("UsersByEmail");
assert!(cache.insert(recipe), "insert must return true");
assert_eq!(cache.len(), 1, "cache length must be 1");
let cached = cache
.get_index_recipe("UsersByEmail")
.expect("recipe should be cached");
assert_eq!(
cached.index_name().expect("index name present"),
"UsersByEmail"
);
}
#[test]
fn insert_and_get_query_recipe() {
let cache = KeyRecipeCache::new();
let recipe = KeyRecipe::new().set_target(Target::from_operation_uid(12345u64));
assert!(cache.insert(recipe), "insert must return true");
assert_eq!(cache.len(), 1, "cache length must be 1");
let cached = cache
.get_query_recipe(12345)
.expect("recipe should be cached");
assert_eq!(
cached.operation_uid().expect("operation uid present"),
&12345
);
}
#[test]
fn get_non_existent_recipes_returns_none() {
let cache = KeyRecipeCache::new();
assert!(cache.get_table_recipe("NonExistent").is_none());
assert!(cache.get_index_recipe("NonExistent").is_none());
assert!(cache.get_query_recipe(9999).is_none());
}
#[test]
fn insert_overwrites_existing_recipe() {
let cache = KeyRecipeCache::new();
let first_recipe = KeyRecipe::new().set_table_name("Orders");
let second_recipe = KeyRecipe::new().set_table_name("Orders");
assert!(cache.insert(first_recipe), "first insert must return true");
assert!(
cache.insert(second_recipe),
"second insert must return true"
);
assert_eq!(cache.len(), 1, "cache length must remain 1 after overwrite");
}
#[test]
fn clear_removes_all_recipes() {
let cache = KeyRecipeCache::new();
assert!(cache.insert(KeyRecipe::new().set_table_name("Users")));
assert!(cache.insert(KeyRecipe::new().set_index_name("IndexA")));
assert_eq!(cache.len(), 2, "cache length must be 2");
cache.clear();
assert!(cache.is_empty(), "cache must be empty after clear");
assert_eq!(cache.len(), 0, "cache length must be zero after clear");
}
#[test]
fn concurrent_read_write_access() {
let cache = Arc::new(KeyRecipeCache::new());
let mut handles = Vec::new();
for i in 0..10 {
let cache_clone = Arc::clone(&cache);
handles.push(thread::spawn(move || {
let table_name = format!("Table_{i}");
cache_clone.insert(KeyRecipe::new().set_table_name(&table_name));
let found = cache_clone.get_table_recipe(&table_name);
assert!(
found.is_some(),
"thread must observe its own inserted recipe"
);
}));
}
for handle in handles {
handle.join().expect("thread should finish cleanly");
}
assert_eq!(cache.len(), 10, "all 10 concurrent inserts must be stored");
}
#[test]
fn cache_debug_formatting() {
let cache = KeyRecipeCache::new();
assert!(
format!("{cache:?}").contains("entry_count: 0"),
"debug format must show zero entry count for empty cache"
);
cache.insert(KeyRecipe::new().set_table_name("Users"));
assert!(
format!("{cache:?}").contains("entry_count: 1"),
"debug format must show updated entry count"
);
}
#[test]
fn insert_batch_empty_or_no_targets() {
let cache = KeyRecipeCache::new();
cache.insert_batch(&[]);
assert!(
cache.is_empty(),
"cache must remain empty after empty batch"
);
let untargeted_recipe = KeyRecipe::new();
cache.insert_batch(&[untargeted_recipe]);
assert!(
cache.is_empty(),
"cache must remain empty when batch contains only untargeted recipes"
);
}
#[test]
fn insert_batch_all_target_types() {
let cache = KeyRecipeCache::new();
let table_recipe = KeyRecipe::new().set_table_name("Albums");
let index_recipe = KeyRecipe::new().set_index_name("AlbumsByArtist");
let query_recipe = KeyRecipe::new().set_operation_uid(42u64);
let untargeted_recipe = KeyRecipe::new();
cache.insert_batch(&[table_recipe, index_recipe, query_recipe, untargeted_recipe]);
assert_eq!(
cache.len(),
3,
"cache length must be 3 for the 3 targeted recipes"
);
assert!(
cache.get_table_recipe("Albums").is_some(),
"table recipe must be retrieved"
);
assert!(
cache.get_index_recipe("AlbumsByArtist").is_some(),
"index recipe must be retrieved"
);
assert!(
cache.get_query_recipe(42).is_some(),
"query recipe must be retrieved"
);
}
#[test]
fn update_from_recipe_list_inserts_all_recipes() {
let cache = KeyRecipeCache::new();
let recipe_list = RecipeList::new().set_recipe(vec![
KeyRecipe::new().set_table_name("Albums"),
KeyRecipe::new().set_index_name("AlbumsBySinger"),
KeyRecipe::new().set_operation_uid(12345u64),
]);
cache.update_from_recipe_list(recipe_list);
assert_eq!(cache.len(), 3, "all 3 recipes in list should be inserted");
assert!(
cache.get_table_recipe("Albums").is_some(),
"table recipe must be present"
);
assert!(
cache.get_index_recipe("AlbumsBySinger").is_some(),
"index recipe must be present"
);
assert!(
cache.get_query_recipe(12345u64).is_some(),
"query recipe must be present"
);
}
#[test]
fn query_recipe_cache_is_bounded_on_insert() {
let cache = KeyRecipeCache::with_query_capacity(3);
for i in 1..=5 {
let recipe = KeyRecipe::new().set_operation_uid(i as u64);
assert!(cache.insert(recipe), "insert must succeed");
}
assert_eq!(
cache.len(),
3,
"cache length must not exceed configured capacity 3"
);
assert!(
cache.get_query_recipe(1).is_none(),
"query 1 must be evicted"
);
assert!(
cache.get_query_recipe(2).is_none(),
"query 2 must be evicted"
);
assert!(
cache.get_query_recipe(3).is_some(),
"query 3 must remain cached"
);
assert!(
cache.get_query_recipe(4).is_some(),
"query 4 must remain cached"
);
assert!(
cache.get_query_recipe(5).is_some(),
"query 5 must remain cached"
);
}
#[test]
fn query_recipe_cache_is_bounded_on_insert_batch() {
let cache = KeyRecipeCache::with_query_capacity(2);
let batch = vec![
KeyRecipe::new().set_operation_uid(100u64),
KeyRecipe::new().set_operation_uid(200u64),
KeyRecipe::new().set_operation_uid(300u64),
KeyRecipe::new().set_table_name("UnboundedTable"),
];
cache.insert_batch(&batch);
assert_eq!(
cache.len(),
3,
"cache must have 1 table and 2 query recipes"
);
assert!(
cache.get_table_recipe("UnboundedTable").is_some(),
"table recipe must not be subject to query capacity limits"
);
assert!(
cache.get_query_recipe(100).is_none(),
"query 100 must be evicted"
);
assert!(
cache.get_query_recipe(200).is_some(),
"query 200 must remain cached"
);
assert!(
cache.get_query_recipe(300).is_some(),
"query 300 must remain cached"
);
}
#[test]
fn query_recipe_cache_overwrite_does_not_evict() {
let cache = KeyRecipeCache::with_query_capacity(2);
assert!(cache.insert(KeyRecipe::new().set_operation_uid(1u64)));
assert!(cache.insert(KeyRecipe::new().set_operation_uid(2u64)));
assert_eq!(cache.len(), 2);
assert!(cache.insert(KeyRecipe::new().set_operation_uid(1u64)));
assert_eq!(
cache.len(),
2,
"overwriting existing query must not change count"
);
assert!(cache.get_query_recipe(1).is_some());
assert!(cache.get_query_recipe(2).is_some());
}
#[test]
fn query_recipe_cache_overwrite_marks_referenced_for_second_chance() {
let cache = KeyRecipeCache::with_query_capacity(2);
assert!(cache.insert(KeyRecipe::new().set_operation_uid(1u64)));
assert!(cache.insert(KeyRecipe::new().set_operation_uid(2u64)));
let updated_recipe = KeyRecipe::new()
.set_operation_uid(1u64)
.set_part(vec![crate::model::key_recipe::Part::new()]);
assert!(cache.insert(updated_recipe));
assert!(cache.insert(KeyRecipe::new().set_operation_uid(3u64)));
assert_eq!(cache.len(), 2, "cache length must remain at capacity 2");
let cached_query_1 = cache
.get_query_recipe(1)
.expect("overwritten query 1 must survive eviction via second chance");
assert_eq!(
cached_query_1.part.len(),
1,
"updated parts must be present"
);
assert!(
cache.get_query_recipe(2).is_none(),
"unreferenced query 2 must be evicted"
);
assert!(
cache.get_query_recipe(3).is_some(),
"new query 3 must be cached"
);
}
#[test]
fn query_recipe_cache_zero_capacity() {
let cache = KeyRecipeCache::with_query_capacity(0);
let query_recipe = KeyRecipe::new().set_operation_uid(1u64);
assert!(
cache.insert(query_recipe),
"insert returns true for valid target"
);
assert_eq!(
cache.len(),
0,
"cache with zero query capacity must store 0 query recipes"
);
assert!(cache.get_query_recipe(1).is_none());
assert!(cache.insert(KeyRecipe::new().set_table_name("Users")));
assert_eq!(cache.len(), 1);
assert!(cache.get_table_recipe("Users").is_some());
}
#[test]
fn clear_preserves_query_capacity() {
let cache = KeyRecipeCache::with_query_capacity(2);
assert!(cache.insert(KeyRecipe::new().set_operation_uid(1u64)));
assert!(cache.insert(KeyRecipe::new().set_operation_uid(2u64)));
assert_eq!(cache.len(), 2);
cache.clear();
assert!(cache.is_empty());
assert!(cache.insert(KeyRecipe::new().set_operation_uid(10u64)));
assert!(cache.insert(KeyRecipe::new().set_operation_uid(20u64)));
assert!(cache.insert(KeyRecipe::new().set_operation_uid(30u64)));
assert_eq!(
cache.len(),
2,
"capacity limit of 2 must be preserved after clear"
);
assert!(cache.get_query_recipe(10).is_none());
assert!(cache.get_query_recipe(20).is_some());
assert!(cache.get_query_recipe(30).is_some());
}
#[test]
fn query_recipe_cache_clock_second_chance_eviction() {
let cache = KeyRecipeCache::with_query_capacity(2);
assert!(cache.insert(KeyRecipe::new().set_operation_uid(1u64)));
assert!(cache.insert(KeyRecipe::new().set_operation_uid(2u64)));
let query_1 = cache.get_query_recipe(1);
assert!(query_1.is_some(), "query 1 must be present");
assert!(cache.insert(KeyRecipe::new().set_operation_uid(3u64)));
assert_eq!(cache.len(), 2, "cache length must remain at capacity 2");
assert!(
cache.get_query_recipe(1).is_some(),
"query 1 received a second chance and must remain cached"
);
assert!(
cache.get_query_recipe(2).is_none(),
"query 2 was not referenced and must be evicted"
);
assert!(
cache.get_query_recipe(3).is_some(),
"newly inserted query 3 must be cached"
);
}
#[test]
fn query_recipe_cache_scan_resistance_against_ad_hoc_burst() {
let cache = KeyRecipeCache::with_query_capacity(3);
assert!(cache.insert(KeyRecipe::new().set_operation_uid(1u64)));
assert!(cache.insert(KeyRecipe::new().set_operation_uid(2u64)));
assert!(cache.get_query_recipe(1).is_some());
assert!(cache.get_query_recipe(2).is_some());
for i in 100u64..110u64 {
assert!(cache.insert(KeyRecipe::new().set_operation_uid(i)));
assert!(cache.get_query_recipe(1).is_some());
assert!(cache.get_query_recipe(2).is_some());
}
assert_eq!(cache.len(), 3, "cache must be capped at 3 entries");
assert!(
cache.get_query_recipe(1).is_some(),
"hot query 1 must survive ad-hoc query flood"
);
assert!(
cache.get_query_recipe(2).is_some(),
"hot query 2 must survive ad-hoc query flood"
);
assert!(
cache.get_query_recipe(109).is_some(),
"latest ad-hoc query 109 should be present"
);
assert!(
cache.get_query_recipe(100).is_none(),
"earlier ad-hoc query 100 must have been evicted"
);
}
}