max / makenotwork
4 files changed,
+120 insertions,
-7 deletions
A
multithreaded/.sqlx/query-7a9ba61b2f86825819aba03c7d967ff04423f9436c2d9480c33e26dc93085e55.json
+28
| @@ -0,0 +1,28 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "SELECT post_id, COUNT(*) AS \"count!\"\n FROM post_endorsements\n WHERE post_id = ANY($1)\n GROUP BY post_id", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "post_id", | |
| 9 | + | "type_info": "Uuid" | |
| 10 | + | }, | |
| 11 | + | { | |
| 12 | + | "ordinal": 1, | |
| 13 | + | "name": "count!", | |
| 14 | + | "type_info": "Int8" | |
| 15 | + | } | |
| 16 | + | ], | |
| 17 | + | "parameters": { | |
| 18 | + | "Left": [ | |
| 19 | + | "UuidArray" | |
| 20 | + | ] | |
| 21 | + | }, | |
| 22 | + | "nullable": [ | |
| 23 | + | false, | |
| 24 | + | null | |
| 25 | + | ] | |
| 26 | + | }, | |
| 27 | + | "hash": "7a9ba61b2f86825819aba03c7d967ff04423f9436c2d9480c33e26dc93085e55" | |
| 28 | + | } |
A
multithreaded/.sqlx/query-ef9eaed6231cbd1cad17c68a9c76a26b89a6fea7790348d4fd43c9128c863538.json
+23
| @@ -0,0 +1,23 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "SELECT post_id FROM post_endorsements WHERE post_id = ANY($1) AND endorser_id = $2", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "post_id", | |
| 9 | + | "type_info": "Uuid" | |
| 10 | + | } | |
| 11 | + | ], | |
| 12 | + | "parameters": { | |
| 13 | + | "Left": [ | |
| 14 | + | "UuidArray", | |
| 15 | + | "Uuid" | |
| 16 | + | ] | |
| 17 | + | }, | |
| 18 | + | "nullable": [ | |
| 19 | + | false | |
| 20 | + | ] | |
| 21 | + | }, | |
| 22 | + | "hash": "ef9eaed6231cbd1cad17c68a9c76a26b89a6fea7790348d4fd43c9128c863538" | |
| 23 | + | } |
| @@ -1267,6 +1267,13 @@ pub struct EndorsementRow { | |||
| 1267 | 1267 | pub endorser_id: Uuid, | |
| 1268 | 1268 | } | |
| 1269 | 1269 | ||
| 1270 | + | /// One post's total endorsement count (from an aggregated `GROUP BY`). | |
| 1271 | + | #[derive(sqlx::FromRow)] | |
| 1272 | + | pub struct EndorsementCount { | |
| 1273 | + | pub post_id: Uuid, | |
| 1274 | + | pub count: i64, | |
| 1275 | + | } | |
| 1276 | + | ||
| 1270 | 1277 | // ============================================================================ | |
| 1271 | 1278 | // Tracked thread queries | |
| 1272 | 1279 | // ============================================================================ | |
| @@ -1673,6 +1680,12 @@ pub async fn search_threads( | |||
| 1673 | 1680 | // ============================================================================ | |
| 1674 | 1681 | ||
| 1675 | 1682 | /// Batch-fetch endorsements for a set of post IDs. | |
| 1683 | + | /// | |
| 1684 | + | /// Loads one row per endorsement — fine when the caller needs the endorser ids, | |
| 1685 | + | /// but the thread view must NOT use this: a hot post with thousands of | |
| 1686 | + | /// endorsements would stream every row on every (unauthenticated, unthrottled) | |
| 1687 | + | /// page load. That path uses [`count_endorsements_for_posts`] + | |
| 1688 | + | /// [`list_user_endorsed_posts`] instead. | |
| 1676 | 1689 | #[tracing::instrument(skip_all)] | |
| 1677 | 1690 | pub async fn list_endorsements_for_posts( | |
| 1678 | 1691 | pool: &PgPool, | |
| @@ -1687,6 +1700,47 @@ pub async fn list_endorsements_for_posts( | |||
| 1687 | 1700 | .await | |
| 1688 | 1701 | } | |
| 1689 | 1702 | ||
| 1703 | + | /// Per-post endorsement counts, aggregated in Postgres. | |
| 1704 | + | /// | |
| 1705 | + | /// Replaces streaming every endorsement row into the app just to `len()` them: | |
| 1706 | + | /// a `GROUP BY` returns at most one row per visible post regardless of how many | |
| 1707 | + | /// endorsements a post has (fuzz-2026-07-06 endorsement fan-out). Posts with | |
| 1708 | + | /// zero endorsements are absent — the caller defaults them to 0. | |
| 1709 | + | #[tracing::instrument(skip_all)] | |
| 1710 | + | pub async fn count_endorsements_for_posts( | |
| 1711 | + | pool: &PgPool, | |
| 1712 | + | post_ids: &[Uuid], | |
| 1713 | + | ) -> Result<Vec<EndorsementCount>, sqlx::Error> { | |
| 1714 | + | sqlx::query_as!( | |
| 1715 | + | EndorsementCount, | |
| 1716 | + | r#"SELECT post_id, COUNT(*) AS "count!" | |
| 1717 | + | FROM post_endorsements | |
| 1718 | + | WHERE post_id = ANY($1) | |
| 1719 | + | GROUP BY post_id"#, | |
| 1720 | + | post_ids, | |
| 1721 | + | ) | |
| 1722 | + | .fetch_all(pool) | |
| 1723 | + | .await | |
| 1724 | + | } | |
| 1725 | + | ||
| 1726 | + | /// The subset of `post_ids` the given user has endorsed. Bounded by the page's | |
| 1727 | + | /// post count (not the endorsement total), so the thread view can mark the | |
| 1728 | + | /// viewer's own endorsements without loading every endorser id. | |
| 1729 | + | #[tracing::instrument(skip_all)] | |
| 1730 | + | pub async fn list_user_endorsed_posts( | |
| 1731 | + | pool: &PgPool, | |
| 1732 | + | post_ids: &[Uuid], | |
| 1733 | + | user_id: Uuid, | |
| 1734 | + | ) -> Result<Vec<Uuid>, sqlx::Error> { | |
| 1735 | + | sqlx::query_scalar!( | |
| 1736 | + | "SELECT post_id FROM post_endorsements WHERE post_id = ANY($1) AND endorser_id = $2", | |
| 1737 | + | post_ids, | |
| 1738 | + | user_id, | |
| 1739 | + | ) | |
| 1740 | + | .fetch_all(pool) | |
| 1741 | + | .await | |
| 1742 | + | } | |
| 1743 | + | ||
| 1690 | 1744 | // ============================================================================ | |
| 1691 | 1745 | // Image queries | |
| 1692 | 1746 | // ============================================================================ |
| @@ -72,14 +72,16 @@ pub(in crate::routes) async fn thread( | |||
| 72 | 72 | // this page. They're independent (all keyed off the same post_ids), so run | |
| 73 | 73 | // them concurrently rather than as three serial round-trips. | |
| 74 | 74 | let post_ids: Vec<uuid::Uuid> = db_posts.iter().map(|p| p.id).collect(); | |
| 75 | - | let (all_footnotes, all_endorsements, all_link_previews) = tokio::try_join!( | |
| 75 | + | let (all_footnotes, endorsement_totals, all_link_previews) = tokio::try_join!( | |
| 76 | 76 | async { | |
| 77 | 77 | mt_db::queries::list_footnotes_for_posts(&state.db, &post_ids) | |
| 78 | 78 | .await | |
| 79 | 79 | .map_err(db_error) | |
| 80 | 80 | }, | |
| 81 | 81 | async { | |
| 82 | - | mt_db::queries::list_endorsements_for_posts(&state.db, &post_ids) | |
| 82 | + | // Aggregate in the DB rather than streaming every endorsement row: a | |
| 83 | + | // hot post's thousands of endorsements must not load per page view. | |
| 84 | + | mt_db::queries::count_endorsements_for_posts(&state.db, &post_ids) | |
| 83 | 85 | .await | |
| 84 | 86 | .map_err(db_error) | |
| 85 | 87 | }, | |
| @@ -90,13 +92,19 @@ pub(in crate::routes) async fn thread( | |||
| 90 | 92 | }, | |
| 91 | 93 | )?; | |
| 92 | 94 | ||
| 93 | - | // Group endorsements: counts per post + set of posts current user endorsed | |
| 95 | + | // Counts per post from the aggregate; the viewer's own endorsements come from | |
| 96 | + | // a separate bounded query (only when logged in) so we never load endorser ids. | |
| 94 | 97 | let mut endorsement_counts: HashMap<String, u32> = HashMap::new(); | |
| 98 | + | for e in &endorsement_totals { | |
| 99 | + | endorsement_counts.insert(e.post_id.to_string(), e.count.max(0) as u32); | |
| 100 | + | } | |
| 95 | 101 | let mut user_endorsed: std::collections::HashSet<String> = std::collections::HashSet::new(); | |
| 96 | - | for e in &all_endorsements { | |
| 97 | - | *endorsement_counts.entry(e.post_id.to_string()).or_insert(0) += 1; | |
| 98 | - | if session_user.as_ref().is_some_and(|u| u.user_id == e.endorser_id) { | |
| 99 | - | user_endorsed.insert(e.post_id.to_string()); | |
| 102 | + | if let Some(u) = session_user.as_ref() { | |
| 103 | + | let mine = mt_db::queries::list_user_endorsed_posts(&state.db, &post_ids, u.user_id) | |
| 104 | + | .await | |
| 105 | + | .map_err(db_error)?; | |
| 106 | + | for post_id in mine { | |
| 107 | + | user_endorsed.insert(post_id.to_string()); | |
| 100 | 108 | } | |
| 101 | 109 | } | |
| 102 | 110 |