Skip to main content

cloudillo_core/
doc_format.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Resolving a document format: the tenant's row, or the bundle's default.
5//!
6//! Two tiers answer "which app owns this content type, and how is it indexed":
7//! the tenant's own `doc_formats` row, and the in-memory
8//! [`crate::bundled_apps::BundledAppRegistry`] this build loaded from `dist`.
9//!
10//! **The tenant row always wins**, whatever version either side states. A row
11//! means the tenant deliberately installed something — usually a packaged app
12//! whose code lives in a blob — so a backend upgrade shipping a newer bundled
13//! manifest must never silently repoint it. Dropping the row
14//! (`DELETE /api/doc-formats/{content_type}`) reverts to the bundled entry; there
15//! is no way to suppress a bundled format outright, only to override it.
16//!
17//! Every *reader* goes through here so the two tiers cannot drift apart. The
18//! *writer* deliberately does not: `put_doc_format`'s claim check reads the tenant
19//! row alone, because a bundled manifest is a default rather than a claim.
20
21use std::{num::NonZeroUsize, sync::Arc};
22
23use cloudillo_types::meta_adapter::DocFormat;
24use lru::LruCache;
25
26use crate::prelude::*;
27
28/// Which tier a resolved format came from.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Source {
31	/// A `doc_formats` row this tenant owns.
32	Tenant,
33	/// This build's bundle, with no tenant row overriding it.
34	Bundled,
35}
36
37impl Source {
38	/// Wire form, for the `GET /api/doc-formats` listing.
39	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/// What a cache lookup found. Three states, not two: `Hit(None)` — "cached as
51/// governed by no format" — is a real answer, and the one that would otherwise
52/// cost the round trip on every lookup.
53// `Hit` is matched and dropped at the call site, never stored, so the padding
54// never outlives one expression — and a `Box` would cost an allocation per hit.
55#[allow(clippy::large_enum_variant)]
56enum Cached {
57	Miss,
58	Hit(Option<DocFormat>),
59}
60
61/// Resolved formats, keyed by `(tn_id, content_type)`.
62///
63/// A per-`App` extension rather than a static, so two `App`s in one process —
64/// integration tests, embedded or multi-instance hosting — cannot contradict each
65/// other's tenant rows. Keyed per tenant because the tenant's own `doc_formats`
66/// row wins over the bundle. Invalidated by [`invalidate`] on every write through
67/// `put_doc_format`/`delete_doc_format`.
68///
69/// [`resolve`] sits on two hot paths: one read per distinct content type in a
70/// search result page (on the anonymous search route), and one read per document
71/// during an index sweep. `None` is cached too — a content type governed by no
72/// format is the common answer, and exactly the one that costs the round trip.
73///
74/// `parking_lot::Mutex` rather than an `RwLock`, as in
75/// [`crate::dir_cache::DirCache`]: an LRU read promotes its entry, so even a
76/// lookup needs exclusive access. `content_type` comes from
77/// `files.content_type` and is caller-influenced, so an unbounded negative cache
78/// would be a memory-growth vector; eviction bounds it without throwing away the
79/// hot working set the way a clear-on-full cap would.
80#[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	/// The cached resolution, promoting it.
102	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
118/// A handful of content types per tenant is the realistic working set; the cap is
119/// there to bound the caller-influenced miss stream, not to size the hot set.
120const DOC_FORMAT_CACHE_CAPACITY: usize = 1024;
121
122/// Build an empty [`DocFormatCache`], so the server crate can register one
123/// without taking `lru`/`parking_lot` dependencies of its own.
124pub fn new_doc_format_cache() -> DocFormatCache {
125	DocFormatCache::new(DOC_FORMAT_CACHE_CAPACITY)
126}
127
128/// The format that governs `content_type` for this tenant, if any.
129pub async fn resolve(app: &App, tn_id: TnId, content_type: &str) -> ClResult<Option<DocFormat>> {
130	// Absent when `App` is built without the server crate's extension set — in
131	// tests. Resolve uncached rather than fail, as `action_rules` does: the cache
132	// is an optimization, not a source of truth.
133	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
151/// Drop one `(tn_id, content_type)` entry. Call after every successful write to
152/// `doc_formats`, or a `PUT` does not take effect until restart.
153pub 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
159/// Every format in effect for this tenant: its own rows, plus each bundled entry
160/// no row overrides.
161pub 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		// A content type governed by no format is the common answer, and it is the
202		// one that costs the read — so it is cached too, and must read back as a
203		// hit carrying `None` rather than as a miss.
204		cache.put(TnId(1), "text/plain", None);
205		assert!(matches!(cache.get(TnId(1), "text/plain"), Cached::Hit(None)));
206	}
207
208	/// A `PUT` that changes the nav param must be visible to the next resolve
209	/// once the write path has invalidated — otherwise the old param is served
210	/// until restart.
211	#[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		// What `put_doc_format` does after the adapter write succeeds.
221		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		// The resolution is per-tenant — the tenant's own row wins over the
234		// bundle — so tenant 2 must not read tenant 1's answer.
235		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		// Without this a `PUT /api/doc-formats/{content_type}` would not take
244		// effect until restart.
245		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		// `content_type` comes from `files.content_type` and is caller-influenced,
251		// so an unbounded negative cache would be a memory-growth vector.
252		let cache = DocFormatCache::new(2);
253		cache.put(TnId(1), "a", None);
254		cache.put(TnId(1), "b", None);
255		// Promotes "a", so "b" becomes the least recently used.
256		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// vim: ts=4