use std::{num::NonZeroUsize, sync::Arc};
use cloudillo_types::meta_adapter::DocFormat;
use lru::LruCache;
use crate::prelude::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
Tenant,
Bundled,
}
impl Source {
pub fn as_str(self) -> &'static str {
match self {
Source::Tenant => "tenant",
Source::Bundled => "bundled",
}
}
}
type CacheKey = (TnId, Box<str>);
type CacheInner = LruCache<CacheKey, Option<DocFormat>>;
#[allow(clippy::large_enum_variant)]
enum Cached {
Miss,
Hit(Option<DocFormat>),
}
#[derive(Clone)]
pub struct DocFormatCache {
inner: Arc<parking_lot::Mutex<CacheInner>>,
}
impl std::fmt::Debug for DocFormatCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let inner = self.inner.lock();
f.debug_struct("DocFormatCache")
.field("len", &inner.len())
.field("cap", &inner.cap())
.finish()
}
}
impl DocFormatCache {
pub fn new(capacity: usize) -> Self {
let n = NonZeroUsize::new(capacity.max(1)).unwrap_or(NonZeroUsize::MIN);
Self { inner: Arc::new(parking_lot::Mutex::new(LruCache::new(n))) }
}
fn get(&self, tn_id: TnId, content_type: &str) -> Cached {
self.inner
.lock()
.get(&(tn_id, content_type.into()))
.map_or(Cached::Miss, |v| Cached::Hit(v.clone()))
}
fn put(&self, tn_id: TnId, content_type: &str, format: Option<DocFormat>) {
self.inner.lock().put((tn_id, content_type.into()), format);
}
fn pop(&self, tn_id: TnId, content_type: &str) {
self.inner.lock().pop(&(tn_id, content_type.into()));
}
}
const DOC_FORMAT_CACHE_CAPACITY: usize = 1024;
pub fn new_doc_format_cache() -> DocFormatCache {
DocFormatCache::new(DOC_FORMAT_CACHE_CAPACITY)
}
pub async fn resolve(app: &App, tn_id: TnId, content_type: &str) -> ClResult<Option<DocFormat>> {
let cache = app.ext::<DocFormatCache>().ok();
if let Some(cache) = cache
&& let Cached::Hit(cached) = cache.get(tn_id, content_type)
{
return Ok(cached);
}
let resolved = match app.meta_adapter.read_doc_format(tn_id, content_type).await? {
Some(row) => Some(row),
None => app.bundled_apps.get(content_type).cloned(),
};
if let Some(cache) = cache {
cache.put(tn_id, content_type, resolved.clone());
}
Ok(resolved)
}
pub fn invalidate(app: &App, tn_id: TnId, content_type: &str) {
if let Ok(cache) = app.ext::<DocFormatCache>() {
cache.pop(tn_id, content_type);
}
}
pub async fn resolve_list(app: &App, tn_id: TnId) -> ClResult<Vec<(DocFormat, Source)>> {
let rows = app.meta_adapter.list_doc_formats(tn_id).await?;
let mut out: Vec<(DocFormat, Source)> = Vec::with_capacity(rows.len() + app.bundled_apps.len());
for row in rows {
out.push((row, Source::Tenant));
}
for bundled in app.bundled_apps.iter() {
if !out.iter().any(|(fmt, _)| fmt.content_type == bundled.content_type) {
out.push((bundled.clone(), Source::Bundled));
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn format(content_type: &str, nav_param: &str) -> DocFormat {
DocFormat {
content_type: content_type.into(),
publisher_tag: "alice.example".into(),
app_name: "notillo".into(),
format_version: Some(1_000_000),
store_tp: Some("CRDT".into()),
nav_param: Some(nav_param.into()),
search: None,
x: None,
updated_at: Timestamp(0),
}
}
#[test]
fn a_miss_and_a_negative_hit_are_different_answers() {
let cache = DocFormatCache::new(8);
assert!(
matches!(cache.get(TnId(1), "cloudillo/notillo"), Cached::Miss),
"nothing cached yet"
);
cache.put(TnId(1), "text/plain", None);
assert!(matches!(cache.get(TnId(1), "text/plain"), Cached::Hit(None)));
}
#[test]
fn a_write_followed_by_an_invalidate_serves_the_new_value() {
let cache = DocFormatCache::new(8);
cache.put(TnId(1), "cloudillo/notillo", Some(format("cloudillo/notillo", "nav")));
let Cached::Hit(Some(cached)) = cache.get(TnId(1), "cloudillo/notillo") else {
panic!("expected a cached format");
};
assert_eq!(cached.nav_param.as_deref(), Some("nav"));
cache.pop(TnId(1), "cloudillo/notillo");
cache.put(TnId(1), "cloudillo/notillo", Some(format("cloudillo/notillo", "page")));
let Cached::Hit(Some(cached)) = cache.get(TnId(1), "cloudillo/notillo") else {
panic!("expected a cached format");
};
assert_eq!(cached.nav_param.as_deref(), Some("page"));
}
#[test]
fn entries_are_keyed_per_tenant() {
let cache = DocFormatCache::new(8);
cache.put(TnId(1), "cloudillo/notillo", Some(format("cloudillo/notillo", "nav")));
assert!(matches!(cache.get(TnId(2), "cloudillo/notillo"), Cached::Miss));
}
#[test]
fn invalidating_drops_the_entry_so_the_next_resolve_re_reads() {
let cache = DocFormatCache::new(8);
cache.put(TnId(1), "cloudillo/notillo", Some(format("cloudillo/notillo", "nav")));
cache.pop(TnId(1), "cloudillo/notillo");
assert!(matches!(cache.get(TnId(1), "cloudillo/notillo"), Cached::Miss));
}
#[test]
fn the_cache_evicts_least_recently_used_rather_than_growing() {
let cache = DocFormatCache::new(2);
cache.put(TnId(1), "a", None);
cache.put(TnId(1), "b", None);
assert!(matches!(cache.get(TnId(1), "a"), Cached::Hit(None)));
cache.put(TnId(1), "c", None);
assert!(
matches!(cache.get(TnId(1), "a"), Cached::Hit(None)),
"the promoted entry survives"
);
assert!(matches!(cache.get(TnId(1), "c"), Cached::Hit(None)));
assert!(
matches!(cache.get(TnId(1), "b"), Cached::Miss),
"the least recently used entry is evicted"
);
}
}