Batch the ratings page's per-row queries

The Current tab ran three statements per rated article: two for
`get_article` (the article and its social rows) and one for the newest
explicit `rating_events` source. That is fine on the dev seed and
linear in a production ratings table, and per-statement overhead, not
SQLite's work, was most of the page's origin time.

Collect the article ids up front, load them with the batched
`Db::get_articles` added for the issue page, and resolve every source
in one windowed query per chunk of 500 ids. Semantics are unchanged:
ties still break on `id DESC`, and an article with no explicit event
still renders an empty source.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
This commit is contained in:
2026-09-05 02:02:04 +00:00
co-authored by Claude Fable 5.1
parent f6b2d3ea9a
commit 1e362fee3e
+85 -30
View File
@@ -649,8 +649,11 @@ async fn build_current_rows(
let mut rows = Vec::new(); let mut rows = Vec::new();
let mut feeds: BTreeMap<FeedId, String> = BTreeMap::new(); let mut feeds: BTreeMap<FeedId, String> = BTreeMap::new();
let mut rank = 0usize; let mut rank = 0usize;
let ids: Vec<ArticleId> = ratings.iter().map(|rating| rating.article_id).collect();
let articles = db.get_articles(&ids).await?;
let sources = event_sources_for(db, &ids).await?;
for rating in ratings { for rating in ratings {
let article = db.get_article(rating.article_id).await?; let article = articles.get(&rating.article_id);
let this_rank = if rating.label == "cleared" { let this_rank = if rating.label == "cleared" {
None None
} else { } else {
@@ -659,22 +662,12 @@ async fn build_current_rows(
Some(current) Some(current)
}; };
let has_embedding = embedded.contains(&rating.article_id); let has_embedding = embedded.contains(&rating.article_id);
let contribution = contribution( let contribution = contribution(rating, article, has_embedding, this_rank, now, config);
rating, let direct = article.map(signals::direct_feeds).unwrap_or_default();
article.as_ref(),
has_embedding,
this_rank,
now,
config,
);
let direct = article
.as_ref()
.map(signals::direct_feeds)
.unwrap_or_default();
for feed_id in &direct { for feed_id in &direct {
feeds feeds
.entry(*feed_id) .entry(*feed_id)
.or_insert_with(|| feed_title_for(article.as_ref(), *feed_id)); .or_insert_with(|| feed_title_for(article, *feed_id));
} }
let (badge, _) = widget_label(&rating.label); let (badge, _) = widget_label(&rating.label);
@@ -691,7 +684,7 @@ async fn build_current_rows(
{ {
continue; continue;
} }
let source = event_source_for(db, rating).await?; let source = sources.get(&rating.article_id).cloned().unwrap_or_default();
if filters if filters
.source .source
.as_deref() .as_deref()
@@ -754,21 +747,43 @@ async fn build_current_rows(
Ok((rows, feeds)) Ok((rows, feeds))
} }
/// The `source` of the event behind a current verdict (`RatedArticle` does not /// The `source` of the event behind each current verdict (`RatedArticle` does
/// carry it). /// not carry it), for every article of the page in one statement per chunk.
async fn event_source_for(db: &Db, rating: &RatedArticle) -> Result<String, WebError> { /// An article with no explicit event is absent from the map; the caller shows
let row = sqlx::query( /// an empty source for it.
"SELECT source FROM rating_events async fn event_sources_for(
WHERE article_id = ? AND kind = 'explicit' db: &Db,
ORDER BY event_at DESC, id DESC LIMIT 1", ids: &[ArticleId],
) ) -> Result<HashMap<ArticleId, String>, WebError> {
.bind(rating.article_id) let mut sources = HashMap::with_capacity(ids.len());
.fetch_optional(db.pool()) // SQLite's default bound-parameter ceiling is 32 766; stay well under.
.await for chunk in ids.chunks(500) {
.map_err(DbError::from)?; // Only the placeholder count is interpolated; every value is bound,
Ok(row // which is what `AssertSqlSafe` asserts.
.map(|row| row.get::<String, _>("source")) let placeholders = vec!["?"; chunk.len()].join(", ");
.unwrap_or_default()) let mut query = sqlx::query(sqlx::AssertSqlSafe(format!(
"SELECT article_id, source FROM (
SELECT article_id, source,
ROW_NUMBER() OVER (
PARTITION BY article_id
ORDER BY event_at DESC, id DESC
) AS event_rank
FROM rating_events
WHERE kind = 'explicit' AND article_id IN ({placeholders})
)
WHERE event_rank = 1"
)));
for id in chunk {
query = query.bind(*id);
}
for row in query.fetch_all(db.pool()).await.map_err(DbError::from)? {
sources.insert(
row.get::<ArticleId, _>("article_id"),
row.get::<String, _>("source"),
);
}
}
Ok(sources)
} }
async fn load_events( async fn load_events(
@@ -1292,6 +1307,12 @@ mod tests {
assert!(body.contains("Postgres failover story")); assert!(body.contains("Postgres failover story"));
assert!(!body.contains("A listicle")); assert!(!body.contains("A listicle"));
let by_source_current =
get(&app, "/dashboard/ratings?source=dashboard", Some(&cookie)).await;
let body = text(by_source_current).await;
assert!(body.contains("Postgres failover story"));
assert!(!body.contains("A listicle"));
let events = get(&app, "/dashboard/ratings?tab=events", Some(&cookie)).await; let events = get(&app, "/dashboard/ratings?tab=events", Some(&cookie)).await;
assert_eq!(events.status(), StatusCode::OK); assert_eq!(events.status(), StatusCode::OK);
let body = text(events).await; let body = text(events).await;
@@ -1326,6 +1347,40 @@ mod tests {
assert!(!body.contains("A listicle")); assert!(!body.contains("A listicle"));
} }
#[tokio::test]
async fn event_sources_take_the_newest_explicit_event() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
.await
.unwrap();
let rated = seed_article(&db, 1, 1001, "Rated twice").await;
let unrated = seed_article(&db, 2, 1002, "Never rated").await;
seed_event(
&db,
rated,
"good",
0.35,
"cli",
None,
"2026-08-01T00:00:00Z",
)
.await;
seed_event(
&db,
rated,
"loved",
1.0,
"dashboard",
None,
"2026-08-20T00:00:00Z",
)
.await;
let sources = event_sources_for(&db, &[rated, unrated]).await.unwrap();
assert_eq!(sources.get(&rated).map(String::as_str), Some("dashboard"));
assert_eq!(sources.get(&unrated), None);
assert!(event_sources_for(&db, &[]).await.unwrap().is_empty());
}
#[tokio::test] #[tokio::test]
async fn ratings_page_is_admin_only() { async fn ratings_page_is_admin_only() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();