Skip to main content

hashiverse_lib/client/timeline/
recent_posts_pen.rs

1//! # Scratch pad for just-authored local posts
2//!
3//! When the user hits "post", the resulting signed post must make its way out onto the
4//! DHT before it can be read back through the usual bucket fetch. The network path
5//! takes at least a round trip plus proof-of-work — far too long for "post disappears
6//! for 10 seconds then reappears in my own timeline" to feel acceptable.
7//!
8//! `RecentPostsPen` closes that gap. Every locally-authored post is deposited here
9//! indexed by `(BucketType, base_id)` with a short TTL (~10 minutes). Every timeline
10//! walk consults it before diving into the bucket fetch, so the user's own posts surface
11//! in their own timelines immediately. Old entries age out automatically, and
12//! deduplication against the timeline's seen set prevents duplicate rendering once the
13//! network fetch catches up.
14
15use bytes::Bytes;
16use std::collections::HashSet;
17
18use crate::tools::buckets::{BucketLocation, BucketType};
19use crate::tools::time::{DurationMillis, MILLIS_IN_MINUTE, TimeMillis};
20use crate::tools::types::Id;
21
22const RECENT_POSTS_PEN_TTL: DurationMillis = MILLIS_IN_MINUTE.const_mul(10);
23
24pub struct RecentPostsPenEntry {
25    pub bucket_location: BucketLocation,
26    pub post_id: Id,
27    pub encoded_post_bytes: Bytes,
28    pub time_millis: TimeMillis,
29}
30
31/// Short-lived scratch space for posts the local client has just submitted.
32///
33/// After a successful `submit_post`, the resulting commit tokens and encoded post bytes are
34/// recorded here. When any `SingleTimeline` calls `get_more_posts`, it consults the pen for
35/// entries whose `BucketLocation` matches the timeline being viewed. This ensures the user's
36/// own posts appear immediately — even before the post bundles have propagated through the
37/// network caches — and works across all timeline types (User, Hashtag, Mention, Reply, Sequel, etc.).
38///
39/// Entries expire after 10 minutes (by which time the network caches will have refreshed and
40/// the posts will appear naturally from the `PostBundleManager`). Deduplication against the
41/// `SingleTimeline`'s `post_ids_already_seen` set prevents a post from showing up twice once
42/// it does arrive from the network.
43pub struct RecentPostsPen {
44    entries: Vec<RecentPostsPenEntry>,
45}
46
47impl Default for RecentPostsPen {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53impl RecentPostsPen {
54    pub fn new() -> Self {
55        Self { entries: Vec::new() }
56    }
57
58    /// Add pen entries from commit tokens. Each token represents the post committed to a
59    /// particular timeline (User, Hashtag, Mention, etc.) — we store one entry per token.
60    pub fn add_all(&mut self, bucket_locations_and_post_ids: &[(BucketLocation, Id)], encoded_post_bytes: Bytes, time_millis: TimeMillis) {
61        for (bucket_location, post_id) in bucket_locations_and_post_ids {
62            self.entries.push(RecentPostsPenEntry {
63                bucket_location: bucket_location.clone(),
64                post_id: *post_id,
65                encoded_post_bytes: encoded_post_bytes.clone(),
66                time_millis,
67            });
68        }
69    }
70
71    /// Returns matching pen posts for the given timeline, excluding expired and already-seen entries.
72    /// Multiple entries for the same post_id may be returned (e.g. from multiple commit tokens);
73    /// `SingleTimeline` handles deduplication via `post_ids_already_seen`.
74    pub fn get_matching_posts(
75        &mut self,
76        bucket_type: BucketType,
77        base_id: &Id,
78        already_seen_ids: &HashSet<Id>,
79        current_time: TimeMillis,
80    ) -> Vec<(BucketLocation, Bytes, Id)> {
81        // Prune expired entries
82        let cutoff = current_time - RECENT_POSTS_PEN_TTL;
83        self.entries.retain(|entry| entry.time_millis >= cutoff);
84
85        let mut matching_posts: Vec<(BucketLocation, Bytes, Id)> = Vec::new();
86
87        for entry in &self.entries {
88            if entry.bucket_location.bucket_type != bucket_type || entry.bucket_location.base_id != *base_id {
89                continue;
90            }
91            if already_seen_ids.contains(&entry.post_id) {
92                continue;
93            }
94
95            matching_posts.push((entry.bucket_location.clone(), entry.encoded_post_bytes.clone(), entry.post_id));
96        }
97
98        matching_posts
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::tools::time::MILLIS_IN_MINUTE;
106
107    fn make_entry(bucket_type: BucketType, base_id: Id, post_id: Id, time: TimeMillis) -> (BucketLocation, Id) {
108        let bucket_location = BucketLocation::new(bucket_type, base_id, MILLIS_IN_MINUTE, time).unwrap();
109        (bucket_location, post_id)
110    }
111
112    #[test]
113    fn test_matching_by_bucket_type_and_base_id() {
114        let mut pen = RecentPostsPen::new();
115        let base_id = Id::random();
116        let other_base_id = Id::random();
117        let post_id = Id::random();
118        let time = TimeMillis::from_epoch_offset_str("1M").unwrap();
119
120        let entries = vec![
121            make_entry(BucketType::User, base_id, post_id, time),
122            make_entry(BucketType::Hashtag, other_base_id, post_id, time),
123        ];
124        pen.add_all(&entries, Bytes::from_static(b"test post"), time);
125
126        let already_seen = HashSet::new();
127
128        // Should match User timeline for base_id
129        let result = pen.get_matching_posts(BucketType::User, &base_id, &already_seen, time);
130        assert_eq!(result.len(), 1);
131
132        // Should match Hashtag timeline for other_base_id
133        let result = pen.get_matching_posts(BucketType::Hashtag, &other_base_id, &already_seen, time);
134        assert_eq!(result.len(), 1);
135
136        // Should NOT match User timeline for other_base_id
137        let result = pen.get_matching_posts(BucketType::User, &other_base_id, &already_seen, time);
138        assert_eq!(result.len(), 0);
139
140        // Should NOT match Hashtag timeline for base_id
141        let result = pen.get_matching_posts(BucketType::Hashtag, &base_id, &already_seen, time);
142        assert_eq!(result.len(), 0);
143    }
144
145    #[test]
146    fn test_ttl_expiration() {
147        let mut pen = RecentPostsPen::new();
148        let base_id = Id::random();
149        let post_id = Id::random();
150        let time = TimeMillis::from_epoch_offset_str("1M").unwrap();
151
152        pen.add_all(&[make_entry(BucketType::User, base_id, post_id, time)], Bytes::from_static(b"post"), time);
153
154        let already_seen = HashSet::new();
155
156        // Still within TTL
157        let within_ttl = time + MILLIS_IN_MINUTE.const_mul(9);
158        let result = pen.get_matching_posts(BucketType::User, &base_id, &already_seen, within_ttl);
159        assert_eq!(result.len(), 1);
160
161        // Past TTL
162        let past_ttl = time + MILLIS_IN_MINUTE.const_mul(11);
163        let result = pen.get_matching_posts(BucketType::User, &base_id, &already_seen, past_ttl);
164        assert_eq!(result.len(), 0);
165    }
166
167    #[test]
168    fn test_multiple_tokens_same_post_returns_all() {
169        let mut pen = RecentPostsPen::new();
170        let base_id = Id::random();
171        let post_id = Id::random();
172        let time = TimeMillis::from_epoch_offset_str("1M").unwrap();
173
174        // 3 commit tokens from 3 different peers for the same post on the same timeline —
175        // the pen returns all of them; deduplication by post_id is SingleTimeline's job.
176        let entries = vec![
177            make_entry(BucketType::User, base_id, post_id, time),
178            make_entry(BucketType::User, base_id, post_id, time),
179            make_entry(BucketType::User, base_id, post_id, time),
180        ];
181        pen.add_all(&entries, Bytes::from_static(b"post"), time);
182
183        let already_seen = HashSet::new();
184        let result = pen.get_matching_posts(BucketType::User, &base_id, &already_seen, time);
185        assert_eq!(result.len(), 3);
186    }
187
188    #[test]
189    fn test_already_seen_filtering() {
190        let mut pen = RecentPostsPen::new();
191        let base_id = Id::random();
192        let post_id = Id::random();
193        let time = TimeMillis::from_epoch_offset_str("1M").unwrap();
194
195        pen.add_all(&[make_entry(BucketType::User, base_id, post_id, time)], Bytes::from_static(b"post"), time);
196
197        let mut already_seen = HashSet::new();
198        already_seen.insert(post_id);
199
200        let result = pen.get_matching_posts(BucketType::User, &base_id, &already_seen, time);
201        assert_eq!(result.len(), 0);
202    }
203
204    #[test]
205    fn test_single_post_multiple_timelines() {
206        let mut pen = RecentPostsPen::new();
207        let user_id = Id::random();
208        let hashtag_id = Id::random();
209        let mention_id = Id::random();
210        let post_id = Id::random();
211        let time = TimeMillis::from_epoch_offset_str("1M").unwrap();
212
213        let entries = vec![
214            make_entry(BucketType::User, user_id, post_id, time),
215            make_entry(BucketType::Hashtag, hashtag_id, post_id, time),
216            make_entry(BucketType::Mention, mention_id, post_id, time),
217        ];
218        pen.add_all(&entries, Bytes::from_static(b"post"), time);
219
220        let already_seen = HashSet::new();
221
222        // Each timeline should independently find the post
223        assert_eq!(pen.get_matching_posts(BucketType::User, &user_id, &already_seen, time).len(), 1);
224        assert_eq!(pen.get_matching_posts(BucketType::Hashtag, &hashtag_id, &already_seen, time).len(), 1);
225        assert_eq!(pen.get_matching_posts(BucketType::Mention, &mention_id, &already_seen, time).len(), 1);
226    }
227}