Skip to main content

max / makenotwork

mt: paginate and flatten list_tracked_threads (F4) A user tracking hundreds of threads rendered an unbounded page, each row driving a double-nested unread-count subquery (a nested SELECT created_at FROM posts for the last-read cutoff plus an EXISTS mention check). The unread count also omitted removed_at IS NULL, so it counted mod-removed posts and couldn't use the m024 partial index. Add LIMIT/OFFSET pagination (count_tracked_threads + 50/page), resolve the last-read cutoff with a single LEFT JOIN instead of a per-row subquery, and filter removed posts out of the unread count. Ultra-fuzz Run #2 finding F4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-18 20:09 UTC
Commit: 77e847105d67b10691f5ceeaf5a63e9aeb2ff795
Parent: f8cd220
5 files changed, +53 insertions, -10 deletions
@@ -1163,11 +1163,19 @@ pub struct TrackedThreadRow {
1163 1163 pub tracked_at: DateTime<Utc>,
1164 1164 }
1165 1165
1166 - /// List a user's tracked threads with unread post counts.
1166 + /// List a user's tracked threads with unread post counts, newest activity first.
1167 + ///
1168 + /// Paginated (`limit`/`offset`) so a user tracking hundreds of threads doesn't
1169 + /// render — and recompute — an unbounded page. The last-read cutoff is resolved
1170 + /// with a single LEFT JOIN rather than a per-row nested subquery, and the unread
1171 + /// count excludes mod-removed posts (`p.removed_at IS NULL`) so the badge can't
1172 + /// be inflated by removed content and can use the `idx_posts_not_removed` index.
1167 1173 #[tracing::instrument(skip_all)]
1168 1174 pub async fn list_tracked_threads(
1169 1175 pool: &PgPool,
1170 1176 user_id: Uuid,
1177 + limit: i64,
1178 + offset: i64,
1171 1179 ) -> Result<Vec<TrackedThreadRow>, sqlx::Error> {
1172 1180 sqlx::query_as::<_, TrackedThreadRow>(
1173 1181 "SELECT tt.thread_id,
@@ -1177,9 +1185,8 @@ pub async fn list_tracked_threads(
1177 1185 cat.slug AS category_slug,
1178 1186 (SELECT COUNT(*) FROM posts p
1179 1187 WHERE p.thread_id = tt.thread_id
1180 - AND (tt.last_read_post_id IS NULL OR p.created_at > (
1181 - SELECT created_at FROM posts WHERE id = tt.last_read_post_id
1182 - ))
1188 + AND p.removed_at IS NULL
1189 + AND (lrp.created_at IS NULL OR p.created_at > lrp.created_at)
1183 1190 ) AS unread_count,
1184 1191 EXISTS (
1185 1192 SELECT 1 FROM post_mentions pm
@@ -1192,14 +1199,35 @@ pub async fn list_tracked_threads(
1192 1199 JOIN threads t ON t.id = tt.thread_id
1193 1200 JOIN categories cat ON cat.id = t.category_id
1194 1201 JOIN communities co ON co.id = cat.community_id
1202 + LEFT JOIN posts lrp ON lrp.id = tt.last_read_post_id
1195 1203 WHERE tt.user_id = $1 AND t.deleted_at IS NULL AND co.suspended_at IS NULL
1196 - ORDER BY t.last_activity_at DESC",
1204 + ORDER BY t.last_activity_at DESC
1205 + LIMIT $2 OFFSET $3",
1197 1206 )
1198 1207 .bind(user_id)
1208 + .bind(limit)
1209 + .bind(offset)
1199 1210 .fetch_all(pool)
1200 1211 .await
1201 1212 }
1202 1213
1214 + /// Count a user's visible tracked threads (for pagination). Mirrors the
1215 + /// visibility filters of `list_tracked_threads`.
1216 + #[tracing::instrument(skip_all)]
1217 + pub async fn count_tracked_threads(pool: &PgPool, user_id: Uuid) -> Result<i64, sqlx::Error> {
1218 + sqlx::query_scalar(
1219 + "SELECT COUNT(*)
1220 + FROM tracked_threads tt
1221 + JOIN threads t ON t.id = tt.thread_id
1222 + JOIN categories cat ON cat.id = t.category_id
1223 + JOIN communities co ON co.id = cat.community_id
1224 + WHERE tt.user_id = $1 AND t.deleted_at IS NULL AND co.suspended_at IS NULL",
1225 + )
1226 + .bind(user_id)
1227 + .fetch_one(pool)
1228 + .await
1229 + }
1230 +
1203 1231 // ============================================================================
1204 1232 // Tag queries
1205 1233 // ============================================================================
@@ -1,7 +1,7 @@
1 1 //! Thread tracking handlers — track, untrack, tracked threads list.
2 2
3 3 use axum::{
4 - extract::Path,
4 + extract::{Path, Query},
5 5 http::StatusCode,
6 6 response::{IntoResponse, Redirect, Response},
7 7 };
@@ -102,18 +102,30 @@ pub(super) async fn tracking_info_page(
102 102 pub(super) async fn tracked_threads_page(
103 103 axum::extract::State(state): axum::extract::State<AppState>,
104 104 session: Session,
105 + Query(query): Query<super::ForumDirectoryQuery>,
105 106 MaybeUser(session_user): MaybeUser,
106 107 ) -> Result<impl IntoResponse, Response> {
107 108 let csrf_token = Some(csrf::get_or_create_token(&session).await);
108 109 let user = session_user
109 110 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
110 111
111 - let db_tracked = mt_db::queries::list_tracked_threads(&state.db, user.user_id)
112 + const PER_PAGE: i64 = 50;
113 + let total = mt_db::queries::count_tracked_threads(&state.db, user.user_id)
112 114 .await
113 115 .map_err(|e| {
114 - tracing::error!(error = ?e, "db error listing tracked threads");
116 + tracing::error!(error = ?e, "db error counting tracked threads");
115 117 StatusCode::INTERNAL_SERVER_ERROR.into_response()
116 118 })?;
119 + let pagination = Pagination::new(query.page.unwrap_or(1).max(1), total, PER_PAGE);
120 + let offset = pagination.offset(PER_PAGE);
121 +
122 + let db_tracked =
123 + mt_db::queries::list_tracked_threads(&state.db, user.user_id, PER_PAGE, offset)
124 + .await
125 + .map_err(|e| {
126 + tracing::error!(error = ?e, "db error listing tracked threads");
127 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
128 + })?;
117 129
118 130 let threads = db_tracked
119 131 .into_iter()
@@ -133,5 +145,6 @@ pub(super) async fn tracked_threads_page(
133 145 session_user: Some(template_user(&user, state.config.platform_admin_id)),
134 146 mnw_base_url: state.config.mnw_base_url.clone(),
135 147 threads,
148 + pagination,
136 149 })
137 150 }
@@ -478,6 +478,7 @@ pub struct TrackedThreadsTemplate {
478 478 pub session_user: Option<TemplateSessionUser>,
479 479 pub mnw_base_url: std::sync::Arc<str>,
480 480 pub threads: Vec<TrackedThreadViewRow>,
481 + pub pagination: Pagination,
481 482 }
482 483
483 484 // ============================================================================
@@ -59,6 +59,7 @@
59 59 {% endfor %}
60 60 </tbody>
61 61 </table>
62 + {% include "partials/pagination.html" %}
62 63 {% endif %}
63 64 </div>
64 65 {% endblock %}
@@ -115,7 +115,7 @@ async fn unread_count_tracking() {
115 115 .unwrap();
116 116
117 117 // Check tracked threads — should show unread
118 - let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id).await.unwrap();
118 + let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0).await.unwrap();
119 119 assert_eq!(tracked.len(), 1);
120 120 assert!(tracked[0].unread_count > 0, "Should have unread posts");
121 121 }
@@ -138,7 +138,7 @@ async fn stop_tracking_all() {
138 138 let resp = h.client.post_form("/tracked/stop-all", "").await;
139 139 assert!(resp.status.is_redirection(), "Expected redirect, got {}", resp.status);
140 140
141 - let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id).await.unwrap();
141 + let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0).await.unwrap();
142 142 assert_eq!(tracked.len(), 0, "All tracked threads should be removed");
143 143 }
144 144