1use std::{num::NonZeroUsize, sync::Arc};
22
23use cloudillo_types::meta_adapter::DocFormat;
24use lru::LruCache;
25
26use crate::prelude::*;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Source {
31 Tenant,
33 Bundled,
35}
36
37impl Source {
38 pub fn as_str(self) -> &'static str {
40 match self {
41 Source::Tenant => "tenant",
42 Source::Bundled => "bundled",
43 }
44 }
45}
46
47type CacheKey = (TnId, Box<str>);
48type CacheInner = LruCache<CacheKey, Option<DocFormat>>;
49
50#[allow(clippy::large_enum_variant)]
56enum Cached {
57 Miss,
58 Hit(Option<DocFormat>),
59}
60
61#[derive(Clone)]
81pub struct DocFormatCache {
82 inner: Arc<parking_lot::Mutex<CacheInner>>,
83}
84
85impl std::fmt::Debug for DocFormatCache {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 let inner = self.inner.lock();
88 f.debug_struct("DocFormatCache")
89 .field("len", &inner.len())
90 .field("cap", &inner.cap())
91 .finish()
92 }
93}
94
95impl DocFormatCache {
96 pub fn new(capacity: usize) -> Self {
97 let n = NonZeroUsize::new(capacity.max(1)).unwrap_or(NonZeroUsize::MIN);
98 Self { inner: Arc::new(parking_lot::Mutex::new(LruCache::new(n))) }
99 }
100
101 fn get(&self, tn_id: TnId, content_type: &str) -> Cached {
103 self.inner
104 .lock()
105 .get(&(tn_id, content_type.into()))
106 .map_or(Cached::Miss, |v| Cached::Hit(v.clone()))
107 }
108
109 fn put(&self, tn_id: TnId, content_type: &str, format: Option<DocFormat>) {
110 self.inner.lock().put((tn_id, content_type.into()), format);
111 }
112
113 fn pop(&self, tn_id: TnId, content_type: &str) {
114 self.inner.lock().pop(&(tn_id, content_type.into()));
115 }
116}
117
118const DOC_FORMAT_CACHE_CAPACITY: usize = 1024;
121
122pub fn new_doc_format_cache() -> DocFormatCache {
125 DocFormatCache::new(DOC_FORMAT_CACHE_CAPACITY)
126}
127
128pub async fn resolve(app: &App, tn_id: TnId, content_type: &str) -> ClResult<Option<DocFormat>> {
130 let cache = app.ext::<DocFormatCache>().ok();
134 if let Some(cache) = cache
135 && let Cached::Hit(cached) = cache.get(tn_id, content_type)
136 {
137 return Ok(cached);
138 }
139
140 let resolved = match app.meta_adapter.read_doc_format(tn_id, content_type).await? {
141 Some(row) => Some(row),
142 None => app.bundled_apps.get(content_type).cloned(),
143 };
144
145 if let Some(cache) = cache {
146 cache.put(tn_id, content_type, resolved.clone());
147 }
148 Ok(resolved)
149}
150
151pub fn invalidate(app: &App, tn_id: TnId, content_type: &str) {
154 if let Ok(cache) = app.ext::<DocFormatCache>() {
155 cache.pop(tn_id, content_type);
156 }
157}
158
159pub async fn resolve_list(app: &App, tn_id: TnId) -> ClResult<Vec<(DocFormat, Source)>> {
162 let rows = app.meta_adapter.list_doc_formats(tn_id).await?;
163 let mut out: Vec<(DocFormat, Source)> = Vec::with_capacity(rows.len() + app.bundled_apps.len());
164 for row in rows {
165 out.push((row, Source::Tenant));
166 }
167 for bundled in app.bundled_apps.iter() {
168 if !out.iter().any(|(fmt, _)| fmt.content_type == bundled.content_type) {
169 out.push((bundled.clone(), Source::Bundled));
170 }
171 }
172 Ok(out)
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 fn format(content_type: &str, nav_param: &str) -> DocFormat {
180 DocFormat {
181 content_type: content_type.into(),
182 publisher_tag: "alice.example".into(),
183 app_name: "notillo".into(),
184 format_version: Some(1_000_000),
185 store_tp: Some("CRDT".into()),
186 nav_param: Some(nav_param.into()),
187 search: None,
188 x: None,
189 updated_at: Timestamp(0),
190 }
191 }
192
193 #[test]
194 fn a_miss_and_a_negative_hit_are_different_answers() {
195 let cache = DocFormatCache::new(8);
196 assert!(
197 matches!(cache.get(TnId(1), "cloudillo/notillo"), Cached::Miss),
198 "nothing cached yet"
199 );
200
201 cache.put(TnId(1), "text/plain", None);
205 assert!(matches!(cache.get(TnId(1), "text/plain"), Cached::Hit(None)));
206 }
207
208 #[test]
212 fn a_write_followed_by_an_invalidate_serves_the_new_value() {
213 let cache = DocFormatCache::new(8);
214 cache.put(TnId(1), "cloudillo/notillo", Some(format("cloudillo/notillo", "nav")));
215 let Cached::Hit(Some(cached)) = cache.get(TnId(1), "cloudillo/notillo") else {
216 panic!("expected a cached format");
217 };
218 assert_eq!(cached.nav_param.as_deref(), Some("nav"));
219
220 cache.pop(TnId(1), "cloudillo/notillo");
222 cache.put(TnId(1), "cloudillo/notillo", Some(format("cloudillo/notillo", "page")));
223 let Cached::Hit(Some(cached)) = cache.get(TnId(1), "cloudillo/notillo") else {
224 panic!("expected a cached format");
225 };
226 assert_eq!(cached.nav_param.as_deref(), Some("page"));
227 }
228
229 #[test]
230 fn entries_are_keyed_per_tenant() {
231 let cache = DocFormatCache::new(8);
232 cache.put(TnId(1), "cloudillo/notillo", Some(format("cloudillo/notillo", "nav")));
233 assert!(matches!(cache.get(TnId(2), "cloudillo/notillo"), Cached::Miss));
236 }
237
238 #[test]
239 fn invalidating_drops_the_entry_so_the_next_resolve_re_reads() {
240 let cache = DocFormatCache::new(8);
241 cache.put(TnId(1), "cloudillo/notillo", Some(format("cloudillo/notillo", "nav")));
242 cache.pop(TnId(1), "cloudillo/notillo");
243 assert!(matches!(cache.get(TnId(1), "cloudillo/notillo"), Cached::Miss));
246 }
247
248 #[test]
249 fn the_cache_evicts_least_recently_used_rather_than_growing() {
250 let cache = DocFormatCache::new(2);
253 cache.put(TnId(1), "a", None);
254 cache.put(TnId(1), "b", None);
255 assert!(matches!(cache.get(TnId(1), "a"), Cached::Hit(None)));
257 cache.put(TnId(1), "c", None);
258
259 assert!(
260 matches!(cache.get(TnId(1), "a"), Cached::Hit(None)),
261 "the promoted entry survives"
262 );
263 assert!(matches!(cache.get(TnId(1), "c"), Cached::Hit(None)));
264 assert!(
265 matches!(cache.get(TnId(1), "b"), Cached::Miss),
266 "the least recently used entry is evicted"
267 );
268 }
269}
270
271