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
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user