Skip to main content

vector_core/community/
cache.rs

1//! Per-account RAM cache for Community sync state.
2//!
3//! Consolidates what were three scattered `LazyLock` statics (oldest-page cursor, history-start
4//! floors, in-flight page de-dup) into ONE structure under ONE invalidation key: the session
5//! generation. Any access after an account swap — which bumps `current_session_generation()` —
6//! transparently resets the cache, so stale per-channel state can never bleed into the next
7//! account. Holds the page cursors (oldest back-paging floor + newest `since` floor), history-start
8//! flags, and in-flight page de-dup; future RAM-cache work (e.g. invite preload) layers onto the
9//! same structure + invalidation discipline.
10
11use nostr_sdk::Event;
12use std::collections::{HashMap, HashSet};
13use std::sync::{LazyLock, Mutex, MutexGuard};
14use std::time::{Duration, Instant};
15
16#[derive(Default)]
17struct CommunityCache {
18    /// Session generation this cache reflects; a mismatch on access means an account swap
19    /// happened → the cache is reset before use.
20    generation: u64,
21    /// In-flight page fetches, keyed `"{channel_id}:{older|latest}"`. Anti-stampede: an eager
22    /// user scrolling/clicking can't fire the same page twice — the duplicate no-ops.
23    inflight: HashSet<String>,
24    /// Channels whose network history-start has been reached (an older-page fetch found nothing
25    /// strictly older than the cursor). Older-page requests for these go DB-only.
26    history_start: HashSet<String>,
27    /// Oldest OUTER (wire send-time) created_at, in seconds, fetched per channel. The relay
28    /// filters `until` against the outer created_at, so the back-paging cursor MUST be on that
29    /// clock — not the inner authored `at`, which a hostile member can backdate/post-date.
30    oldest_cursor: HashMap<String, u64>,
31    /// Newest OUTER created_at (seconds) seen on a LATEST-page fetch per channel. Used as `since`
32    /// on the next latest fetch so a routine re-sync returns only genuinely-new events instead of
33    /// re-downloading + re-decrypting the same newest page. Advanced ONLY by latest fetches (never
34    /// older pages) — it means "nothing newer than this needs a top-fetch"; any below-page gap is
35    /// a back-pagination concern, not a top-fetch one.
36    newest_cursor: HashMap<String, u64>,
37}
38
39static CACHE: LazyLock<Mutex<CommunityCache>> = LazyLock::new(|| Mutex::new(CommunityCache::default()));
40
41/// Lock the cache, transparently resetting it if the session generation advanced (account swap).
42fn locked() -> MutexGuard<'static, CommunityCache> {
43    let generation = crate::state::current_session_generation();
44    // Poison-tolerant: this cache is pure optimization state, so recover a poisoned guard rather
45    // than cascade-panic every future community sync.
46    let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
47    if cache.generation != generation {
48        *cache = CommunityCache { generation, ..Default::default() };
49    }
50    cache
51}
52
53/// Claim an in-flight page fetch (key `"{channel_id}:{older|latest}"`). Returns `false` if one is
54/// already running — the caller should no-op. Pair with [`end_page_fetch`].
55pub fn try_begin_page_fetch(key: &str) -> bool {
56    locked().inflight.insert(key.to_string())
57}
58
59/// Release an in-flight page-fetch claim (success or error).
60pub fn end_page_fetch(key: &str) {
61    locked().inflight.remove(key);
62}
63
64/// Has the channel's network history-start been reached? Older pages then stay DB-only.
65pub fn is_at_history_start(channel_id: &str) -> bool {
66    locked().history_start.contains(channel_id)
67}
68
69/// Mark the channel as having reached its network history-start.
70pub fn mark_history_start(channel_id: &str) {
71    locked().history_start.insert(channel_id.to_string());
72}
73
74/// Oldest OUTER created_at (seconds) fetched for the channel — the back-paging cursor.
75pub fn oldest_cursor(channel_id: &str) -> Option<u64> {
76    locked().oldest_cursor.get(channel_id).copied()
77}
78
79/// Advance the back-paging cursor to the oldest wire time this page returned (monotonic — only
80/// ever steps further back).
81pub fn advance_oldest_cursor(channel_id: &str, oldest_secs: u64) {
82    let mut cache = locked();
83    let slot = cache.oldest_cursor.entry(channel_id.to_string()).or_insert(oldest_secs);
84    *slot = (*slot).min(oldest_secs);
85}
86
87/// Newest OUTER created_at (seconds) seen on a latest page for the channel — the `since` floor
88/// for the next latest fetch. `None` before the first latest fetch this session (→ full newest page).
89pub fn newest_cursor(channel_id: &str) -> Option<u64> {
90    locked().newest_cursor.get(channel_id).copied()
91}
92
93/// Advance the latest-page `since` floor to the newest wire time this page returned (monotonic —
94/// only ever steps forward). Call ONLY for latest-page fetches.
95pub fn advance_newest_cursor(channel_id: &str, newest_secs: u64) {
96    let mut cache = locked();
97    let slot = cache.newest_cursor.entry(channel_id.to_string()).or_insert(newest_secs);
98    *slot = (*slot).max(newest_secs);
99}
100
101/// Clear a channel's back-paging floors (history-start + oldest cursor) — e.g. after a
102/// multi-epoch backfill makes older history reachable again.
103pub fn clear_channel_floors(channel_id: &str) {
104    let mut cache = locked();
105    cache.history_start.remove(channel_id);
106    cache.oldest_cursor.remove(channel_id);
107}
108
109/// Drop ALL of a channel's sync state (floors + the latest-page `since` cursor) — community
110/// teardown. A surviving `since` cursor makes a same-session REJOIN sync "since I left"
111/// instead of cold, so the rejoined chat opens empty despite plenty of history.
112pub fn clear_channel_sync_state(channel_id: &str) {
113    let mut cache = locked();
114    cache.history_start.remove(channel_id);
115    cache.oldest_cursor.remove(channel_id);
116    cache.newest_cursor.remove(channel_id);
117}
118
119// ── Invite preload ──────────────────────────────────────────────────────────
120// Warmed-ahead-of-Join state: the primary channel's first page, fetched at invite-receive /
121// public-preview time so a Join can open to a populated chat instead of a ~10s sync. RAM-only —
122// nothing is persisted for a community the user hasn't joined, so a declined invite leaves no DB
123// trace. Generation-stamped (cleared on account swap) + TTL'd + capped.
124
125/// How long a warmed page stays promotable. Past this, Join falls back to a normal sync.
126pub(crate) const PRELOAD_TTL: Duration = Duration::from_secs(120);
127/// Max communities warmed at once (bounds memory; oldest evicted on overflow).
128const PRELOAD_MAX: usize = 8;
129
130/// How long the sync will adopt an in-flight (Pending) preload before giving up and fetching itself.
131/// Generous: the preload fetch is itself relay-racing, so adopting it is never slower than firing a
132/// parallel fetch — and a failed preload aborts (→ absent) so the sync falls back immediately, not
133/// at the deadline.
134const PRELOAD_ADOPT_TIMEOUT: Duration = Duration::from_secs(12);
135
136enum PreloadState {
137    /// A warm-up fetch is in flight. A Join can ADOPT it (await this result) instead of firing its
138    /// own — so the speedup holds even when the user taps Join before the warm-up finished.
139    Pending,
140    /// The warmed page is ready to promote/adopt.
141    Ready(Vec<Event>),
142}
143
144struct Preload {
145    state: PreloadState,
146    fetched_at: Instant,
147    generation: u64,
148}
149
150static PRELOAD: LazyLock<Mutex<HashMap<String, Preload>>> =
151    LazyLock::new(|| Mutex::new(HashMap::new()));
152
153fn preload_locked() -> MutexGuard<'static, HashMap<String, Preload>> {
154    PRELOAD.lock().unwrap_or_else(|e| e.into_inner())
155}
156
157/// Mark a community's warm-up as in-flight (so a racing Join adopts it rather than double-fetching).
158/// Evicts stale/cross-generation entries and, if over the cap, the oldest.
159pub fn begin_preload(community_id: &str) {
160    let generation = crate::state::current_session_generation();
161    let mut map = preload_locked();
162    map.retain(|_, p| p.generation == generation && p.fetched_at.elapsed() < PRELOAD_TTL);
163    if map.len() >= PRELOAD_MAX {
164        if let Some(oldest) = map.iter().min_by_key(|(_, p)| p.fetched_at).map(|(k, _)| k.clone()) {
165            map.remove(&oldest);
166        }
167    }
168    map.insert(
169        community_id.to_string(),
170        Preload { state: PreloadState::Pending, fetched_at: Instant::now(), generation },
171    );
172}
173
174/// The warm-up fetch landed — make its page available to promote/adopt.
175pub fn finish_preload(community_id: &str, page: Vec<Event>) {
176    let generation = crate::state::current_session_generation();
177    let mut map = preload_locked();
178    // A warm-up begun under a previous session must not be re-stamped into this one —
179    // drop it instead (the new account fetches its own page if it ever joins).
180    let stale = match map.get_mut(community_id) {
181        Some(p) if p.generation == generation => {
182            p.state = PreloadState::Ready(page);
183            p.fetched_at = Instant::now();
184            false
185        }
186        Some(_) => true,
187        None => false,
188    };
189    if stale {
190        map.remove(community_id);
191    }
192}
193
194/// The warm-up fetch failed/was cancelled — drop the entry so an adopter falls back immediately.
195pub fn abort_preload(community_id: &str) {
196    preload_locked().remove(community_id);
197}
198
199/// Non-blocking take for promotion at Accept: returns the page ONLY if already Ready, leaving a
200/// still-Pending warm-up in place for the sync to adopt. `None` if absent / Pending / stale.
201pub fn take_ready_preload(community_id: &str) -> Option<Vec<Event>> {
202    let generation = crate::state::current_session_generation();
203    let mut map = preload_locked();
204    let fresh = matches!(map.get(community_id), Some(p)
205        if p.generation == generation
206            && p.fetched_at.elapsed() < PRELOAD_TTL
207            && matches!(p.state, PreloadState::Ready(_)));
208    if !fresh {
209        return None;
210    }
211    match map.remove(community_id) {
212        Some(Preload { state: PreloadState::Ready(page), .. }) => Some(page),
213        _ => None,
214    }
215}
216
217/// Adopt a community's warm-up as this sync's page: Ready → take it; Pending → await it (the
218/// in-flight fetch IS the page, so this waits only the request's remaining time, never firing a
219/// second); absent/stale/failed → `None` so the caller fetches normally. Polls at coarse granularity
220/// (imperceptible vs. a fresh round-trip) to stay free of notification races.
221pub async fn take_or_await_preload(community_id: &str) -> Option<Vec<Event>> {
222    let deadline = Instant::now() + PRELOAD_ADOPT_TIMEOUT;
223    loop {
224        {
225            let generation = crate::state::current_session_generation();
226            let mut map = preload_locked();
227            match map.get(community_id) {
228                Some(p) if p.generation == generation && p.fetched_at.elapsed() < PRELOAD_TTL => {
229                    if matches!(p.state, PreloadState::Ready(_)) {
230                        return match map.remove(community_id) {
231                            Some(Preload { state: PreloadState::Ready(page), .. }) => Some(page),
232                            _ => None,
233                        };
234                    }
235                    // Pending → keep waiting.
236                }
237                _ => return None, // absent / stale / aborted → fetch normally
238            }
239        }
240        if Instant::now() >= deadline {
241            return None;
242        }
243        tokio::time::sleep(Duration::from_millis(50)).await;
244    }
245}
246
247/// Drop all cached state (account swap / reset). Access-time generation checks self-reset too,
248/// so this is an explicit belt-and-suspenders teardown.
249pub fn clear() {
250    *PRELOAD.lock().unwrap_or_else(|e| e.into_inner()) = HashMap::new();
251    *CACHE.lock().unwrap_or_else(|e| e.into_inner()) = CommunityCache {
252        generation: crate::state::current_session_generation(),
253        ..Default::default()
254    };
255}