Skip to main content

vector_core/community/
cache.rs

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