Skip to main content

max / makenotwork

mt: aggregate endorsement counts in the DB for the thread view The thread view streamed every endorsement row (list_endorsements_for_posts) just to count them per post and mark the viewer's own — an unbounded load on an unauthenticated, unthrottled GET, so a hot post's thousands of endorsements loaded on every view. Use a GROUP BY count plus a separate page-bounded query for the viewer's endorsements (only when logged in). The row-level query stays for the callers that genuinely need endorser ids (tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-07 15:06 UTC
Commit: 0116a14e5c71f01d1e40358aac4be941b4053fc1
Parent: b8c7acd
4 files changed, +120 insertions, -7 deletions
@@ -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 + }
@@ -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