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