Skip to main content

cloudillo_core/
dir_cache.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Shared LRU cache of folder metadata (parent id, name, is_folder) used to
5//! resolve `withParent` / `withPath` listing options and bounded parent-chain
6//! walks without recursive SQL. The cache holds folder rows only.
7//!
8//! Tenant safety invariants:
9//! - The cache key is `(TnId, file_id)`. Callers MUST pass the requesting
10//!   tenant's `TnId`; never look up by `file_id` alone.
11//! - On insert, the `TnId` recorded MUST be the tenant that owns the row that
12//!   produced the entry. Never insert a row read from a different tenant under
13//!   the requestor's `TnId`.
14//! - Entries store only `parent_id`, `name`, and `is_folder`. They do NOT cache
15//!   access control. ACL checks happen at the call site, not via the cache.
16
17use lru::LruCache;
18use std::num::NonZeroUsize;
19use std::sync::Arc;
20
21use cloudillo_types::prelude::TnId;
22
23#[derive(Debug, Clone)]
24pub struct DirEntry {
25	/// Parent folder file_id. `None` means a root child. Sentinel parents like
26	/// `__root__`, `__trash__`, or the managed-parent constant are stored as-is.
27	pub parent_id: Option<Box<str>>,
28	pub name: Box<str>,
29	/// True when the row is a folder (`file_tp == "FLDR"`). The cache stores ONLY
30	/// folder rows, so every *cached* entry has `is_folder == true`; a non-folder
31	/// `DirEntry` is only ever returned transiently from `resolve_dir_entry`
32	/// (the first hop of a descendant walk) and is never inserted.
33	pub is_folder: bool,
34}
35
36type Key = (TnId, Box<str>);
37
38/// Process-wide LRU cache shared across all tenants. The `TnId` in the key
39/// prevents cross-tenant leakage even when `file_id`s collide.
40#[derive(Clone)]
41pub struct DirCache {
42	inner: Arc<parking_lot::Mutex<LruCache<Key, DirEntry>>>,
43}
44
45impl std::fmt::Debug for DirCache {
46	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47		let inner = self.inner.lock();
48		f.debug_struct("DirCache")
49			.field("len", &inner.len())
50			.field("cap", &inner.cap())
51			.finish()
52	}
53}
54
55impl DirCache {
56	pub fn new(capacity: usize) -> Self {
57		let n = NonZeroUsize::new(capacity.max(1)).unwrap_or(NonZeroUsize::MIN);
58		Self { inner: Arc::new(parking_lot::Mutex::new(LruCache::new(n))) }
59	}
60
61	pub fn get(&self, tn_id: TnId, file_id: &str) -> Option<DirEntry> {
62		let mut cache = self.inner.lock();
63		cache.get(&(tn_id, Box::from(file_id))).cloned()
64	}
65
66	pub fn put(&self, tn_id: TnId, file_id: &str, entry: DirEntry) {
67		let mut cache = self.inner.lock();
68		cache.put((tn_id, Box::from(file_id)), entry);
69	}
70
71	pub fn invalidate(&self, tn_id: TnId, file_id: &str) {
72		let mut cache = self.inner.lock();
73		cache.pop(&(tn_id, Box::from(file_id)));
74	}
75
76	#[cfg(test)]
77	pub fn len(&self) -> usize {
78		self.inner.lock().len()
79	}
80
81	#[cfg(test)]
82	pub fn is_empty(&self) -> bool {
83		self.inner.lock().is_empty()
84	}
85}
86
87/// Construct the process-wide folder-metadata cache with the default capacity.
88/// Capacity is fixed for now (~1k entries ≈ ~150 KB shared across tenants).
89pub fn new_dir_cache() -> DirCache {
90	DirCache::new(1_000)
91}
92
93#[cfg(test)]
94mod tests {
95	use super::*;
96
97	fn entry(parent: Option<&str>, name: &str) -> DirEntry {
98		DirEntry { parent_id: parent.map(Box::from), name: Box::from(name), is_folder: true }
99	}
100
101	#[test]
102	fn insert_get_invalidate() {
103		let cache = DirCache::new(8);
104		let tn = TnId(1);
105
106		assert!(cache.get(tn, "f1").is_none());
107
108		cache.put(tn, "f1", entry(Some("p1"), "Folder One"));
109		let got = cache.get(tn, "f1").expect("present");
110		assert_eq!(got.parent_id.as_deref(), Some("p1"));
111		assert_eq!(got.name.as_ref(), "Folder One");
112
113		cache.invalidate(tn, "f1");
114		assert!(cache.get(tn, "f1").is_none());
115	}
116
117	#[test]
118	fn lru_eviction_beyond_capacity() {
119		let cache = DirCache::new(2);
120		let tn = TnId(1);
121
122		cache.put(tn, "a", entry(None, "A"));
123		cache.put(tn, "b", entry(None, "B"));
124		// Touch "a" so "b" is the least recently used
125		let _ = cache.get(tn, "a");
126		cache.put(tn, "c", entry(None, "C"));
127
128		assert!(cache.get(tn, "a").is_some());
129		assert!(cache.get(tn, "b").is_none(), "b should have been evicted");
130		assert!(cache.get(tn, "c").is_some());
131		assert_eq!(cache.len(), 2);
132	}
133
134	#[test]
135	fn tenant_isolation_same_file_id() {
136		let cache = DirCache::new(8);
137		let tn_a = TnId(1);
138		let tn_b = TnId(2);
139
140		cache.put(tn_a, "shared-id", entry(Some("p-a"), "From A"));
141		cache.put(tn_b, "shared-id", entry(Some("p-b"), "From B"));
142
143		let a = cache.get(tn_a, "shared-id").expect("a");
144		let b = cache.get(tn_b, "shared-id").expect("b");
145		assert_eq!(a.name.as_ref(), "From A");
146		assert_eq!(b.name.as_ref(), "From B");
147		assert_eq!(a.parent_id.as_deref(), Some("p-a"));
148		assert_eq!(b.parent_id.as_deref(), Some("p-b"));
149
150		cache.invalidate(tn_a, "shared-id");
151		assert!(cache.get(tn_a, "shared-id").is_none());
152		assert!(cache.get(tn_b, "shared-id").is_some(), "b unaffected by a invalidation");
153	}
154}
155
156// vim: ts=4