simple-web-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit f4bedce994cdcaf00e89b6cd7bfb68b50c3412ba
parent cb91fbe80c07ac11a0792795961e322a1cbecce9
Author: Silas Brack <silasbrack@gmail.com>
Date:   Tue,  9 Jun 2026 20:07:28 +0200

fix: batch-fetch comment authors and tag queries to eliminate N+1

- Add /enrich_comments WASM endpoint for batch user profile lookup
- Add /stories_by_tag WASM endpoint with proper JOIN/LIMIT/OFFSET
- Replace per-comment author fetch loop with single batch call
- Replace unbounded junction table scan in get_stories_by_tag
- Fix N+1 in tag handler using enrich_stories batch endpoint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Diffstat:
Mguests/rust/src/lib.rs | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/handlers/feed.rs | 2+-
Msrc/handlers/story.rs | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Msrc/handlers/tag.rs | 31+++++++++++++------------------
Msrc/trailbase.rs | 60++++++++++++++++++++++++++++++++++++++++++++++--------------
Mtraildepot/wasm/search_guest.wasm | 0
6 files changed, 214 insertions(+), 66 deletions(-)

diff --git a/guests/rust/src/lib.rs b/guests/rust/src/lib.rs @@ -154,11 +154,87 @@ async fn enrich_handler(req: Request) -> Result<Json<Vec<StoryMeta>>, HttpError> Ok(Json(result)) } +// GET /enrich_comments?user_ids=abc,def +// Returns a mapping from user_id to username for a batch of user IDs. +#[derive(serde::Serialize)] +struct UserMapping { + user_id: String, + username: String, +} + +async fn enrich_comments_handler(req: Request) -> Result<Json<Vec<UserMapping>>, HttpError> { + let ids_str = req + .query_param("user_ids") + .ok_or_else(|| HttpError::message(StatusCode::BAD_REQUEST, "Missing 'user_ids' parameter"))?; + + let ids: Vec<&str> = ids_str.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); + + if ids.is_empty() { + return Ok(Json(vec![])); + } + + let placeholders: Vec<String> = (1..=ids.len()).map(|i| format!("${}", i)).collect(); + let placeholder_str = placeholders.join(","); + + let sql = format!( + "SELECT user_id, username FROM user_profile WHERE user_id IN ({})", + placeholder_str + ); + let params: Vec<Value> = ids.iter().map(|&id| Value::Text(id.to_string())).collect(); + let rows = query(&sql, params).await.map_err(err)?; + + let result = rows + .iter() + .map(|row| UserMapping { + user_id: as_text(&row[0]), + username: as_text(&row[1]), + }) + .collect(); + + Ok(Json(result)) +} + +// GET /stories_by_tag?category_id=5&limit=31&offset=0 +// Returns story IDs for a category, ordered by published date descending. +async fn stories_by_tag_handler(req: Request) -> Result<Json<Vec<i64>>, HttpError> { + let category_id: i64 = req + .query_param("category_id") + .and_then(|s| s.parse().ok()) + .ok_or_else(|| HttpError::message(StatusCode::BAD_REQUEST, "Missing 'category_id' parameter"))?; + + let limit: i64 = req + .query_param("limit") + .and_then(|s| s.parse().ok()) + .unwrap_or(31); + + let offset: i64 = req + .query_param("offset") + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + let rows = query( + "SELECT s.id + FROM story s + JOIN story_category sc ON sc.news_item_id = s.id + WHERE sc.category_id = $1 + ORDER BY s.published DESC + LIMIT $2 OFFSET $3", + [Value::Integer(category_id), Value::Integer(limit), Value::Integer(offset)], + ) + .await + .map_err(err)?; + + let ids = rows.iter().map(|row| as_integer(&row[0])).collect(); + Ok(Json(ids)) +} + impl Guest for ApiGuest { fn http_handlers() -> Vec<HttpRoute> { vec![ routing::get("/search", search_handler), routing::get("/enrich", enrich_handler), + routing::get("/enrich_comments", enrich_comments_handler), + routing::get("/stories_by_tag", stories_by_tag_handler), ] } } diff --git a/src/handlers/feed.rs b/src/handlers/feed.rs @@ -56,7 +56,7 @@ async fn fetch_feed( // Fetch stories based on sort and optional tag filter let stories = match query.tag { Some(tag_id) => { - trailbase::get_stories_by_tag(&client, tag_id, PAGE_LIMIT + 1, offset).await? + trailbase::get_stories_by_tag(&state.http_client, &state.trailbase_url, &client, tag_id, PAGE_LIMIT + 1, offset).await? } None => match sort { FeedSort::Hot => { diff --git a/src/handlers/story.rs b/src/handlers/story.rs @@ -11,9 +11,63 @@ use crate::db::{CommentWithMeta, StoryWithMeta}; use crate::error::AppError; use crate::handlers::auth::get_tokens_from_cookies; use crate::state::AppState; -use crate::templates::{ApplicationTemplate, EditTemplate, StoryTemplate, SubmitTemplate}; +use crate::templates::{ApplicationTemplate, CommentsTemplate, EditTemplate, StoryTemplate, SubmitTemplate}; use crate::trailbase::{self, TARGET_TYPE_COMMENT, TARGET_TYPE_STORY}; +async fn fetch_enriched_comments( + state: &AppState, + tokens: &Option<trailbase::StoredTokens>, + client: &trailbase_client::Client, + story_id: i64, +) -> Result<Vec<CommentWithMeta>, AppError> { + let comments = trailbase::get_comments_for_story(client, story_id).await?; + + let comment_ids: Vec<i64> = comments.iter().map(|c| c.id).collect(); + let voted_comment_ids = if tokens.is_some() && !comment_ids.is_empty() { + trailbase::get_user_votes(client, TARGET_TYPE_COMMENT, &comment_ids) + .await + .inspect_err(|e| tracing::error!("Failed to get comment votes: {}", e)) + .unwrap_or_default() + } else { + vec![] + }; + + // Batch-fetch comment author usernames (1 query instead of N) + let unique_user_ids: Vec<&str> = { + let mut ids: Vec<&str> = comments.iter().map(|c| c.created_by.as_str()).collect(); + ids.sort_unstable(); + ids.dedup(); + ids + }; + let author_map = trailbase::enrich_comment_authors( + &state.http_client, + &state.trailbase_url, + &unique_user_ids, + ) + .await + .inspect_err(|e| tracing::error!("Failed to enrich comment authors: {}", e)) + .unwrap_or_default(); + + let mut enriched = Vec::with_capacity(comments.len()); + for comment in comments { + let comment_author = author_map + .iter() + .find(|m| m.user_id == comment.created_by) + .map(|m| m.username.clone()); + let comment_time_ago = trailbase::time_ago(&comment.created_at); + let comment_voted = voted_comment_ids.contains(&comment.id); + + enriched.push(CommentWithMeta::from_comment( + comment, + comment_author, + comment_time_ago, + comment_voted, + )); + } + + Ok(enriched) +} + pub async fn show_story( State(state): State<AppState>, jar: CookieJar, @@ -30,9 +84,6 @@ pub async fn show_story( .await? .ok_or_else(|| AppError::NotFound("Story not found".to_string()))?; - // Fetch comments (already ordered by path from the API) - let comments = trailbase::get_comments_for_story(&client, id).await?; - // Get tags for the story let tags = trailbase::get_categories_for_story(&client, id) .await @@ -60,39 +111,11 @@ pub async fn show_story( false }; - let comment_ids: Vec<i64> = comments.iter().map(|c| c.id).collect(); - let voted_comment_ids = if tokens.is_some() && !comment_ids.is_empty() { - trailbase::get_user_votes(&client, TARGET_TYPE_COMMENT, &comment_ids) - .await - .inspect_err(|e| tracing::error!("Failed to get comment votes: {}", e)) - .unwrap_or_default() - } else { - vec![] - }; - // Enrich story let time_ago = trailbase::time_ago(&story.published); let story_with_meta = StoryWithMeta::from_story(story, tags, author_username, time_ago, story_voted, 0); - // Enrich comments (flat list sorted by path for display) - let mut enriched_comments = Vec::with_capacity(comments.len()); - for comment in comments { - let comment_author = trailbase::get_user_profile_by_id(&client, &comment.created_by) - .await - .inspect_err(|e| tracing::error!("Failed to get profile for {}: {}", comment.created_by, e)) - .ok() - .flatten() - .map(|p| p.username); - let comment_time_ago = trailbase::time_ago(&comment.created_at); - let comment_voted = voted_comment_ids.contains(&comment.id); - - enriched_comments.push(CommentWithMeta::from_comment( - comment, - comment_author, - comment_time_ago, - comment_voted, - )); - } + let enriched_comments = fetch_enriched_comments(&state, &tokens, &client, id).await?; let is_logged_in = tokens.is_some(); let current_user_id = tokens @@ -112,6 +135,28 @@ pub async fn show_story( Ok(Html(app.render()?)) } +/// Returns just the comments HTML fragment for a story (used by SSE refresh) +pub async fn story_comments_fragment( + State(state): State<AppState>, + jar: CookieJar, + Path(id): Path<i64>, +) -> Result<Html<String>, AppError> { + let tokens = get_tokens_from_cookies(&jar); + let client = match &tokens { + Some(t) => trailbase::client_with_tokens(&state.trailbase_url, t)?, + None => trailbase::new_client(&state.trailbase_url)?, + }; + + let enriched_comments = fetch_enriched_comments(&state, &tokens, &client, id).await?; + let is_logged_in = tokens.is_some(); + + let template = CommentsTemplate { + comments: enriched_comments, + is_logged_in, + }; + Ok(Html(template.render()?)) +} + pub async fn show_submit( State(state): State<AppState>, jar: CookieJar, diff --git a/src/handlers/tag.rs b/src/handlers/tag.rs @@ -6,7 +6,7 @@ use axum::{ use axum_extra::extract::cookie::CookieJar; use serde::Deserialize; -use crate::db::StoryWithMeta; +use crate::db::{Category, StoryWithMeta}; use crate::error::AppError; use crate::handlers::auth::get_tokens_from_cookies; use crate::state::AppState; @@ -41,15 +41,14 @@ pub async fn show_tag( .ok_or_else(|| AppError::NotFound(format!("Tag '{}' not found", tag_name)))?; let offset = query.page * PAGE_LIMIT; - let stories = trailbase::get_stories_by_tag(&client, category.id, PAGE_LIMIT + 1, offset).await?; + let stories = trailbase::get_stories_by_tag(&state.http_client, &state.trailbase_url, &client, category.id, PAGE_LIMIT + 1, offset).await?; let reached_end = stories.len() <= PAGE_LIMIT; let stories: Vec<_> = stories.into_iter().take(PAGE_LIMIT).collect(); - // Get story IDs for vote checking + // Get story IDs for vote checking and enrichment let story_ids: Vec<i64> = stories.iter().map(|s| s.id).collect(); - // Check which stories the user has voted on let voted_ids = if tokens.is_some() && !story_ids.is_empty() { trailbase::get_user_votes(&client, TARGET_TYPE_STORY, &story_ids) .await @@ -59,25 +58,21 @@ pub async fn show_tag( vec![] }; + // Batch fetch tags and authors + let meta = trailbase::enrich_stories(&state.http_client, &state.trailbase_url, &story_ids) + .await + .inspect_err(|e| tracing::error!("Failed to enrich stories: {}", e)) + .unwrap_or_default(); + let mut enriched_stories = Vec::with_capacity(stories.len()); let base_rank = query.page * PAGE_LIMIT; for (idx, story) in stories.into_iter().enumerate() { - let tags = trailbase::get_categories_for_story(&client, story.id) - .await - .inspect_err(|e| tracing::error!("Failed to get tags for story {}: {}", story.id, e)) + let story_meta = meta.iter().find(|m| m.story_id == story.id); + let tags: Vec<Category> = story_meta + .map(|m| m.tags.iter().map(|name| Category { id: 0, name: Some(name.clone()) }).collect()) .unwrap_or_default(); - - let author_username = if let Some(ref created_by) = story.created_by { - trailbase::get_user_profile_by_id(&client, created_by) - .await - .inspect_err(|e| tracing::error!("Failed to get profile for {}: {}", created_by, e)) - .ok() - .flatten() - .map(|p| p.username) - } else { - None - }; + let author_username = story_meta.and_then(|m| m.author_username.clone()); let time_ago = trailbase::time_ago(&story.published); let user_voted = voted_ids.contains(&story.id); diff --git a/src/trailbase.rs b/src/trailbase.rs @@ -33,6 +33,34 @@ pub struct StoryMeta { pub tags: Vec<String>, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserMapping { + pub user_id: String, + pub username: String, +} + +pub async fn enrich_comment_authors( + http_client: &reqwest::Client, + trailbase_url: &str, + user_ids: &[&str], +) -> Result<Vec<UserMapping>, ClientError> { + if user_ids.is_empty() { + return Ok(vec![]); + } + let ids_str = user_ids.join(","); + let base = trailbase_url.trim_end_matches('/'); + let url = format!("{}/enrich_comments?user_ids={}", base, ids_str); + let response = http_client + .get(&url) + .send() + .await + .map_err(|e| ClientError::Auth(format!("Enrich comments request failed: {}", e)))?; + response + .json() + .await + .map_err(|e| ClientError::Auth(format!("Enrich comments parse failed: {}", e))) +} + pub async fn enrich_stories( http_client: &reqwest::Client, trailbase_url: &str, @@ -258,36 +286,40 @@ pub async fn create_story(client: &Client, story: CreateStory) -> Result<Story, /// Get stories by tag/category pub async fn get_stories_by_tag( + http_client: &reqwest::Client, + trailbase_url: &str, client: &Client, category_id: i64, limit: usize, offset: usize, ) -> Result<Vec<Story>, ClientError> { - // First get story IDs for this category - let junction_args = ListArguments::new() - .with_filters(Filter::new("category_id", CompareOp::Equal, category_id.to_string())); - - let junction: trailbase_client::ListResponse<serde_json::Value> = - client.records("story_category").list(junction_args).await?; - - let story_ids: Vec<i64> = junction - .records - .iter() - .filter_map(|v| v.get("news_item_id").and_then(|id| id.as_i64())) - .collect(); + // Use WASM endpoint for paginated JOIN query + let base = trailbase_url.trim_end_matches('/'); + let url = format!( + "{}/stories_by_tag?category_id={}&limit={}&offset={}", + base, category_id, limit, offset + ); + let story_ids: Vec<i64> = http_client + .get(&url) + .send() + .await + .map_err(|e| ClientError::Auth(format!("stories_by_tag request failed: {}", e)))? + .json() + .await + .map_err(|e| ClientError::Auth(format!("stories_by_tag parse failed: {}", e)))?; if story_ids.is_empty() { return Ok(vec![]); } - // Build filters for stories (OR of all IDs) + // Fetch full story records by ID let filters: Vec<Filter> = story_ids .iter() .map(|id| Filter::new("id", CompareOp::Equal, id.to_string())) .collect(); let args = ListArguments::new() - .with_pagination(Pagination::new().with_limit(limit).with_offset(offset)) + .with_pagination(Pagination::new().with_limit(limit)) .with_order(&["-published"]) .with_filters(filters); diff --git a/traildepot/wasm/search_guest.wasm b/traildepot/wasm/search_guest.wasm Binary files differ.