commit 492f110322442a94b16500601576bd274ded2fcf
parent 62abf675b44c0508a225bcd243e72231f6ba8695
Author: Silas Brack <silasbrack@gmail.com>
Date: Sun, 7 Jun 2026 23:09:59 +0200
perf: batch story enrichment via WASM guest /enrich endpoint
Replace N+1 tag/author lookups (2 HTTP calls per story) with a
single /enrich?ids=... call that returns tags and author usernames
for all stories in 2 SQL queries.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat:
4 files changed, 146 insertions(+), 30 deletions(-)
diff --git a/guests/rust/src/lib.rs b/guests/rust/src/lib.rs
@@ -4,16 +4,7 @@ use trailbase_wasm::db::{query, Value};
use trailbase_wasm::http::{HttpError, HttpRoute, Json, Request, StatusCode, routing};
use trailbase_wasm::{Guest, export};
-struct SearchGuest;
-
-#[derive(serde::Serialize)]
-struct SearchResult {
- id: i64,
- title: String,
- text: String,
- score: i64,
- comment_count: i64,
-}
+struct ApiGuest;
fn as_integer(v: &Value) -> i64 {
match v {
@@ -30,6 +21,27 @@ fn as_text(v: &Value) -> String {
}
}
+fn as_opt_text(v: &Value) -> Option<String> {
+ match v {
+ Value::Text(s) => Some(s.clone()),
+ _ => None,
+ }
+}
+
+fn err(msg: impl std::fmt::Display) -> HttpError {
+ HttpError::message(StatusCode::INTERNAL_SERVER_ERROR, msg.to_string())
+}
+
+// GET /search?q=...&limit=20
+#[derive(serde::Serialize)]
+struct SearchResult {
+ id: i64,
+ title: String,
+ text: String,
+ score: i64,
+ comment_count: i64,
+}
+
async fn search_handler(req: Request) -> Result<Json<Vec<SearchResult>>, HttpError> {
let q = req
.query_param("q")
@@ -49,9 +61,9 @@ async fn search_handler(req: Request) -> Result<Json<Vec<SearchResult>>, HttpErr
[Value::Text(q), Value::Integer(limit)],
)
.await
- .map_err(|e| HttpError::message(StatusCode::INTERNAL_SERVER_ERROR, format!("Search failed: {e}")))?;
+ .map_err(err)?;
- let results: Vec<SearchResult> = rows
+ let results = rows
.iter()
.map(|row| SearchResult {
id: as_integer(&row[0]),
@@ -65,10 +77,90 @@ async fn search_handler(req: Request) -> Result<Json<Vec<SearchResult>>, HttpErr
Ok(Json(results))
}
-impl Guest for SearchGuest {
+// GET /enrich?ids=1,2,3
+// Returns tags and author usernames for multiple stories in one round trip.
+#[derive(serde::Serialize)]
+struct StoryMeta {
+ story_id: i64,
+ author_username: Option<String>,
+ tags: Vec<String>,
+}
+
+async fn enrich_handler(req: Request) -> Result<Json<Vec<StoryMeta>>, HttpError> {
+ let ids_str = req
+ .query_param("ids")
+ .ok_or_else(|| HttpError::message(StatusCode::BAD_REQUEST, "Missing 'ids' parameter"))?;
+
+ let ids: Vec<i64> = ids_str
+ .split(',')
+ .filter_map(|s| s.trim().parse().ok())
+ .collect();
+
+ if ids.is_empty() {
+ return Ok(Json(vec![]));
+ }
+
+ // Build placeholders: $1, $2, $3, ...
+ let placeholders: Vec<String> = (1..=ids.len()).map(|i| format!("${}", i)).collect();
+ let placeholder_str = placeholders.join(",");
+
+ // Fetch author usernames
+ let author_sql = format!(
+ "SELECT s.id, up.username
+ FROM story s
+ LEFT JOIN user_profile up ON up.user_id = s.created_by
+ WHERE s.id IN ({})",
+ placeholder_str
+ );
+ let params: Vec<Value> = ids.iter().map(|&id| Value::Integer(id)).collect();
+ let author_rows = query(&author_sql, params.clone()).await.map_err(err)?;
+
+ // Fetch tags
+ let tag_sql = format!(
+ "SELECT sc.news_item_id, c.name
+ FROM story_category sc
+ JOIN category c ON c.id = sc.category_id
+ WHERE sc.news_item_id IN ({})",
+ placeholder_str
+ );
+ let tag_rows = query(&tag_sql, params).await.map_err(err)?;
+
+ // Build result
+ let mut result: Vec<StoryMeta> = ids
+ .iter()
+ .map(|&id| StoryMeta {
+ story_id: id,
+ author_username: None,
+ tags: vec![],
+ })
+ .collect();
+
+ for row in &author_rows {
+ let id = as_integer(&row[0]);
+ let username = as_opt_text(&row[1]);
+ if let Some(meta) = result.iter_mut().find(|m| m.story_id == id) {
+ meta.author_username = username;
+ }
+ }
+
+ for row in &tag_rows {
+ let id = as_integer(&row[0]);
+ let tag_name = as_text(&row[1]);
+ if let Some(meta) = result.iter_mut().find(|m| m.story_id == id) {
+ meta.tags.push(tag_name);
+ }
+ }
+
+ Ok(Json(result))
+}
+
+impl Guest for ApiGuest {
fn http_handlers() -> Vec<HttpRoute> {
- vec![routing::get("/search", search_handler)]
+ vec![
+ routing::get("/search", search_handler),
+ routing::get("/enrich", enrich_handler),
+ ]
}
}
-export!(SearchGuest);
+export!(ApiGuest);
diff --git a/src/handlers/feed.rs b/src/handlers/feed.rs
@@ -87,26 +87,21 @@ async fn fetch_feed(
vec![]
};
- // Fetch tags for each story and build enriched stories
+ // Batch fetch tags and authors in 2 SQL queries instead of N+1
+ 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
@@ -26,6 +26,35 @@ pub struct SearchResult {
pub comment_count: i64,
}
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct StoryMeta {
+ pub story_id: i64,
+ pub author_username: Option<String>,
+ pub tags: Vec<String>,
+}
+
+pub async fn enrich_stories(
+ http_client: &reqwest::Client,
+ trailbase_url: &str,
+ story_ids: &[i64],
+) -> Result<Vec<StoryMeta>, ClientError> {
+ if story_ids.is_empty() {
+ return Ok(vec![]);
+ }
+ let ids_str: String = story_ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
+ let base = trailbase_url.trim_end_matches('/');
+ let url = format!("{}/enrich?ids={}", base, ids_str);
+ let response = http_client
+ .get(&url)
+ .send()
+ .await
+ .map_err(|e| ClientError::Auth(format!("Enrich request failed: {}", e)))?;
+ response
+ .json()
+ .await
+ .map_err(|e| ClientError::Auth(format!("Enrich parse failed: {}", e)))
+}
+
/// Tokens stored in cookies for authenticated requests
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredTokens {
diff --git a/traildepot/wasm/search_guest.wasm b/traildepot/wasm/search_guest.wasm
Binary files differ.