Skip to main content

nexus_common/models/post/
stream.rs

1use super::{Bookmark, PostCounts, PostDetails, PostView};
2use crate::db::kv::{ScoreAction, SortOrder};
3use crate::db::{get_neo4j_graph, queries, RedisOps};
4use crate::models::{
5    follow::{Followers, Following, Friends, UserFollows},
6    post::search::PostsByTagSearch,
7};
8use crate::types::{DynError, Pagination, StreamSorting};
9use pubky_app_specs::PubkyAppPostKind;
10use serde::{Deserialize, Serialize};
11use tokio::task::spawn;
12use tokio::time::{timeout, Duration};
13use utoipa::ToSchema;
14
15pub const POST_TIMELINE_KEY_PARTS: [&str; 3] = ["Posts", "Global", "Timeline"];
16pub const POST_TOTAL_ENGAGEMENT_KEY_PARTS: [&str; 3] = ["Posts", "Global", "TotalEngagement"];
17pub const POST_PER_USER_KEY_PARTS: [&str; 2] = ["Posts", "AuthorParents"];
18pub const POST_REPLIES_PER_USER_KEY_PARTS: [&str; 2] = ["Posts", "AuthorReplies"];
19pub const POST_REPLIES_PER_POST_KEY_PARTS: [&str; 2] = ["Posts", "PostReplies"];
20const BOOKMARKS_USER_KEY_PARTS: [&str; 2] = ["Bookmarks", "User"];
21
22#[derive(ToSchema, Deserialize, Debug, Clone, PartialEq, Default)]
23#[serde(tag = "source", rename_all = "snake_case")]
24pub enum StreamSource {
25    PostReplies {
26        post_id: String,
27        author_id: String,
28    },
29    Following {
30        observer_id: String,
31    },
32    Followers {
33        observer_id: String,
34    },
35    Friends {
36        observer_id: String,
37    },
38    Bookmarks {
39        observer_id: String,
40    },
41    Author {
42        author_id: String,
43    },
44    AuthorReplies {
45        author_id: String,
46    },
47    #[default]
48    All,
49}
50
51impl StreamSource {
52    pub fn get_observer(&self) -> Option<&String> {
53        match self {
54            StreamSource::Followers { observer_id }
55            | StreamSource::Following { observer_id }
56            | StreamSource::Friends { observer_id }
57            | StreamSource::Bookmarks { observer_id } => Some(observer_id),
58            _ => None,
59        }
60    }
61
62    pub fn get_author(&self) -> Option<&String> {
63        match self {
64            StreamSource::PostReplies {
65                author_id,
66                post_id: _,
67            } => Some(author_id),
68            StreamSource::Author { author_id } => Some(author_id),
69            StreamSource::AuthorReplies { author_id } => Some(author_id),
70            _ => None,
71        }
72    }
73}
74
75#[derive(Serialize, Deserialize, ToSchema, Debug, Default)]
76pub struct PostStream(pub Vec<PostView>);
77
78impl RedisOps for PostStream {}
79
80impl PostStream {
81    pub fn extend(&mut self, post_stream: PostStream) {
82        self.0.extend(post_stream.0);
83    }
84    pub async fn get_posts(
85        source: StreamSource,
86        pagination: Pagination,
87        order: SortOrder,
88        sorting: StreamSorting,
89        viewer_id: Option<String>,
90        tags: Option<Vec<String>>,
91        kind: Option<PubkyAppPostKind>,
92    ) -> Result<Option<Self>, DynError> {
93        // Decide whether to use index or fallback to graph query
94        let use_index = Self::can_use_index(&sorting, &source, &tags, &kind);
95
96        let post_keys = match use_index {
97            true => Self::get_from_index(source, sorting, order, &tags, pagination).await?,
98            false => Self::get_from_graph(source, sorting, &tags, pagination, kind).await?,
99        };
100
101        if post_keys.is_empty() {
102            return Ok(None);
103        }
104
105        Self::from_listed_post_ids(viewer_id, &post_keys).await
106    }
107
108    // Determine if we have a quick access sorted set for this combination
109    fn can_use_index(
110        sorting: &StreamSorting,
111        source: &StreamSource,
112        tags: &Option<Vec<String>>,
113        kind: &Option<PubkyAppPostKind>,
114    ) -> bool {
115        if kind.is_some() {
116            return false;
117        }
118        match (sorting, source, tags) {
119            // We have a sorted set for posts by a specific author
120            (StreamSorting::Timeline, StreamSource::Author { .. }, None) => true,
121            // We have a sorted set for global for any sorting
122            (_, StreamSource::All, None) => true,
123            // We have a sorted set for posts by tags for any sorting for a single tag
124            (_, StreamSource::All, Some(tags)) if tags.len() == 1 => true,
125            // We can use sorted set for posts by source only for timeline
126            (StreamSorting::Timeline, StreamSource::Following { .. }, None) => true,
127            (StreamSorting::Timeline, StreamSource::Followers { .. }, None) => true,
128            (StreamSorting::Timeline, StreamSource::Friends { .. }, None) => true,
129            // We have a sorted set for bookmarks only for timeline
130            (StreamSorting::Timeline, StreamSource::Bookmarks { .. }, None) => true,
131            // We can use sorted set of post replies
132            (_, StreamSource::PostReplies { .. }, _) => true,
133            // We can use sorted set of author replies
134            (_, StreamSource::AuthorReplies { .. }, _) => true,
135            // Other combinations require querying the graph
136            _ => false,
137        }
138    }
139
140    // Fetch posts from index
141    async fn get_from_index(
142        source: StreamSource,
143        sorting: StreamSorting,
144        order: SortOrder,
145        tags: &Option<Vec<String>>,
146        pagination: Pagination,
147    ) -> Result<Vec<String>, DynError> {
148        let start = pagination.start;
149        let end = pagination.end;
150        let skip = pagination.skip;
151        let limit = pagination.limit;
152
153        match (source, tags) {
154            // Global post streams
155            (StreamSource::All, None) => {
156                Self::get_global_posts_keys(sorting, order, start, end, skip, limit).await
157            }
158            // Streams by tags
159            (StreamSource::All, Some(tags)) if tags.len() == 1 => {
160                Self::get_posts_keys_by_tag(&tags[0], sorting, start, end, skip, limit).await
161            }
162            // Bookmark streams
163            (StreamSource::Bookmarks { observer_id }, None) => {
164                Self::get_bookmarked_posts(&observer_id, order, start, end, skip, limit).await
165            }
166            // Stream of replies to specific a post
167            (StreamSource::PostReplies { author_id, post_id }, None) => {
168                Self::get_post_replies(&author_id, &post_id, order, start, end, skip, limit).await
169            }
170            // Stream of parent post from a given author
171            (StreamSource::Author { author_id }, None) => {
172                Self::get_author_posts(&author_id, order, start, end, skip, limit, false).await
173            }
174            // Streams of replies from a given author
175            (StreamSource::AuthorReplies { author_id }, None) => {
176                Self::get_author_posts(&author_id, order, start, end, skip, limit, true).await
177            }
178            // Streams by simple source/reach: Following, Followers, Friends
179            (source, None) => {
180                Self::get_posts_by_source(source, order, start, end, skip, limit).await
181            }
182            _ => Ok(vec![]),
183        }
184    }
185
186    // Fetch posts from index
187    async fn get_from_graph(
188        source: StreamSource,
189        sorting: StreamSorting,
190        tags: &Option<Vec<String>>,
191        pagination: Pagination,
192        kind: Option<PubkyAppPostKind>,
193    ) -> Result<Vec<String>, DynError> {
194        let mut result;
195        {
196            let graph = get_neo4j_graph()?;
197            let query = queries::get::post_stream(source, sorting, tags, pagination, kind);
198
199            let graph = graph.lock().await;
200
201            // Set a 10-second timeout for the query execution
202            result = match timeout(Duration::from_secs(10), graph.execute(query)).await {
203                Ok(Ok(res)) => res,                    // Successfully executed within the timeout
204                Ok(Err(e)) => return Err(Box::new(e)), // Query failed
205                Err(_) => return Err("Query timed out".into()), // Timeout error
206            };
207        }
208
209        let mut post_keys = Vec::new();
210
211        while let Some(row) = result.next().await? {
212            let author_id: String = row.get("author_id")?;
213            let post_id: String = row.get("post_id")?;
214            post_keys.push(format!("{author_id}:{post_id}"));
215        }
216
217        Ok(post_keys)
218    }
219
220    pub async fn get_global_posts_keys(
221        sorting: StreamSorting,
222        order: SortOrder,
223        start: Option<f64>,
224        end: Option<f64>,
225        skip: Option<usize>,
226        limit: Option<usize>,
227    ) -> Result<Vec<String>, DynError> {
228        let sorted_set = match sorting {
229            StreamSorting::TotalEngagement => {
230                Self::try_from_index_sorted_set(
231                    &POST_TOTAL_ENGAGEMENT_KEY_PARTS,
232                    start,
233                    end,
234                    skip,
235                    limit,
236                    order,
237                    None,
238                )
239                .await?
240            }
241            StreamSorting::Timeline => {
242                Self::try_from_index_sorted_set(
243                    &POST_TIMELINE_KEY_PARTS,
244                    start,
245                    end,
246                    skip,
247                    limit,
248                    order,
249                    None,
250                )
251                .await?
252            }
253        };
254        match sorted_set {
255            Some(post_keys) => Ok(post_keys.into_iter().map(|(key, _)| key).collect()),
256            // The index does not exist
257            None => Ok(vec![]),
258        }
259    }
260
261    pub async fn get_posts_keys_by_tag(
262        label: &str,
263        sorting: StreamSorting,
264        start: Option<f64>,
265        end: Option<f64>,
266        skip: Option<usize>,
267        limit: Option<usize>,
268    ) -> Result<Vec<String>, DynError> {
269        let skip = skip.unwrap_or(0);
270        let limit = limit.unwrap_or(10);
271
272        let pag = Pagination {
273            start,
274            end,
275            skip: Some(skip),
276            limit: Some(limit),
277        };
278
279        let post_search_result = PostsByTagSearch::get_by_label(label, Some(sorting), pag).await?;
280
281        match post_search_result {
282            Some(post_keys) => Ok(post_keys
283                .into_iter()
284                .map(|post_score| post_score.post_key)
285                .collect()),
286            None => Ok(vec![]),
287        }
288    }
289
290    pub async fn get_author_posts(
291        user_id: &str,
292        order: SortOrder,
293        start: Option<f64>,
294        end: Option<f64>,
295        skip: Option<usize>,
296        limit: Option<usize>,
297        replies: bool,
298    ) -> Result<Vec<String>, DynError> {
299        // Retrieve only parents or only reply posts written by the author from index
300        let key_parts = match replies {
301            true => POST_REPLIES_PER_USER_KEY_PARTS,
302            false => POST_PER_USER_KEY_PARTS,
303        };
304
305        let key_parts = [&key_parts[..], &[user_id]].concat();
306        let post_ids =
307            Self::try_from_index_sorted_set(&key_parts, start, end, skip, limit, order, None)
308                .await?;
309
310        if let Some(post_ids) = post_ids {
311            let post_keys = post_ids
312                .into_iter()
313                .map(|(post_id, _)| format!("{user_id}:{post_id}"))
314                .collect();
315            Ok(post_keys)
316        } else {
317            Ok(vec![])
318        }
319    }
320
321    pub async fn get_posts_by_source(
322        source: StreamSource,
323        order: SortOrder,
324        start: Option<f64>,
325        end: Option<f64>,
326        skip: Option<usize>,
327        limit: Option<usize>,
328    ) -> Result<Vec<String>, DynError> {
329        let custom_limit = Some(200);
330        let mut user_ids = match &source {
331            StreamSource::Following { observer_id } => {
332                Following::get_by_id(observer_id, None, custom_limit)
333                    .await?
334                    .unwrap_or_default()
335                    .0
336            }
337            StreamSource::Followers { observer_id } => {
338                Followers::get_by_id(observer_id, None, custom_limit)
339                    .await?
340                    .unwrap_or_default()
341                    .0
342            }
343            StreamSource::Friends { observer_id } => {
344                Friends::get_by_id(observer_id, None, custom_limit)
345                    .await?
346                    .unwrap_or_default()
347                    .0
348            }
349            _ => vec![],
350        };
351
352        if !user_ids.is_empty() {
353            // Include the observer in the post stream
354            if let Some(observer_id) = source.get_observer() {
355                user_ids.push(observer_id.to_string());
356            }
357
358            let post_keys = Self::get_posts_for_user_ids(
359                &user_ids.iter().map(AsRef::as_ref).collect::<Vec<_>>(),
360                order,
361                start,
362                end,
363                skip,
364                limit,
365            )
366            .await?;
367            Ok(post_keys)
368        } else {
369            Ok(vec![])
370        }
371    }
372
373    pub async fn get_bookmarked_posts(
374        user_id: &str,
375        order: SortOrder,
376        start: Option<f64>,
377        end: Option<f64>,
378        skip: Option<usize>,
379        limit: Option<usize>,
380    ) -> Result<Vec<String>, DynError> {
381        let key_parts = [&BOOKMARKS_USER_KEY_PARTS[..], &[user_id]].concat();
382        let post_keys =
383            Self::try_from_index_sorted_set(&key_parts, start, end, skip, limit, order, None)
384                .await?;
385
386        if let Some(post_keys) = post_keys {
387            Ok(post_keys.into_iter().map(|(key, _)| key).collect())
388        } else {
389            Ok(vec![])
390        }
391    }
392
393    pub async fn get_post_replies(
394        author_id: &str,
395        post_id: &str,
396        order: SortOrder,
397        start: Option<f64>,
398        end: Option<f64>,
399        skip: Option<usize>,
400        limit: Option<usize>,
401    ) -> Result<Vec<String>, DynError> {
402        let key_parts = [&POST_REPLIES_PER_POST_KEY_PARTS[..], &[author_id, post_id]].concat();
403        let post_replies =
404            Self::try_from_index_sorted_set(&key_parts, start, end, skip, limit, order, None)
405                .await?;
406        let replies_keys = post_replies.map_or(Vec::new(), |post_entry| {
407            post_entry.into_iter().map(|(post_id, _)| post_id).collect()
408        });
409        Ok(replies_keys)
410    }
411
412    // Streams for followers / followings / friends are expensive.
413    // We are truncating to the first 200 user_ids. We could also random draw 200.
414    // TODO rethink, we could also fallback to graph
415    async fn get_posts_for_user_ids(
416        user_ids: &[&str],
417        order: SortOrder,
418        start: Option<f64>,
419        end: Option<f64>,
420        skip: Option<usize>,
421        limit: Option<usize>,
422    ) -> Result<Vec<String>, DynError> {
423        let mut post_keys = Vec::new();
424        // Limit the number of user IDs to process to the first 200
425        let max_user_ids = 200;
426        let truncated_user_ids: Vec<&str> = user_ids.iter().take(max_user_ids).cloned().collect();
427
428        // Retrieve posts for each user and collect them
429        for user_id in &truncated_user_ids {
430            let key_parts = [&POST_PER_USER_KEY_PARTS[..], &[user_id]].concat();
431            if let Some(post_ids) = Self::try_from_index_sorted_set(
432                &key_parts,
433                start,
434                end,
435                None, // We do not apply skip and limit here, as we need the full sorted set
436                None,
437                order.clone(),
438                None,
439            )
440            .await?
441            {
442                let user_post_keys: Vec<(f64, String)> = post_ids
443                    .into_iter()
444                    .map(|(post_id, score)| (score, format!("{user_id}:{post_id}")))
445                    .collect();
446                post_keys.extend(user_post_keys);
447            }
448        }
449
450        // The selected user_ids does not have any post
451        if post_keys.is_empty() {
452            return Ok(Vec::new());
453        }
454
455        // Sort all the collected posts globally by their score (descending)
456        post_keys.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
457
458        // Apply global skip and limit after sorting
459        let start_index = skip.unwrap_or(0).clamp(0, post_keys.len());
460        let end_index = limit
461            .map(|l| (start_index + l).min(post_keys.len()))
462            .unwrap_or(post_keys.len());
463
464        // Ensure valid slice range
465        if start_index >= end_index {
466            return Ok(Vec::new());
467        }
468
469        let selected_post_keys = post_keys[start_index..end_index]
470            .iter()
471            .map(|(_, post_key)| post_key.clone())
472            .collect();
473
474        Ok(selected_post_keys)
475    }
476
477    pub async fn from_listed_post_ids(
478        viewer_id: Option<String>,
479        post_keys: &[String],
480    ) -> Result<Option<Self>, DynError> {
481        let viewer_id = viewer_id.map(|id| id.to_string());
482        let mut handles = Vec::with_capacity(post_keys.len());
483
484        for post_key in post_keys {
485            let (author_id, post_id) = post_key.split_once(':').unwrap_or_default();
486            let author_id = author_id.to_string();
487            let viewer_id = viewer_id.clone();
488            let post_id = post_id.to_string();
489            let handle = spawn(async move {
490                PostView::get_by_id(&author_id, &post_id, viewer_id.as_deref(), None, None).await
491            });
492            handles.push(handle);
493        }
494
495        let mut post_views = Vec::with_capacity(post_keys.len());
496
497        for handle in handles {
498            if let Some(post_view) = handle.await?? {
499                post_views.push(post_view);
500            }
501        }
502
503        Ok(Some(Self(post_views)))
504    }
505
506    /// Adds the post to a Redis sorted set using the `indexed_at` timestamp as the score.
507    pub async fn add_to_timeline_sorted_set(details: &PostDetails) -> Result<(), DynError> {
508        let element = format!("{}:{}", details.author, details.id);
509        let score = details.indexed_at as f64;
510        Self::put_index_sorted_set(
511            &POST_TIMELINE_KEY_PARTS,
512            &[(score, element.as_str())],
513            None,
514            None,
515        )
516        .await
517    }
518
519    /// Adds the post to a Redis sorted set using the `indexed_at` timestamp as the score.
520    pub async fn remove_from_timeline_sorted_set(
521        author_id: &str,
522        post_id: &str,
523    ) -> Result<(), DynError> {
524        let element = format!("{author_id}:{post_id}");
525        Self::remove_from_index_sorted_set(None, &POST_TIMELINE_KEY_PARTS, &[element.as_str()])
526            .await
527    }
528
529    /// Adds the post to a Redis sorted set using the `indexed_at` timestamp as the score.
530    pub async fn add_to_per_user_sorted_set(details: &PostDetails) -> Result<(), DynError> {
531        let key_parts = [&POST_PER_USER_KEY_PARTS[..], &[details.author.as_str()]].concat();
532        let score = details.indexed_at as f64;
533        Self::put_index_sorted_set(&key_parts, &[(score, details.id.as_str())], None, None).await
534    }
535
536    /// Adds the post to a Redis sorted set using the `indexed_at` timestamp as the score.
537    pub async fn remove_from_per_user_sorted_set(
538        author_id: &str,
539        post_id: &str,
540    ) -> Result<(), DynError> {
541        let key_parts = [&POST_PER_USER_KEY_PARTS[..], &[author_id]].concat();
542        Self::remove_from_index_sorted_set(None, &key_parts, &[post_id]).await
543    }
544
545    /// Adds the post response to a Redis sorted set using the `indexed_at` timestamp as the score.
546    pub async fn add_to_post_reply_sorted_set(
547        // parent_user_id: &str,
548        // parent_post_id: &str,
549        parent_post_key_parts: &[&str; 2],
550        author_id: &str,
551        reply_id: &str,
552        indexed_at: i64,
553    ) -> Result<(), DynError> {
554        let key_parts = [&POST_REPLIES_PER_POST_KEY_PARTS[..], parent_post_key_parts].concat();
555        let score = indexed_at as f64;
556        let element = format!("{author_id}:{reply_id}");
557        Self::put_index_sorted_set(&key_parts, &[(score, element.as_str())], None, None).await
558    }
559
560    /// Adds the post response to a Redis sorted set using the `indexed_at` timestamp as the score.
561    pub async fn remove_from_post_reply_sorted_set(
562        parent_post_key_parts: &[&str; 2],
563        author_id: &str,
564        reply_id: &str,
565    ) -> Result<(), DynError> {
566        let key_parts = [&POST_REPLIES_PER_POST_KEY_PARTS[..], parent_post_key_parts].concat();
567        let element = format!("{author_id}:{reply_id}");
568        Self::remove_from_index_sorted_set(None, &key_parts, &[element.as_str()]).await
569    }
570
571    /// Adds the post to a Redis sorted set of replies per author using the `indexed_at` timestamp as the score.
572    pub async fn add_to_replies_per_user_sorted_set(details: &PostDetails) -> Result<(), DynError> {
573        let key_parts = [
574            &POST_REPLIES_PER_USER_KEY_PARTS[..],
575            &[details.author.as_str()],
576        ]
577        .concat();
578        let score = details.indexed_at as f64;
579        Self::put_index_sorted_set(&key_parts, &[(score, details.id.as_str())], None, None).await
580    }
581
582    /// Adds the post to a Redis sorted set using the `indexed_at` timestamp as the score.
583    pub async fn remove_from_replies_per_user_sorted_set(
584        author_id: &str,
585        post_id: &str,
586    ) -> Result<(), DynError> {
587        let key_parts = [&POST_REPLIES_PER_USER_KEY_PARTS[..], &[author_id]].concat();
588        Self::remove_from_index_sorted_set(None, &key_parts, &[post_id]).await
589    }
590
591    /// Adds a bookmark to Redis sorted set using the `indexed_at` timestamp as the score.
592    pub async fn add_to_bookmarks_sorted_set(
593        bookmark: &Bookmark,
594        bookmarker_id: &str,
595        post_id: &str,
596        author_id: &str,
597    ) -> Result<(), DynError> {
598        let key_parts = [&BOOKMARKS_USER_KEY_PARTS[..], &[bookmarker_id]].concat();
599        let post_key = format!("{author_id}:{post_id}");
600        let score = bookmark.indexed_at as f64;
601        Self::put_index_sorted_set(&key_parts, &[(score, post_key.as_str())], None, None).await
602    }
603
604    /// Remove a bookmark from Redis sorted
605    pub async fn remove_from_bookmarks_sorted_set(
606        bookmarker_id: &str,
607        post_id: &str,
608        author_id: &str,
609    ) -> Result<(), DynError> {
610        let key_parts = [&BOOKMARKS_USER_KEY_PARTS[..], &[bookmarker_id]].concat();
611        let post_key = format!("{author_id}:{post_id}");
612        Self::remove_from_index_sorted_set(None, &key_parts, &[&post_key]).await
613    }
614
615    /// Adds the post to a Redis sorted set using the total engagement as the score.
616    pub async fn add_to_engagement_sorted_set(
617        counts: &PostCounts,
618        author_id: &str,
619        post_id: &str,
620    ) -> Result<(), DynError> {
621        let element = format!("{author_id}:{post_id}");
622        let score = counts.tags + counts.replies + counts.reposts;
623        let score = score as f64;
624
625        Self::put_index_sorted_set(
626            &POST_TOTAL_ENGAGEMENT_KEY_PARTS,
627            &[(score, element.as_str())],
628            None,
629            None,
630        )
631        .await
632    }
633
634    pub async fn delete_from_engagement_sorted_set(
635        author_id: &str,
636        post_id: &str,
637    ) -> Result<(), DynError> {
638        let post_key = format!("{author_id}:{post_id}");
639        Self::remove_from_index_sorted_set(None, &POST_TOTAL_ENGAGEMENT_KEY_PARTS, &[&post_key])
640            .await
641    }
642
643    pub async fn update_index_score(
644        author_id: &str,
645        post_id: &str,
646        score_action: ScoreAction,
647    ) -> Result<(), DynError> {
648        let post_key_slice = &[author_id, post_id];
649        Self::put_score_index_sorted_set(
650            &POST_TOTAL_ENGAGEMENT_KEY_PARTS,
651            post_key_slice,
652            score_action,
653        )
654        .await
655    }
656}