use std::sync::Arc;
use std::time::Duration;
use super::{BoxedCache, Cache, CacheError};
pub const TENANT_PREFIX: &str = "tenant";
#[derive(Clone)]
pub struct ScopedCache {
inner: BoxedCache,
prefix: String,
}
impl ScopedCache {
#[must_use]
pub fn new(inner: BoxedCache, namespace: impl AsRef<str>) -> Self {
Self {
prefix: format!("{}:", namespace.as_ref()),
inner,
}
}
#[must_use]
pub fn for_tenant(inner: BoxedCache, slug: impl AsRef<str>) -> Self {
Self::new(inner, format!("{TENANT_PREFIX}:{}", slug.as_ref()))
}
#[must_use]
pub fn prefix(&self) -> &str {
&self.prefix
}
#[must_use]
pub fn boxed(self) -> BoxedCache {
Arc::new(self)
}
fn k(&self, key: &str) -> String {
format!("{}{key}", self.prefix)
}
}
#[async_trait::async_trait]
impl Cache for ScopedCache {
async fn get(&self, key: &str) -> Result<Option<String>, CacheError> {
self.inner.get(&self.k(key)).await
}
async fn set(&self, key: &str, value: &str, ttl: Option<Duration>) -> Result<(), CacheError> {
self.inner.set(&self.k(key), value, ttl).await
}
async fn delete(&self, key: &str) -> Result<(), CacheError> {
self.inner.delete(&self.k(key)).await
}
async fn exists(&self, key: &str) -> Result<bool, CacheError> {
self.inner.exists(&self.k(key)).await
}
async fn clear(&self) -> Result<(), CacheError> {
self.inner.delete_prefix(&self.prefix).await
}
async fn incr(&self, key: &str, by: i64, ttl: Option<Duration>) -> Result<i64, CacheError> {
self.inner.incr(&self.k(key), by, ttl).await
}
async fn add(&self, key: &str, value: &str, ttl: Option<Duration>) -> Result<bool, CacheError> {
self.inner.add(&self.k(key), value, ttl).await
}
async fn touch(&self, key: &str, ttl: Option<Duration>) -> Result<bool, CacheError> {
self.inner.touch(&self.k(key), ttl).await
}
async fn get_many(
&self,
keys: &[&str],
) -> Result<std::collections::HashMap<String, String>, CacheError> {
let scoped: Vec<String> = keys.iter().map(|k| self.k(k)).collect();
let refs: Vec<&str> = scoped.iter().map(String::as_str).collect();
let got = self.inner.get_many(&refs).await?;
let mut out = std::collections::HashMap::with_capacity(got.len());
for (i, sk) in scoped.iter().enumerate() {
if let Some(v) = got.get(sk) {
out.insert(keys[i].to_owned(), v.clone());
}
}
Ok(out)
}
async fn set_many(
&self,
entries: &[(&str, &str)],
ttl: Option<Duration>,
) -> Result<(), CacheError> {
let scoped: Vec<(String, &str)> = entries.iter().map(|(k, v)| (self.k(k), *v)).collect();
let refs: Vec<(&str, &str)> = scoped.iter().map(|(k, v)| (k.as_str(), *v)).collect();
self.inner.set_many(&refs, ttl).await
}
async fn delete_many(&self, keys: &[&str]) -> Result<(), CacheError> {
let scoped: Vec<String> = keys.iter().map(|k| self.k(k)).collect();
let refs: Vec<&str> = scoped.iter().map(String::as_str).collect();
self.inner.delete_many(&refs).await
}
async fn delete_prefix(&self, prefix: &str) -> Result<(), CacheError> {
self.inner.delete_prefix(&self.k(prefix)).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::InMemoryCache;
fn mem() -> BoxedCache {
Arc::new(InMemoryCache::new())
}
#[tokio::test]
async fn tenants_do_not_see_each_others_keys() {
let shared = mem();
let acme = ScopedCache::for_tenant(shared.clone(), "acme");
let globex = ScopedCache::for_tenant(shared.clone(), "globex");
acme.set("stats", "acme-value", None).await.unwrap();
assert_eq!(
acme.get("stats").await.unwrap().as_deref(),
Some("acme-value")
);
assert_eq!(
globex.get("stats").await.unwrap(),
None,
"globex must not read acme's entry for the same logical key"
);
assert!(!globex.exists("stats").await.unwrap());
}
#[tokio::test]
async fn scoped_clear_leaves_other_tenants_intact() {
let shared = mem();
let acme = ScopedCache::for_tenant(shared.clone(), "acme");
let globex = ScopedCache::for_tenant(shared.clone(), "globex");
acme.set("a", "1", None).await.unwrap();
acme.set("b", "2", None).await.unwrap();
globex.set("a", "9", None).await.unwrap();
acme.clear().await.unwrap();
assert_eq!(acme.get("a").await.unwrap(), None);
assert_eq!(acme.get("b").await.unwrap(), None);
assert_eq!(
globex.get("a").await.unwrap().as_deref(),
Some("9"),
"clearing acme must not touch globex"
);
}
#[tokio::test]
async fn prefix_overlap_between_slugs_is_not_a_collision() {
let shared = mem();
let acme = ScopedCache::for_tenant(shared.clone(), "acme");
let acme_corp = ScopedCache::for_tenant(shared.clone(), "acme-corp");
acme.set("k", "short", None).await.unwrap();
acme_corp.set("k", "long", None).await.unwrap();
acme.clear().await.unwrap();
assert_eq!(acme.get("k").await.unwrap(), None);
assert_eq!(
acme_corp.get("k").await.unwrap().as_deref(),
Some("long"),
"the trailing separator must keep `acme` from matching `acme-corp`"
);
}
#[tokio::test]
async fn get_many_returns_unprefixed_keys() {
let acme = ScopedCache::for_tenant(mem(), "acme");
acme.set_many(&[("x", "1"), ("y", "2")], None)
.await
.unwrap();
let got = acme.get_many(&["x", "y", "absent"]).await.unwrap();
assert_eq!(got.get("x").map(String::as_str), Some("1"));
assert_eq!(got.get("y").map(String::as_str), Some("2"));
assert!(!got.contains_key("absent"));
assert!(
got.keys().all(|k| !k.contains("tenant:")),
"the prefix must not leak back to the caller: {got:?}"
);
}
#[tokio::test]
async fn counters_are_namespaced() {
let shared = mem();
let acme = ScopedCache::for_tenant(shared.clone(), "acme");
let globex = ScopedCache::for_tenant(shared.clone(), "globex");
assert_eq!(acme.incr("hits", 1, None).await.unwrap(), 1);
assert_eq!(acme.incr("hits", 1, None).await.unwrap(), 2);
assert_eq!(
globex.incr("hits", 1, None).await.unwrap(),
1,
"globex starts its own count"
);
}
#[tokio::test]
async fn deletes_are_namespaced() {
let shared = mem();
let acme = ScopedCache::for_tenant(shared.clone(), "acme");
let globex = ScopedCache::for_tenant(shared.clone(), "globex");
acme.set_many(&[("p", "1"), ("q", "2")], None)
.await
.unwrap();
globex
.set_many(&[("p", "8"), ("q", "9")], None)
.await
.unwrap();
acme.delete_many(&["p", "q"]).await.unwrap();
assert_eq!(acme.get("p").await.unwrap(), None);
assert_eq!(globex.get("p").await.unwrap().as_deref(), Some("8"));
assert_eq!(globex.get("q").await.unwrap().as_deref(), Some("9"));
}
#[tokio::test]
async fn null_cache_prefix_delete_is_a_noop() {
let null: BoxedCache = Arc::new(crate::cache::NullCache);
let scoped = ScopedCache::for_tenant(null, "acme");
scoped.clear().await.unwrap();
assert_eq!(scoped.get("anything").await.unwrap(), None);
}
}