Cut per-request origin work: batched article loads, no session write per page
The origin was 3–8 ms per page, almost all of it per-statement overhead: an issue page ran two statements per pick (~55 on a 25-article issue) and every signed-in page wrote its session row back because `take_flash` called `Session::remove`, which marks the session modified even when the key is absent. - `Db::get_articles` loads an issue's articles and their social rows in two statements; both branches of `web::issue::load` use it. The single-id and batch queries share one projection via a macro. - `take_flash` reads before removing, and touches a signed-in session at most once a day so the inactivity expiry still slides. Anonymous requests never create a session. - `Server-Timing: app;dur=<ms>` on every response, outermost layer. - `reject_early_data`: 425 for a non-safe method that arrived as TLS 0-RTT data, so nginx `ssl_early_data on` is safe (RFC 8470 §5.2). - `[profile.release]`: fat LTO, one codegen unit (binary 46 → 29 MB). Docs: the Cloudflare proxy was retired on 2026-09-05 after measuring +43 ms per signed-in page from Boston; README reverse-proxy section is now the direct setup (upstream keepalive, 0-RTT lines) and the CDN runbook carries a retired-status banner. Dev seed, app-side: `/` 21 → 5 statements, 3.7 → 1.0 ms; `/feed.xml` 44 → 12, 9.1 → 3.0 ms; session writes per signed-in page 1 → 0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
//! (implementation notes §2). Pipeline writes are idempotent upserts so that
|
||||
//! `generate --date X` can be re-run safely; feedback events are append-only.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
@@ -333,6 +333,46 @@ impl Db {
|
||||
Ok(Some(article))
|
||||
}
|
||||
|
||||
/// Every article in `ids`, keyed by id, with its social refs attached:
|
||||
/// one statement for the articles and one for the social rows, however
|
||||
/// many ids there are. Ids without a row are simply absent. This is what
|
||||
/// a page that shows a whole issue should call; `get_article` in a loop
|
||||
/// costs two statements per pick, and the per-statement overhead, not the
|
||||
/// SQLite work, was most of that page's origin time.
|
||||
pub async fn get_articles(&self, ids: &[ArticleId]) -> Result<HashMap<ArticleId, Article>> {
|
||||
let mut articles = HashMap::with_capacity(ids.len());
|
||||
// SQLite's default bound-parameter ceiling is 32 766; stay well under.
|
||||
for chunk in ids.chunks(500) {
|
||||
// Only the placeholder count is interpolated; every value is bound,
|
||||
// which is what `AssertSqlSafe` asserts.
|
||||
let placeholders = vec!["?"; chunk.len()].join(", ");
|
||||
let mut query = sqlx::query(sqlx::AssertSqlSafe(format!(
|
||||
"{ARTICLE_SELECT_BY_IDS} ({placeholders})"
|
||||
)));
|
||||
for id in chunk {
|
||||
query = query.bind(*id);
|
||||
}
|
||||
for row in query.fetch_all(&self.pool).await? {
|
||||
let article = article_from_row(&row)?;
|
||||
articles.insert(article.id, article);
|
||||
}
|
||||
let mut query = sqlx::query(sqlx::AssertSqlSafe(format!(
|
||||
"SELECT article_id, source, item_id, score, num_comments, item_url, fetched_at
|
||||
FROM social WHERE article_id IN ({placeholders}) ORDER BY article_id, source"
|
||||
)));
|
||||
for id in chunk {
|
||||
query = query.bind(*id);
|
||||
}
|
||||
for row in query.fetch_all(&self.pool).await? {
|
||||
let social = social_from_row(&row)?;
|
||||
if let Some(article) = articles.get_mut(&social.article_id) {
|
||||
article.social.push(social);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(articles)
|
||||
}
|
||||
|
||||
pub async fn article_id_for_url(&self, canonical_url: &str) -> Result<Option<ArticleId>> {
|
||||
let row = sqlx::query("SELECT id FROM articles WHERE canonical_url = ?")
|
||||
.bind(canonical_url)
|
||||
@@ -785,8 +825,13 @@ impl Db {
|
||||
}
|
||||
|
||||
/// Joined projection used by [`Db::get_article`]; keep in sync with [`article_from_row`].
|
||||
const ARTICLE_SELECT_BY_ID: &str = "\
|
||||
SELECT a.id AS id, a.canonical_url AS canonical_url, a.title AS title,
|
||||
/// The article projection `article_from_row` reads, with the `WHERE` clause
|
||||
/// supplied by the caller so the single-id and the `IN (...)` lookups cannot
|
||||
/// drift apart.
|
||||
macro_rules! article_select {
|
||||
($where:literal) => {
|
||||
concat!(
|
||||
"SELECT a.id AS id, a.canonical_url AS canonical_url, a.title AS title,
|
||||
a.best_entry_id AS best_entry_id, a.content_html AS content_html,
|
||||
a.word_count AS word_count, a.excerpt_only AS excerpt_only,
|
||||
a.image_count AS image_count, a.sources_json AS sources_json,
|
||||
@@ -795,7 +840,15 @@ SELECT a.id AS id, a.canonical_url AS canonical_url, a.title AS title,
|
||||
e.feed_title AS feed_title, e.category AS category,
|
||||
e.published_at AS published_at, e.comments_url AS comments_url
|
||||
FROM articles a LEFT JOIN entries e ON e.id = a.best_entry_id
|
||||
WHERE a.id = ?";
|
||||
",
|
||||
$where
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
const ARTICLE_SELECT_BY_ID: &str = article_select!("WHERE a.id = ?");
|
||||
/// Followed at runtime by a parenthesised placeholder list.
|
||||
const ARTICLE_SELECT_BY_IDS: &str = article_select!("WHERE a.id IN");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row mapping helpers (implementation notes §1: manual mapping, no macros)
|
||||
@@ -1112,6 +1165,11 @@ mod tests {
|
||||
assert_eq!(loaded.social.len(), 1);
|
||||
assert_eq!(loaded.social[0].score, 342);
|
||||
assert_eq!(loaded.feed_title, "Hacker News");
|
||||
// The batched lookup agrees with the single one and skips unknown ids.
|
||||
let batch = db.get_articles(&[id, 9_999]).await.unwrap();
|
||||
assert_eq!(batch.len(), 1);
|
||||
assert_eq!(batch[&id], loaded);
|
||||
assert!(db.get_articles(&[]).await.unwrap().is_empty());
|
||||
assert_eq!(
|
||||
db.article_id_for_url("https://example.com/1")
|
||||
.await
|
||||
|
||||
Reference in New Issue
Block a user