Skip to main content

max / makenotwork

Scope discover tag-count and follow-membership lookups to the rendered tags Risk remediation (fuzz 2026-07-06 doubledown, Performance axis, C5). - C5-2: the tag-tree page ran get_all_tag_counts, aggregating COUNT over the entire tags x item_tags x items x projects join, then read the map only for the current level's children. Replace with item_counts_by_tag(&child_ids) (WHERE t.id = ANY), mirroring the count_children_by_parents call beside it. - C5-1: the discover facet sidebar fetched the viewer's entire followed-tag set to test membership for the ~10 rendered facets. Add follows::following_subset (target_id = ANY) scoped to those ids and drop the now-dead get_followed_tag_ids. Both are runtime queries (no offline-cache change). cargo clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-06 22:16 UTC
Commit: caf93423308963dd51565bb192bd7b45597548c5
Parent: 3bad930
3 files changed, +41 insertions, -9 deletions
@@ -221,11 +221,25 @@ pub async fn count_followed_feed_items(
221 221
222 222 /// Get all tag IDs that a user follows, for batch lookup on the discover page.
223 223 #[tracing::instrument(skip_all)]
224 - pub async fn get_followed_tag_ids(pool: &PgPool, follower_id: UserId) -> Result<HashSet<Uuid>> {
225 - let rows: Vec<(Uuid,)> = sqlx::query_as(
226 - "SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'tag'",
224 + /// Which of `tag_ids` the follower follows. Scoped variant of
225 + /// `get_followed_tag_ids`: the discover facet sidebar only renders ~10 tags and
226 + /// tests membership for those, so fetching the viewer's entire followed-tag set
227 + /// per render was an over-fetch (fuzz 2026-07-06 C5-1). Single `= ANY($2)`
228 + /// roundtrip. Returns an empty set for an empty input.
229 + pub async fn following_subset(
230 + pool: &PgPool,
231 + follower_id: UserId,
232 + tag_ids: &[crate::db::TagId],
233 + ) -> Result<HashSet<crate::db::TagId>> {
234 + if tag_ids.is_empty() {
235 + return Ok(HashSet::new());
236 + }
237 + let rows: Vec<(crate::db::TagId,)> = sqlx::query_as(
238 + "SELECT target_id FROM follows \
239 + WHERE follower_id = $1 AND target_type = 'tag' AND target_id = ANY($2)",
227 240 )
228 241 .bind(follower_id)
242 + .bind(tag_ids)
229 243 .fetch_all(pool)
230 244 .await?;
231 245
@@ -284,7 +284,19 @@ pub async fn get_tag_ancestors(pool: &PgPool, tag_id: TagId) -> Result<Vec<DbTag
284 284
285 285 /// Count public items per tag for tree browser display.
286 286 #[tracing::instrument(skip_all)]
287 - pub async fn get_all_tag_counts(pool: &PgPool) -> Result<std::collections::HashMap<TagId, i64>> {
287 + /// Public item counts for a specific set of tags. Replaced the former
288 + /// `get_all_tag_counts`, which aggregated over the ENTIRE tags×item_tags×items×
289 + /// projects join to build a map the caller only read for the current level's
290 + /// children (fuzz 2026-07-06 C5-2). Mirrors the `= ANY($1)` shape of the
291 + /// sibling `count_children_by_parents` that already runs two lines away.
292 + #[tracing::instrument(skip_all)]
293 + pub async fn item_counts_by_tag(
294 + pool: &PgPool,
295 + tag_ids: &[TagId],
296 + ) -> Result<std::collections::HashMap<TagId, i64>> {
297 + if tag_ids.is_empty() {
298 + return Ok(std::collections::HashMap::new());
299 + }
288 300 let rows: Vec<(TagId, i64)> = sqlx::query_as(
289 301 r#"
290 302 SELECT t.id, COUNT(p.id)
@@ -292,9 +304,11 @@ pub async fn get_all_tag_counts(pool: &PgPool) -> Result<std::collections::HashM
292 304 LEFT JOIN item_tags it ON it.tag_id = t.id
293 305 LEFT JOIN items i ON i.id = it.item_id AND i.is_public = true AND i.listed = true AND i.deleted_at IS NULL
294 306 LEFT JOIN projects p ON i.project_id = p.id AND p.is_public = true
307 + WHERE t.id = ANY($1)
295 308 GROUP BY t.id
296 309 "#,
297 310 )
311 + .bind(tag_ids)
298 312 .fetch_all(pool)
299 313 .await?;
300 314
@@ -330,11 +330,10 @@ pub(super) async fn tag_tree(
330 330 // Fetch children at this level
331 331 let children = db::tags::get_child_tags(&state.db, parent_id).await?;
332 332
333 - // Fetch item counts for all tags
334 - let tag_counts = db::tags::get_all_tag_counts(&state.db).await?;
335 -
336 - // Batch-fetch child counts for all children (single query instead of N+1)
333 + // Item counts + child counts, both scoped to just this level's children
334 + // rather than aggregating over every tag in the catalog (fuzz 2026-07-06 C5-2).
337 335 let child_ids: Vec<_> = children.iter().map(|c| c.id).collect();
336 + let tag_counts = db::tags::item_counts_by_tag(&state.db, &child_ids).await?;
338 337 let grandchild_counts = db::tags::count_children_by_parents(&state.db, &child_ids).await?;
339 338
340 339 let categories: Vec<TagTreeNode> = children.iter().map(|child| {
@@ -436,8 +435,13 @@ pub(super) async fn discover(
436 435 let (type_counts, tag_counts, ai_counts, price_counts) = cached_facets(
437 436 &state.db, search_filter, item_type_filter, tag_filter, query.min_price, query.max_price,
438 437 ).await?;
438 + // Only the first ~10 tag facets are rendered (see `.take(10)` below), so
439 + // test follow-membership against just those rather than fetching the
440 + // viewer's entire followed-tag set (fuzz 2026-07-06 C5-1).
439 441 let followed_tag_ids = if let Some(uid) = viewer_id {
440 - db::follows::get_followed_tag_ids(&state.db, uid).await?
442 + let rendered_tag_ids: Vec<_> =
443 + tag_counts.iter().take(10).map(|tc| tc.tag_id).collect();
444 + db::follows::following_subset(&state.db, uid, &rendered_tag_ids).await?
441 445 } else {
442 446 std::collections::HashSet::new()
443 447 };