Skip to main content

cloudillo_core/
profile_me_cache.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! In-memory cache for the `GET /api/me` content-derived ETag.
5//!
6//! `/api/me` is polled frequently by federation followers doing conditional
7//! refreshes. Each request would otherwise hit the auth + meta adapters and
8//! re-serialize the same `ProfileBase`. This cache holds only the
9//! content-derived ETag keyed on `TnId` so a warm conditional poll can answer
10//! `304 Not Modified` without a meta-adapter read. The body is cheap to rebuild
11//! and is regenerated on every `200`, so it is not cached.
12//!
13//! The ETag is a content-derived, truncated SHA-256 digest of the serialized
14//! `ProfileBase` — see `get_tenant_profile_base`. SHA-256 has a fixed, specified
15//! output, so it is stable for unchanged content across Rust versions and
16//! restarts and a follower's `If-None-Match` keeps matching even across cache
17//! rebuilds. The short TTL is only a staleness backstop; explicit `invalidate`
18//! calls on profile/key writes are the real freshness mechanism.
19//!
20//! Modeled directly on `proxy_token_cache::ProxyTokenCache` (`lru::LruCache` +
21//! `parking_lot::Mutex`, `Timestamp`-based TTL).
22
23use std::num::NonZeroUsize;
24use std::sync::Arc;
25
26use lru::LruCache;
27
28use crate::prelude::*;
29
30/// Safety-net TTL for a cached `/api/me` entry. The ETag is content-derived and
31/// stable, so this only bounds staleness for a change not covered by an explicit
32/// `invalidate` (e.g. an unforeseen write path) — kept short.
33const PROFILE_ME_CACHE_TTL_SECS: i64 = 30;
34
35/// Default LRU capacity. ~few hundred bytes per entry × 256 ≈ tens of KB,
36/// comfortably large for realistic tenant counts. The `None` fallback is
37/// unreachable (256 is non-zero) but coded as `NonZeroUsize::MIN` rather than a
38/// panic so this stays panic-free in spirit and in form.
39const DEFAULT_CAPACITY: NonZeroUsize = match NonZeroUsize::new(256) {
40	Some(n) => n,
41	None => NonZeroUsize::MIN,
42};
43
44/// A cached `/api/me` ETag: the content-derived ETag and its expiry. The body
45/// is not cached — the handler rebuilds it on every `200`.
46#[derive(Debug, Clone)]
47struct ProfileMeEntry {
48	etag: Box<str>,
49	valid_until: Timestamp,
50}
51
52type ProfileMeCacheInner = LruCache<TnId, ProfileMeEntry>;
53
54/// LRU-bounded cache of `/api/me` responses keyed on `TnId`.
55///
56/// Uses `parking_lot::Mutex` (no poisoning) because `LruCache::get` mutates
57/// recency state.
58#[derive(Debug)]
59pub struct ProfileMeCache {
60	entries: Arc<parking_lot::Mutex<ProfileMeCacheInner>>,
61}
62
63impl ProfileMeCache {
64	pub fn new() -> Self {
65		Self::with_capacity(DEFAULT_CAPACITY)
66	}
67
68	pub fn with_capacity(capacity: NonZeroUsize) -> Self {
69		Self { entries: Arc::new(parking_lot::Mutex::new(LruCache::new(capacity))) }
70	}
71
72	/// Returns the cached ETag if one is still valid; `None` otherwise.
73	pub fn get(&self, tn_id: TnId) -> Option<Box<str>> {
74		let mut cache = self.entries.lock();
75		let now = Timestamp::now();
76		cache.get(&tn_id).filter(|e| e.valid_until.0 > now.0).map(|e| e.etag.clone())
77	}
78
79	/// Inserts a freshly-computed ETag, stamping its expiry from the configured TTL.
80	pub fn insert(&self, tn_id: TnId, etag: Box<str>) {
81		let valid_until = Timestamp::from_now(PROFILE_ME_CACHE_TTL_SECS);
82		let mut cache = self.entries.lock();
83		cache.put(tn_id, ProfileMeEntry { etag, valid_until });
84	}
85
86	/// Refresh an existing entry's TTL without recomputing its ETag. Called on a
87	/// 304 fast-path hit so a continuously-polled tenant stays warm. No-op if the
88	/// entry is already gone.
89	///
90	/// This makes expiry *sliding*: a steady poll stream keeps an entry alive
91	/// indefinitely, so the 30s TTL no longer bounds staleness for a write path
92	/// that forgets to call `invalidate`. That is acceptable — explicit
93	/// `invalidate` is the real freshness mechanism; the TTL is only a backstop
94	/// for a genuinely idle entry.
95	pub fn touch(&self, tn_id: TnId) {
96		let mut cache = self.entries.lock();
97		if let Some(entry) = cache.get_mut(&tn_id) {
98			entry.valid_until = Timestamp::from_now(PROFILE_ME_CACHE_TTL_SECS);
99		}
100	}
101
102	/// Drops the cached entry for `tn_id`. Call immediately after any write that
103	/// can change what `/api/me` returns (profile fields, signing keys).
104	pub fn invalidate(&self, tn_id: TnId) {
105		let mut cache = self.entries.lock();
106		cache.pop(&tn_id);
107	}
108}
109
110impl Default for ProfileMeCache {
111	fn default() -> Self {
112		Self::new()
113	}
114}
115
116#[cfg(test)]
117mod tests {
118	use super::*;
119
120	#[test]
121	fn touch_extends_valid_until() {
122		let cache = ProfileMeCache::new();
123		let tn_id = TnId(1);
124		cache.insert(tn_id, "etag".into());
125
126		// Force the entry's expiry into the past, then confirm `touch` pushes it
127		// back into the future (sliding expiry) without changing the ETag.
128		{
129			let mut inner = cache.entries.lock();
130			if let Some(e) = inner.get_mut(&tn_id) {
131				e.valid_until = Timestamp(0);
132			}
133		}
134		cache.touch(tn_id);
135
136		let now = Timestamp::now().0;
137		let inner = cache.entries.lock();
138		let entry = inner.peek(&tn_id);
139		assert!(entry.is_some(), "entry should still be present after touch");
140		assert!(
141			entry.is_some_and(|e| e.valid_until.0 > now),
142			"touch should extend valid_until past now"
143		);
144		assert!(entry.is_some_and(|e| &*e.etag == "etag"), "touch must not change the ETag");
145	}
146
147	#[test]
148	fn touch_is_noop_on_missing_key() {
149		let cache = ProfileMeCache::new();
150		// No panic, and nothing materializes for an absent key.
151		cache.touch(TnId(42));
152		assert!(cache.get(TnId(42)).is_none());
153	}
154}
155
156// vim: ts=4