Skip to main content

hashiverse_server_lib/server/
post_bundle_caching.rs

1//! # Post-bundle read cache
2//!
3//! A `moka` weighted cache that holds recently-served post bundles in RAM so repeated
4//! reads for the same `(location_id, originator)` don't thrash disk. Multiple versions
5//! per `location_id` are kept because different peer originators hold different slices
6//! of the same bucket — the cache index is keyed by `(location_id, originator_id)`.
7//!
8//! The cache doubles as a **propagation hint engine**: once a hot entry has been
9//! requested more than [`crate::server::post_bundle_caching_shared::CACHE_HIT_THRESHOLD`]
10//! times (10), the next requester's response includes a signed
11//! [`hashiverse_lib::protocol::payload::payload::CacheRequestTokenV1`] asking them to
12//! forward the bundle to additional servers. This turns read pressure into better
13//! cache distribution without requiring explicit coordination.
14//!
15//! An `inflight` sibling cache tracks outstanding tokens so the same hot bundle doesn't
16//! generate redundant cache-request tokens in parallel.
17
18use bytes::Bytes;
19use hashiverse_lib::protocol::payload::payload::CacheRequestTokenV1;
20use hashiverse_lib::protocol::peer::Peer;
21use hashiverse_lib::tools::buckets::BucketLocation;
22use hashiverse_lib::tools::server_id::ServerId;
23use hashiverse_lib::tools::time::{TimeMillis, MILLIS_IN_MINUTE};
24use hashiverse_lib::tools::time_provider::moka_clock::TimeProviderMokaClock;
25use hashiverse_lib::tools::time_provider::time_provider::TimeProvider;
26use hashiverse_lib::tools::tools::leading_agreement_bits_xor;
27use hashiverse_lib::tools::types::Id;
28use moka::sync::Cache;
29use std::collections::HashMap;
30use std::sync::{Arc, Mutex};
31
32use crate::server::post_bundle_caching_shared::{CachedBundle, GetCacheResult, CACHE_HIT_THRESHOLD, CACHE_LOCATION_TTI, CACHE_REQUEST_TOKEN_TTL_DURATION, CACHE_REQUEST_TOKEN_TTL_DURATION_MILLIS};
33
34/// Placeholder weight for post bundle entries — approximates the real data that will replace it.
35/// Ballpark: 20 posts × 50 KB each × up to 5 originators ≈ 5 MB per location.
36const POST_BUNDLE_PLACEHOLDER_WEIGHT: u32 = 4 * 1024 * 1024;
37
38/// A cached bundle plus its signed header timestamp, so a replayed older version from the same originator can't replace a newer one.
39struct CachedPostBundle {
40    bundle_time_millis: TimeMillis,
41    cached_bundle: CachedBundle,
42}
43
44// --------------------------------------------------------------------------------------------
45// CachedPostBundleLocationEntry
46// --------------------------------------------------------------------------------------------
47
48/// Per-location entry for post bundles.
49/// Multiple originator versions are stored — different servers may hold different subsets of posts.
50struct CachedPostBundleLocationEntry {
51    /// Originator peer_id → cached bundle.
52    bundles: HashMap<Id, CachedPostBundle>,
53    hit_count: u32,
54}
55
56impl CachedPostBundleLocationEntry {
57    fn placeholder() -> Self {
58        Self { bundles: HashMap::new(), hit_count: 0 }
59    }
60
61    fn weight(&self) -> u32 {
62        let total: u32 = self.bundles.values().map(|cached_post_bundle| cached_post_bundle.cached_bundle.bytes.len() as u32).sum();
63        if total == 0 { POST_BUNDLE_PLACEHOLDER_WEIGHT } else { total }
64    }
65}
66
67// --------------------------------------------------------------------------------------------
68// PostBundleCache
69// --------------------------------------------------------------------------------------------
70
71/// Intermediate-server cache for `EncodedPostBundleV1` data.
72///
73/// Two Moka caches:
74/// - `bundles`: weighted `Cache<Id, Arc<Mutex<CachedPostBundleLocationEntry>>>` with TTI.
75///   If a location_id hasn't been queried within `CACHE_LOCATION_TTI`, the entire entry is evicted.
76///   Individual bundles within an entry may also be stale (live bundles have a per-bundle
77///   `expires_at`; sealed bundles are never individually stale).
78/// - `inflight`: `Cache<Id, ()>` with 30-second TTL — tracks locations for which a
79///   `CacheRequestToken` has been issued but the client hasn't uploaded yet.
80pub struct PostBundleCache {
81    max_originators_per_location: usize,
82    bundles: Cache<Id, Arc<Mutex<CachedPostBundleLocationEntry>>>,
83    inflight: Cache<Id, ()>,
84}
85
86impl PostBundleCache {
87    pub fn new(max_originators_per_location: usize, max_bytes: u64, time_provider: Arc<dyn TimeProvider>) -> Self {
88        // Drive moka's TTI/TTL from our TimeProvider (scaled in tests) rather than wall time.
89        let clock = Arc::new(TimeProviderMokaClock::new(time_provider));
90
91        let bundles = Cache::builder()
92            .weigher(|_key: &Id, entry: &Arc<Mutex<CachedPostBundleLocationEntry>>| {
93                entry.lock().map(|e| e.weight()).unwrap_or(POST_BUNDLE_PLACEHOLDER_WEIGHT)
94            })
95            .max_capacity(max_bytes)
96            .time_to_idle(CACHE_LOCATION_TTI)
97            .external_clock(clock.clone())
98            .build();
99
100        let inflight = Cache::builder()
101            .time_to_live(CACHE_REQUEST_TOKEN_TTL_DURATION)
102            .external_clock(clock)
103            .build();
104
105        Self { max_originators_per_location, bundles, inflight }
106    }
107
108    /// Called by the dispatch handler when serving a `GetPostBundleV1` request.
109    ///
110    /// - `bucket_location` — used as the cache key (`location_id`) and included in any token issued.
111    /// - `already_retrieved_peer_ids` — originator IDs the client already has; filtered out of `cached_items`.
112    /// - `peer_self` / `server_id` — used to sign the `CacheRequestToken` if one is issued.
113    /// - `now` — current time.
114    pub fn on_get(
115        &self,
116        bucket_location: &BucketLocation,
117        already_retrieved_peer_ids: &[Id],
118        peer_self: &Peer,
119        server_id: &ServerId,
120        now: TimeMillis,
121    ) -> GetCacheResult {
122        let location_id = bucket_location.location_id;
123        let entry_arc = self.bundles.get_with(location_id, || Arc::new(Mutex::new(CachedPostBundleLocationEntry::placeholder())));
124
125        let (cached_items, already_cached_peer_ids, should_issue_token) = {
126            let mut entry = entry_arc.lock().unwrap();
127            entry.hit_count += 1;
128
129            let already_retrieved_set: std::collections::HashSet<Id> = already_retrieved_peer_ids.iter().copied().collect();
130            let cached_items: Vec<Bytes> = entry.bundles
131                .iter()
132                .filter(|(originator_id, cached_post_bundle)| !already_retrieved_set.contains(originator_id) && !cached_post_bundle.cached_bundle.is_stale(now))
133                .map(|(_, cached_post_bundle)| cached_post_bundle.cached_bundle.bytes.clone())
134                .collect();
135
136            let already_cached_peer_ids: Vec<Id> = entry.bundles.keys().copied().collect();
137            let should_issue_token = entry.hit_count >= CACHE_HIT_THRESHOLD && !self.inflight.contains_key(&location_id);
138            (cached_items, already_cached_peer_ids, should_issue_token)
139        };
140
141        let cache_request_token = if should_issue_token {
142            self.inflight.insert(location_id, ());
143            let expires_at = now + CACHE_REQUEST_TOKEN_TTL_DURATION_MILLIS;
144            Some(CacheRequestTokenV1::new(peer_self.clone(), bucket_location.clone(), expires_at, already_cached_peer_ids, &server_id.keys.signature_key))
145        } else {
146            None
147        };
148
149        GetCacheResult { cached_items, cache_request_token }
150    }
151
152    /// Called by the dispatch handler when a `CachePostBundleV1` upload arrives.
153    /// The token must have been verified and expiry-checked by the caller.
154    /// `bundle_time_millis` is the bundle's signed `header.time_millis`: an upload older than the bundle already cached
155    /// for the same originator is rejected (newest wins). There is deliberately no flat age limit - old sealed bundles are fine.
156    /// Returns `true` if accepted, `false` if the entry was evicted before the upload arrived, the upload is older than
157    /// the cached bundle for this originator, or it lost the per-location capacity eviction.
158    pub fn on_upload(
159        &self,
160        location_id: Id,
161        originator_peer_id: Id,
162        bundle_bytes: Bytes,
163        bundle_time_millis: TimeMillis,
164        server_time: TimeMillis,
165        is_sealed: bool,
166    ) -> bool {
167        let entry_arc = match self.bundles.get(&location_id) {
168            Some(e) => e,
169            None => return false,   // Entry evicted between token issuance and upload — reject.
170        };
171
172        let mut entry = entry_arc.lock().unwrap();
173        if let Some(existing_cached_post_bundle) = entry.bundles.get(&originator_peer_id)
174            && bundle_time_millis < existing_cached_post_bundle.bundle_time_millis
175        {
176            return false;
177        }
178
179        let expires_at = if is_sealed { None } else { Some(server_time + MILLIS_IN_MINUTE.const_mul(5)) };
180        let cached_post_bundle = CachedPostBundle { bundle_time_millis, cached_bundle: CachedBundle { bytes: bundle_bytes, expires_at } };
181
182        // Insert (or update) the new originator
183        entry.bundles.insert(originator_peer_id, cached_post_bundle);
184
185        // If over capacity, evict the worst entry: furthest from location_id,
186        // breaking ties by expires_at (stalest loses). This may evict the entry
187        // we just inserted if it's the worst — that's correct.
188        // This will also prevent sybils trying to insert garbage cache items - they would have to control the location_id
189        while entry.bundles.len() > self.max_originators_per_location {
190            let evict_key = entry.bundles
191                .iter()
192                .min_by(|(id_a, cached_post_bundle_a), (id_b, cached_post_bundle_b)| {
193                    let distance_a = leading_agreement_bits_xor(id_a.as_ref(), location_id.as_ref());
194                    let distance_b = leading_agreement_bits_xor(id_b.as_ref(), location_id.as_ref());
195                    distance_a.cmp(&distance_b).then_with(|| {
196                        let expires_a = cached_post_bundle_a.cached_bundle.expires_at.unwrap_or(TimeMillis(i64::MAX));
197                        let expires_b = cached_post_bundle_b.cached_bundle.expires_at.unwrap_or(TimeMillis(i64::MAX));
198                        expires_a.cmp(&expires_b)
199                    })
200                })
201                .map(|(id, _)| *id);
202            if let Some(k) = evict_key {
203                entry.bundles.remove(&k);
204            }
205        }
206
207        // Return whether our insertion survived eviction
208        entry.bundles.contains_key(&originator_peer_id)
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use bytes::Bytes;
216    use hashiverse_lib::tools::buckets::{BucketLocation, BucketType, BUCKET_DURATIONS};
217    use hashiverse_lib::tools::server_id::ServerId;
218    use hashiverse_lib::tools::time::TimeMillis;
219    use hashiverse_lib::tools::time_provider::time_provider::RealTimeProvider;
220    use hashiverse_lib::tools::pow_generator::single_threaded_pow_generator::SingleThreadedPowGenerator;
221    use hashiverse_lib::tools::types::{Id, Pow};
222
223    async fn make_test_server_and_peer() -> anyhow::Result<(ServerId, hashiverse_lib::protocol::peer::Peer)> {
224        let time_provider = RealTimeProvider;
225        let pow_generator = SingleThreadedPowGenerator::new();
226        let server_id = ServerId::new("own_pow", &time_provider, Pow(0), true, &pow_generator).await?;
227        let peer = server_id.to_peer(&time_provider)?;
228        Ok((server_id, peer))
229    }
230
231    fn make_test_bucket_location() -> BucketLocation {
232        BucketLocation::new(BucketType::User, Id::random(), BUCKET_DURATIONS[0], TimeMillis(1_000_000)).unwrap()
233    }
234
235    #[tokio::test]
236    async fn test_below_threshold_no_token() -> anyhow::Result<()> {
237        let (server_id, peer_self) = make_test_server_and_peer().await?;
238        let cache = PostBundleCache::new(5, 64 * 1024 * 1024, Arc::new(RealTimeProvider));
239        let bucket_location = make_test_bucket_location();
240        let now = TimeMillis(1_000_000);
241
242        for _ in 0..(CACHE_HIT_THRESHOLD - 1) {
243            let result = cache.on_get(&bucket_location, &[], &peer_self, &server_id, now);
244            assert!(result.cache_request_token.is_none());
245            assert!(result.cached_items.is_empty());
246        }
247
248        Ok(())
249    }
250
251    #[tokio::test]
252    async fn test_at_threshold_token_issued_then_deduplicated() -> anyhow::Result<()> {
253        let (server_id, peer_self) = make_test_server_and_peer().await?;
254        let cache = PostBundleCache::new(5, 64 * 1024 * 1024, Arc::new(RealTimeProvider));
255        let bucket_location = make_test_bucket_location();
256        let now = TimeMillis(1_000_000);
257
258        for _ in 0..(CACHE_HIT_THRESHOLD - 1) {
259            let result = cache.on_get(&bucket_location, &[], &peer_self, &server_id, now);
260            assert!(result.cache_request_token.is_none());
261        }
262
263        // The threshold-th call issues a token
264        let result = cache.on_get(&bucket_location, &[], &peer_self, &server_id, now);
265        assert!(result.cache_request_token.is_some());
266
267        // Subsequent calls must NOT double-issue (inflight dedupe)
268        let result2 = cache.on_get(&bucket_location, &[], &peer_self, &server_id, now);
269        assert!(result2.cache_request_token.is_none());
270
271        Ok(())
272    }
273
274    #[tokio::test]
275    async fn test_upload_and_retrieval() -> anyhow::Result<()> {
276        let (server_id, peer_self) = make_test_server_and_peer().await?;
277        let cache = PostBundleCache::new(5, 64 * 1024 * 1024, Arc::new(RealTimeProvider));
278        let bucket_location = make_test_bucket_location();
279        let location_id = bucket_location.location_id;
280        let now = TimeMillis(1_000_000);
281        let originator_id = Id::random();
282        let bundle_bytes = Bytes::from_static(b"test_bundle");
283
284        // Register the placeholder entry via on_get
285        cache.on_get(&bucket_location, &[], &peer_self, &server_id, now);
286
287        let accepted = cache.on_upload(location_id, originator_id, bundle_bytes.clone(), now, now, false);
288        assert!(accepted);
289
290        let result = cache.on_get(&bucket_location, &[], &peer_self, &server_id, now);
291        assert_eq!(result.cached_items, vec![bundle_bytes]);
292
293        Ok(())
294    }
295
296    #[tokio::test]
297    async fn test_already_retrieved_filtered() -> anyhow::Result<()> {
298        let (server_id, peer_self) = make_test_server_and_peer().await?;
299        let cache = PostBundleCache::new(5, 64 * 1024 * 1024, Arc::new(RealTimeProvider));
300        let bucket_location = make_test_bucket_location();
301        let location_id = bucket_location.location_id;
302        let now = TimeMillis(1_000_000);
303        let originator_id = Id::random();
304
305        cache.on_get(&bucket_location, &[], &peer_self, &server_id, now);
306        cache.on_upload(location_id, originator_id, Bytes::from_static(b"bundle"), now, now, false);
307
308        let result = cache.on_get(&bucket_location, &[originator_id], &peer_self, &server_id, now);
309        assert!(result.cached_items.is_empty());
310
311        Ok(())
312    }
313
314    #[tokio::test]
315    async fn test_upload_returns_false_when_not_in_cache() -> anyhow::Result<()> {
316        let cache = PostBundleCache::new(5, 64 * 1024 * 1024, Arc::new(RealTimeProvider));
317        let location_id = Id::random();
318        let originator_id = Id::random();
319
320        // No on_get call — entry was never inserted — upload must be rejected
321        let accepted = cache.on_upload(location_id, originator_id, Bytes::from_static(b"bundle"), TimeMillis(1_000_000), TimeMillis(1_000_000), false);
322        assert!(!accepted);
323
324        Ok(())
325    }
326
327    /// When more originators are uploaded than `max_originators_per_location`, the cache keeps the
328    /// ones *closest* to the location_id (highest leading-agreement-bits) and evicts the furthest.
329    #[tokio::test]
330    async fn test_overflow_keeps_closest_originators() -> anyhow::Result<()> {
331        let (server_id, peer_self) = make_test_server_and_peer().await?;
332        let cache = PostBundleCache::new(3, 64 * 1024 * 1024, Arc::new(RealTimeProvider)); // keep at most 3
333        let bucket_location = make_test_bucket_location();
334        let location_id = bucket_location.location_id;
335        let now = TimeMillis(1_000_000);
336
337        // Register the placeholder entry so uploads are accepted.
338        cache.on_get(&bucket_location, &[], &peer_self, &server_id, now);
339
340        // Flipping bit `p` of the location_id yields an originator whose leading-agreement-bits
341        // with the location_id is exactly `p` — i.e. a controllable XOR distance.
342        let originator_at = |flip_bit: usize| -> Id {
343            let mut bytes = location_id.0;
344            bytes[flip_bit / 8] ^= 1 << (7 - (flip_bit % 8));
345            Id(bytes)
346        };
347
348        // Agreements 20,40,60,80,100. With a cap of 3 the three closest (60,80,100) must survive.
349        for &p in &[20usize, 40, 60, 80, 100] {
350            let bytes = Bytes::from(format!("bundle-agreement-{}", p));
351            // sealed => never individually stale, so on_get returns every survivor.
352            cache.on_upload(location_id, originator_at(p), bytes, now, now, true);
353        }
354
355        let result = cache.on_get(&bucket_location, &[], &peer_self, &server_id, now);
356        let cached: std::collections::HashSet<Vec<u8>> = result.cached_items.iter().map(|b| b.to_vec()).collect();
357        assert_eq!(3, cached.len(), "cache must keep exactly max_originators_per_location entries");
358        for &p in &[60usize, 80, 100] {
359            assert!(cached.contains(format!("bundle-agreement-{}", p).as_bytes()), "closest originator (agreement {}) must be kept", p);
360        }
361        for &p in &[20usize, 40] {
362            assert!(!cached.contains(format!("bundle-agreement-{}", p).as_bytes()), "furthest originator (agreement {}) must be evicted", p);
363        }
364        Ok(())
365    }
366
367    /// A replayed older (but genuinely signed) bundle from the same originator must not roll back a newer cached one,
368    /// while an older bundle from a *different* originator is still accepted - no flat age limit.
369    #[tokio::test]
370    async fn test_upload_keeps_newer_bundle_for_same_originator() -> anyhow::Result<()> {
371        let (server_id, peer_self) = make_test_server_and_peer().await?;
372        let cache = PostBundleCache::new(5, 64 * 1024 * 1024, Arc::new(RealTimeProvider));
373        let bucket_location = make_test_bucket_location();
374        let location_id = bucket_location.location_id;
375        let now = TimeMillis(1_000_000);
376        let originator_id = Id::random();
377        let other_originator_id = Id::random();
378        let newer_bundle_bytes = Bytes::from_static(b"newer_bundle");
379        let older_bundle_bytes = Bytes::from_static(b"older_bundle");
380        let newest_bundle_bytes = Bytes::from_static(b"newest_bundle");
381        let other_originator_bundle_bytes = Bytes::from_static(b"other_originator_old_bundle");
382
383        cache.on_get(&bucket_location, &[], &peer_self, &server_id, now);
384
385        assert!(cache.on_upload(location_id, originator_id, newer_bundle_bytes.clone(), TimeMillis(900_000), now, true));
386        assert!(!cache.on_upload(location_id, originator_id, older_bundle_bytes, TimeMillis(800_000), now, true), "older bundle from the same originator must be rejected");
387        let result = cache.on_get(&bucket_location, &[], &peer_self, &server_id, now);
388        assert_eq!(result.cached_items, vec![newer_bundle_bytes]);
389
390        assert!(cache.on_upload(location_id, originator_id, newest_bundle_bytes.clone(), TimeMillis(950_000), now, true), "newer bundle from the same originator must replace the cached one");
391        assert!(cache.on_upload(location_id, other_originator_id, other_originator_bundle_bytes.clone(), TimeMillis(100_000), now, true), "an old bundle from another originator is still cacheable");
392        let result = cache.on_get(&bucket_location, &[], &peer_self, &server_id, now);
393        let cached_items: std::collections::HashSet<Bytes> = result.cached_items.into_iter().collect();
394        assert_eq!(cached_items, std::collections::HashSet::from([newest_bundle_bytes, other_originator_bundle_bytes]));
395
396        Ok(())
397    }
398
399    /// The `CacheRequestToken` the server issues carries a TTL-bounded expiry — exactly the window
400    /// the `CachePostBundleV1` handler enforces via `token.is_expired(now)`. This is the
401    /// deterministic counterpart to that handler check (the integration tests can't exercise the
402    /// real RPC upload reliably because, under the scaled clock, the 30s TTL is only ~33ms of real
403    /// time and the upload RPC can outlive it — the very race that made the cache tests flaky).
404    /// (Token signature/PoW verification needs a fully-PoW'd identity and is covered by the
405    /// end-to-end `test_caching_spreads_via_client_fetches`.)
406    #[tokio::test]
407    async fn test_cache_request_token_expiry() -> anyhow::Result<()> {
408        let (server_id, peer_self) = make_test_server_and_peer().await?;
409        let cache = PostBundleCache::new(5, 64 * 1024 * 1024, Arc::new(RealTimeProvider));
410        let bucket_location = make_test_bucket_location();
411        let now = TimeMillis(1_000_000);
412
413        // Drive the hit threshold so the server issues a token.
414        let mut token = None;
415        for _ in 0..CACHE_HIT_THRESHOLD {
416            token = cache.on_get(&bucket_location, &[], &peer_self, &server_id, now).cache_request_token.or(token);
417        }
418        let token = token.expect("server issues a token at the hit threshold");
419
420        assert!(!token.is_expired(now), "token must be valid at issue time");
421        assert!(!token.is_expired(TimeMillis(now.0 + CACHE_REQUEST_TOKEN_TTL_DURATION_MILLIS.0 - 1)), "token must be valid just before its TTL elapses");
422        assert!(token.is_expired(TimeMillis(now.0 + CACHE_REQUEST_TOKEN_TTL_DURATION_MILLIS.0 + 1)), "token must be expired once its TTL has elapsed");
423
424        Ok(())
425    }
426}