use maud::{Markup, PreEscaped};
use super::{Cache, get_cached, insert_cached};
fn tenant_key_component() -> Option<String> {
crate::tenancy::CURRENT_TENANT
.try_with(Clone::clone)
.ok()
.flatten()
}
fn fragment_key(
prefix: &str,
tenant: Option<&str>,
identity: &str,
version: impl std::fmt::Display,
) -> String {
tenant.map_or_else(
|| format!("{prefix}{}:{identity}:{version}", identity.len()),
|tenant| {
format!(
"{prefix}tenant={}:{tenant}:{}:{identity}:{version}",
tenant.len(),
identity.len()
)
},
)
}
pub fn cache_fragment(
cache: Option<&dyn Cache>,
identity: impl std::fmt::Display,
version: impl std::fmt::Display,
ttl: Option<std::time::Duration>,
render: impl FnOnce() -> Markup,
) -> Markup {
let Some(cache) = cache else {
return render();
};
let identity = identity.to_string();
let tenant = tenant_key_component();
let key = fragment_key("fragment:", tenant.as_deref(), &identity, version);
if let Some(html) = get_cached::<String>(cache, &key) {
return PreEscaped(html);
}
let markup = render();
insert_cached(cache, &key, markup.0.clone(), ttl);
markup
}
pub fn cache_fragment_global(
identity: impl std::fmt::Display,
version: impl std::fmt::Display,
ttl: Option<std::time::Duration>,
render: impl FnOnce() -> Markup,
) -> Markup {
let global = super::global_cache();
cache_fragment(global.as_deref(), identity, version, ttl, render)
}
pub fn cache_fragment_in(
cache: Option<&dyn Cache>,
namespace: &str,
identity: impl std::fmt::Display,
version: impl std::fmt::Display,
ttl: Option<std::time::Duration>,
render: impl FnOnce() -> Markup,
) -> Markup {
let Some(cache) = cache else {
return render();
};
let epoch = super::coherence::namespace_epoch(namespace);
let sampled = epoch.load(std::sync::atomic::Ordering::Acquire);
let identity = identity.to_string();
let tenant = tenant_key_component();
let key = fragment_key(
&format!("{namespace}:fragment:"),
tenant.as_deref(),
&identity,
version,
);
if let Some(html) = get_cached::<String>(cache, &key) {
return PreEscaped(html);
}
let markup = render();
super::coherence::with_fill_fence(&epoch, sampled, || {
insert_cached(cache, &key, markup.0.clone(), ttl);
});
markup
}
pub fn cache_fragment_global_in(
namespace: &str,
identity: impl std::fmt::Display,
version: impl std::fmt::Display,
ttl: Option<std::time::Duration>,
render: impl FnOnce() -> Markup,
) -> Markup {
let global = super::global_cache();
cache_fragment_in(global.as_deref(), namespace, identity, version, ttl, render)
}
#[cfg(all(test, feature = "cache-moka"))]
mod tests {
use std::sync::Arc;
use std::sync::PoisonError;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use maud::{Markup, html};
use super::{
cache_fragment, cache_fragment_global, cache_fragment_global_in, cache_fragment_in,
};
use crate::cache::{
Cache, GLOBAL_CACHE_TEST_LOCK, MokaCache, clear_global_cache, set_global_cache,
};
fn make_cache(capacity: u64) -> MokaCache {
MokaCache::new(capacity, None)
}
#[test]
fn hit_does_not_run_closure() {
let cache = make_cache(100);
let counter = Arc::new(AtomicUsize::new(0));
let first = {
let counter = counter.clone();
cache_fragment(Some(&cache), "post:1", "v1", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { p { "rendered" } }
})
};
assert_eq!(
counter.load(Ordering::SeqCst),
1,
"miss must run closure once"
);
assert!(first.into_string().contains("rendered"));
let second = {
let counter = counter.clone();
cache_fragment(Some(&cache), "post:1", "v1", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { p { "SHOULD NOT APPEAR" } }
})
};
assert_eq!(
counter.load(Ordering::SeqCst),
1,
"hit must not run the render closure"
);
assert!(second.into_string().contains("rendered"));
}
#[test]
fn version_bump_causes_miss() {
let cache = make_cache(100);
let counter = Arc::new(AtomicUsize::new(0));
let v1 = {
let counter = counter.clone();
cache_fragment(Some(&cache), "post:7", "2024-01-01", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { p { "version-one" } }
})
};
assert_eq!(counter.load(Ordering::SeqCst), 1);
assert!(v1.into_string().contains("version-one"));
let v2 = {
let counter = counter.clone();
cache_fragment(Some(&cache), "post:7", "2024-06-01", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { p { "version-two" } }
})
};
assert_eq!(
counter.load(Ordering::SeqCst),
2,
"version bump must produce a miss"
);
assert!(v2.into_string().contains("version-two"));
}
#[test]
fn no_cache_renders_directly_without_panic() {
let counter = Arc::new(AtomicUsize::new(0));
for _ in 0..3 {
let counter = counter.clone();
let result = cache_fragment(None, "post:99", "v1", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { span { "fallback" } }
});
assert!(result.into_string().contains("fallback"));
}
assert_eq!(
counter.load(Ordering::SeqCst),
3,
"no cache must render on every call"
);
}
#[test]
fn ttl_parameter_accepted_and_hit_still_works() {
let cache = make_cache(100);
let counter = Arc::new(AtomicUsize::new(0));
{
let counter = counter.clone();
cache_fragment(
Some(&cache),
"post:2",
"v1",
Some(Duration::from_secs(60)),
move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { em { "ttl-test" } }
},
);
}
assert_eq!(counter.load(Ordering::SeqCst), 1);
{
let counter = counter.clone();
cache_fragment(
Some(&cache),
"post:2",
"v1",
Some(Duration::from_secs(60)),
move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { em { "ttl-test" } }
},
);
}
assert_eq!(counter.load(Ordering::SeqCst), 1, "hit must not re-render");
}
fn inner(cache: &dyn Cache, id: &str, version: &str, counter: &Arc<AtomicUsize>) -> Markup {
let counter = counter.clone();
let version_owned = version.to_owned();
cache_fragment(Some(cache), id, version, None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { li { "fragment " (version_owned) } }
})
}
#[test]
fn russian_doll_nesting_sibling_hit_unchanged_inner() {
let cache = make_cache(100);
let cache_ref: &dyn Cache = &cache;
let inner_a = Arc::new(AtomicUsize::new(0));
let inner_b = Arc::new(AtomicUsize::new(0));
let outer = Arc::new(AtomicUsize::new(0));
{
let a = inner(cache_ref, "inner:a", "v1", &inner_a);
let b = inner(cache_ref, "inner:b", "v1", &inner_b);
let outer_c = outer.clone();
cache_fragment(Some(cache_ref), "outer:list", "outer-v1", None, move || {
outer_c.fetch_add(1, Ordering::SeqCst);
html! { ul { (a) (b) } }
});
}
assert_eq!(inner_a.load(Ordering::SeqCst), 1, "inner-a warmed once");
assert_eq!(inner_b.load(Ordering::SeqCst), 1, "inner-b warmed once");
assert_eq!(outer.load(Ordering::SeqCst), 1, "outer warmed once");
{
let a = inner(cache_ref, "inner:a", "v1", &inner_a);
let b = inner(cache_ref, "inner:b", "v1", &inner_b);
let outer_c = outer.clone();
cache_fragment(Some(cache_ref), "outer:list", "outer-v1", None, move || {
outer_c.fetch_add(1, Ordering::SeqCst);
html! { ul { (a) (b) } }
});
}
assert_eq!(inner_a.load(Ordering::SeqCst), 1, "inner-a stays cached");
assert_eq!(inner_b.load(Ordering::SeqCst), 1, "inner-b stays cached");
assert_eq!(
outer.load(Ordering::SeqCst),
1,
"outer hit: not re-rendered"
);
{
let a = inner(cache_ref, "inner:a", "v2", &inner_a); let b = inner(cache_ref, "inner:b", "v1", &inner_b); let outer_c = outer.clone();
cache_fragment(Some(cache_ref), "outer:list", "outer-v2", None, move || {
outer_c.fetch_add(1, Ordering::SeqCst);
html! { ul { (a) (b) } }
});
}
assert_eq!(
inner_a.load(Ordering::SeqCst),
2,
"inner-a re-renders on version bump"
);
assert_eq!(
inner_b.load(Ordering::SeqCst),
1,
"inner-b sibling stays cached"
);
assert_eq!(
outer.load(Ordering::SeqCst),
2,
"outer re-renders when its version bumped"
);
}
#[test]
fn global_variant_hits_process_global_cache() {
let _guard = GLOBAL_CACHE_TEST_LOCK
.lock()
.unwrap_or_else(PoisonError::into_inner);
clear_global_cache();
let moka = Arc::new(MokaCache::new(100, None));
set_global_cache(moka as Arc<dyn Cache>);
let counter = Arc::new(AtomicUsize::new(0));
{
let counter = counter.clone();
cache_fragment_global("post:global", "v1", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { div { "global" } }
});
}
assert_eq!(counter.load(Ordering::SeqCst), 1, "first call must miss");
{
let counter = counter.clone();
cache_fragment_global("post:global", "v1", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { div { "global" } }
});
}
assert_eq!(counter.load(Ordering::SeqCst), 1, "second call must hit");
clear_global_cache();
}
#[test]
fn global_variant_no_global_cache_renders_fallback() {
let _guard = GLOBAL_CACHE_TEST_LOCK
.lock()
.unwrap_or_else(PoisonError::into_inner);
clear_global_cache();
let result = cache_fragment_global("post:fallback", "v1", None, || html! { span { "ok" } });
assert!(
result.into_string().contains("ok"),
"must render when no global cache"
);
}
#[test]
fn cached_markup_matches_rendered_markup() {
let cache = make_cache(10);
let first: Markup = cache_fragment(Some(&cache), "post:html", "v1", None, || {
html! { article { h1 { "Hello" } p { "World" } } }
});
let first_html = first.into_string();
let second: Markup = cache_fragment(Some(&cache), "post:html", "v1", None, || {
html! { span { "WRONG" } }
});
let second_html = second.into_string();
assert_eq!(first_html, second_html, "cached markup must equal original");
assert!(second_html.contains("Hello"));
assert!(second_html.contains("World"));
assert!(!second_html.contains("WRONG"));
}
#[test]
fn different_identities_are_independent() {
let cache = make_cache(100);
let counter = Arc::new(AtomicUsize::new(0));
let render = |id: &str| {
let counter = counter.clone();
cache_fragment(Some(&cache), id, "v1", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { span { "x" } }
});
};
render("post:A");
render("post:B");
render("post:A"); render("post:B");
assert_eq!(
counter.load(Ordering::SeqCst),
2,
"each identity must be cached independently"
);
}
#[test]
fn colon_in_identity_does_not_alias_distinct_fragments() {
let cache = make_cache(100);
let counter = Arc::new(AtomicUsize::new(0));
let first = {
let counter = counter.clone();
cache_fragment(Some(&cache), "a:b", "c", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { p { "left" } }
})
};
assert_eq!(counter.load(Ordering::SeqCst), 1, "first pair is a miss");
assert!(first.into_string().contains("left"));
let second = {
let counter = counter.clone();
cache_fragment(Some(&cache), "a", "b:c", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { p { "right" } }
})
};
assert_eq!(
counter.load(Ordering::SeqCst),
2,
"a `:` in the identity must not collide with a different (identity, version) split"
);
assert!(second.into_string().contains("right"));
}
#[test]
fn a_namespaced_fragment_is_reachable_by_namespace_invalidation() {
let _guard = GLOBAL_CACHE_TEST_LOCK
.lock()
.unwrap_or_else(PoisonError::into_inner);
clear_global_cache();
let cache: Arc<dyn Cache> = Arc::new(MokaCache::new(64, None));
set_global_cache(cache);
let counter = Arc::new(AtomicUsize::new(0));
let render = |counter: Arc<AtomicUsize>| {
move || -> Markup {
counter.fetch_add(1, Ordering::SeqCst);
html! { p { "sidebar" } }
}
};
cache_fragment_global_in(
"blog::sidebar_fragment",
"post:1",
"v1",
None,
render(counter.clone()),
);
assert_eq!(counter.load(Ordering::SeqCst), 1, "first call is a miss");
cache_fragment_global_in(
"blog::sidebar_fragment",
"post:1",
"v1",
None,
render(counter.clone()),
);
assert_eq!(counter.load(Ordering::SeqCst), 1, "second call is a hit");
assert!(crate::cache::coherence::invalidate_namespace(
"blog::sidebar_fragment"
));
cache_fragment_global_in(
"blog::sidebar_fragment",
"post:1",
"v1",
None,
render(counter.clone()),
);
assert_eq!(
counter.load(Ordering::SeqCst),
2,
"the invalidation must have dropped the entry"
);
clear_global_cache();
}
#[test]
fn a_render_that_spans_an_invalidation_does_not_publish_stale_markup() {
let _guard = GLOBAL_CACHE_TEST_LOCK
.lock()
.unwrap_or_else(PoisonError::into_inner);
clear_global_cache();
let cache: Arc<dyn Cache> = Arc::new(MokaCache::new(64, None));
set_global_cache(cache);
let markup = cache_fragment_global_in("blog::racing", "post:1", "v1", None, || {
assert!(crate::cache::coherence::invalidate_namespace(
"blog::racing"
));
html! { p { "stale" } }
});
assert!(markup.into_string().contains("stale"));
let renders = Arc::new(AtomicUsize::new(0));
let tally = renders.clone();
cache_fragment_global_in("blog::racing", "post:1", "v1", None, move || {
tally.fetch_add(1, Ordering::SeqCst);
html! { p { "fresh" } }
});
assert_eq!(
renders.load(Ordering::SeqCst),
1,
"the fill that raced the invalidation must not have been published"
);
clear_global_cache();
}
#[test]
fn a_plain_fragment_is_not_reachable_by_namespace_invalidation() {
let _guard = GLOBAL_CACHE_TEST_LOCK
.lock()
.unwrap_or_else(PoisonError::into_inner);
clear_global_cache();
let cache: Arc<dyn Cache> = Arc::new(MokaCache::new(64, None));
set_global_cache(cache);
let counter = Arc::new(AtomicUsize::new(0));
let render = |counter: Arc<AtomicUsize>| {
move || -> Markup {
counter.fetch_add(1, Ordering::SeqCst);
html! { p { "sidebar" } }
}
};
cache_fragment_global("post:1", "v1", None, render(counter.clone()));
assert_eq!(counter.load(Ordering::SeqCst), 1);
assert!(crate::cache::coherence::invalidate_namespace(
"blog::sidebar_fragment"
));
cache_fragment_global("post:1", "v1", None, render(counter.clone()));
assert_eq!(
counter.load(Ordering::SeqCst),
1,
"if this became a miss, `cache_fragment` gained a namespace and the \
docs steering declared fragments to `cache_fragment_in` are stale"
);
clear_global_cache();
}
#[test]
fn invalidating_one_fragment_namespace_leaves_the_others() {
let _guard = GLOBAL_CACHE_TEST_LOCK
.lock()
.unwrap_or_else(PoisonError::into_inner);
clear_global_cache();
let cache: Arc<dyn Cache> = Arc::new(MokaCache::new(64, None));
set_global_cache(cache);
let other = Arc::new(AtomicUsize::new(0));
let render = |counter: Arc<AtomicUsize>| {
move || -> Markup {
counter.fetch_add(1, Ordering::SeqCst);
html! { p { "footer" } }
}
};
cache_fragment_global_in("blog::sidebar", "post:1", "v1", None, render(other.clone()));
cache_fragment_global_in("blog::footer", "post:1", "v1", None, render(other.clone()));
assert_eq!(other.load(Ordering::SeqCst), 2);
assert!(crate::cache::coherence::invalidate_namespace(
"blog::sidebar"
));
cache_fragment_global_in("blog::footer", "post:1", "v1", None, render(other.clone()));
assert_eq!(
other.load(Ordering::SeqCst),
2,
"the footer fragment must survive a sidebar invalidation"
);
clear_global_cache();
}
#[test]
fn a_namespaced_identity_containing_a_colon_still_cannot_alias() {
let cache = MokaCache::new(64, None);
let counter = Arc::new(AtomicUsize::new(0));
let first = {
let counter = counter.clone();
cache_fragment_in(Some(&cache), "ns", "a:b", "c", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { p { "left" } }
})
};
assert!(first.into_string().contains("left"));
let second = {
let counter = counter.clone();
cache_fragment_in(Some(&cache), "ns", "a", "b:c", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { p { "right" } }
})
};
assert_eq!(
counter.load(Ordering::SeqCst),
2,
"a `:` in the identity must not collide with a different split"
);
assert!(second.into_string().contains("right"));
}
#[test]
fn two_namespaces_do_not_share_a_fragment() {
let cache = MokaCache::new(64, None);
let counter = Arc::new(AtomicUsize::new(0));
for ns in ["a", "b"] {
let counter = counter.clone();
cache_fragment_in(Some(&cache), ns, "post:1", "v1", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { p { "x" } }
});
}
assert_eq!(
counter.load(Ordering::SeqCst),
2,
"each namespace owns its own entry"
);
}
#[test]
fn a_namespaced_fragment_without_a_cache_still_renders() {
let counter = Arc::new(AtomicUsize::new(0));
for _ in 0..2 {
let counter = counter.clone();
cache_fragment_in(None, "ns", "post:1", "v1", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { p { "x" } }
});
}
assert_eq!(
counter.load(Ordering::SeqCst),
2,
"no cache → always render"
);
}
#[test]
fn no_tenant_context_reads_a_pre_fix_legacy_key() {
let cache = make_cache(100);
let identity = "post:1";
let legacy_key = format!("fragment:{}:{identity}:v1", identity.len());
crate::cache::insert_cached(&cache, &legacy_key, "legacy markup".to_owned(), None);
let markup = cache_fragment(Some(&cache), identity, "v1", None, || {
panic!("must hit the pre-existing legacy key, not re-render")
});
assert_eq!(markup.into_string(), "legacy markup");
}
#[tokio::test]
async fn empty_string_tenant_does_not_alias_no_tenant_context() {
let cache = make_cache(100);
let identity = "post:1";
let outside_any_tenant = cache_fragment(Some(&cache), identity, "v1", None, || {
html! { p { "no-tenant-context" } }
});
let empty_tenant = crate::tenancy::with_tenant(String::new(), async {
cache_fragment(Some(&cache), identity, "v1", None, || {
html! { p { "empty-string-tenant" } }
})
})
.await;
assert_eq!(outside_any_tenant.into_string(), "<p>no-tenant-context</p>");
assert_eq!(
empty_tenant.into_string(),
"<p>empty-string-tenant</p>",
"an empty resolved tenant must get its own isolated cache slot, not alias the \
no-tenant entry"
);
}
}