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:
2026-09-05 01:34:39 +00:00
co-authored by Claude Fable 5.1
parent bc40773964
commit e727d53b85
8 changed files with 353 additions and 27 deletions
+62 -4
View File
@@ -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
+2
View File
@@ -182,7 +182,9 @@ pub fn router(state: AppState) -> Router {
state.clone(),
crate::web::session::require_same_origin,
))
.layer(from_fn(crate::web::reject_early_data))
.layer(from_fn(crate::web::security_headers))
.layer(from_fn(crate::web::server_timing))
.with_state(state)
}
+13 -2
View File
@@ -89,8 +89,17 @@ pub async fn load(
};
let (mut issue, from_json) = if let Some(raw) = row.issue_json.as_deref() {
let mut issue: Issue = serde_json::from_str(raw).context("decoding issues.issue_json")?;
// The snapshot's articles are refreshed from the live rows (social
// scores move after publish) in one batched lookup.
let ids: Vec<ArticleId> = issue
.lineup
.picks
.iter()
.map(|pick| pick.article.id)
.collect();
let mut articles = db.get_articles(&ids).await?;
for pick in &mut issue.lineup.picks {
if let Some(article) = db.get_article(pick.article.id).await? {
if let Some(article) = articles.remove(&pick.article.id) {
pick.article = article;
}
}
@@ -103,12 +112,14 @@ pub async fn load(
.bind(date.to_string())
.fetch_all(db.pool())
.await?;
let ids: Vec<ArticleId> = rows.iter().map(|row| row.get("article_id")).collect();
let mut articles = db.get_articles(&ids).await?;
let mut picks = Vec::with_capacity(rows.len());
let mut seen_sections = Vec::new();
let mut summaries = BTreeMap::new();
for pick_row in rows {
let article_id: i64 = pick_row.get("article_id");
let Some(article) = db.get_article(article_id).await? else {
let Some(article) = articles.remove(&article_id) else {
continue;
};
let section: String = pick_row.get("section");
+202 -4
View File
@@ -191,7 +191,7 @@ impl WebState {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Flash {
pub kind: String,
pub text: String,
@@ -329,11 +329,104 @@ impl Page {
}
}
/// Consume the one-shot flash message for this page, if there is one.
///
/// Reads before removing: `Session::remove` marks the session modified even
/// when the key is absent, and a modified session is written back to SQLite
/// at the end of the request, so the naive version cost every signed-in page
/// a write. Because a session is only saved when modified, its inactivity
/// expiry only slides when something changes it; [`touch_session`] keeps the
/// `server.session_days` window sliding by writing at most once a day.
pub async fn take_flash(session: &Session) -> Result<Option<Flash>, WebError> {
session
.remove("flash")
let flash = session
.get::<Flash>(FLASH_KEY)
.await
.map_err(|error| WebError::Internal(error.into()))
.map_err(session_error)?;
if flash.is_some() {
session
.remove_value(FLASH_KEY)
.await
.map_err(session_error)?;
return Ok(flash);
}
touch_session(session).await?;
Ok(None)
}
const FLASH_KEY: &str = "flash";
/// Session key holding the unix time of the last expiry-extending write.
const TOUCHED_KEY: &str = "touched_at";
/// How often a signed-in session is written just to slide its expiry.
const TOUCH_INTERVAL_SECS: i64 = 24 * 60 * 60;
/// Slide a signed-in session's inactivity expiry, at most once a day.
///
/// Only a session that already carries a login is touched. An anonymous
/// request must never create a session: the cookie would follow that reader
/// to every public page and take them out of the shared cache.
async fn touch_session(session: &Session) -> Result<(), WebError> {
let signed_in = session
.get_value(session::AUTH_DATA_KEY)
.await
.map_err(session_error)?
.is_some();
if !signed_in {
return Ok(());
}
let now = Timestamp::now().as_second();
let last = session
.get::<i64>(TOUCHED_KEY)
.await
.map_err(session_error)?;
if last.is_none_or(|last| now - last >= TOUCH_INTERVAL_SECS) {
session
.insert(TOUCHED_KEY, now)
.await
.map_err(session_error)?;
}
Ok(())
}
fn session_error(error: axum_login::tower_sessions::session::Error) -> WebError {
WebError::Internal(error.into())
}
/// `Server-Timing: app;dur=<ms>` on every response: the time this process
/// spent on the request, so the browser's DevTools (or `curl -sI`) can split
/// origin work from the network. Outermost layer, so it covers the session
/// load, auth and rendering.
pub async fn server_timing(request: Request, next: Next) -> Response {
let started = std::time::Instant::now();
let mut response = next.run(request).await;
let millis = started.elapsed().as_secs_f64() * 1000.0;
if let Ok(value) = HeaderValue::from_str(&format!("app;dur={millis:.2}")) {
response
.headers_mut()
.insert(header::HeaderName::from_static("server-timing"), value);
}
response
}
/// Refuse TLS 1.3 0-RTT data for anything but a safe method (RFC 8470 §5.2).
///
/// With `ssl_early_data on`, nginx forwards `Early-Data: 1` for a request the
/// browser sent inside the handshake. A replayed early-data `GET` is
/// harmless; a replayed `POST` (a rating, a login attempt, a job start) is
/// not, so those get 425 and the browser resends after the handshake.
pub async fn reject_early_data(request: Request, next: Next) -> Response {
let early = request
.headers()
.get("early-data")
.is_some_and(|value| value == "1");
if early && !request.method().is_safe() {
return (
StatusCode::TOO_EARLY,
[(header::CACHE_CONTROL, "no-store")],
"retry after the TLS handshake completes",
)
.into_response();
}
next.run(request).await
}
pub struct Html<T: Template>(pub T);
@@ -753,6 +846,111 @@ mod tests {
.unwrap()
}
#[tokio::test]
async fn take_flash_reads_without_dirtying_an_empty_session() {
let (_dir, state) = test_state(Config::default()).await;
let store = std::sync::Arc::new(session::SqliteSessionStore::new(state.db.pool().clone()));
let session = Session::new(None, store, None);
assert_eq!(take_flash(&session).await.unwrap(), None);
assert!(!session.is_modified());
assert!(session.is_empty().await);
let flash = Flash {
kind: "ok".into(),
text: "saved".into(),
};
session.insert(FLASH_KEY, &flash).await.unwrap();
assert_eq!(take_flash(&session).await.unwrap(), Some(flash));
assert_eq!(take_flash(&session).await.unwrap(), None);
}
#[tokio::test]
async fn signed_in_pages_touch_the_session_once_a_day_not_every_request() {
let mut config = Config::default();
config.server.public_url = "https://daily.example".into();
let (_dir, state) = test_state(config).await;
// Only the dashboard and the signed-in issue pages consume flashes, so
// an admin exercises the path without an issue in the database.
users::add(&state.db, "admin", "correct horse battery", true)
.await
.unwrap();
let app = router(state.clone());
let cookie = login_cookie(&app, "admin", "correct horse battery").await;
let stamp = || async {
sqlx::query_scalar::<_, String>("SELECT updated_at || ' ' || data FROM sessions")
.fetch_one(state.db.pool())
.await
.unwrap()
};
let get = |uri: &str| {
Request::builder()
.uri(uri)
.header(header::COOKIE, cookie.clone())
.body(Body::empty())
.unwrap()
};
// The first page after login writes the daily touch stamp…
let response = app.clone().oneshot(get("/dashboard/articles")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let first = stamp().await;
assert!(first.contains(TOUCHED_KEY), "{first}");
// …and the pages after it leave the row alone.
for uri in [
"/dashboard/articles",
"/dashboard/ratings",
"/dashboard/stats",
"/",
"/account",
] {
let response = app.clone().oneshot(get(uri)).await.unwrap();
assert_eq!(response.status(), StatusCode::OK, "{uri}");
}
assert_eq!(stamp().await, first);
}
#[tokio::test]
async fn server_timing_header_and_early_data_guard() {
let (_dir, state) = test_state(Config::default()).await;
let app = router(state);
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/healthz")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let timing = response
.headers()
.get("server-timing")
.unwrap()
.to_str()
.unwrap();
assert!(timing.starts_with("app;dur="), "{timing}");
// A safe method may arrive as 0-RTT data; a POST may not.
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/healthz")
.header("early-data", "1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let mut request = post("/login", "username=a&password=b", "192.0.2.1");
request
.headers_mut()
.insert("early-data", HeaderValue::from_static("1"));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::TOO_EARLY);
}
#[tokio::test]
async fn login_cookie_account_logout_and_anonymous_pages() {
let mut config = Config::default();
+5
View File
@@ -20,6 +20,11 @@ use crate::server::AppState;
use crate::web::users::{self, Role, User};
use crate::web::{Html, Page, WebError};
/// The session key axum-login keeps the signed-in user under (its default
/// `data_key`); the presence of this key is what "signed in" means to
/// [`crate::web::take_flash`]'s once-a-day session touch.
pub const AUTH_DATA_KEY: &str = "axum-login.data";
const DUMMY_HASH: &str = "$argon2i$v=19$m=65536,t=1,p=1$c29tZXNhbHQAAAAAAAAAAA$+r0d29hqEB0yasKr55ZgICsQGSkl0v0kgwhd+U3wyRo";
#[derive(Clone, Debug)]