From 7031828c3d529affef5af7f578a67deaef4b5e08 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 13 Oct 2026 22:14:27 +0100 Subject: [PATCH 01/23] The source cache and the asset cache, in core Core gains source_cache.rs, which keeps each address's record of what the origin's responses vary on and a copy under each request's values for those headers, both through the loading cache, so requests for an address share one load and a copy is served only to a request whose values match, and asset_cache.rs, which is what a kept asset holds and the address it is held under. Both are selected by one table, [cache.assets], which CacheSettings lists after [cache.robots-txt], so a deployment names the module whose loading cache holds its assets as it does for robots.txt, and a build that links one takes it without a line in the document. Nothing holds an asset yet. The publisher path does in the next commit. --- .../tests/seam_probe.rs | 2 +- crates/trusted-server-core/src/asset_cache.rs | 431 ++++++++++ .../src/integrations/mod.rs | 10 +- crates/trusted-server-core/src/lib.rs | 2 + crates/trusted-server-core/src/settings.rs | 41 +- .../trusted-server-core/src/source_cache.rs | 786 ++++++++++++++++++ 6 files changed, 1254 insertions(+), 18 deletions(-) create mode 100644 crates/trusted-server-core/src/asset_cache.rs create mode 100644 crates/trusted-server-core/src/source_cache.rs diff --git a/crates/trusted-server-adapter-axum/tests/seam_probe.rs b/crates/trusted-server-adapter-axum/tests/seam_probe.rs index 5a3d74601a..e2dde68941 100644 --- a/crates/trusted-server-adapter-axum/tests/seam_probe.rs +++ b/crates/trusted-server-adapter-axum/tests/seam_probe.rs @@ -540,7 +540,7 @@ async fn a_cache_table_no_module_declares_is_refused_by_the_deploy_check_and_at_ "should name the table: {message}" ); assert!( - message.contains("robots-txt, seam-probe-answers"), + message.contains("robots-txt, assets, seam-probe-answers"), "should list the caches this build declares: {message}" ); assert_eq!( diff --git a/crates/trusted-server-core/src/asset_cache.rs b/crates/trusted-server-core/src/asset_cache.rs new file mode 100644 index 0000000000..7b5442549a --- /dev/null +++ b/crates/trusted-server-core/src/asset_cache.rs @@ -0,0 +1,431 @@ +//! What the asset cache keeps for a response that is not a page, being the +//! response as the fetch phase left it, and the address it is kept under. +//! +//! A copy holds the headers a reader is served and the body. A body the fetch +//! phase rewrote is kept decoded, and each reader's copy is encoded as it is +//! served. Every copy is kept under what the origin's own `Vary` names, see +//! `crate::source_cache`. + +use edgezero_core::body::Body as EdgeBody; +use http::{HeaderName, HeaderValue, Response, StatusCode, header}; +use sha2::{Digest as _, Sha256}; + +/// Version of what a kept copy holds and how it is written. Bump on any change +/// to either, so a copy written by another build is never read. +pub const ASSET_SCHEMA_VERSION: u32 = 1; + +/// The most bytes a kept copy's body holds. A larger response is served as it +/// streams from the origin, because every copy is held whole, in memory and in +/// the shared store. +pub const MAX_ASSET_COPY_BYTES: usize = 1024 * 1024; + +/// Response headers never kept with a copy, being those describing one +/// connection, one reader's cookies, or the length and age, which are set again +/// for each reader. +const NOT_KEPT: &[&str] = &[ + "age", + "connection", + "content-length", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "set-cookie", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +/// Inputs that select one asset's address. +/// +/// Every field changes the bytes the fetch phase makes of the same response. +/// What the origin's response varies on is not here, because it is read from +/// the address's record once the origin has said it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AssetCacheKey { + /// The URL asked of the origin, without its query where the publisher's + /// declaration names the parameters that matter. + pub url: String, + /// The host the reader asked, which the fetch phase writes into the body + /// in place of the origin's. + pub request_host: String, + /// The scheme the reader asked over, written into the body with the host. + pub request_scheme: String, + /// The origin URL and the `Host` it is asked for. + pub origin_identity: String, + /// Digest of every setting and module that can shape the fetch phase, + /// and of the build, so a build that rewrites bodies differently never + /// serves a copy an earlier one made. + pub fingerprint: String, +} + +impl AssetCacheKey { + /// A fixed-size key naming no part of the request. + /// + /// Each input is written with its length before it, so no two sets of + /// inputs run together into the same key. + #[must_use] + pub fn to_cache_key(&self) -> String { + fn push(hasher: &mut Sha256, part: &[u8]) { + hasher.update((part.len() as u64).to_be_bytes()); + hasher.update(part); + } + + let mut hasher = Sha256::new(); + push(&mut hasher, b"ts-asset-cache"); + push(&mut hasher, &ASSET_SCHEMA_VERSION.to_be_bytes()); + push(&mut hasher, self.request_scheme.as_bytes()); + push(&mut hasher, self.request_host.as_bytes()); + push(&mut hasher, self.origin_identity.as_bytes()); + push(&mut hasher, self.url.as_bytes()); + push(&mut hasher, self.fingerprint.as_bytes()); + format!( + "ts-asset-cache-v{ASSET_SCHEMA_VERSION}-{}", + hex::encode(hasher.finalize()) + ) + } +} + +/// One kept response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AssetCopy { + /// The headers a reader is served, as the fetch phase left them. + pub headers: Vec<(String, String)>, + /// Whether the fetch phase decoded and rewrote the body, in which case it + /// is kept decoded and encoded for each reader as it is served. + pub decoded: bool, + /// When the copy was made, in seconds since the Unix epoch. + pub made_at: u64, + /// The response's age when the copy was made, in seconds. + pub age_at_made: u64, + /// The body. + pub body: Vec, +} + +impl AssetCopy { + /// The headers of `response_headers` a copy keeps, or `None` when one + /// cannot be written down, as a value that is not visible text cannot. + /// + /// `Content-Encoding` is left out of a decoded copy, which holds identity + /// bytes. + #[must_use] + pub fn kept_headers( + response_headers: &http::HeaderMap, + decoded: bool, + ) -> Option> { + let mut kept = Vec::new(); + for (name, value) in response_headers { + if NOT_KEPT.contains(&name.as_str()) || (decoded && name == header::CONTENT_ENCODING) { + continue; + } + let value = value.to_str().ok()?; + kept.push((name.as_str().to_owned(), value.to_owned())); + } + Some(kept) + } + + /// The response this copy is served as, `now` seconds since the Unix + /// epoch, its `Age` counting the time it has been kept. + pub fn into_response(self, now: u64) -> Response { + let mut response = Response::new(EdgeBody::empty()); + *response.status_mut() = StatusCode::OK; + for (name, value) in &self.headers { + // Both were checked when the copy was read. + if let (Ok(name), Ok(value)) = ( + HeaderName::from_bytes(name.as_bytes()), + HeaderValue::from_str(value), + ) { + response.headers_mut().append(name, value); + } + } + let age = self + .age_at_made + .saturating_add(now.saturating_sub(self.made_at)); + response + .headers_mut() + .insert(header::AGE, HeaderValue::from(age)); + response.headers_mut().insert( + header::CONTENT_LENGTH, + HeaderValue::from(self.body.len() as u64), + ); + *response.body_mut() = EdgeBody::from(self.body); + response + } +} + +/// What the asset cache holds for one request's values. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AssetEntry { + /// A response every request with the same values may be served. + Copy(AssetCopy), + /// The origin's response for these values may not be shared, so each + /// request fetches its own. Held briefly, so the requests that follow do + /// not wait on a load that ends the same way. + NotShareable, +} + +/// The first byte of a held [`AssetEntry`]. +const ENTRY_NOT_SHAREABLE: u8 = 0; +const ENTRY_COPY: u8 = 1; + +/// Why a held asset entry could not be read. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +pub enum AssetCacheMiss { + /// Written by a build keeping another format. + #[display("the kept asset was written in another format")] + OtherFormat, + /// Unreadable, or shorter than it says it is. + #[display("the kept asset is unreadable")] + Unreadable, +} + +impl core::error::Error for AssetCacheMiss {} + +impl AssetEntry { + /// The bytes the cache holds for this entry, being a kind byte, then for a + /// copy the length of its description as a little-endian `u32`, the + /// description, one line per field, and the body. + #[must_use] + pub fn encode(&self) -> Vec { + match self { + Self::NotShareable => vec![ENTRY_NOT_SHAREABLE], + Self::Copy(copy) => { + let mut description = format!( + "v={ASSET_SCHEMA_VERSION}\nd={}\nt={}\na={}\nlen={}", + u8::from(copy.decoded), + copy.made_at, + copy.age_at_made, + copy.body.len() + ); + for (name, value) in ©.headers { + description.push_str(&format!("\nh={name}:{value}")); + } + let length = u32::try_from(description.len()).unwrap_or(u32::MAX); + let mut bytes = Vec::with_capacity(5 + description.len() + copy.body.len()); + bytes.push(ENTRY_COPY); + bytes.extend_from_slice(&length.to_le_bytes()); + bytes.extend_from_slice(description.as_bytes()); + bytes.extend_from_slice(©.body); + bytes + } + } + } + + /// The entry `bytes` hold. + /// + /// # Errors + /// + /// When the bytes are not an entry this build wrote. + pub fn decode(bytes: &[u8]) -> Result { + match bytes.split_first() { + Some((&ENTRY_NOT_SHAREABLE, [])) => Ok(Self::NotShareable), + Some((&ENTRY_COPY, rest)) => { + let (length, rest) = rest + .split_first_chunk::<4>() + .ok_or(AssetCacheMiss::Unreadable)?; + let length = usize::try_from(u32::from_le_bytes(*length)) + .map_err(|_| AssetCacheMiss::Unreadable)?; + if rest.len() < length { + return Err(AssetCacheMiss::Unreadable); + } + let (description, body) = rest.split_at(length); + let description = + std::str::from_utf8(description).map_err(|_| AssetCacheMiss::Unreadable)?; + decode_copy(description, body).map(Self::Copy) + } + _ => Err(AssetCacheMiss::Unreadable), + } + } +} + +fn decode_copy(description: &str, body: &[u8]) -> Result { + let unreadable = AssetCacheMiss::Unreadable; + let mut version = None; + let mut decoded = None; + let mut made_at = None; + let mut age_at_made = None; + let mut length = None; + let mut headers = Vec::new(); + for line in description.split('\n') { + let (field, value) = line.split_once('=').ok_or(unreadable)?; + let once = |held: Option| held.is_none().then_some(()).ok_or(unreadable); + match field { + "v" => { + once(version)?; + version = Some(value.parse::().map_err(|_| unreadable)?); + } + "d" => { + decoded = Some(match value { + "0" if decoded.is_none() => false, + "1" if decoded.is_none() => true, + _ => return Err(unreadable), + }); + } + "t" => { + once(made_at)?; + made_at = Some(value.parse().map_err(|_| unreadable)?); + } + "a" => { + once(age_at_made)?; + age_at_made = Some(value.parse().map_err(|_| unreadable)?); + } + "len" => { + once(length)?; + length = Some(value.parse().map_err(|_| unreadable)?); + } + "h" => { + let (name, header_value) = value.split_once(':').ok_or(unreadable)?; + let parsed = HeaderName::from_bytes(name.as_bytes()).map_err(|_| unreadable)?; + if parsed.as_str() != name || NOT_KEPT.contains(&name) { + return Err(unreadable); + } + HeaderValue::from_str(header_value).map_err(|_| unreadable)?; + headers.push((name.to_owned(), header_value.to_owned())); + } + _ => return Err(unreadable), + } + } + if version.ok_or(unreadable)? != u64::from(ASSET_SCHEMA_VERSION) { + return Err(AssetCacheMiss::OtherFormat); + } + if length.ok_or(unreadable)? != body.len() as u64 { + return Err(unreadable); + } + Ok(AssetCopy { + headers, + decoded: decoded.ok_or(unreadable)?, + made_at: made_at.ok_or(unreadable)?, + age_at_made: age_at_made.ok_or(unreadable)?, + body: body.to_vec(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key() -> AssetCacheKey { + AssetCacheKey { + url: "https://origin.example.com/assets/site.css".to_owned(), + request_host: "www.example.com".to_owned(), + request_scheme: "https".to_owned(), + origin_identity: "https://origin.example.com\0origin.example.com".to_owned(), + fingerprint: "abc123".to_owned(), + } + } + + fn copy() -> AssetCopy { + AssetCopy { + headers: vec![ + ("content-type".to_owned(), "text/css".to_owned()), + ("vary".to_owned(), "Accept-Encoding".to_owned()), + ], + decoded: true, + made_at: 1_000, + age_at_made: 5, + body: b"body { color: red }".to_vec(), + } + } + + #[test] + fn every_input_changes_the_key_and_none_appears_in_it() { + let base = key().to_cache_key(); + let changes: [fn(&mut AssetCacheKey); 5] = [ + |key| key.url.push_str("?v=2"), + |key| key.request_host = "other.example.com".to_owned(), + |key| key.request_scheme = "http".to_owned(), + |key| key.origin_identity.push('x'), + |key| key.fingerprint = "def456".to_owned(), + ]; + for change in changes { + let mut changed = key(); + change(&mut changed); + assert_ne!(changed.to_cache_key(), base); + } + assert!(base.starts_with("ts-asset-cache-v1-")); + for named in ["example.com", "site.css", "abc123"] { + assert!(!base.contains(named), "the key names `{named}`: {base}"); + } + } + + #[test] + fn an_entry_reads_back_as_written() { + for entry in [AssetEntry::Copy(copy()), AssetEntry::NotShareable] { + assert_eq!(AssetEntry::decode(&entry.encode()), Ok(entry)); + } + } + + #[test] + fn a_short_foreign_or_other_format_entry_is_not_read() { + let bytes = AssetEntry::Copy(copy()).encode(); + assert_eq!( + AssetEntry::decode(&bytes[..bytes.len() - 1]), + Err(AssetCacheMiss::Unreadable) + ); + for foreign in [&b""[..], b"\x07", b"\x01\xff\xff", b"\x00extra"] { + assert!(AssetEntry::decode(foreign).is_err(), "{foreign:?}"); + } + let other_version = String::from_utf8(bytes.clone()) + .expect("the test copy is text") + .replacen("v=1", "v=9", 1); + assert_eq!( + AssetEntry::decode(other_version.as_bytes()), + Err(AssetCacheMiss::OtherFormat) + ); + } + + #[test] + fn a_copy_keeps_no_connection_cookie_or_length_header() { + let mut response = http::HeaderMap::new(); + for (name, value) in [ + ("content-type", "image/png"), + ("content-encoding", "gzip"), + ("set-cookie", "a=b"), + ("content-length", "10"), + ("age", "3"), + ("connection", "keep-alive"), + ("cache-control", "public, max-age=60"), + ] { + response.append( + HeaderName::from_static(name), + HeaderValue::from_static(value), + ); + } + + let raw = AssetCopy::kept_headers(&response, false).expect("should keep the headers"); + let decoded = AssetCopy::kept_headers(&response, true).expect("should keep the headers"); + + let names = |kept: &[(String, String)]| -> Vec { + kept.iter().map(|(name, _)| name.clone()).collect() + }; + assert_eq!( + names(&raw), + ["content-type", "content-encoding", "cache-control"] + ); + assert_eq!( + names(&decoded), + ["content-type", "cache-control"], + "a decoded copy's encoding is set for each reader" + ); + } + + #[test] + fn a_served_copy_counts_its_age_from_when_it_was_made() { + let response = copy().into_response(1_030); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::AGE), + Some(&HeaderValue::from_static("35")) + ); + assert_eq!( + response.headers().get(header::CONTENT_LENGTH), + Some(&HeaderValue::from_static("19")) + ); + assert_eq!( + response.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("text/css")) + ); + } +} diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 78068f906d..b269bcd301 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -831,14 +831,16 @@ pub(crate) fn all_builders( /// /// # Errors /// -/// When a builder declares core's own `robots-txt`, or two builders declare +/// When a builder declares one of core's own caches, or two builders declare /// one name, naming the builders, since the two would otherwise share one /// cache and its keys without a word. pub fn declared_caches( builders: &[IntegrationBuilder], ) -> Result, Report> { - let mut declared: Vec<(&'static str, &'static str)> = - vec![(crate::settings::ROBOTS_TXT_CACHE, "core")]; + let mut declared: Vec<(&'static str, &'static str)> = vec![ + (crate::settings::ROBOTS_TXT_CACHE, "core"), + (crate::settings::ASSETS_CACHE, "core"), + ]; for builder in builders { for name in builder.cache_features() { if let Some((_, by)) = declared.iter().find(|(declared, _)| declared == name) { @@ -1126,7 +1128,7 @@ mod loading_caches_from_tests { let message = format!("{refused:?}"); assert!(message.contains("[cache.answres]"), "{message}"); - assert!(message.contains("robots-txt, answers"), "{message}"); + assert!(message.contains("robots-txt, assets, answers"), "{message}"); } #[test] diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 1efb8d127c..5f30d74e65 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -28,6 +28,7 @@ ) )] +pub mod asset_cache; pub(crate) mod asset_image_optimizer; pub mod attestation; pub mod auction; @@ -77,6 +78,7 @@ pub mod secret_resolution; pub mod service_version; pub mod settings; pub mod settings_data; +pub mod source_cache; pub mod streaming_processor; pub mod streaming_replacer; pub mod tdl; diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index b45b4b157f..7d747ec166 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -3032,6 +3032,10 @@ pub struct CacheSettings { skip_serializing_if = "LoadingCacheConfig::is_default" )] pub robots_txt: LoadingCacheConfig, + /// How long the copies of the assets the publisher's origin sends last, + /// `[cache.assets]`. + #[serde(default, skip_serializing_if = "LoadingCacheConfig::is_default")] + pub assets: LoadingCacheConfig, /// The caches modules declare and load through, each in its own /// `[cache.]` table with the same settings as `[cache.robots-txt]`. /// A name no linked module declares is refused when the deployment's @@ -3047,6 +3051,10 @@ pub const MAX_LOADING_CACHE_SECONDS: u32 = 365 * 24 * 60 * 60; /// settings are `[cache.robots-txt]`. pub const ROBOTS_TXT_CACHE: &str = "robots-txt"; +/// The name of the cache that holds the copies of the assets the publisher's +/// origin sends, whose settings are `[cache.assets]`. +pub const ASSETS_CACHE: &str = "assets"; + /// How long one loading cache's copies last, each field defaulting to the /// cache's own default when absent. /// @@ -3136,13 +3144,17 @@ impl CacheSettings { /// modules declare, in name order. #[must_use] pub fn loading_caches(&self) -> Vec<(&str, &LoadingCacheConfig)> { - std::iter::once((ROBOTS_TXT_CACHE, &self.robots_txt)) - .chain( - self.declared - .iter() - .map(|(name, config)| (name.as_str(), config)), - ) - .collect() + [ + (ROBOTS_TXT_CACHE, &self.robots_txt), + (ASSETS_CACHE, &self.assets), + ] + .into_iter() + .chain( + self.declared + .iter() + .map(|(name, config)| (name.as_str(), config)), + ) + .collect() } fn normalize(&mut self) { @@ -7052,10 +7064,10 @@ module = \"none\"", let caches = settings.cache.loading_caches(); - assert_eq!(caches.len(), 2); - assert_eq!(caches[1].0, "answers"); - assert_eq!(caches[1].1.module.as_deref(), Some("51degrees")); - assert_eq!(caches[1].1.store_seconds, Some(3600)); + assert_eq!(caches.len(), 3); + assert_eq!(caches[2].0, "answers"); + assert_eq!(caches[2].1.module.as_deref(), Some("51degrees")); + assert_eq!(caches[2].1.store_seconds, Some(3600)); let written = serde_json::to_value(&settings.cache).expect("should serialize"); assert_eq!(written["answers"]["module"], "51degrees"); } @@ -7079,8 +7091,11 @@ module = \"none\"", assert!(!settings.cache.robots_txt.is_default()); assert_eq!( settings.cache.loading_caches(), - vec![(super::ROBOTS_TXT_CACHE, &settings.cache.robots_txt)], - "should list the robots.txt cache by its name" + vec![ + (super::ROBOTS_TXT_CACHE, &settings.cache.robots_txt), + (super::ASSETS_CACHE, &settings.cache.assets), + ], + "should list core's own caches by name, robots.txt then assets" ); let written = serde_json::to_value(&settings.cache).expect("should serialize"); assert_eq!( diff --git a/crates/trusted-server-core/src/source_cache.rs b/crates/trusted-server-core/src/source_cache.rs new file mode 100644 index 0000000000..30093d20b9 --- /dev/null +++ b/crates/trusted-server-core/src/source_cache.rs @@ -0,0 +1,786 @@ +//! The cache that holds what the fetch phase made of a source's response, +//! keyed the way the source says its responses vary. +//! +//! A request's address is what it asks the source for. The first response for +//! an address records the request headers its `Vary` names, and that record is +//! held in the loading cache like any other value. A later request for the +//! address reads the record and is served the copy kept under its own values +//! for those headers, as RFC 9111 section 4.1 describes, together with what +//! the publisher's rule adds for inputs the source varies on without saying +//! so. +//! +//! Requests for an address no record covers share one load. The request that +//! leads it fetches the source and writes its copy under its own values before +//! the record is published, so a waiting request with the same values is +//! served that copy, and one with other values loads its own, sharing that +//! load with every request waiting on the same values. +//! +//! A response whose `Vary` is `*`, names `Cookie`, or cannot be read can be +//! served to no other request, so its record says no copy is kept, and every +//! request for the address fetches its own response until the record ends. + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; +use http::HeaderMap; +use http::header::{self, HeaderName}; +use sha2::{Digest as _, Sha256}; + +use crate::platform::{ + CacheSpec, LoadError, LoadedValue, Loader, RuntimeServices, ServerKeyPrefix, StoreKey, +}; + +/// What a source's response says it varies on, read from its `Vary`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SourceVary { + /// These request headers, in lowercase, sorted and each named once. + /// Empty when the response varies on nothing. + Headers(Vec), + /// `Vary: *`, which no other request matches. + Any, + /// `Cookie` is named, which keys a copy on every cookie a reader carries, + /// so each reader would have a copy of their own. + Cookie, + /// A `Vary` that is not a list of header names. + Unreadable, +} + +impl SourceVary { + /// What a response with `headers` says it varies on. + pub fn of(headers: &HeaderMap) -> Self { + let mut names = Vec::new(); + for value in headers.get_all(header::VARY) { + let Ok(value) = value.to_str() else { + return Self::Unreadable; + }; + for name in value + .split(',') + .map(str::trim) + .filter(|name| !name.is_empty()) + { + if name == "*" { + return Self::Any; + } + let Ok(name) = HeaderName::from_bytes(name.as_bytes()) else { + return Self::Unreadable; + }; + if name == header::COOKIE { + return Self::Cookie; + } + names.push(name.as_str().to_owned()); + } + } + names.sort_unstable(); + names.dedup(); + Self::Headers(names) + } + + /// The same, leaving out `name`, a header whose value never selects a + /// different copy, such as `Accept-Encoding` for a copy kept decoded and + /// encoded for each reader as it is served. + #[must_use] + pub fn without(self, name: &HeaderName) -> Self { + match self { + Self::Headers(mut names) => { + names.retain(|kept| kept != name.as_str()); + Self::Headers(names) + } + other => other, + } + } +} + +/// What the cache holds for an address. +#[derive(Debug, Clone, PartialEq, Eq)] +enum AddressRecord { + /// Each copy at the address is found by a request's values for these + /// headers. + VariesOn(Vec), + /// No copy at the address is served to another request. + NoCopies, +} + +impl AddressRecord { + fn encode(&self) -> Bytes { + match self { + Self::VariesOn(names) => Bytes::from(format!("v1:{}", names.join(","))), + Self::NoCopies => Bytes::from_static(b"v1*"), + } + } + + /// The record `bytes` hold, or `None` for bytes this build did not write. + fn decode(bytes: &[u8]) -> Option { + let text = std::str::from_utf8(bytes).ok()?; + if text == "v1*" { + return Some(Self::NoCopies); + } + let names = text.strip_prefix("v1:")?; + if names.is_empty() { + return Some(Self::VariesOn(Vec::new())); + } + names + .split(',') + .map(|name| { + HeaderName::from_bytes(name.as_bytes()) + .ok() + .filter(|parsed| parsed.as_str() == name) + .map(|_| name.to_owned()) + }) + .collect::>>() + .map(Self::VariesOn) + } +} + +/// One address, made of the publisher's prefix, so two publishers never share +/// a record or a copy, and a digest of what the request asks the source for. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceAddress { + prefix: ServerKeyPrefix, + key: String, +} + +impl SourceAddress { + /// The address `key` names for the publisher `prefix` names. The key + /// should be short, such as a digest, because a store refuses a long key. + #[must_use] + pub fn new(prefix: ServerKeyPrefix, key: String) -> Self { + Self { prefix, key } + } + + fn record_key(&self, cache: &SourceCache) -> StoreKey { + StoreKey::new(&self.prefix, cache.records.name, self.key.clone()) + } + + fn copy_key(&self, cache: &SourceCache, variant: &str) -> StoreKey { + StoreKey::new( + &self.prefix, + cache.copies.name, + format!("{}-{variant}", self.key), + ) + } + + /// The key this address's record is held under in `cache`, for a test. + #[cfg(test)] + #[must_use] + pub fn record_key_for_test(&self, cache: &SourceCache) -> StoreKey { + self.record_key(cache) + } + + /// The key one of this address's copies is held under in `cache`, for a + /// test. + #[cfg(test)] + #[must_use] + pub fn copy_key_for_test(&self, cache: &SourceCache, variant: &str) -> StoreKey { + self.copy_key(cache, variant) + } +} + +/// Which copy a request is served, as a digest of its values for `names` and +/// of what the publisher's rule declared for it. +/// +/// An absent header and one present with an empty value are different requests +/// to the source, so they give different digests, as do repeated values in a +/// different order. +fn variant(names: &[String], headers: &HeaderMap, declared: Option<&str>) -> String { + fn push(hasher: &mut Sha256, part: &[u8]) { + hasher.update((part.len() as u64).to_be_bytes()); + hasher.update(part); + } + + let mut hasher = Sha256::new(); + push(&mut hasher, b"torque-variant-v1"); + push(&mut hasher, &(names.len() as u64).to_be_bytes()); + for name in names { + push(&mut hasher, name.as_bytes()); + let values: Vec<&[u8]> = headers + .get_all(name.as_str()) + .iter() + .map(http::HeaderValue::as_bytes) + .collect(); + if values.is_empty() { + push(&mut hasher, b"absent"); + } else { + push(&mut hasher, b"present"); + push(&mut hasher, &(values.len() as u64).to_be_bytes()); + for value in values { + push(&mut hasher, value); + } + } + } + match declared { + None => push(&mut hasher, b"undeclared"), + Some(token) => { + push(&mut hasher, b"declared"); + push(&mut hasher, token.as_bytes()); + } + } + // Half the digest. 128 bits keeps two variants of one address from ever + // sharing a key, and a key is read by stores that refuse long ones. + hex::encode(&hasher.finalize()[..16]) +} + +/// The two loading caches one kind of response uses, one holding each +/// address's record, and one holding the copies. +#[derive(Debug, Clone)] +pub struct SourceCache { + /// Holds each address's record of what its responses vary on. + pub records: CacheSpec, + /// Holds the copies, each under its address and a request's values. + pub copies: CacheSpec, +} + +/// What one fetch of the source gave the request that made it. +#[derive(Debug, Clone)] +pub struct Fetched { + /// What the source's response varies on. + pub vary: SourceVary, + /// The copy cache's value for the request that fetched it, in the caller's + /// own form, which may say the response may not be served to another + /// request. + pub copy: Bytes, + /// How long the copy may be used. The record lasts as long. + pub lifetime: Duration, +} + +/// The work of one [`Fetch`]. +pub type FetchFuture = Pin>>>; + +/// Fetches the source for one request, run at most once for its record and +/// once for its copy, by whichever of the two loads this request leads. +pub type Fetch = Arc FetchFuture + Send + Sync>; + +/// One request's lookup. +pub struct Lookup<'a> { + /// The request's services, whose loading cache holds the records and the + /// copies. + pub services: &'a RuntimeServices, + /// The caches this kind of response uses. + pub cache: &'a SourceCache, + /// What the request asks the source for. + pub address: &'a SourceAddress, + /// The request's headers, as they are sent to the source. + pub headers: &'a HeaderMap, + /// What the publisher's rule says separates this request's copy, beyond + /// the source's own `Vary`. + pub declared: Option<&'a str>, + /// The copy cache's value saying a response may not be served to another + /// request, in the caller's form. + pub not_shareable: &'a Bytes, +} + +/// What a lookup found. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Found { + /// The copy cache's value for this request's values, in the caller's form. + Copy { + /// The value. + value: Bytes, + /// Where it is kept, for a caller that finds it cannot use it. + key: StoreKey, + }, + /// No copy at the address is served to another request, so this request + /// fetches its own response. + NoCopies, +} + +/// The copy for `lookup`'s request, from the record and copy caches or from +/// `fetch`. +/// +/// # Errors +/// +/// The error of a fetch this request ran or waited on. Every request waiting +/// on that fetch receives it, and nothing is kept. +pub async fn get_or_fetch(lookup: &Lookup<'_>, fetch: Fetch) -> Result { + let record_key = lookup.address.record_key(lookup.cache); + let record = lookup + .services + .load_cached( + &lookup.cache.records, + &record_key, + record_loader(lookup, Arc::clone(&fetch)), + ) + .await?; + let Some(record) = AddressRecord::decode(&record) else { + log::warn!("source_cache: a record this build did not write was dropped"); + lookup + .services + .loading_cache_for(lookup.cache.records.name) + .remove(lookup.services, &lookup.cache.records, &record_key) + .await; + return Ok(Found::NoCopies); + }; + let names = match record { + AddressRecord::NoCopies => return Ok(Found::NoCopies), + AddressRecord::VariesOn(names) => names, + }; + let copy_key = lookup.address.copy_key( + lookup.cache, + &variant(&names, lookup.headers, lookup.declared), + ); + let value = lookup + .services + .load_cached( + &lookup.cache.copies, + ©_key, + copy_loader(lookup, names, fetch), + ) + .await?; + Ok(Found::Copy { + value, + key: copy_key, + }) +} + +/// The record's load, which fetches the source and records what its response +/// varies on. The fetching request's copy is written under its own values +/// first, so every request waiting on the record finds it. +fn record_loader(lookup: &Lookup<'_>, fetch: Fetch) -> Loader { + let services = lookup.services.clone(); + let cache = lookup.cache.clone(); + let address = lookup.address.clone(); + let headers = lookup.headers.clone(); + let declared = lookup.declared.map(str::to_owned); + Box::new(move || { + Box::pin(async move { + let fetched = fetch().await?; + let SourceVary::Headers(names) = fetched.vary else { + return Ok(LoadedValue::new(AddressRecord::NoCopies.encode()) + .with_lifetime(fetched.lifetime)); + }; + let copy_key = + address.copy_key(&cache, &variant(&names, &headers, declared.as_deref())); + let copy = fetched.copy; + let lifetime = fetched.lifetime; + services + .load_cached( + &cache.copies, + ©_key, + Box::new(move || { + Box::pin(async move { Ok(LoadedValue::new(copy).with_lifetime(lifetime)) }) + }), + ) + .await?; + Ok(LoadedValue::new(AddressRecord::VariesOn(names).encode()) + .with_lifetime(fetched.lifetime)) + }) + }) +} + +/// A copy's load, which fetches the source for this request's values. +/// +/// A response that no longer varies on what the record names cannot be kept +/// under a key the record made, so no request is served it, and the record is +/// dropped so the next request learns what the source varies on now. +fn copy_loader(lookup: &Lookup<'_>, names: Vec, fetch: Fetch) -> Loader { + let services = lookup.services.clone(); + let cache = lookup.cache.clone(); + let address = lookup.address.clone(); + let not_shareable = lookup.not_shareable.clone(); + Box::new(move || { + Box::pin(async move { + let fetched = fetch().await?; + if fetched.vary != SourceVary::Headers(names) { + log::debug!( + "source_cache: the source no longer varies as its record says, so the \ + record is dropped" + ); + services + .loading_cache_for(cache.records.name) + .remove(&services, &cache.records, &address.record_key(&cache)) + .await; + return Ok(LoadedValue::new(not_shareable).with_lifetime(Duration::ZERO)); + } + Ok(LoadedValue::new(fetched.copy).with_lifetime(fetched.lifetime)) + }) + }) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use http::HeaderValue; + + use super::*; + use crate::platform::{ + CacheLifetimes, ClientInfo, PlatformLoadingCache, + test_support::{NoopConfigStore, NoopGeo, NoopSecretStore, StubBackend, StubHttpClient}, + }; + + /// A loading cache that keeps every value with a lifetime under its key + /// and runs each load in its caller. + #[derive(Default)] + struct MemoryCache { + values: Mutex>, + } + + #[async_trait::async_trait(?Send)] + impl PlatformLoadingCache for MemoryCache { + async fn get_or_load( + &self, + _services: &RuntimeServices, + _spec: &CacheSpec, + key: &StoreKey, + load: Loader, + ) -> Result { + let key = key.to_string(); + let held = self + .values + .lock() + .expect("should lock the values") + .get(&key) + .cloned(); + if let Some(value) = held { + return Ok(value); + } + let loaded = load().await?; + if loaded.lifetime.is_none_or(|lifetime| !lifetime.is_zero()) { + self.values + .lock() + .expect("should lock the values") + .insert(key, loaded.bytes.clone()); + } + Ok(loaded.bytes) + } + + async fn remove(&self, _services: &RuntimeServices, _spec: &CacheSpec, key: &StoreKey) { + self.values + .lock() + .expect("should lock the values") + .remove(&key.to_string()); + } + } + + fn services(cache: Arc) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(StubBackend)) + .http_client(Arc::new(StubHttpClient::new())) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) + .client_info(ClientInfo::default()) + .loading_caches(Arc::new(crate::platform::LoadingCaches::every(cache))) + .build() + } + + fn spec(name: &'static str) -> CacheSpec { + CacheSpec { + name, + lifetimes: CacheLifetimes { + memory: Duration::from_secs(5), + store: Duration::from_secs(60), + store_idle: Duration::from_secs(60), + }, + memory_entries: 8, + detachable: false, + } + } + + fn cache() -> SourceCache { + SourceCache { + records: spec("test-vary"), + copies: spec("test"), + } + } + + fn headers(pairs: &[(&'static str, &str)]) -> HeaderMap { + let mut headers = HeaderMap::new(); + for (name, value) in pairs { + headers.append( + HeaderName::from_static(name), + HeaderValue::from_str(value).expect("should be a header value"), + ); + } + headers + } + + /// A source answering with `vary`, whose copy names the edition the + /// request asked for, counting its fetches. + fn source(vary: SourceVary, edition: &str, fetches: &Arc) -> Fetch { + let fetches = Arc::clone(fetches); + let copy = Bytes::from(format!("edition {edition}")); + Arc::new(move || { + let fetches = Arc::clone(&fetches); + let vary = vary.clone(); + let copy = copy.clone(); + Box::pin(async move { + fetches.fetch_add(1, Ordering::SeqCst); + Ok(Fetched { + vary, + copy, + lifetime: Duration::from_secs(30), + }) + }) + }) + } + + fn copy(found: Found) -> Option { + match found { + Found::Copy { value, .. } => Some(value), + Found::NoCopies => None, + } + } + + async fn look_up( + services: &RuntimeServices, + request: &HeaderMap, + declared: Option<&str>, + fetch: Fetch, + ) -> Found { + let address = SourceAddress::new( + ServerKeyPrefix::new("ts", "example.com"), + "address".to_owned(), + ); + get_or_fetch( + &Lookup { + services, + cache: &cache(), + address: &address, + headers: request, + declared, + not_shareable: &Bytes::from_static(b"not shareable"), + }, + fetch, + ) + .await + .expect("the lookup should succeed") + } + + #[test] + fn record_and_copy_keys_follow_the_publisher() { + crate::prefix::test_support::assert_keys_follow_the_publisher(|domain| { + let address = + SourceAddress::new(ServerKeyPrefix::new("ts", domain), "address".to_owned()); + format!( + "{} {}", + address.record_key(&cache()), + address.copy_key(&cache(), "variant") + ) + }); + } + + #[test] + fn vary_names_are_lowercase_sorted_and_named_once() { + let response = headers(&[ + ("vary", "X-Edition, Accept-Encoding"), + ("vary", "x-edition"), + ]); + + assert_eq!( + SourceVary::of(&response), + SourceVary::Headers(vec!["accept-encoding".to_owned(), "x-edition".to_owned()]) + ); + assert_eq!( + SourceVary::of(&response).without(&header::ACCEPT_ENCODING), + SourceVary::Headers(vec!["x-edition".to_owned()]), + "a header that never selects a copy is left out" + ); + assert_eq!( + SourceVary::of(&HeaderMap::new()), + SourceVary::Headers(Vec::new()) + ); + } + + #[test] + fn a_wildcard_a_cookie_or_an_unreadable_vary_allows_no_copies() { + for (vary, expected) in [ + ("*", SourceVary::Any), + ("x-edition, *", SourceVary::Any), + ("Cookie", SourceVary::Cookie), + ("x edition", SourceVary::Unreadable), + ] { + assert_eq!( + SourceVary::of(&headers(&[("vary", vary)])), + expected, + "{vary}" + ); + } + } + + #[test] + fn a_record_reads_back_as_written_and_a_foreign_one_does_not_read() { + for record in [ + AddressRecord::VariesOn(Vec::new()), + AddressRecord::VariesOn(vec!["accept-language".to_owned(), "x-edition".to_owned()]), + AddressRecord::NoCopies, + ] { + assert_eq!(AddressRecord::decode(&record.encode()), Some(record)); + } + for foreign in [&b"v2:x-edition"[..], b"", b"v1:X-Edition", b"v1:x edition"] { + assert_eq!(AddressRecord::decode(foreign), None, "{foreign:?}"); + } + } + + #[test] + fn a_variant_tells_absent_empty_and_reordered_values_apart() { + let names = vec!["x-edition".to_owned()]; + let digest = |pairs: &[(&'static str, &str)]| variant(&names, &headers(pairs), None); + + let absent = digest(&[]); + let empty = digest(&[("x-edition", "")]); + let one_then_two = digest(&[("x-edition", "one"), ("x-edition", "two")]); + let two_then_one = digest(&[("x-edition", "two"), ("x-edition", "one")]); + + assert_ne!(absent, empty); + assert_ne!(one_then_two, two_then_one); + assert_eq!( + digest(&[("x-edition", "one"), ("x-other", "a")]), + digest(&[("x-edition", "one"), ("x-other", "b")]), + "a header the source does not vary on does not separate copies" + ); + assert_ne!( + variant(&names, &HeaderMap::new(), Some("token")), + variant(&names, &HeaderMap::new(), None), + "what the publisher's rule declares separates copies" + ); + } + + #[tokio::test] + async fn the_first_response_records_its_vary_and_each_value_gets_its_own_copy() { + let memory = Arc::new(MemoryCache::default()); + let services = services(Arc::clone(&memory)); + let fetches = Arc::new(AtomicUsize::new(0)); + let vary = SourceVary::Headers(vec!["x-edition".to_owned()]); + let red = headers(&[("x-edition", "red")]); + let blue = headers(&[("x-edition", "blue")]); + + let first = look_up(&services, &red, None, source(vary.clone(), "red", &fetches)).await; + let again = look_up(&services, &red, None, source(vary.clone(), "red", &fetches)).await; + let other = look_up( + &services, + &blue, + None, + source(vary.clone(), "blue", &fetches), + ) + .await; + let other_again = look_up(&services, &blue, None, source(vary, "blue", &fetches)).await; + + assert_eq!(copy(first), Some(Bytes::from("edition red"))); + assert_eq!(copy(again), Some(Bytes::from("edition red"))); + assert_eq!(copy(other), Some(Bytes::from("edition blue"))); + assert_eq!(copy(other_again), Some(Bytes::from("edition blue"))); + assert_eq!( + fetches.load(Ordering::SeqCst), + 2, + "each value is fetched once, and the first fetch also made the record" + ); + } + + #[tokio::test] + async fn a_response_that_allows_no_copies_has_every_request_fetch_its_own() { + let memory = Arc::new(MemoryCache::default()); + let services = services(Arc::clone(&memory)); + let fetches = Arc::new(AtomicUsize::new(0)); + let request = headers(&[("x-edition", "red")]); + + for _ in 0..2 { + let found = look_up( + &services, + &request, + None, + source(SourceVary::Any, "red", &fetches), + ) + .await; + assert_eq!(found, Found::NoCopies); + } + assert_eq!( + fetches.load(Ordering::SeqCst), + 1, + "the record saying no copies are kept is itself kept" + ); + } + + #[tokio::test] + async fn a_source_that_changes_its_vary_has_its_record_dropped() { + let memory = Arc::new(MemoryCache::default()); + let services = services(Arc::clone(&memory)); + let fetches = Arc::new(AtomicUsize::new(0)); + let red = headers(&[("x-edition", "red")]); + let blue = headers(&[("x-edition", "blue")]); + look_up( + &services, + &red, + None, + source( + SourceVary::Headers(vec!["x-edition".to_owned()]), + "red", + &fetches, + ), + ) + .await; + + let changed = look_up( + &services, + &blue, + None, + source( + SourceVary::Headers(vec!["x-region".to_owned()]), + "blue", + &fetches, + ), + ) + .await; + + assert_eq!( + copy(changed), + Some(Bytes::from_static(b"not shareable")), + "a copy made under another Vary is served to nobody" + ); + let keys: Vec = memory + .values + .lock() + .expect("should lock the values") + .keys() + .cloned() + .collect(); + assert!( + keys.iter().all(|key| !key.contains("/test-vary/")), + "the record is dropped so the next request learns the new Vary: {keys:?}" + ); + } + + #[tokio::test] + async fn the_publishers_rule_separates_copies_the_source_does_not() { + let memory = Arc::new(MemoryCache::default()); + let services = services(Arc::clone(&memory)); + let fetches = Arc::new(AtomicUsize::new(0)); + let request = HeaderMap::new(); + let nothing = SourceVary::Headers(Vec::new()); + + let uk = look_up( + &services, + &request, + Some("uk"), + source(nothing.clone(), "uk", &fetches), + ) + .await; + let us = look_up( + &services, + &request, + Some("us"), + source(nothing.clone(), "us", &fetches), + ) + .await; + let uk_again = look_up( + &services, + &request, + Some("uk"), + source(nothing, "x", &fetches), + ) + .await; + + assert_eq!(copy(uk), Some(Bytes::from("edition uk"))); + assert_eq!(copy(us), Some(Bytes::from("edition us"))); + assert_eq!(copy(uk_again), Some(Bytes::from("edition uk"))); + assert_eq!(fetches.load(Ordering::SeqCst), 2); + } +} From 4e0bb71e0f8e7b07fb10d4a83a0355a8449912c7 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 13 Oct 2026 22:30:59 +0100 Subject: [PATCH 02/23] The publisher path holds the origin's assets through the loading cache Every GET that is not a page is looked for in the asset cache before the origin is fetched, under the publisher's prefix and the asset's address, and a copy found is served as the origin's response would be, with an Age that counts from the origin's. A miss loads once however many requests ask at the same moment, keeps the response as the origin sent it when it may be shared, and hands a response that may not be shared back to the request whose load fetched it, so that request is served from one fetch and the requests that follow fetch their own for a minute rather than wait. What is never kept: anything but a 200, HTML, a response carrying Set-Cookie, one whose cache policy says private, no-store or no-cache or gives no lifetime, one that varies on * or on Cookie, one larger than a copy holds, and the answer to a request carrying Authorization. The record of what an address varies on and the copies are two caches selected by the one [cache.assets] table, and a deployment whose selected cache keeps nothing asks the origin as before. --- .../tests/seam_probe.rs | 2 +- .../src/integrations/mod.rs | 6 +- crates/trusted-server-core/src/publisher.rs | 829 +++++++++++++++++- crates/trusted-server-core/src/settings.rs | 17 +- 4 files changed, 846 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-adapter-axum/tests/seam_probe.rs b/crates/trusted-server-adapter-axum/tests/seam_probe.rs index e2dde68941..404f183f8a 100644 --- a/crates/trusted-server-adapter-axum/tests/seam_probe.rs +++ b/crates/trusted-server-adapter-axum/tests/seam_probe.rs @@ -540,7 +540,7 @@ async fn a_cache_table_no_module_declares_is_refused_by_the_deploy_check_and_at_ "should name the table: {message}" ); assert!( - message.contains("robots-txt, assets, seam-probe-answers"), + message.contains("robots-txt, assets, assets-vary, seam-probe-answers"), "should list the caches this build declares: {message}" ); assert_eq!( diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index b269bcd301..16c3d3e8f5 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -840,6 +840,7 @@ pub fn declared_caches( let mut declared: Vec<(&'static str, &'static str)> = vec![ (crate::settings::ROBOTS_TXT_CACHE, "core"), (crate::settings::ASSETS_CACHE, "core"), + (crate::settings::ASSETS_VARY_CACHE, "core"), ]; for builder in builders { for name in builder.cache_features() { @@ -1128,7 +1129,10 @@ mod loading_caches_from_tests { let message = format!("{refused:?}"); assert!(message.contains("[cache.answres]"), "{message}"); - assert!(message.contains("robots-txt, assets, answers"), "{message}"); + assert!( + message.contains("robots-txt, assets, assets-vary, answers"), + "{message}" + ); } #[test] diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 5d36cab1b6..884d65dc2a 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -94,6 +94,7 @@ use crate::settings::{ AUCTION_DEBUG_METADATA_ALLOWLIST, AUCTION_DEBUG_UPSTREAM_METADATA_KEYS, AuctionDebugCommentFormat, AuctionDebugCommentOptions, AuctionDebugCommentVerbosity, Settings, }; +use crate::source_cache::{Found, Lookup, SourceAddress, SourceCache, SourceVary}; use crate::streaming_processor::{ BodyStreamDecoder, BodyStreamEncoder, Compression, GzipDecodeReader, PipelineConfig, STREAM_CHUNK_SIZE, StreamProcessor, StreamingPipeline, @@ -5398,6 +5399,24 @@ pub async fn handle_publisher_request( ), schema_version: crate::platform::TEMPLATE_SCHEMA_VERSION, }); + // Every other response the origin sends may be kept as it sent it, in + // every assembly mode, keyed the way a page is and kept for as long as + // the origin allows, see `asset_cache`. + let request_can_use_asset_copy = method_is_cacheable + && !request_is_document + && !request_host.is_empty() + && !authorization_disqualifies + && !request_requires_origin + && services + .loading_cache_for(crate::settings::ASSETS_CACHE) + .keeps_values(); + let asset_cache_key = request_can_use_asset_copy.then(|| crate::asset_cache::AssetCacheKey { + url: target_uri.to_string(), + request_host: request_host.to_string(), + request_scheme: request_scheme.to_string(), + origin_identity: format!("{}\0{}", settings.publisher.origin_url, origin_host_header), + fingerprint: template_fingerprint(settings, integration_registry, services.public_prefix()), + }); let mut template_cache_response_state = matches!(assembly_mode, AssemblyMode::Esi) .then_some(TemplateCacheResponseState::BypassRequest); rewrite_origin_request(&mut req, target_uri, &origin_host_header)?; @@ -5434,6 +5453,7 @@ pub async fn handle_publisher_request( // every request in the default `Inline` assembly mode — keep the overlap. if should_preload_ec && template_cache_key.is_none() + && asset_cache_key.is_none() && services.http_client().supports_concurrent_fanout() && services .http_client() @@ -5801,7 +5821,73 @@ pub async fn handle_publisher_request( // SSP requests are already racing through the platform HTTP client, so // origin TTFB tracks origin latency rather than the auction timeout. - let origin_result = if let Some(pending) = pending_origin { + // Any other response is looked for the same way, before the origin is + // fetched, and a copy found is served as the origin's response would be. + let mut kept_copy = None; + let mut handed_back_origin = None; + if let Some(key) = asset_cache_key.as_ref() { + let cache = asset_cache(settings); + let address = asset_address(services, key); + let prepared = origin_request.as_ref().ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "publisher origin request was already consumed".to_owned(), + }) + })?; + let asset_hand_back = HandBack::new(); + let fetch = asset_fetch( + Arc::new(AssetLoad { + services: services.clone(), + uri: prepared.uri().clone(), + headers: prepared.headers().clone(), + backend_name: backend_name.clone(), + stream_response: services.http_client().supports_streaming_responses(), + max_lifetime: cache.copies.lifetimes.store, + }), + asset_hand_back.id, + ); + let not_shareable = + bytes::Bytes::from(crate::asset_cache::AssetEntry::NotShareable.encode()); + let found = crate::source_cache::get_or_fetch( + &Lookup { + services, + cache: &cache, + address: &address, + headers: prepared.headers(), + declared: None, + not_shareable: ¬_shareable, + }, + fetch, + ) + .await; + match found { + Ok(Found::Copy { value, key }) => { + match crate::asset_cache::AssetEntry::decode(&value) { + Ok(crate::asset_cache::AssetEntry::Copy(copy)) => kept_copy = Some(copy), + Ok(crate::asset_cache::AssetEntry::NotShareable) => { + // This request's own load fetched the asset, so it is served + // from that fetch rather than a second one. + handed_back_origin = asset_hand_back.take(); + } + Err(miss) => { + log::warn!("asset_cache invalid entry: {miss}; removing it"); + services + .loading_cache_for(cache.copies.name) + .remove(services, &cache.copies, &key) + .await; + } + } + } + Ok(Found::NoCopies) => handed_back_origin = asset_hand_back.take(), + Err(error) => log::warn!("asset_cache load failed: {error}; fetching the origin"), + } + } + let origin_result = if let Some(copy) = kept_copy { + Ok(crate::platform::PlatformResponse::new( + copy.into_response(unix_seconds()), + )) + } else if let Some(response) = handed_back_origin { + Ok(crate::platform::PlatformResponse::new(response)) + } else if let Some(pending) = pending_origin { services.http_client().wait(pending).await } else { let origin_req = origin_request.take().ok_or_else(|| { @@ -8087,8 +8173,749 @@ fn mark_deprecated_alias(response: &mut Response, is_legacy_alias: boo ); } +/// The lifetimes `[cache.assets]` leaves unset. An asset's own lifetime ends +/// each copy sooner than the store lifetime. +const ASSET_DEFAULT_LIFETIMES: crate::platform::CacheLifetimes = crate::platform::CacheLifetimes { + memory: Duration::from_secs(5), + store: Duration::from_secs(60 * 60), + store_idle: Duration::from_secs(24 * 60 * 60), +}; + +/// How long an asset the origin says may not be shared is held as such, so +/// the requests that follow fetch their own rather than waiting on a load. +const ASSET_NOT_SHAREABLE_LIFETIME: Duration = Duration::from_secs(60); + +/// The asset caches, being one holding each address's record of what its +/// response varies on and one holding the kept copies, both with the +/// lifetimes `[cache.assets]` sets. An asset's load awaits the origin rather +/// than blocking a thread, so a host that can run work on its own may run it +/// apart from the request that started it. +fn asset_cache(settings: &Settings) -> SourceCache { + let lifetimes = settings.cache.assets.lifetimes(ASSET_DEFAULT_LIFETIMES); + let spec = |name, memory_entries| crate::platform::CacheSpec { + name, + lifetimes, + memory_entries, + detachable: true, + }; + SourceCache { + records: spec(crate::settings::ASSETS_VARY_CACHE, 256), + copies: spec(crate::settings::ASSETS_CACHE, 32), + } +} + +/// The address one asset's copies are held under, made of the publisher's +/// prefix the request's services carry, so two publishers never share one, +/// and the asset's own key. +fn asset_address( + services: &RuntimeServices, + key: &crate::asset_cache::AssetCacheKey, +) -> SourceAddress { + SourceAddress::new(services.server_key_prefix().clone(), key.to_cache_key()) +} + +/// Why an asset's response is not kept. +#[derive(Debug, derive_more::Display)] +enum AssetCacheBypassReason { + /// Only a whole `200` is kept. + #[display("status was not 200 OK")] + NonOkStatus, + /// The origin set a cookie, which a copy would replay to the next reader. + #[display("origin response carries Set-Cookie")] + OriginSetCookie, + /// A page is kept as a template, by the template cache, or not at all. + #[display("the response is HTML, which is not kept as an asset")] + Html, + /// The body is larger than a copy holds. + #[display("the response is larger than a kept asset holds")] + TooLarge, + /// A header cannot be written down to be served again. + #[display("a response header cannot be kept")] + UnkeptHeader, + /// The response varies on every request header. + #[display("origin response varies on *")] + VaryWildcard, + /// The response varies on the reader's cookies. + #[display("origin response varies on Cookie")] + VaryCookie, + /// The response's `Vary` cannot be read. + #[display("origin response Vary cannot be read")] + VaryUnreadable, + /// The origin's cache policy does not allow a shared copy, or cannot be + /// read. + #[display("{_0}")] + Policy(TemplateCacheBypassReason), +} + +/// Why a response that varies this way is served to no other request. +fn vary_bypass_reason(vary: &SourceVary) -> Option { + match vary { + SourceVary::Headers(_) => None, + SourceVary::Any => Some(AssetCacheBypassReason::VaryWildcard), + SourceVary::Cookie => Some(AssetCacheBypassReason::VaryCookie), + SourceVary::Unreadable => Some(AssetCacheBypassReason::VaryUnreadable), + } +} + +/// How long an asset's response may be kept, from its own cache policy and +/// capped at `max_lifetime`, or why it is not kept at all. +fn asset_cache_ttl( + status: StatusCode, + content_type: &str, + headers: &edgezero_core::http::HeaderMap, + max_lifetime: Duration, +) -> Result { + if status != StatusCode::OK { + return Err(AssetCacheBypassReason::NonOkStatus); + } + if headers.contains_key(header::SET_COOKIE) { + return Err(AssetCacheBypassReason::OriginSetCookie); + } + if is_html_content_type(content_type) { + return Err(AssetCacheBypassReason::Html); + } + if crate::cache_policy::EDGE_CACHE_HEADER_NAMES + .iter() + .filter(|name| **name != "surrogate-control") + .any(|name| headers.contains_key(*name)) + { + return Err(AssetCacheBypassReason::Policy( + TemplateCacheBypassReason::OriginNotShareable, + )); + } + origin_shared_ttl(headers, max_lifetime).map_err(AssetCacheBypassReason::Policy) +} + +/// Seconds since the Unix epoch, from the wasm-safe clock. +fn unix_seconds() -> u64 { + crate::ec::std_system_time_now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |since| since.as_secs()) +} + +type HandBackSlot = std::cell::RefCell>>; + +thread_local! { + static HANDED_BACK: std::cell::RefCell>> = + std::cell::RefCell::new(std::collections::HashMap::new()); +} + +static NEXT_LOAD: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// Where a load leaves a response the request that started it is served, +/// because the origin answered with something no other request may be +/// served, so that request is served from the one fetch rather than a +/// second one. The slot lives as long as the request's `HandBack`, so a load +/// that outlives its request hands back nothing. +struct HandBack { + id: u64, + slot: std::rc::Rc, +} + +impl HandBack { + fn new() -> Self { + let id = NEXT_LOAD.fetch_add(1, Ordering::Relaxed); + let slot = std::rc::Rc::new(std::cell::RefCell::new(None)); + HANDED_BACK.with(|handed| { + handed + .borrow_mut() + .insert(id, std::rc::Rc::downgrade(&slot)); + }); + Self { id, slot } + } + + fn take(&self) -> Option> { + self.slot.borrow_mut().take() + } +} + +impl Drop for HandBack { + fn drop(&mut self) { + HANDED_BACK.with(|handed| { + handed.borrow_mut().remove(&self.id); + }); + } +} + +fn hand_back(id: u64, response: Response) { + let slot = HANDED_BACK.with(|handed| handed.borrow().get(&id).and_then(std::rc::Weak::upgrade)); + if let Some(slot) = slot { + *slot.borrow_mut() = Some(response); + } +} + +/// What a load owns to fetch one asset, because it may outlive the request +/// that started it. +struct AssetLoad { + services: RuntimeServices, + /// The origin request, already prepared for the origin. Only a `GET` is + /// kept, which carries no body. + uri: http::Uri, + headers: http::HeaderMap, + backend_name: String, + stream_response: bool, + /// The longest a copy may be kept, whatever the origin asks. + max_lifetime: Duration, +} + +/// What one fetch of an asset made. +struct AssetLoaded { + /// What the origin's response varies on. + vary: SourceVary, + /// The copy with the time it may be used for, or `None` when the response + /// may not be shared. + copy: Option<(crate::asset_cache::AssetCopy, Duration)>, +} + +/// The asset cache's fetch for one request, which fetches the asset and, when +/// it may be shared, keeps it as the origin sent it. +/// +/// An asset that may not be shared is held as +/// [`AssetEntry::NotShareable`](crate::asset_cache::AssetEntry::NotShareable) +/// for [`ASSET_NOT_SHAREABLE_LIFETIME`], and handed back to the request whose +/// load fetched it through `hand_back_id`, see [`HandBack`]. +fn asset_fetch(load: Arc, hand_back_id: u64) -> crate::source_cache::Fetch { + Arc::new(move || { + let load = Arc::clone(&load); + Box::pin(async move { + let AssetLoaded { vary, copy } = load_asset(&load, hand_back_id).await?; + let (entry, lifetime) = match copy { + Some((copy, lifetime)) => (crate::asset_cache::AssetEntry::Copy(copy), lifetime), + None => ( + crate::asset_cache::AssetEntry::NotShareable, + ASSET_NOT_SHAREABLE_LIFETIME, + ), + }; + Ok(crate::source_cache::Fetched { + vary, + copy: entry.encode().into(), + lifetime, + }) + }) + }) +} + +/// Fetches an asset and keeps it as the origin sent it, with the time it may +/// be used for, or no copy when it may not be shared. +/// +/// # Errors +/// +/// When the origin cannot be reached. +async fn load_asset( + load: &AssetLoad, + hand_back_id: u64, +) -> Result { + let load_error = |what: &str, report: &dyn core::fmt::Debug| { + crate::platform::LoadError::new(format!("{what}: {report:?}")) + }; + let mut request = http::Request::builder() + .method(Method::GET) + .uri(load.uri.clone()) + .body(EdgeBody::empty()) + .map_err(|error| load_error("the origin request could not be built", &error))?; + *request.headers_mut() = load.headers.clone(); + let mut platform_request = PlatformHttpRequest::new(request, load.backend_name.clone()); + if load.stream_response { + platform_request = platform_request.with_stream_response(); + } + let response = load + .services + .http_client() + .send(platform_request) + .await + .map_err(|report| load_error("the origin could not be reached", &report))? + .response; + let made = Instant::now(); + let (parts, body) = response.into_parts(); + let content_type = parts + .headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let vary = SourceVary::of(&parts.headers); + let not_shareable = |vary, parts, body| { + hand_back(hand_back_id, Response::from_parts(parts, body)); + Ok(AssetLoaded { vary, copy: None }) + }; + if let Some(reason) = vary_bypass_reason(&vary) { + log::debug!("asset_cache bypass: {reason}"); + return not_shareable(vary, parts, body); + } + let ttl = match asset_cache_ttl( + parts.status, + &content_type, + &parts.headers, + load.max_lifetime, + ) { + Ok(ttl) => ttl, + Err(reason) => { + log::debug!("asset_cache bypass: {reason}"); + return not_shareable(vary, parts, body); + } + }; + let declared_length = parts + .headers + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse::().ok()); + if declared_length + .is_some_and(|length| length > crate::asset_cache::MAX_ASSET_COPY_BYTES as u64) + { + log::debug!("asset_cache bypass: {}", AssetCacheBypassReason::TooLarge); + return not_shareable(vary, parts, body); + } + let Some(headers) = crate::asset_cache::AssetCopy::kept_headers(&parts.headers, false) else { + log::debug!( + "asset_cache bypass: {}", + AssetCacheBypassReason::UnkeptHeader + ); + return not_shareable(vary, parts, body); + }; + let age_at_made = single_header_value(&parts.headers, header::AGE) + .ok() + .flatten() + .and_then(|value| parse_delta_seconds(value).ok()) + .unwrap_or(0); + let raw = match body + .into_bytes_bounded(crate::asset_cache::MAX_ASSET_COPY_BYTES) + .await + { + Ok(raw) => raw, + Err(error) => { + log::debug!("asset_cache bypass: the response could not be read whole ({error})"); + return Ok(AssetLoaded { vary, copy: None }); + } + }; + let lifetime = ttl.saturating_sub(made.elapsed()); + if !lifetime.is_zero() { + log::debug!("asset_cache stored {} bytes", raw.len()); + } + Ok(AssetLoaded { + vary, + copy: Some(( + crate::asset_cache::AssetCopy { + headers, + decoded: false, + made_at: unix_seconds(), + age_at_made, + body: raw.to_vec(), + }, + lifetime, + )), + }) +} + #[cfg(test)] mod tests { + mod asset_cache_tests { + use std::collections::HashMap; + use std::sync::Mutex; + + use super::*; + use crate::platform::test_support::{ + NoopConfigStore, NoopGeo, NoopSecretStore, StubBackend, StubHttpClient, + }; + use crate::platform::{ClientInfo, PlatformLoadingCache}; + + /// A loading cache that keeps every value with its lifetime under its + /// key and runs each load in its caller. + #[derive(Default)] + struct MemoryLoadingCache { + values: Mutex>, + lifetimes: Mutex>, + } + + impl MemoryLoadingCache { + /// The lifetimes of the kept copies, leaving out the records and + /// the marks of what may not be shared. + fn copy_lifetimes(&self) -> Vec { + let values = self.values.lock().expect("should lock the values"); + self.lifetimes + .lock() + .expect("should lock the lifetimes") + .iter() + .filter(|(key, _)| { + key.contains(&format!("/{}/", crate::settings::ASSETS_CACHE)) + }) + .filter(|(key, _)| { + values.get(*key).is_some_and(|value| { + matches!( + crate::asset_cache::AssetEntry::decode(value), + Ok(crate::asset_cache::AssetEntry::Copy(_)) + ) + }) + }) + .map(|(_, lifetime)| *lifetime) + .collect() + } + } + + #[async_trait::async_trait(?Send)] + impl PlatformLoadingCache for MemoryLoadingCache { + async fn get_or_load( + &self, + _services: &RuntimeServices, + _spec: &crate::platform::CacheSpec, + key: &crate::platform::StoreKey, + load: crate::platform::Loader, + ) -> Result { + let key = key.to_string(); + let held = self + .values + .lock() + .expect("should lock the values") + .get(&key) + .cloned(); + if let Some(value) = held { + return Ok(value); + } + let loaded = load().await?; + let lifetime = loaded.lifetime.unwrap_or(Duration::MAX); + if !lifetime.is_zero() { + self.values + .lock() + .expect("should lock the values") + .insert(key.clone(), loaded.bytes.clone()); + self.lifetimes + .lock() + .expect("should lock the lifetimes") + .insert(key, lifetime); + } + Ok(loaded.bytes) + } + + async fn remove( + &self, + _services: &RuntimeServices, + _spec: &crate::platform::CacheSpec, + key: &crate::platform::StoreKey, + ) { + self.values + .lock() + .expect("should lock the values") + .remove(&key.to_string()); + } + } + + fn services( + stub: &Arc, + cache: &Arc, + ) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(StubBackend)) + .http_client(Arc::clone(stub) as _) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) + .client_info(ClientInfo::default()) + .loading_caches(Arc::new(crate::platform::LoadingCaches::every( + Arc::clone(cache) as _, + ))) + .build() + } + + fn settings() -> Arc { + Arc::new(crate::test_support::tests::create_test_settings()) + } + + fn asset_request(path_and_query: &str, destination: &str) -> Request { + Request::builder() + .method(Method::GET) + .uri(format!("https://ts.example.com{path_and_query}")) + .header(header::HOST, "ts.example.com") + .header("sec-fetch-dest", destination) + .body(EdgeBody::empty()) + .expect("should build the asset request") + } + + fn queue(stub: &StubHttpClient, content_type: &str, body: &[u8], extra: &[(&str, &str)]) { + let mut headers = vec![("content-type", content_type)]; + headers.extend_from_slice(extra); + stub.push_response_with_headers(200, body.to_vec(), headers); + } + + async fn run( + settings: &Arc, + services: &RuntimeServices, + request: Request, + ) -> Response { + let registry = + IntegrationRegistry::new(settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(settings), + ); + let consent = crate::consent::ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..Default::default() + }; + let mut ec_context = EcContext::new_for_test(None, consent); + let publisher_response = handle_publisher_request( + AppContext { + settings, + integration_registry: ®istry, + }, + services, + None, + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &[], + registry: None, + }, + request, + EdgeCacheHeader::SMaxageFallback, + ) + .await + .expect("should proxy the asset request"); + buffer_publisher_response_async( + publisher_response, + &Method::GET, + settings, + ®istry, + &orchestrator, + services, + ) + .await + .expect("should finalize the asset response") + } + + async fn body_of(response: Response) -> Vec { + response + .into_body() + .into_bytes_bounded(4 * 1024 * 1024) + .await + .expect("should read the body") + .to_vec() + } + + #[tokio::test] + async fn an_image_is_kept_as_the_origin_sent_it() { + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryLoadingCache::default()); + let services = services(&stub, &cache); + let settings = settings(); + let image = [0x89_u8, b'P', b'N', b'G', 0, 1, 2, 3]; + queue( + &stub, + "image/png", + &image, + &[("cache-control", "public, max-age=300")], + ); + + for _ in 0..3 { + let response = run(&settings, &services, asset_request("/a.png", "image")).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(body_of(response).await, image); + } + + assert_eq!( + stub.recorded_request_uris().len(), + 1, + "one fetch serves all three" + ); + } + + #[tokio::test] + async fn a_stylesheet_is_served_from_one_fetch_with_its_age() { + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryLoadingCache::default()); + let services = services(&stub, &cache); + let settings = settings(); + queue( + &stub, + "text/css", + b"body{color:red}", + &[("cache-control", "public, max-age=300")], + ); + + let first = + body_of(run(&settings, &services, asset_request("/site.css", "style")).await).await; + let second = run(&settings, &services, asset_request("/site.css", "style")).await; + assert!( + second.headers().contains_key(header::AGE), + "a kept copy says how long it has been kept" + ); + let second = body_of(second).await; + + assert_eq!( + stub.recorded_request_uris().len(), + 1, + "one fetch serves both" + ); + assert_eq!( + second, first, + "the kept copy is what the first reader was served" + ); + } + + #[tokio::test] + async fn a_wildcard_vary_a_private_or_a_no_store_response_is_never_kept() { + for (cache_control, vary, kept) in [ + ("public, max-age=300", None, true), + ("public, max-age=300", Some("*"), false), + ("public, max-age=300", Some("Cookie"), false), + ("private, max-age=300", None, false), + ("no-store", None, false), + ("public, max-age=300, no-store", None, false), + ] { + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryLoadingCache::default()); + let services = services(&stub, &cache); + let settings = settings(); + for _ in 0..2 { + let mut extra = vec![("cache-control", cache_control)]; + extra.extend(vary.map(|vary| ("vary", vary))); + queue(&stub, "text/css", b"body{}", &extra); + let response = + run(&settings, &services, asset_request("/site.css", "style")).await; + assert_eq!(body_of(response).await, b"body{}"); + } + assert_eq!( + stub.recorded_request_uris().len(), + if kept { 1 } else { 2 }, + "Cache-Control {cache_control} and Vary {vary:?}" + ); + assert_eq!( + cache.copy_lifetimes().is_empty(), + !kept, + "Cache-Control {cache_control} and Vary {vary:?}" + ); + } + } + + #[tokio::test] + async fn s_maxage_then_max_age_set_how_long_a_copy_is_kept() { + for (cache_control, seconds) in [ + ("public, max-age=300", 300), + ("public, max-age=300, s-maxage=60", 60), + ("public, s-maxage=600, max-age=30", 600), + ] { + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryLoadingCache::default()); + let services = services(&stub, &cache); + queue( + &stub, + "text/css", + b"body{}", + &[("cache-control", cache_control)], + ); + + let response = + run(&settings(), &services, asset_request("/site.css", "style")).await; + let _ = body_of(response).await; + + let lifetimes = cache.copy_lifetimes(); + assert_eq!(lifetimes.len(), 1, "{cache_control}"); + let expected = Duration::from_secs(seconds); + assert!( + lifetimes[0] <= expected && lifetimes[0] > expected - Duration::from_secs(5), + "{cache_control} should keep a copy for {expected:?}, not {:?}", + lifetimes[0] + ); + } + } + + #[tokio::test] + async fn a_header_the_origin_varies_on_separates_kept_copies() { + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryLoadingCache::default()); + let services = services(&stub, &cache); + let settings = settings(); + for edition in ["red", "blue"] { + queue( + &stub, + "text/css", + format!("/* {edition} */").as_bytes(), + &[ + ("cache-control", "public, max-age=300"), + ("vary", "X-Edition"), + ], + ); + } + let request = |edition: &str| { + let mut request = asset_request("/site.css", "style"); + request.headers_mut().insert( + "x-edition", + HeaderValue::from_str(edition).expect("should be a header value"), + ); + request + }; + + for edition in ["red", "blue", "red", "blue"] { + let css = body_of(run(&settings, &services, request(edition)).await).await; + assert_eq!(css, format!("/* {edition} */").into_bytes()); + } + + assert_eq!( + stub.recorded_request_uris().len(), + 2, + "each edition is fetched once" + ); + } + + #[tokio::test] + async fn a_request_carrying_authorization_is_fetched_for_itself() { + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryLoadingCache::default()); + let services = services(&stub, &cache); + let settings = settings(); + for _ in 0..2 { + queue( + &stub, + "image/png", + b"png", + &[("cache-control", "public, max-age=300")], + ); + let mut request = asset_request("/a.png", "image"); + request.headers_mut().insert( + header::AUTHORIZATION, + HeaderValue::from_static("Bearer reader-token"), + ); + let _ = body_of(run(&settings, &services, request).await).await; + } + + assert_eq!( + stub.recorded_request_uris().len(), + 2, + "an authenticated request never shares a copy" + ); + assert!(cache.copy_lifetimes().is_empty()); + } + + #[tokio::test] + async fn a_page_navigation_never_reads_an_asset_copy() { + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryLoadingCache::default()); + let services = services(&stub, &cache); + let settings = settings(); + for _ in 0..2 { + queue( + &stub, + "text/html; charset=utf-8", + b"page", + &[("cache-control", "public, max-age=300")], + ); + let mut request = asset_request("/page", "document"); + request + .headers_mut() + .insert("sec-fetch-mode", HeaderValue::from_static("navigate")); + let _ = body_of(run(&settings, &services, request).await).await; + } + + assert_eq!( + stub.recorded_request_uris().len(), + 2, + "an inline page is fetched for each reader" + ); + assert!(cache.copy_lifetimes().is_empty()); + } + } + use std::future::Future as _; use std::io::{self, Read as _, Write as _}; use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 7d747ec166..8b5fac687f 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -3055,6 +3055,11 @@ pub const ROBOTS_TXT_CACHE: &str = "robots-txt"; /// origin sends, whose settings are `[cache.assets]`. pub const ASSETS_CACHE: &str = "assets"; +/// The name of the cache that holds each asset's record of what its +/// responses vary on, selected by the same `[cache.assets]` table as the +/// copies, so one line selects both. +pub const ASSETS_VARY_CACHE: &str = "assets-vary"; + /// How long one loading cache's copies last, each field defaulting to the /// cache's own default when absent. /// @@ -3147,6 +3152,7 @@ impl CacheSettings { [ (ROBOTS_TXT_CACHE, &self.robots_txt), (ASSETS_CACHE, &self.assets), + (ASSETS_VARY_CACHE, &self.assets), ] .into_iter() .chain( @@ -7064,10 +7070,10 @@ module = \"none\"", let caches = settings.cache.loading_caches(); - assert_eq!(caches.len(), 3); - assert_eq!(caches[2].0, "answers"); - assert_eq!(caches[2].1.module.as_deref(), Some("51degrees")); - assert_eq!(caches[2].1.store_seconds, Some(3600)); + assert_eq!(caches.len(), 4); + assert_eq!(caches[3].0, "answers"); + assert_eq!(caches[3].1.module.as_deref(), Some("51degrees")); + assert_eq!(caches[3].1.store_seconds, Some(3600)); let written = serde_json::to_value(&settings.cache).expect("should serialize"); assert_eq!(written["answers"]["module"], "51degrees"); } @@ -7094,8 +7100,9 @@ module = \"none\"", vec![ (super::ROBOTS_TXT_CACHE, &settings.cache.robots_txt), (super::ASSETS_CACHE, &settings.cache.assets), + (super::ASSETS_VARY_CACHE, &settings.cache.assets), ], - "should list core's own caches by name, robots.txt then assets" + "should list core's own caches by name, robots.txt then the two of assets" ); let written = serde_json::to_value(&settings.cache).expect("should serialize"); assert_eq!( From 87a55aa19205c951f72ca0a05a06916e8173faaf Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 14 Oct 2026 09:34:31 +0100 Subject: [PATCH 03/23] An asset route's answer is held through the loading cache as the publisher's assets are A file served through an asset route, under [[proxy.asset_routes]], streamed from its origin on every request, however many readers and instances asked for it, while a file the publisher's own origin sends was held through the loading cache. The route handler now looks for a copy under the same two caches [cache.assets] selects before it asks the origin, and keeps the origin's answer the way the publisher path keeps an asset: under what the origin says it varies on, for the lifetime its cache policy gives, served with the headers that describe it and an Age that counts from the origin's. The key carries the route's origin and prefix, so two routes to one address keep their own copies. Nothing is kept that may not be shared, by the same rules, and a request a copy cannot answer is not served one: anything but a GET, a request carrying Authorization, a conditional header or a range, one that names no host, and any request while the selected cache keeps nothing. An answer the platform's image optimizer makes is not kept either, because the platform holds it. An answer that may not be shared is served to the request whose load fetched it, from that one fetch. The publisher path's asset helpers are open to the rest of core so the route handler shares them, and the memory loading cache the publisher's tests used lives in the platform's test support so the route's tests use it too. Four tests cover an answer held and served to the next request with one origin call, an answer carrying Set-Cookie fetched for each request, a range request fetched rather than served a copy, and nothing held while no loading cache keeps values. The guide documents [cache.assets] beside [cache.robots-txt], with what is held and what never is, and the changelog says what the asset cache adds. --- CHANGELOG.md | 1 + .../src/platform/test_support.rs | 80 ++++ crates/trusted-server-core/src/proxy.rs | 343 +++++++++++++++++- crates/trusted-server-core/src/publisher.rs | 134 ++----- docs/guide/configuration.md | 18 + 5 files changed, 454 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 433635fc99..672fd3e795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `[ad-tag.google] write_loader` writes the loader tag into the head, last among the module's inserts and asking for the loader on the module's own route, for a page that carries no loader of its own. Off by default, because a page that already carries the loader would otherwise load it twice. +- The publisher's assets and the answers of the asset routes are held through the loading cache, selected in `[cache.assets]`, so an asset is fetched once however many readers and instances ask for it at the same moment, and the copy in the host's shared store answers every instance until the origin's own lifetime ends. A copy is kept under what the origin says it varies on, served with the headers that describe it and an `Age` that counts from the origin's, and nothing is kept that may not be shared. A deployment that names no module takes the one loading cache module the build links, and a build that links none holds nothing, as before. - A Google tag module, `[tag] modules = ["google-tag"]` with `[tag.google-tag]`, from `crates/tag/google-tag`, which writes the consent mode defaults, Tag Manager's container script, the gtag.js snippet and the AdSense loader in the head immediately before the script bundle's tag, and Tag Manager's noscript iframe after the opening body tag, each copied from Google's documentation. The consent defaults come first, the four consent types Google requires for its own services default to denied, the tag waits `consent_defaults.wait_for_update` milliseconds (500 unless set) for a consent management platform's update before it fires with them, and `consent_defaults.region` scopes them to country or region codes. Where the Tag Manager module serves the same tag or container the loader names the first-party path, and a page's own loader for the configured tag is removed. - A middleware may write markup immediately after the opening body tag, with `body_open_inserts` on its action, once per document, beside the places it had for the head and for straight after the script bundle. - A creative-opportunity format may name `min_viewport_width` and `max_viewport_width`, and a slot with such a format asks for only the formats that fit each band of viewport widths. The slot's page data carries `viewport_sizes`, and the Google ad tag's head bootstrap and the bundle's slot definition apply it as a size mapping, and the bundle's refresh auction offers a bidder only the sizes of the band the viewport is in. A slot whose formats name no bound behaves as before. diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 82518a290c..2b0c80be88 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -1707,3 +1707,83 @@ mod tests { ); } } + +/// A loading cache that keeps every value with its lifetime under its key +/// and runs each load in its caller, so a test reads what was kept and for +/// how long. +#[derive(Default)] +pub struct LifetimeRecordingCache { + values: Mutex>, + lifetimes: Mutex>, +} + +impl LifetimeRecordingCache { + /// The lifetimes of the kept copies, leaving out the records and + /// the marks of what may not be shared. + /// The lifetimes of the asset copies kept, in no order. + pub fn copy_lifetimes(&self) -> Vec { + let values = self.values.lock().expect("should lock the values"); + self.lifetimes + .lock() + .expect("should lock the lifetimes") + .iter() + .filter(|(key, _)| key.contains(&format!("/{}/", crate::settings::ASSETS_CACHE))) + .filter(|(key, _)| { + values.get(*key).is_some_and(|value| { + matches!( + crate::asset_cache::AssetEntry::decode(value), + Ok(crate::asset_cache::AssetEntry::Copy(_)) + ) + }) + }) + .map(|(_, lifetime)| *lifetime) + .collect() + } +} + +#[async_trait::async_trait(?Send)] +impl super::PlatformLoadingCache for LifetimeRecordingCache { + async fn get_or_load( + &self, + _services: &RuntimeServices, + _spec: &crate::platform::CacheSpec, + key: &crate::platform::StoreKey, + load: crate::platform::Loader, + ) -> Result { + let key = key.to_string(); + let held = self + .values + .lock() + .expect("should lock the values") + .get(&key) + .cloned(); + if let Some(value) = held { + return Ok(value); + } + let loaded = load().await?; + let lifetime = loaded.lifetime.unwrap_or(Duration::MAX); + if !lifetime.is_zero() { + self.values + .lock() + .expect("should lock the values") + .insert(key.clone(), loaded.bytes.clone()); + self.lifetimes + .lock() + .expect("should lock the lifetimes") + .insert(key, lifetime); + } + Ok(loaded.bytes) + } + + async fn remove( + &self, + _services: &RuntimeServices, + _spec: &crate::platform::CacheSpec, + key: &crate::platform::StoreKey, + ) { + self.values + .lock() + .expect("should lock the values") + .remove(&key.to_string()); + } +} diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index e7160087a3..1345bea36e 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -8,6 +8,7 @@ use futures::StreamExt as _; use http::{HeaderValue, Method, Request, Response, StatusCode, header}; use serde::{Deserialize, Serialize}; use std::io::{Cursor, Write}; +use std::sync::Arc; use std::time::Duration; use web_time::{SystemTime, UNIX_EPOCH}; @@ -1171,23 +1172,104 @@ pub async fn handle_asset_proxy_request( apply_asset_origin_auth(req.method(), &target_url, &mut outbound_headers, auth)?; } - let mut platform_req = - build_asset_platform_request(req.method(), &target_url, &outbound_headers, &backend_name)?; - if let Some(image_optimizer) = image_optimizer { - platform_req = platform_req.with_image_optimizer(image_optimizer); - } let stream_response = services.http_client().supports_streaming_responses(); - if stream_response { - platform_req = platform_req.with_stream_response(); + + // The route's answer is looked for among the copies the loading cache + // holds before the origin is asked, and the origin's answer is kept the + // way the publisher path keeps an asset, under the same two caches. An + // answer the platform's image optimizer makes is not kept, because the + // platform holds it. + let copy_key = if image_optimizer.is_none() { + asset_route_copy_key(services, &req, route, &target_url) + } else { + None + }; + let mut kept_copy = None; + let mut handed_back = None; + if let Some(key) = copy_key.as_ref() { + let cache = crate::publisher::asset_cache(settings); + let address = crate::publisher::asset_address(services, key); + let hand_back = crate::publisher::HandBack::new(); + let uri: http::Uri = + target_url + .as_str() + .parse() + .change_context(TrustedServerError::Proxy { + message: "invalid asset target URL".to_string(), + })?; + let fetch = crate::publisher::asset_fetch( + Arc::new(crate::publisher::AssetLoad { + services: services.clone(), + uri, + headers: outbound_headers.clone(), + backend_name: backend_name.clone(), + stream_response, + max_lifetime: cache.copies.lifetimes.store, + }), + hand_back.id, + ); + let not_shareable = + bytes::Bytes::from(crate::asset_cache::AssetEntry::NotShareable.encode()); + let found = crate::source_cache::get_or_fetch( + &crate::source_cache::Lookup { + services, + cache: &cache, + address: &address, + headers: &outbound_headers, + declared: None, + not_shareable: ¬_shareable, + }, + fetch, + ) + .await; + match found { + Ok(crate::source_cache::Found::Copy { value, key }) => { + match crate::asset_cache::AssetEntry::decode(&value) { + Ok(crate::asset_cache::AssetEntry::Copy(copy)) => kept_copy = Some(copy), + Ok(crate::asset_cache::AssetEntry::NotShareable) => { + // This request's own load fetched the answer, so it is + // served from that fetch rather than a second one. + handed_back = hand_back.take(); + } + Err(miss) => { + log::warn!("asset_cache invalid entry: {miss}; removing it"); + services + .loading_cache_for(cache.copies.name) + .remove(services, &cache.copies, &key) + .await; + } + } + } + Ok(crate::source_cache::Found::NoCopies) => handed_back = hand_back.take(), + Err(error) => log::warn!("asset_cache load failed: {error}; fetching the origin"), + } } - let platform_resp = services - .http_client() - .send(platform_req) - .await - .change_context(TrustedServerError::Proxy { - message: "Failed to proxy asset request".to_string(), - })?; + let platform_resp = if let Some(copy) = kept_copy { + PlatformResponse::new(copy.into_response(crate::publisher::unix_seconds())) + } else if let Some(response) = handed_back { + PlatformResponse::new(response) + } else { + let mut platform_req = build_asset_platform_request( + req.method(), + &target_url, + &outbound_headers, + &backend_name, + )?; + if let Some(image_optimizer) = image_optimizer { + platform_req = platform_req.with_image_optimizer(image_optimizer); + } + if stream_response { + platform_req = platform_req.with_stream_response(); + } + services + .http_client() + .send(platform_req) + .await + .change_context(TrustedServerError::Proxy { + message: "Failed to proxy asset request".to_string(), + })? + }; let mut response = if stream_response { platform_response_to_fastly_asset(platform_resp) @@ -1206,6 +1288,60 @@ pub async fn handle_asset_proxy_request( Ok(response) } +/// The key an asset route's answer is held under, or nothing for a request +/// whose answer is not kept: one that is not a `GET`, one carrying +/// `Authorization`, a conditional header or a range, which a copy cannot +/// answer, one that names no host, and any request while the cache +/// `[cache.assets]` selects keeps nothing. The route's origin and prefix are +/// part of the key, so two routes to one address keep their own copies. +fn asset_route_copy_key( + services: &RuntimeServices, + req: &Request, + route: &ProxyAssetRoute, + target_url: &url::Url, +) -> Option { + const NOT_FROM_A_COPY: [header::HeaderName; 7] = [ + header::AUTHORIZATION, + header::IF_NONE_MATCH, + header::IF_MODIFIED_SINCE, + header::IF_MATCH, + header::IF_UNMODIFIED_SINCE, + header::RANGE, + header::IF_RANGE, + ]; + if req.method() != Method::GET + || NOT_FROM_A_COPY + .iter() + .any(|name| req.headers().contains_key(name)) + { + return None; + } + let request_host = req + .uri() + .host() + .map(str::to_owned) + .or_else(|| { + req.headers() + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + }) + .filter(|host| !host.is_empty())?; + if !services + .loading_cache_for(crate::settings::ASSETS_CACHE) + .keeps_values() + { + return None; + } + Some(crate::asset_cache::AssetCacheKey { + url: target_url.to_string(), + request_host, + request_scheme: req.uri().scheme_str().unwrap_or("https").to_owned(), + origin_identity: format!("{}\0{}", route.origin_url, route.prefix), + fingerprint: String::new(), + }) +} + /// Upserts the `ts-ec` query parameter on a URL, replacing any existing value. fn upsert_ec_query_param(url: &mut url::Url, ec_id: &str) { let mut pairs: Vec<(String, String)> = url @@ -5020,6 +5156,185 @@ mod tests { ); } + /// The services an asset route's tests use, with every loading cache + /// served by one memory cache, so a copy kept by one request is there + /// for the next. + fn asset_route_services( + stub: &Arc, + cache: &Arc, + ) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(crate::platform::test_support::NoopConfigStore)) + .secret_store(Arc::new(crate::platform::test_support::NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(crate::platform::test_support::StubBackend)) + .http_client(Arc::clone(stub) as _) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(crate::platform::test_support::NoopGeo), + ))) + .client_info(crate::platform::ClientInfo::default()) + .loading_caches(Arc::new(crate::platform::LoadingCaches::every( + Arc::clone(cache) as _, + ))) + .build() + } + + fn held_asset_stub() -> Arc { + let stub = Arc::new(crate::platform::test_support::StubHttpClient::new()); + stub.set_streaming_responses_supported(false); + stub + } + + fn shareable_stylesheet(stub: &crate::platform::test_support::StubHttpClient) { + stub.push_response_with_headers( + 200, + b"body { color: red }".to_vec(), + vec![ + ("content-type", "text/css"), + ("cache-control", "public, max-age=300"), + ], + ); + } + + async fn asset_route_answer( + services: &RuntimeServices, + request: HttpRequest, + ) -> Response { + let settings = create_test_settings(); + let route = ProxyAssetRoute::new("/.images/", "https://assets.example.com"); + handle_asset_proxy_request(&settings, services, request, &route) + .await + .expect("should answer the asset request") + .into_response() + .expect("should return a buffered asset response") + } + + #[test] + fn an_asset_route_answer_is_held_and_serves_the_next_request() { + futures::executor::block_on(async { + let stub = held_asset_stub(); + shareable_stylesheet(&stub); + let cache = Arc::new(crate::platform::test_support::LifetimeRecordingCache::default()); + let services = asset_route_services(&stub, &cache); + let uri = "https://www.example.com/.images/site.css"; + + let first = asset_route_answer(&services, build_http_request(Method::GET, uri)).await; + let second = asset_route_answer(&services, build_http_request(Method::GET, uri)).await; + + assert_eq!(first.status(), StatusCode::OK); + assert_eq!( + stub.recorded_request_uris().len(), + 1, + "should ask the origin once for two requests of one asset" + ); + assert_eq!(second.status(), StatusCode::OK); + assert_eq!( + second + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("text/css"), + "should serve the copy with the headers that describe it" + ); + assert!( + second.headers().contains_key(header::AGE), + "should serve the copy with an Age" + ); + assert!( + matches!(second.into_body(), EdgeBody::Once(bytes) if bytes.as_ref() == b"body { color: red }"), + "should serve the copy's bytes" + ); + let lifetimes = cache.copy_lifetimes(); + assert!( + lifetimes.len() == 1 + && lifetimes[0] > std::time::Duration::from_secs(295) + && lifetimes[0] <= std::time::Duration::from_secs(300), + "should keep the copy for the lifetime the origin gave it, less the time since, got {lifetimes:?}" + ); + }); + } + + #[test] + fn an_asset_route_answer_with_a_cookie_is_not_held() { + futures::executor::block_on(async { + let stub = held_asset_stub(); + for _ in 0..2 { + stub.push_response_with_headers( + 200, + b"body { color: red }".to_vec(), + vec![ + ("content-type", "text/css"), + ("cache-control", "public, max-age=300"), + ("set-cookie", "session=1"), + ], + ); + } + let cache = Arc::new(crate::platform::test_support::LifetimeRecordingCache::default()); + let services = asset_route_services(&stub, &cache); + let uri = "https://www.example.com/.images/site.css"; + + let first = asset_route_answer(&services, build_http_request(Method::GET, uri)).await; + let second = asset_route_answer(&services, build_http_request(Method::GET, uri)).await; + + assert_eq!(first.status(), StatusCode::OK); + assert_eq!(second.status(), StatusCode::OK); + assert_eq!( + stub.recorded_request_uris().len(), + 2, + "should ask the origin for each request of an answer that set a cookie" + ); + assert!(cache.copy_lifetimes().is_empty(), "should keep no copy"); + }); + } + + #[test] + fn a_range_request_through_an_asset_route_is_not_served_a_copy() { + futures::executor::block_on(async { + let stub = held_asset_stub(); + shareable_stylesheet(&stub); + shareable_stylesheet(&stub); + let cache = Arc::new(crate::platform::test_support::LifetimeRecordingCache::default()); + let services = asset_route_services(&stub, &cache); + let uri = "https://www.example.com/.images/site.css"; + + asset_route_answer(&services, build_http_request(Method::GET, uri)).await; + let mut ranged = build_http_request(Method::GET, uri); + ranged + .headers_mut() + .insert(header::RANGE, HeaderValue::from_static("bytes=0-3")); + asset_route_answer(&services, ranged).await; + + assert_eq!( + stub.recorded_request_uris().len(), + 2, + "should ask the origin for a range, which a copy cannot answer" + ); + }); + } + + #[test] + fn an_asset_route_holds_nothing_while_no_cache_keeps_values() { + futures::executor::block_on(async { + let stub = held_asset_stub(); + shareable_stylesheet(&stub); + shareable_stylesheet(&stub); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let uri = "https://www.example.com/.images/site.css"; + + asset_route_answer(&services, build_http_request(Method::GET, uri)).await; + asset_route_answer(&services, build_http_request(Method::GET, uri)).await; + + assert_eq!( + stub.recorded_request_uris().len(), + 2, + "should ask the origin for each request while no loading cache keeps values" + ); + }); + } + #[test] fn handle_asset_proxy_request_forwards_asset_headers_and_host() { futures::executor::block_on(async { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 884d65dc2a..002f4346d0 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -8190,7 +8190,7 @@ const ASSET_NOT_SHAREABLE_LIFETIME: Duration = Duration::from_secs(60); /// lifetimes `[cache.assets]` sets. An asset's load awaits the origin rather /// than blocking a thread, so a host that can run work on its own may run it /// apart from the request that started it. -fn asset_cache(settings: &Settings) -> SourceCache { +pub(crate) fn asset_cache(settings: &Settings) -> SourceCache { let lifetimes = settings.cache.assets.lifetimes(ASSET_DEFAULT_LIFETIMES); let spec = |name, memory_entries| crate::platform::CacheSpec { name, @@ -8207,7 +8207,7 @@ fn asset_cache(settings: &Settings) -> SourceCache { /// The address one asset's copies are held under, made of the publisher's /// prefix the request's services carry, so two publishers never share one, /// and the asset's own key. -fn asset_address( +pub(crate) fn asset_address( services: &RuntimeServices, key: &crate::asset_cache::AssetCacheKey, ) -> SourceAddress { @@ -8287,7 +8287,7 @@ fn asset_cache_ttl( } /// Seconds since the Unix epoch, from the wasm-safe clock. -fn unix_seconds() -> u64 { +pub(crate) fn unix_seconds() -> u64 { crate::ec::std_system_time_now() .duration_since(SystemTime::UNIX_EPOCH) .map_or(0, |since| since.as_secs()) @@ -8307,13 +8307,13 @@ static NEXT_LOAD: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::n /// served, so that request is served from the one fetch rather than a /// second one. The slot lives as long as the request's `HandBack`, so a load /// that outlives its request hands back nothing. -struct HandBack { - id: u64, +pub(crate) struct HandBack { + pub(crate) id: u64, slot: std::rc::Rc, } impl HandBack { - fn new() -> Self { + pub(crate) fn new() -> Self { let id = NEXT_LOAD.fetch_add(1, Ordering::Relaxed); let slot = std::rc::Rc::new(std::cell::RefCell::new(None)); HANDED_BACK.with(|handed| { @@ -8324,7 +8324,7 @@ impl HandBack { Self { id, slot } } - fn take(&self) -> Option> { + pub(crate) fn take(&self) -> Option> { self.slot.borrow_mut().take() } } @@ -8346,16 +8346,16 @@ fn hand_back(id: u64, response: Response) { /// What a load owns to fetch one asset, because it may outlive the request /// that started it. -struct AssetLoad { - services: RuntimeServices, +pub(crate) struct AssetLoad { + pub(crate) services: RuntimeServices, /// The origin request, already prepared for the origin. Only a `GET` is /// kept, which carries no body. - uri: http::Uri, - headers: http::HeaderMap, - backend_name: String, - stream_response: bool, + pub(crate) uri: http::Uri, + pub(crate) headers: http::HeaderMap, + pub(crate) backend_name: String, + pub(crate) stream_response: bool, /// The longest a copy may be kept, whatever the origin asks. - max_lifetime: Duration, + pub(crate) max_lifetime: Duration, } /// What one fetch of an asset made. @@ -8374,7 +8374,7 @@ struct AssetLoaded { /// [`AssetEntry::NotShareable`](crate::asset_cache::AssetEntry::NotShareable) /// for [`ASSET_NOT_SHAREABLE_LIFETIME`], and handed back to the request whose /// load fetched it through `hand_back_id`, see [`HandBack`]. -fn asset_fetch(load: Arc, hand_back_id: u64) -> crate::source_cache::Fetch { +pub(crate) fn asset_fetch(load: Arc, hand_back_id: u64) -> crate::source_cache::Fetch { Arc::new(move || { let load = Arc::clone(&load); Box::pin(async move { @@ -8509,98 +8509,16 @@ async fn load_asset( #[cfg(test)] mod tests { mod asset_cache_tests { - use std::collections::HashMap; - use std::sync::Mutex; - use super::*; + use crate::platform::ClientInfo; use crate::platform::test_support::{ - NoopConfigStore, NoopGeo, NoopSecretStore, StubBackend, StubHttpClient, + LifetimeRecordingCache, NoopConfigStore, NoopGeo, NoopSecretStore, StubBackend, + StubHttpClient, }; - use crate::platform::{ClientInfo, PlatformLoadingCache}; - - /// A loading cache that keeps every value with its lifetime under its - /// key and runs each load in its caller. - #[derive(Default)] - struct MemoryLoadingCache { - values: Mutex>, - lifetimes: Mutex>, - } - - impl MemoryLoadingCache { - /// The lifetimes of the kept copies, leaving out the records and - /// the marks of what may not be shared. - fn copy_lifetimes(&self) -> Vec { - let values = self.values.lock().expect("should lock the values"); - self.lifetimes - .lock() - .expect("should lock the lifetimes") - .iter() - .filter(|(key, _)| { - key.contains(&format!("/{}/", crate::settings::ASSETS_CACHE)) - }) - .filter(|(key, _)| { - values.get(*key).is_some_and(|value| { - matches!( - crate::asset_cache::AssetEntry::decode(value), - Ok(crate::asset_cache::AssetEntry::Copy(_)) - ) - }) - }) - .map(|(_, lifetime)| *lifetime) - .collect() - } - } - - #[async_trait::async_trait(?Send)] - impl PlatformLoadingCache for MemoryLoadingCache { - async fn get_or_load( - &self, - _services: &RuntimeServices, - _spec: &crate::platform::CacheSpec, - key: &crate::platform::StoreKey, - load: crate::platform::Loader, - ) -> Result { - let key = key.to_string(); - let held = self - .values - .lock() - .expect("should lock the values") - .get(&key) - .cloned(); - if let Some(value) = held { - return Ok(value); - } - let loaded = load().await?; - let lifetime = loaded.lifetime.unwrap_or(Duration::MAX); - if !lifetime.is_zero() { - self.values - .lock() - .expect("should lock the values") - .insert(key.clone(), loaded.bytes.clone()); - self.lifetimes - .lock() - .expect("should lock the lifetimes") - .insert(key, lifetime); - } - Ok(loaded.bytes) - } - - async fn remove( - &self, - _services: &RuntimeServices, - _spec: &crate::platform::CacheSpec, - key: &crate::platform::StoreKey, - ) { - self.values - .lock() - .expect("should lock the values") - .remove(&key.to_string()); - } - } fn services( stub: &Arc, - cache: &Arc, + cache: &Arc, ) -> RuntimeServices { RuntimeServices::builder() .config_store(Arc::new(NoopConfigStore)) @@ -8696,7 +8614,7 @@ mod tests { #[tokio::test] async fn an_image_is_kept_as_the_origin_sent_it() { let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryLoadingCache::default()); + let cache = Arc::new(LifetimeRecordingCache::default()); let services = services(&stub, &cache); let settings = settings(); let image = [0x89_u8, b'P', b'N', b'G', 0, 1, 2, 3]; @@ -8723,7 +8641,7 @@ mod tests { #[tokio::test] async fn a_stylesheet_is_served_from_one_fetch_with_its_age() { let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryLoadingCache::default()); + let cache = Arc::new(LifetimeRecordingCache::default()); let services = services(&stub, &cache); let settings = settings(); queue( @@ -8764,7 +8682,7 @@ mod tests { ("public, max-age=300, no-store", None, false), ] { let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryLoadingCache::default()); + let cache = Arc::new(LifetimeRecordingCache::default()); let services = services(&stub, &cache); let settings = settings(); for _ in 0..2 { @@ -8796,7 +8714,7 @@ mod tests { ("public, s-maxage=600, max-age=30", 600), ] { let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryLoadingCache::default()); + let cache = Arc::new(LifetimeRecordingCache::default()); let services = services(&stub, &cache); queue( &stub, @@ -8823,7 +8741,7 @@ mod tests { #[tokio::test] async fn a_header_the_origin_varies_on_separates_kept_copies() { let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryLoadingCache::default()); + let cache = Arc::new(LifetimeRecordingCache::default()); let services = services(&stub, &cache); let settings = settings(); for edition in ["red", "blue"] { @@ -8861,7 +8779,7 @@ mod tests { #[tokio::test] async fn a_request_carrying_authorization_is_fetched_for_itself() { let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryLoadingCache::default()); + let cache = Arc::new(LifetimeRecordingCache::default()); let services = services(&stub, &cache); let settings = settings(); for _ in 0..2 { @@ -8890,7 +8808,7 @@ mod tests { #[tokio::test] async fn a_page_navigation_never_reads_an_asset_copy() { let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryLoadingCache::default()); + let cache = Arc::new(LifetimeRecordingCache::default()); let services = services(&stub, &cache); let settings = settings(); for _ in 0..2 { diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 918324fc7a..04c0671561 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1761,12 +1761,30 @@ or several, making each answer every time it is asked for. A name that no linked crate offers a cache from is refused when the service starts, with the names that could be written. +Every response the publisher's origin sends that is not a page, and every +answer of an asset route under `[[proxy.asset_routes]]`, is held the same way, +selected by `[cache.assets]`, for the lifetime the origin's cache policy gives +it and under what the origin says it varies on, so an asset is fetched once +however many readers and instances ask for it at the same moment. A copy is +served with the headers that describe it and an `Age` that counts from the +origin's. Nothing is kept that may not be shared: anything but a `GET`, +anything but a `200`, HTML, a response carrying `Set-Cookie`, one whose policy +says `private`, `no-store` or `no-cache` or gives no lifetime, one that varies +on `*` or on `Cookie`, one larger than a megabyte, an answer the platform's +image optimizer makes, and the answer to a request carrying `Authorization`, a +conditional header or a range. A response that may not be shared is served to +the request whose load fetched it from that one fetch. + | Table | Field | Default | Range | Description | | -------------------- | -------------------- | ------------------ | ------------- | ------------------------------------------------------------------------------- | | `[cache.robots-txt]` | `module` | none | | The module whose loading cache holds each robots.txt module's answer. | | `[cache.robots-txt]` | `memory_seconds` | `60` | 0 to 31536000 | How long the copy in memory is used. Zero keeps none. | | `[cache.robots-txt]` | `store_seconds` | `604800`, a week | 1 to 31536000 | The longest a module's answer is used. The module's own refresh ends it sooner. | | `[cache.robots-txt]` | `store_idle_seconds` | `2592000`, 30 days | 1 to 31536000 | How long an answer in the shared store lasts unused. | +| `[cache.assets]` | `module` | none | | The module whose loading cache holds the publisher's assets and route answers. | +| `[cache.assets]` | `memory_seconds` | `5` | 0 to 31536000 | How long the copy in memory is used. Zero keeps none. | +| `[cache.assets]` | `store_seconds` | `3600`, an hour | 1 to 31536000 | The longest a copy is used. The origin's own lifetime ends it sooner. | +| `[cache.assets]` | `store_idle_seconds` | `86400`, a day | 1 to 31536000 | How long a copy in the shared store lasts unused. | A module may declare caches of its own, named by the module, and a deployment selects a loading cache for each in its own `[cache.]` table with the From f6e598fda76c2d51e7a86ce55bef2fca9c0f491a Mon Sep 17 00:00:00 2001 From: James Rogers Date: Wed, 14 Oct 2026 14:13:52 +0100 Subject: [PATCH 04/23] The request whose load fetched an asset is served that fetch A body that outgrows a copy while it is read is handed back as what was read and then the rest. A response whose Vary no longer matches its record is served to the request that fetched it. Both were dropped, and that request asked the origin a second time. The asset caches run their loads in the request that started them, and an asset route refuses a copy to a request whose Cache-Control asks for the origin, as the publisher path does. The two lookups share one function, the origin checks the template cache and asset cache share are one helper, the three length-prefixed hashes share one, and the tests use the memory loading cache and the shared test services. The docs describe the copy and its key as they are built today. --- crates/trusted-server-core/src/asset_cache.rs | 19 +- .../trusted-server-core/src/platform/mod.rs | 1 + .../src/platform/template_cache.rs | 18 +- .../src/platform/test_support.rs | 112 ++--- crates/trusted-server-core/src/proxy.rs | 142 +++--- crates/trusted-server-core/src/publisher.rs | 417 +++++++++++------- .../trusted-server-core/src/source_cache.rs | 155 +++---- docs/guide/configuration.md | 3 +- 8 files changed, 433 insertions(+), 434 deletions(-) diff --git a/crates/trusted-server-core/src/asset_cache.rs b/crates/trusted-server-core/src/asset_cache.rs index 7b5442549a..97680605b9 100644 --- a/crates/trusted-server-core/src/asset_cache.rs +++ b/crates/trusted-server-core/src/asset_cache.rs @@ -1,9 +1,8 @@ //! What the asset cache keeps for a response that is not a page, being the //! response as the fetch phase left it, and the address it is kept under. //! -//! A copy holds the headers a reader is served and the body. A body the fetch -//! phase rewrote is kept decoded, and each reader's copy is encoded as it is -//! served. Every copy is kept under what the origin's own `Vary` names, see +//! A copy holds the headers a reader is served and the body, as the origin +//! sent them. Every copy is kept under what the origin's own `Vary` names, see //! `crate::source_cache`. use edgezero_core::body::Body as EdgeBody; @@ -44,8 +43,7 @@ const NOT_KEPT: &[&str] = &[ /// the address's record once the origin has said it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AssetCacheKey { - /// The URL asked of the origin, without its query where the publisher's - /// declaration names the parameters that matter. + /// The URL asked of the origin, with its query. pub url: String, /// The host the reader asked, which the fetch phase writes into the body /// in place of the origin's. @@ -56,7 +54,7 @@ pub struct AssetCacheKey { pub origin_identity: String, /// Digest of every setting and module that can shape the fetch phase, /// and of the build, so a build that rewrites bodies differently never - /// serves a copy an earlier one made. + /// serves a copy an earlier one made. Empty for an asset route's answer. pub fingerprint: String, } @@ -67,10 +65,7 @@ impl AssetCacheKey { /// inputs run together into the same key. #[must_use] pub fn to_cache_key(&self) -> String { - fn push(hasher: &mut Sha256, part: &[u8]) { - hasher.update((part.len() as u64).to_be_bytes()); - hasher.update(part); - } + use crate::platform::hash_length_prefixed as push; let mut hasher = Sha256::new(); push(&mut hasher, b"ts-asset-cache"); @@ -92,8 +87,8 @@ impl AssetCacheKey { pub struct AssetCopy { /// The headers a reader is served, as the fetch phase left them. pub headers: Vec<(String, String)>, - /// Whether the fetch phase decoded and rewrote the body, in which case it - /// is kept decoded and encoded for each reader as it is served. + /// Whether the body is kept without its `Content-Encoding`. Every copy is + /// kept as the origin sent it today, so this is false. pub decoded: bool, /// When the copy was made, in seconds since the Unix epoch. pub made_at: u64, diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index db4640b4ee..e00331317f 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -84,6 +84,7 @@ pub use template_assembly::{ PlatformTemplateAssembler, TemplateAssemblyError, UnavailableTemplateAssembler, contains_publisher_esi_directive, }; +pub(crate) use template_cache::hash_length_prefixed; pub use template_cache::{ PlatformTemplateCache, PlatformTemplateCacheReservation, REPLAYABLE_POLICY_HEADERS, TEMPLATE_CACHE_PURGE_ALL_SURROGATE_KEY, TEMPLATE_SCHEMA_VERSION, TemplateCacheError, diff --git a/crates/trusted-server-core/src/platform/template_cache.rs b/crates/trusted-server-core/src/platform/template_cache.rs index e50bfe5032..cd21381fb6 100644 --- a/crates/trusted-server-core/src/platform/template_cache.rs +++ b/crates/trusted-server-core/src/platform/template_cache.rs @@ -89,6 +89,15 @@ pub struct TemplateCacheKey { pub schema_version: u32, } +/// Writes `part` into `hasher` with its length before it, so no two lists of +/// parts run together into the same digest. +pub(crate) fn hash_length_prefixed(hasher: &mut sha2::Sha256, part: &[u8]) { + use sha2::Digest as _; + + hasher.update((part.len() as u64).to_be_bytes()); + hasher.update(part); +} + impl TemplateCacheKey { /// Render a fixed-size opaque key for the platform cache. /// @@ -98,12 +107,9 @@ impl TemplateCacheKey { pub fn to_cache_key(&self) -> String { use sha2::Digest as _; - fn push(out: &mut Vec, part: &[u8]) { - out.extend_from_slice(&(part.len() as u64).to_be_bytes()); - out.extend_from_slice(part); - } + use hash_length_prefixed as push; - let mut canonical = Vec::new(); + let mut canonical = sha2::Sha256::new(); push(&mut canonical, b"ts-template-cache"); push(&mut canonical, &self.schema_version.to_be_bytes()); push( @@ -155,7 +161,7 @@ impl TemplateCacheKey { } } - let digest = sha2::Sha256::digest(canonical); + let digest = canonical.finalize(); format!( "ts-template-cache-v{}-{}", self.schema_version, diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 2b0c80be88..72a3c0f7f2 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -1369,6 +1369,38 @@ impl MemoryLoadingCache { keys.sort(); keys } + + /// The value held under `key`, with the time it has left, or `None` for + /// its time when its load gave it no lifetime. + pub fn held(&self, key: &str) -> Option<(bytes::Bytes, Option)> { + self.entries + .lock() + .expect("should lock the entries") + .get(key) + .map(|(bytes, end)| { + let left = end.map(|end| end.saturating_duration_since(web_time::Instant::now())); + (bytes.clone(), left) + }) + } +} + +/// The time left to each asset copy `cache` holds, leaving out the records +/// and the marks of what may not be shared, in no order. +pub fn asset_copy_lifetimes(cache: &MemoryLoadingCache) -> Vec { + let copies = format!("/{}/", crate::settings::ASSETS_CACHE); + cache + .keys() + .iter() + .filter(|key| key.contains(&copies)) + .filter_map(|key| cache.held(key)) + .filter(|(value, _)| { + matches!( + crate::asset_cache::AssetEntry::decode(value), + Ok(crate::asset_cache::AssetEntry::Copy(_)) + ) + }) + .map(|(_, left)| left.unwrap_or(Duration::MAX)) + .collect() } #[async_trait::async_trait(?Send)] @@ -1707,83 +1739,3 @@ mod tests { ); } } - -/// A loading cache that keeps every value with its lifetime under its key -/// and runs each load in its caller, so a test reads what was kept and for -/// how long. -#[derive(Default)] -pub struct LifetimeRecordingCache { - values: Mutex>, - lifetimes: Mutex>, -} - -impl LifetimeRecordingCache { - /// The lifetimes of the kept copies, leaving out the records and - /// the marks of what may not be shared. - /// The lifetimes of the asset copies kept, in no order. - pub fn copy_lifetimes(&self) -> Vec { - let values = self.values.lock().expect("should lock the values"); - self.lifetimes - .lock() - .expect("should lock the lifetimes") - .iter() - .filter(|(key, _)| key.contains(&format!("/{}/", crate::settings::ASSETS_CACHE))) - .filter(|(key, _)| { - values.get(*key).is_some_and(|value| { - matches!( - crate::asset_cache::AssetEntry::decode(value), - Ok(crate::asset_cache::AssetEntry::Copy(_)) - ) - }) - }) - .map(|(_, lifetime)| *lifetime) - .collect() - } -} - -#[async_trait::async_trait(?Send)] -impl super::PlatformLoadingCache for LifetimeRecordingCache { - async fn get_or_load( - &self, - _services: &RuntimeServices, - _spec: &crate::platform::CacheSpec, - key: &crate::platform::StoreKey, - load: crate::platform::Loader, - ) -> Result { - let key = key.to_string(); - let held = self - .values - .lock() - .expect("should lock the values") - .get(&key) - .cloned(); - if let Some(value) = held { - return Ok(value); - } - let loaded = load().await?; - let lifetime = loaded.lifetime.unwrap_or(Duration::MAX); - if !lifetime.is_zero() { - self.values - .lock() - .expect("should lock the values") - .insert(key.clone(), loaded.bytes.clone()); - self.lifetimes - .lock() - .expect("should lock the lifetimes") - .insert(key, lifetime); - } - Ok(loaded.bytes) - } - - async fn remove( - &self, - _services: &RuntimeServices, - _spec: &crate::platform::CacheSpec, - key: &crate::platform::StoreKey, - ) { - self.values - .lock() - .expect("should lock the values") - .remove(&key.to_string()); - } -} diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 1345bea36e..7b31ec20f0 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -8,7 +8,6 @@ use futures::StreamExt as _; use http::{HeaderValue, Method, Request, Response, StatusCode, header}; use serde::{Deserialize, Serialize}; use std::io::{Cursor, Write}; -use std::sync::Arc; use std::time::Duration; use web_time::{SystemTime, UNIX_EPOCH}; @@ -1184,12 +1183,8 @@ pub async fn handle_asset_proxy_request( } else { None }; - let mut kept_copy = None; - let mut handed_back = None; + let mut from_asset_cache = None; if let Some(key) = copy_key.as_ref() { - let cache = crate::publisher::asset_cache(settings); - let address = crate::publisher::asset_address(services, key); - let hand_back = crate::publisher::HandBack::new(); let uri: http::Uri = target_url .as_str() @@ -1197,57 +1192,19 @@ pub async fn handle_asset_proxy_request( .change_context(TrustedServerError::Proxy { message: "invalid asset target URL".to_string(), })?; - let fetch = crate::publisher::asset_fetch( - Arc::new(crate::publisher::AssetLoad { - services: services.clone(), - uri, - headers: outbound_headers.clone(), - backend_name: backend_name.clone(), - stream_response, - max_lifetime: cache.copies.lifetimes.store, - }), - hand_back.id, - ); - let not_shareable = - bytes::Bytes::from(crate::asset_cache::AssetEntry::NotShareable.encode()); - let found = crate::source_cache::get_or_fetch( - &crate::source_cache::Lookup { - services, - cache: &cache, - address: &address, - headers: &outbound_headers, - declared: None, - not_shareable: ¬_shareable, - }, - fetch, - ) + from_asset_cache = crate::publisher::asset_from_cache(&crate::publisher::AssetRequest { + settings, + services, + key, + uri: &uri, + headers: &outbound_headers, + backend_name: &backend_name, + stream_response, + }) .await; - match found { - Ok(crate::source_cache::Found::Copy { value, key }) => { - match crate::asset_cache::AssetEntry::decode(&value) { - Ok(crate::asset_cache::AssetEntry::Copy(copy)) => kept_copy = Some(copy), - Ok(crate::asset_cache::AssetEntry::NotShareable) => { - // This request's own load fetched the answer, so it is - // served from that fetch rather than a second one. - handed_back = hand_back.take(); - } - Err(miss) => { - log::warn!("asset_cache invalid entry: {miss}; removing it"); - services - .loading_cache_for(cache.copies.name) - .remove(services, &cache.copies, &key) - .await; - } - } - } - Ok(crate::source_cache::Found::NoCopies) => handed_back = hand_back.take(), - Err(error) => log::warn!("asset_cache load failed: {error}; fetching the origin"), - } } - let platform_resp = if let Some(copy) = kept_copy { - PlatformResponse::new(copy.into_response(crate::publisher::unix_seconds())) - } else if let Some(response) = handed_back { + let platform_resp = if let Some(response) = from_asset_cache { PlatformResponse::new(response) } else { let mut platform_req = build_asset_platform_request( @@ -1291,7 +1248,8 @@ pub async fn handle_asset_proxy_request( /// The key an asset route's answer is held under, or nothing for a request /// whose answer is not kept: one that is not a `GET`, one carrying /// `Authorization`, a conditional header or a range, which a copy cannot -/// answer, one that names no host, and any request while the cache +/// answer, one whose `Cache-Control` asks for the origin, as the publisher +/// path reads it, one that names no host, and any request while the cache /// `[cache.assets]` selects keeps nothing. The route's origin and prefix are /// part of the key, so two routes to one address keep their own copies. fn asset_route_copy_key( @@ -1300,19 +1258,9 @@ fn asset_route_copy_key( route: &ProxyAssetRoute, target_url: &url::Url, ) -> Option { - const NOT_FROM_A_COPY: [header::HeaderName; 7] = [ - header::AUTHORIZATION, - header::IF_NONE_MATCH, - header::IF_MODIFIED_SINCE, - header::IF_MATCH, - header::IF_UNMODIFIED_SINCE, - header::RANGE, - header::IF_RANGE, - ]; if req.method() != Method::GET - || NOT_FROM_A_COPY - .iter() - .any(|name| req.headers().contains_key(name)) + || req.headers().contains_key(header::AUTHORIZATION) + || crate::publisher::request_bypasses_template_cache(req.headers()) { return None; } @@ -5161,23 +5109,10 @@ mod tests { /// for the next. fn asset_route_services( stub: &Arc, - cache: &Arc, + cache: &Arc, ) -> RuntimeServices { - RuntimeServices::builder() - .config_store(Arc::new(crate::platform::test_support::NoopConfigStore)) - .secret_store(Arc::new(crate::platform::test_support::NoopSecretStore)) - .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) - .backend(Arc::new(crate::platform::test_support::StubBackend)) - .http_client(Arc::clone(stub) as _) - .geo(Arc::new(crate::platform::PlatformGeoModule::new( - "noop", - Arc::new(crate::platform::test_support::NoopGeo), - ))) - .client_info(crate::platform::ClientInfo::default()) - .loading_caches(Arc::new(crate::platform::LoadingCaches::every( - Arc::clone(cache) as _, - ))) - .build() + build_services_with_http_client(Arc::clone(stub) as _) + .with_loading_cache(Arc::clone(cache) as _) } fn held_asset_stub() -> Arc { @@ -5215,7 +5150,7 @@ mod tests { futures::executor::block_on(async { let stub = held_asset_stub(); shareable_stylesheet(&stub); - let cache = Arc::new(crate::platform::test_support::LifetimeRecordingCache::default()); + let cache = Arc::new(crate::platform::test_support::MemoryLoadingCache::default()); let services = asset_route_services(&stub, &cache); let uri = "https://www.example.com/.images/site.css"; @@ -5245,7 +5180,7 @@ mod tests { matches!(second.into_body(), EdgeBody::Once(bytes) if bytes.as_ref() == b"body { color: red }"), "should serve the copy's bytes" ); - let lifetimes = cache.copy_lifetimes(); + let lifetimes = crate::platform::test_support::asset_copy_lifetimes(&cache); assert!( lifetimes.len() == 1 && lifetimes[0] > std::time::Duration::from_secs(295) @@ -5270,7 +5205,7 @@ mod tests { ], ); } - let cache = Arc::new(crate::platform::test_support::LifetimeRecordingCache::default()); + let cache = Arc::new(crate::platform::test_support::MemoryLoadingCache::default()); let services = asset_route_services(&stub, &cache); let uri = "https://www.example.com/.images/site.css"; @@ -5284,7 +5219,10 @@ mod tests { 2, "should ask the origin for each request of an answer that set a cookie" ); - assert!(cache.copy_lifetimes().is_empty(), "should keep no copy"); + assert!( + crate::platform::test_support::asset_copy_lifetimes(&cache).is_empty(), + "should keep no copy" + ); }); } @@ -5294,7 +5232,7 @@ mod tests { let stub = held_asset_stub(); shareable_stylesheet(&stub); shareable_stylesheet(&stub); - let cache = Arc::new(crate::platform::test_support::LifetimeRecordingCache::default()); + let cache = Arc::new(crate::platform::test_support::MemoryLoadingCache::default()); let services = asset_route_services(&stub, &cache); let uri = "https://www.example.com/.images/site.css"; @@ -5313,6 +5251,34 @@ mod tests { }); } + #[test] + fn a_request_asking_for_the_origin_through_an_asset_route_is_not_served_a_copy() { + futures::executor::block_on(async { + for cache_control in ["no-cache", "no-store", "min-fresh=10"] { + let stub = held_asset_stub(); + shareable_stylesheet(&stub); + shareable_stylesheet(&stub); + let cache = Arc::new(crate::platform::test_support::MemoryLoadingCache::default()); + let services = asset_route_services(&stub, &cache); + let uri = "https://www.example.com/.images/site.css"; + + asset_route_answer(&services, build_http_request(Method::GET, uri)).await; + let mut reload = build_http_request(Method::GET, uri); + reload.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static(cache_control), + ); + asset_route_answer(&services, reload).await; + + assert_eq!( + stub.recorded_request_uris().len(), + 2, + "should ask the origin for a request carrying Cache-Control {cache_control}" + ); + } + }); + } + #[test] fn an_asset_route_holds_nothing_while_no_cache_keeps_values() { futures::executor::block_on(async { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 002f4346d0..bfa9d82c61 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -5823,69 +5823,25 @@ pub async fn handle_publisher_request( // origin TTFB tracks origin latency rather than the auction timeout. // Any other response is looked for the same way, before the origin is // fetched, and a copy found is served as the origin's response would be. - let mut kept_copy = None; - let mut handed_back_origin = None; + let mut from_asset_cache = None; if let Some(key) = asset_cache_key.as_ref() { - let cache = asset_cache(settings); - let address = asset_address(services, key); let prepared = origin_request.as_ref().ok_or_else(|| { Report::new(TrustedServerError::Proxy { message: "publisher origin request was already consumed".to_owned(), }) })?; - let asset_hand_back = HandBack::new(); - let fetch = asset_fetch( - Arc::new(AssetLoad { - services: services.clone(), - uri: prepared.uri().clone(), - headers: prepared.headers().clone(), - backend_name: backend_name.clone(), - stream_response: services.http_client().supports_streaming_responses(), - max_lifetime: cache.copies.lifetimes.store, - }), - asset_hand_back.id, - ); - let not_shareable = - bytes::Bytes::from(crate::asset_cache::AssetEntry::NotShareable.encode()); - let found = crate::source_cache::get_or_fetch( - &Lookup { - services, - cache: &cache, - address: &address, - headers: prepared.headers(), - declared: None, - not_shareable: ¬_shareable, - }, - fetch, - ) + from_asset_cache = asset_from_cache(&AssetRequest { + settings, + services, + key, + uri: prepared.uri(), + headers: prepared.headers(), + backend_name: &backend_name, + stream_response: services.http_client().supports_streaming_responses(), + }) .await; - match found { - Ok(Found::Copy { value, key }) => { - match crate::asset_cache::AssetEntry::decode(&value) { - Ok(crate::asset_cache::AssetEntry::Copy(copy)) => kept_copy = Some(copy), - Ok(crate::asset_cache::AssetEntry::NotShareable) => { - // This request's own load fetched the asset, so it is served - // from that fetch rather than a second one. - handed_back_origin = asset_hand_back.take(); - } - Err(miss) => { - log::warn!("asset_cache invalid entry: {miss}; removing it"); - services - .loading_cache_for(cache.copies.name) - .remove(services, &cache.copies, &key) - .await; - } - } - } - Ok(Found::NoCopies) => handed_back_origin = asset_hand_back.take(), - Err(error) => log::warn!("asset_cache load failed: {error}; fetching the origin"), - } } - let origin_result = if let Some(copy) = kept_copy { - Ok(crate::platform::PlatformResponse::new( - copy.into_response(unix_seconds()), - )) - } else if let Some(response) = handed_back_origin { + let origin_result = if let Some(response) = from_asset_cache { Ok(crate::platform::PlatformResponse::new(response)) } else if let Some(pending) = pending_origin { services.http_client().wait(pending).await @@ -7050,7 +7006,7 @@ pub(crate) enum TemplateCacheBypassReason { MalformedPolicyHeader, } -fn request_bypasses_template_cache(headers: &edgezero_core::http::HeaderMap) -> bool { +pub(crate) fn request_bypasses_template_cache(headers: &edgezero_core::http::HeaderMap) -> bool { const CONDITIONAL_OR_PARTIAL: &[&str] = &[ "range", "if-range", @@ -7428,6 +7384,32 @@ fn replayable_policy_headers( Ok(captured) } +/// Whether an origin response may be kept for other readers at all. A status +/// other than `200`, a cookie it sets, or an edge cache header this layer does +/// not read keeps it from them, whatever it holds. +fn origin_response_may_be_kept( + status: StatusCode, + headers: &edgezero_core::http::HeaderMap, +) -> Result<(), TemplateCacheBypassReason> { + if status != StatusCode::OK { + return Err(TemplateCacheBypassReason::NonOkStatus); + } + if headers.contains_key(header::SET_COOKIE) { + return Err(TemplateCacheBypassReason::OriginSetCookie); + } + // Core Cache has no HTTP semantics. Fastly's documented Surrogate-Control subset + // is parsed by `origin_shared_ttl`; every other vendor-specific policy remains a + // bypass rather than guessing that unrelated CDNs share its grammar or precedence. + if crate::cache_policy::EDGE_CACHE_HEADER_NAMES + .iter() + .filter(|name| **name != "surrogate-control") + .any(|name| headers.contains_key(*name)) + { + return Err(TemplateCacheBypassReason::OriginNotShareable); + } + Ok(()) +} + fn template_cache_ttl( mode: AssemblyMode, authorization_disqualifies: bool, @@ -7446,19 +7428,7 @@ fn template_cache_ttl( if cookie_disqualifies { return Err(TemplateCacheBypassReason::CookiePolicy); } - if response_headers.contains_key(header::SET_COOKIE) { - return Err(TemplateCacheBypassReason::OriginSetCookie); - } - // Core Cache has no HTTP semantics. Fastly's documented Surrogate-Control subset - // is parsed by `origin_shared_ttl`; every other vendor-specific policy remains a - // bypass rather than guessing that unrelated CDNs share its grammar or precedence. - if crate::cache_policy::EDGE_CACHE_HEADER_NAMES - .iter() - .filter(|name| **name != "surrogate-control") - .any(|name| response_headers.contains_key(*name)) - { - return Err(TemplateCacheBypassReason::OriginNotShareable); - } + origin_response_may_be_kept(status, response_headers)?; // Checked here, among the leak vectors, because storing under a key that does not // cover the origin's Vary is cross-serving rather than mere ineligibility: a request // differing only in the uncovered header would read this template. @@ -7489,9 +7459,6 @@ fn template_cache_ttl( uncovered, ))); } - if status != StatusCode::OK { - return Err(TemplateCacheBypassReason::NonOkStatus); - } let declared_content_type = single_representation_header_value(response_headers, header::CONTENT_TYPE)?; if declared_content_type.is_some_and(|declared| declared != content_type) @@ -8187,16 +8154,15 @@ const ASSET_NOT_SHAREABLE_LIFETIME: Duration = Duration::from_secs(60); /// The asset caches, being one holding each address's record of what its /// response varies on and one holding the kept copies, both with the -/// lifetimes `[cache.assets]` sets. An asset's load awaits the origin rather -/// than blocking a thread, so a host that can run work on its own may run it -/// apart from the request that started it. -pub(crate) fn asset_cache(settings: &Settings) -> SourceCache { +/// lifetimes `[cache.assets]` sets. A load runs in the request that started +/// it, which is served the response that load fetched, see [`HandBack`]. +fn asset_cache(settings: &Settings) -> SourceCache { let lifetimes = settings.cache.assets.lifetimes(ASSET_DEFAULT_LIFETIMES); let spec = |name, memory_entries| crate::platform::CacheSpec { name, lifetimes, memory_entries, - detachable: true, + detachable: false, }; SourceCache { records: spec(crate::settings::ASSETS_VARY_CACHE, 256), @@ -8207,28 +8173,94 @@ pub(crate) fn asset_cache(settings: &Settings) -> SourceCache { /// The address one asset's copies are held under, made of the publisher's /// prefix the request's services carry, so two publishers never share one, /// and the asset's own key. -pub(crate) fn asset_address( +fn asset_address( services: &RuntimeServices, key: &crate::asset_cache::AssetCacheKey, ) -> SourceAddress { SourceAddress::new(services.server_key_prefix().clone(), key.to_cache_key()) } +/// One request the asset cache may answer, already prepared for the origin. +pub(crate) struct AssetRequest<'a> { + pub(crate) settings: &'a Settings, + pub(crate) services: &'a RuntimeServices, + /// The address the request's answer is kept under. + pub(crate) key: &'a crate::asset_cache::AssetCacheKey, + pub(crate) uri: &'a http::Uri, + pub(crate) headers: &'a http::HeaderMap, + pub(crate) backend_name: &'a str, + pub(crate) stream_response: bool, +} + +/// The asset cache's answer for `request`, being a kept copy served as the +/// origin's response would be, or the response this request's own load +/// fetched and may not share, or `None` when the origin is to be asked. +pub(crate) async fn asset_from_cache(request: &AssetRequest<'_>) -> Option> { + let services = request.services; + let cache = asset_cache(request.settings); + let address = asset_address(services, request.key); + let hand_back = HandBack::new(); + let fetch = asset_fetch( + Arc::new(AssetLoad { + services: services.clone(), + uri: request.uri.clone(), + headers: request.headers.clone(), + backend_name: request.backend_name.to_owned(), + stream_response: request.stream_response, + max_lifetime: cache.copies.lifetimes.store, + }), + hand_back.id, + ); + let not_shareable = bytes::Bytes::from(crate::asset_cache::AssetEntry::NotShareable.encode()); + let found = crate::source_cache::get_or_fetch( + &Lookup { + services, + cache: &cache, + address: &address, + headers: request.headers, + declared: None, + not_shareable: ¬_shareable, + }, + fetch, + ) + .await; + match found { + Ok(Found::Copy { value, key }) => match crate::asset_cache::AssetEntry::decode(&value) { + Ok(crate::asset_cache::AssetEntry::Copy(copy)) => { + Some(copy.into_response(unix_seconds())) + } + // This request's own load fetched the asset, so it is served from + // that fetch rather than a second one. + Ok(crate::asset_cache::AssetEntry::NotShareable) => hand_back.take(), + Err(miss) => { + log::warn!("asset_cache invalid entry: {miss}; removing it"); + services + .loading_cache_for(cache.copies.name) + .remove(services, &cache.copies, &key) + .await; + None + } + }, + Ok(Found::NoCopies) => hand_back.take(), + Err(error) => { + log::warn!("asset_cache load failed: {error}; fetching the origin"); + None + } + } +} + /// Why an asset's response is not kept. #[derive(Debug, derive_more::Display)] enum AssetCacheBypassReason { - /// Only a whole `200` is kept. - #[display("status was not 200 OK")] - NonOkStatus, - /// The origin set a cookie, which a copy would replay to the next reader. - #[display("origin response carries Set-Cookie")] - OriginSetCookie, /// A page is kept as a template, by the template cache, or not at all. #[display("the response is HTML, which is not kept as an asset")] Html, /// The body is larger than a copy holds. #[display("the response is larger than a kept asset holds")] TooLarge, + /// The body outgrew a copy, or failed, while it was read. + #[display("the response could not be read whole")] + NotReadWhole, /// A header cannot be written down to be served again. #[display("a response header cannot be kept")] UnkeptHeader, @@ -8241,8 +8273,8 @@ enum AssetCacheBypassReason { /// The response's `Vary` cannot be read. #[display("origin response Vary cannot be read")] VaryUnreadable, - /// The origin's cache policy does not allow a shared copy, or cannot be - /// read. + /// The origin's status, cookies or cache policy do not allow a shared + /// copy, or the policy cannot be read. #[display("{_0}")] Policy(TemplateCacheBypassReason), } @@ -8265,24 +8297,10 @@ fn asset_cache_ttl( headers: &edgezero_core::http::HeaderMap, max_lifetime: Duration, ) -> Result { - if status != StatusCode::OK { - return Err(AssetCacheBypassReason::NonOkStatus); - } - if headers.contains_key(header::SET_COOKIE) { - return Err(AssetCacheBypassReason::OriginSetCookie); - } + origin_response_may_be_kept(status, headers).map_err(AssetCacheBypassReason::Policy)?; if is_html_content_type(content_type) { return Err(AssetCacheBypassReason::Html); } - if crate::cache_policy::EDGE_CACHE_HEADER_NAMES - .iter() - .filter(|name| **name != "surrogate-control") - .any(|name| headers.contains_key(*name)) - { - return Err(AssetCacheBypassReason::Policy( - TemplateCacheBypassReason::OriginNotShareable, - )); - } origin_shared_ttl(headers, max_lifetime).map_err(AssetCacheBypassReason::Policy) } @@ -8305,15 +8323,15 @@ static NEXT_LOAD: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::n /// Where a load leaves a response the request that started it is served, /// because the origin answered with something no other request may be /// served, so that request is served from the one fetch rather than a -/// second one. The slot lives as long as the request's `HandBack`, so a load -/// that outlives its request hands back nothing. -pub(crate) struct HandBack { - pub(crate) id: u64, +/// second one. The slot lives as long as the request's `HandBack`, on the +/// thread that runs the request. +struct HandBack { + id: u64, slot: std::rc::Rc, } impl HandBack { - pub(crate) fn new() -> Self { + fn new() -> Self { let id = NEXT_LOAD.fetch_add(1, Ordering::Relaxed); let slot = std::rc::Rc::new(std::cell::RefCell::new(None)); HANDED_BACK.with(|handed| { @@ -8324,7 +8342,7 @@ impl HandBack { Self { id, slot } } - pub(crate) fn take(&self) -> Option> { + fn take(&self) -> Option> { self.slot.borrow_mut().take() } } @@ -8344,18 +8362,18 @@ fn hand_back(id: u64, response: Response) { } } -/// What a load owns to fetch one asset, because it may outlive the request -/// that started it. -pub(crate) struct AssetLoad { - pub(crate) services: RuntimeServices, +/// What a load owns to fetch one asset, because a loader owns everything it +/// uses. +struct AssetLoad { + services: RuntimeServices, /// The origin request, already prepared for the origin. Only a `GET` is /// kept, which carries no body. - pub(crate) uri: http::Uri, - pub(crate) headers: http::HeaderMap, - pub(crate) backend_name: String, - pub(crate) stream_response: bool, + uri: http::Uri, + headers: http::HeaderMap, + backend_name: String, + stream_response: bool, /// The longest a copy may be kept, whatever the origin asks. - pub(crate) max_lifetime: Duration, + max_lifetime: Duration, } /// What one fetch of an asset made. @@ -8374,7 +8392,7 @@ struct AssetLoaded { /// [`AssetEntry::NotShareable`](crate::asset_cache::AssetEntry::NotShareable) /// for [`ASSET_NOT_SHAREABLE_LIFETIME`], and handed back to the request whose /// load fetched it through `hand_back_id`, see [`HandBack`]. -pub(crate) fn asset_fetch(load: Arc, hand_back_id: u64) -> crate::source_cache::Fetch { +fn asset_fetch(load: Arc, hand_back_id: u64) -> crate::source_cache::Fetch { Arc::new(move || { let load = Arc::clone(&load); Box::pin(async move { @@ -8395,6 +8413,34 @@ pub(crate) fn asset_fetch(load: Arc, hand_back_id: u64) -> crate::sou }) } +/// `body` read whole when it holds at most `limit` bytes, or else the body to +/// serve in its place, being what was read so far and then the rest. +async fn read_whole_or_hand_back(body: EdgeBody, limit: usize) -> Result { + let mut stream = match body { + EdgeBody::Once(bytes) if bytes.len() > limit => return Err(EdgeBody::Once(bytes)), + EdgeBody::Once(bytes) => return Ok(bytes), + EdgeBody::Stream(stream) => stream, + }; + let mut read = Vec::new(); + while let Some(chunk) = stream.next().await { + let failed = match chunk { + Ok(chunk) => { + read.extend_from_slice(&chunk); + if read.len() <= limit { + continue; + } + None + } + Err(error) => Some(Err(error)), + }; + let head = std::iter::once(Ok(bytes::Bytes::from(read))).chain(failed); + return Err(EdgeBody::Stream( + futures::stream::iter(head).chain(stream).boxed_local(), + )); + } + Ok(read.into()) +} + /// Fetches an asset and keeps it as the origin sent it, with the time it may /// be used for, or no copy when it may not be shared. /// @@ -8477,14 +8523,14 @@ async fn load_asset( .flatten() .and_then(|value| parse_delta_seconds(value).ok()) .unwrap_or(0); - let raw = match body - .into_bytes_bounded(crate::asset_cache::MAX_ASSET_COPY_BYTES) - .await - { + let raw = match read_whole_or_hand_back(body, crate::asset_cache::MAX_ASSET_COPY_BYTES).await { Ok(raw) => raw, - Err(error) => { - log::debug!("asset_cache bypass: the response could not be read whole ({error})"); - return Ok(AssetLoaded { vary, copy: None }); + Err(body) => { + log::debug!( + "asset_cache bypass: {}", + AssetCacheBypassReason::NotReadWhole + ); + return not_shareable(vary, parts, body); } }; let lifetime = ttl.saturating_sub(made.elapsed()); @@ -8510,31 +8556,17 @@ async fn load_asset( mod tests { mod asset_cache_tests { use super::*; - use crate::platform::ClientInfo; use crate::platform::test_support::{ - LifetimeRecordingCache, NoopConfigStore, NoopGeo, NoopSecretStore, StubBackend, - StubHttpClient, + MemoryLoadingCache, StubHttpClient, asset_copy_lifetimes, + build_services_with_http_client, }; fn services( stub: &Arc, - cache: &Arc, + cache: &Arc, ) -> RuntimeServices { - RuntimeServices::builder() - .config_store(Arc::new(NoopConfigStore)) - .secret_store(Arc::new(NoopSecretStore)) - .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) - .backend(Arc::new(StubBackend)) - .http_client(Arc::clone(stub) as _) - .geo(Arc::new(crate::platform::PlatformGeoModule::new( - "noop", - Arc::new(NoopGeo), - ))) - .client_info(ClientInfo::default()) - .loading_caches(Arc::new(crate::platform::LoadingCaches::every( - Arc::clone(cache) as _, - ))) - .build() + build_services_with_http_client(Arc::clone(stub) as _) + .with_loading_cache(Arc::clone(cache) as _) } fn settings() -> Arc { @@ -8614,7 +8646,7 @@ mod tests { #[tokio::test] async fn an_image_is_kept_as_the_origin_sent_it() { let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(LifetimeRecordingCache::default()); + let cache = Arc::new(MemoryLoadingCache::default()); let services = services(&stub, &cache); let settings = settings(); let image = [0x89_u8, b'P', b'N', b'G', 0, 1, 2, 3]; @@ -8641,7 +8673,7 @@ mod tests { #[tokio::test] async fn a_stylesheet_is_served_from_one_fetch_with_its_age() { let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(LifetimeRecordingCache::default()); + let cache = Arc::new(MemoryLoadingCache::default()); let services = services(&stub, &cache); let settings = settings(); queue( @@ -8682,7 +8714,7 @@ mod tests { ("public, max-age=300, no-store", None, false), ] { let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(LifetimeRecordingCache::default()); + let cache = Arc::new(MemoryLoadingCache::default()); let services = services(&stub, &cache); let settings = settings(); for _ in 0..2 { @@ -8699,7 +8731,7 @@ mod tests { "Cache-Control {cache_control} and Vary {vary:?}" ); assert_eq!( - cache.copy_lifetimes().is_empty(), + asset_copy_lifetimes(&cache).is_empty(), !kept, "Cache-Control {cache_control} and Vary {vary:?}" ); @@ -8714,7 +8746,7 @@ mod tests { ("public, s-maxage=600, max-age=30", 600), ] { let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(LifetimeRecordingCache::default()); + let cache = Arc::new(MemoryLoadingCache::default()); let services = services(&stub, &cache); queue( &stub, @@ -8727,7 +8759,7 @@ mod tests { run(&settings(), &services, asset_request("/site.css", "style")).await; let _ = body_of(response).await; - let lifetimes = cache.copy_lifetimes(); + let lifetimes = asset_copy_lifetimes(&cache); assert_eq!(lifetimes.len(), 1, "{cache_control}"); let expected = Duration::from_secs(seconds); assert!( @@ -8741,7 +8773,7 @@ mod tests { #[tokio::test] async fn a_header_the_origin_varies_on_separates_kept_copies() { let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(LifetimeRecordingCache::default()); + let cache = Arc::new(MemoryLoadingCache::default()); let services = services(&stub, &cache); let settings = settings(); for edition in ["red", "blue"] { @@ -8776,10 +8808,87 @@ mod tests { ); } + #[tokio::test] + async fn a_body_that_outgrows_a_copy_is_served_from_the_one_fetch() { + for streaming in [false, true] { + let stub = Arc::new(StubHttpClient::new()); + stub.set_streaming_responses_supported(streaming); + let cache = Arc::new(MemoryLoadingCache::default()); + let services = services(&stub, &cache); + let settings = settings(); + let large = vec![b'a'; crate::asset_cache::MAX_ASSET_COPY_BYTES + 1]; + for _ in 0..2 { + queue( + &stub, + "text/plain", + &large, + &[("cache-control", "public, max-age=300")], + ); + } + + let response = run(&settings, &services, asset_request("/big.txt", "empty")).await; + + assert_eq!( + body_of(response).await, + large, + "streaming {streaming}: the reader is served the whole body" + ); + assert_eq!( + stub.recorded_request_uris().len(), + 1, + "streaming {streaming}: a body with no Content-Length that outgrows a copy \ + is served from the one fetch" + ); + assert!(asset_copy_lifetimes(&cache).is_empty()); + } + } + + #[tokio::test] + async fn a_response_whose_vary_changed_is_served_from_the_one_fetch() { + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryLoadingCache::default()); + let services = services(&stub, &cache); + let settings = settings(); + for (edition, vary) in [ + ("red", "X-Edition"), + ("blue", "X-Region"), + ("blue", "X-Region"), + ] { + queue( + &stub, + "text/css", + format!("/* {edition} */").as_bytes(), + &[("cache-control", "public, max-age=300"), ("vary", vary)], + ); + } + let request = |edition: &str| { + let mut request = asset_request("/site.css", "style"); + request.headers_mut().insert( + "x-edition", + HeaderValue::from_str(edition).expect("should be a header value"), + ); + request + }; + + let red = body_of(run(&settings, &services, request("red")).await).await; + let blue = body_of(run(&settings, &services, request("blue")).await).await; + + assert_eq!(red, b"/* red */"); + assert_eq!( + blue, b"/* blue */", + "the request whose fetch found the new Vary is served that fetch" + ); + assert_eq!( + stub.recorded_request_uris().len(), + 2, + "each request fetches the origin once" + ); + } + #[tokio::test] async fn a_request_carrying_authorization_is_fetched_for_itself() { let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(LifetimeRecordingCache::default()); + let cache = Arc::new(MemoryLoadingCache::default()); let services = services(&stub, &cache); let settings = settings(); for _ in 0..2 { @@ -8802,13 +8911,13 @@ mod tests { 2, "an authenticated request never shares a copy" ); - assert!(cache.copy_lifetimes().is_empty()); + assert!(asset_copy_lifetimes(&cache).is_empty()); } #[tokio::test] async fn a_page_navigation_never_reads_an_asset_copy() { let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(LifetimeRecordingCache::default()); + let cache = Arc::new(MemoryLoadingCache::default()); let services = services(&stub, &cache); let settings = settings(); for _ in 0..2 { @@ -8830,7 +8939,7 @@ mod tests { 2, "an inline page is fetched for each reader" ); - assert!(cache.copy_lifetimes().is_empty()); + assert!(asset_copy_lifetimes(&cache).is_empty()); } } diff --git a/crates/trusted-server-core/src/source_cache.rs b/crates/trusted-server-core/src/source_cache.rs index 30093d20b9..222fa7503b 100644 --- a/crates/trusted-server-core/src/source_cache.rs +++ b/crates/trusted-server-core/src/source_cache.rs @@ -5,15 +5,15 @@ //! an address records the request headers its `Vary` names, and that record is //! held in the loading cache like any other value. A later request for the //! address reads the record and is served the copy kept under its own values -//! for those headers, as RFC 9111 section 4.1 describes, together with what -//! the publisher's rule adds for inputs the source varies on without saying -//! so. +//! for those headers, as RFC 9111 section 4.1 describes. //! //! Requests for an address no record covers share one load. The request that //! leads it fetches the source and writes its copy under its own values before //! the record is published, so a waiting request with the same values is //! served that copy, and one with other values loads its own, sharing that -//! load with every request waiting on the same values. +//! load with every request waiting on the same values. The request whose fetch +//! ran is served what it fetched, whatever the cache kept, so no request +//! fetches the source twice. //! //! A response whose `Vary` is `*`, names `Cookie`, or cannot be read can be //! served to no other request, so its record says no copy is kept, and every @@ -21,7 +21,7 @@ use std::future::Future; use std::pin::Pin; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::Duration; use bytes::Bytes; @@ -31,6 +31,7 @@ use sha2::{Digest as _, Sha256}; use crate::platform::{ CacheSpec, LoadError, LoadedValue, Loader, RuntimeServices, ServerKeyPrefix, StoreKey, + hash_length_prefixed as push, }; /// What a source's response says it varies on, read from its `Vary`. @@ -185,11 +186,6 @@ impl SourceAddress { /// to the source, so they give different digests, as do repeated values in a /// different order. fn variant(names: &[String], headers: &HeaderMap, declared: Option<&str>) -> String { - fn push(hasher: &mut Sha256, part: &[u8]) { - hasher.update((part.len() as u64).to_be_bytes()); - hasher.update(part); - } - let mut hasher = Sha256::new(); push(&mut hasher, b"torque-variant-v1"); push(&mut hasher, &(names.len() as u64).to_be_bytes()); @@ -271,6 +267,21 @@ pub struct Lookup<'a> { pub not_shareable: &'a Bytes, } +/// Where a load leaves the copy its own fetch made, for the request whose +/// fetch it was. +type Own = Arc>>; + +fn keep_own(own: &Own, copy: &Bytes) { + *own.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(copy.clone()); +} + +fn take_own(own: &Own) -> Option { + own.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() +} + /// What a lookup found. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Found { @@ -287,20 +298,22 @@ pub enum Found { } /// The copy for `lookup`'s request, from the record and copy caches or from -/// `fetch`. +/// `fetch`. When this request's own fetch ran, it is served what that fetch +/// made, even when the cache keeps it for nobody. /// /// # Errors /// /// The error of a fetch this request ran or waited on. Every request waiting /// on that fetch receives it, and nothing is kept. pub async fn get_or_fetch(lookup: &Lookup<'_>, fetch: Fetch) -> Result { + let own = Own::default(); let record_key = lookup.address.record_key(lookup.cache); let record = lookup .services .load_cached( &lookup.cache.records, &record_key, - record_loader(lookup, Arc::clone(&fetch)), + record_loader(lookup, Arc::clone(&fetch), Arc::clone(&own)), ) .await?; let Some(record) = AddressRecord::decode(&record) else { @@ -320,16 +333,22 @@ pub async fn get_or_fetch(lookup: &Lookup<'_>, fetch: Fetch) -> Result, fetch: Fetch) -> Result, fetch: Fetch) -> Loader { +fn record_loader(lookup: &Lookup<'_>, fetch: Fetch, own: Own) -> Loader { let services = lookup.services.clone(); let cache = lookup.cache.clone(); let address = lookup.address.clone(); @@ -346,6 +365,7 @@ fn record_loader(lookup: &Lookup<'_>, fetch: Fetch) -> Loader { Box::new(move || { Box::pin(async move { let fetched = fetch().await?; + keep_own(&own, &fetched.copy); let SourceVary::Headers(names) = fetched.vary else { return Ok(LoadedValue::new(AddressRecord::NoCopies.encode()) .with_lifetime(fetched.lifetime)); @@ -372,9 +392,10 @@ fn record_loader(lookup: &Lookup<'_>, fetch: Fetch) -> Loader { /// A copy's load, which fetches the source for this request's values. /// /// A response that no longer varies on what the record names cannot be kept -/// under a key the record made, so no request is served it, and the record is -/// dropped so the next request learns what the source varies on now. -fn copy_loader(lookup: &Lookup<'_>, names: Vec, fetch: Fetch) -> Loader { +/// under a key the record made, so only the request that fetched it is served +/// it, and the record is dropped so the next request learns what the source +/// varies on now. +fn copy_loader(lookup: &Lookup<'_>, names: Vec, fetch: Fetch, own: Own) -> Loader { let services = lookup.services.clone(); let cache = lookup.cache.clone(); let address = lookup.address.clone(); @@ -382,6 +403,7 @@ fn copy_loader(lookup: &Lookup<'_>, names: Vec, fetch: Fetch) -> Loader Box::new(move || { Box::pin(async move { let fetched = fetch().await?; + keep_own(&own, &fetched.copy); if fetched.vary != SourceVary::Headers(names) { log::debug!( "source_cache: the source no longer varies as its record says, so the \ @@ -400,76 +422,18 @@ fn copy_loader(lookup: &Lookup<'_>, names: Vec, fetch: Fetch) -> Loader #[cfg(test)] mod tests { - use std::collections::HashMap; - use std::sync::Mutex; use std::sync::atomic::{AtomicUsize, Ordering}; use http::HeaderValue; use super::*; use crate::platform::{ - CacheLifetimes, ClientInfo, PlatformLoadingCache, - test_support::{NoopConfigStore, NoopGeo, NoopSecretStore, StubBackend, StubHttpClient}, + CacheLifetimes, + test_support::{MemoryLoadingCache, StubHttpClient, build_services_with_http_client}, }; - /// A loading cache that keeps every value with a lifetime under its key - /// and runs each load in its caller. - #[derive(Default)] - struct MemoryCache { - values: Mutex>, - } - - #[async_trait::async_trait(?Send)] - impl PlatformLoadingCache for MemoryCache { - async fn get_or_load( - &self, - _services: &RuntimeServices, - _spec: &CacheSpec, - key: &StoreKey, - load: Loader, - ) -> Result { - let key = key.to_string(); - let held = self - .values - .lock() - .expect("should lock the values") - .get(&key) - .cloned(); - if let Some(value) = held { - return Ok(value); - } - let loaded = load().await?; - if loaded.lifetime.is_none_or(|lifetime| !lifetime.is_zero()) { - self.values - .lock() - .expect("should lock the values") - .insert(key, loaded.bytes.clone()); - } - Ok(loaded.bytes) - } - - async fn remove(&self, _services: &RuntimeServices, _spec: &CacheSpec, key: &StoreKey) { - self.values - .lock() - .expect("should lock the values") - .remove(&key.to_string()); - } - } - - fn services(cache: Arc) -> RuntimeServices { - RuntimeServices::builder() - .config_store(Arc::new(NoopConfigStore)) - .secret_store(Arc::new(NoopSecretStore)) - .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) - .backend(Arc::new(StubBackend)) - .http_client(Arc::new(StubHttpClient::new())) - .geo(Arc::new(crate::platform::PlatformGeoModule::new( - "noop", - Arc::new(NoopGeo), - ))) - .client_info(ClientInfo::default()) - .loading_caches(Arc::new(crate::platform::LoadingCaches::every(cache))) - .build() + fn services(cache: Arc) -> RuntimeServices { + build_services_with_http_client(Arc::new(StubHttpClient::new())).with_loading_cache(cache) } fn spec(name: &'static str) -> CacheSpec { @@ -646,7 +610,7 @@ mod tests { #[tokio::test] async fn the_first_response_records_its_vary_and_each_value_gets_its_own_copy() { - let memory = Arc::new(MemoryCache::default()); + let memory = Arc::new(MemoryLoadingCache::default()); let services = services(Arc::clone(&memory)); let fetches = Arc::new(AtomicUsize::new(0)); let vary = SourceVary::Headers(vec!["x-edition".to_owned()]); @@ -677,7 +641,7 @@ mod tests { #[tokio::test] async fn a_response_that_allows_no_copies_has_every_request_fetch_its_own() { - let memory = Arc::new(MemoryCache::default()); + let memory = Arc::new(MemoryLoadingCache::default()); let services = services(Arc::clone(&memory)); let fetches = Arc::new(AtomicUsize::new(0)); let request = headers(&[("x-edition", "red")]); @@ -701,7 +665,7 @@ mod tests { #[tokio::test] async fn a_source_that_changes_its_vary_has_its_record_dropped() { - let memory = Arc::new(MemoryCache::default()); + let memory = Arc::new(MemoryLoadingCache::default()); let services = services(Arc::clone(&memory)); let fetches = Arc::new(AtomicUsize::new(0)); let red = headers(&[("x-edition", "red")]); @@ -732,16 +696,21 @@ mod tests { assert_eq!( copy(changed), - Some(Bytes::from_static(b"not shareable")), - "a copy made under another Vary is served to nobody" + Some(Bytes::from("edition blue")), + "the request that fetched a copy made under another Vary is served it" + ); + assert_eq!( + fetches.load(Ordering::SeqCst), + 2, + "that request fetches the source once" + ); + let keys = memory.keys(); + assert!( + keys.iter() + .filter_map(|key| memory.held(key)) + .all(|(value, _)| value != "edition blue"), + "no other request is served that copy" ); - let keys: Vec = memory - .values - .lock() - .expect("should lock the values") - .keys() - .cloned() - .collect(); assert!( keys.iter().all(|key| !key.contains("/test-vary/")), "the record is dropped so the next request learns the new Vary: {keys:?}" @@ -750,7 +719,7 @@ mod tests { #[tokio::test] async fn the_publishers_rule_separates_copies_the_source_does_not() { - let memory = Arc::new(MemoryCache::default()); + let memory = Arc::new(MemoryLoadingCache::default()); let services = services(Arc::clone(&memory)); let fetches = Arc::new(AtomicUsize::new(0)); let request = HeaderMap::new(); diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 04c0671561..89cab12509 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1772,7 +1772,8 @@ anything but a `200`, HTML, a response carrying `Set-Cookie`, one whose policy says `private`, `no-store` or `no-cache` or gives no lifetime, one that varies on `*` or on `Cookie`, one larger than a megabyte, an answer the platform's image optimizer makes, and the answer to a request carrying `Authorization`, a -conditional header or a range. A response that may not be shared is served to +conditional header, a range, or a `Cache-Control` of `no-cache`, `no-store`, +`min-fresh` or a nonzero `max-age`. A response that may not be shared is served to the request whose load fetched it from that one fetch. | Table | Field | Default | Range | Description | From 67af14ea42b9d3072dae3f806f3ce6ab51b53bde Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 18:24:20 +0100 Subject: [PATCH 05/23] The asset cache design Describes the asset cache as the code at f6e598fda builds it. It covers the key and what goes into it, when a stored asset is used, what is stored and served, the one table that selects both caches, each adapter, consent and privacy, failure handling and rollout for existing deployments. It lists the tests that exist and the joins that have none. --- .../specs/2026-10-15-asset-cache-design.md | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-15-asset-cache-design.md diff --git a/docs/superpowers/specs/2026-10-15-asset-cache-design.md b/docs/superpowers/specs/2026-10-15-asset-cache-design.md new file mode 100644 index 0000000000..0cf2999136 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-asset-cache-design.md @@ -0,0 +1,185 @@ +# Design Spec: The publisher's assets held through the loading cache + +**Status:** Proposed, 2026-10-15. Written after the code, for draft pull request IABTechLab/trusted-server#1375 +**Author:** 51Degrees (contributed), for Tech Lab review +**Related specs:** `2026-10-12-loading-cache-design.md`, `2026-09-15-852-template-and-origin-caching-design.md`, `2026-04-28-multi-backend-asset-proxy-design.md`, `2026-05-19-asset-s3-auth-fastly-io-design.md` +**Related PRs:** IABTechLab/trusted-server#1375, reviewed on jwrosewell/trusted-server#29 +**Issues:** IABTechLab/trusted-server#1324 +**Last updated:** 2026-10-15 + +> **Why this spec exists.** A file that is not a page, whether the publisher's origin sends it or an asset route fetches it, is asked of its origin on every request and by every instance, so a burst of readers for one file is a burst of fetches. + +Every design document of the stack is in IABTechLab/trusted-server#1342, including the specs named above. + +## 1. Problem + +`handle_publisher_request` in `crates/trusted-server-core/src/publisher.rs` keeps a page as a template in the template cache and sends every other request to the publisher's origin. `handle_asset_proxy_request` in `crates/trusted-server-core/src/proxy.rs` fetches each file of an asset route from the route's origin. Core keeps no copy of either kind of file. A deployment with a host cache in front of core already has its files held there. A deployment with nothing in front of core fetches every file from the origin for every reader, on every instance. + +The loading cache that core declares (`2026-10-12-loading-cache-design.md`) loads a missing value once for all the requests that ask for it at the same moment, and gives each of them the value. Each feature that holds answers this way selects its cache in its own `[cache.]` table. The robots.txt answers were the first feature to do so. Assets are the second. + +## 2. Design + +Core holds the origin's response for a file in two loading caches, `assets-vary` and `assets`, and the one table `[cache.assets]` selects both. The publisher path and the asset routes both call `asset_from_cache` in `publisher.rs`. That function answers with a stored copy, or with the response its own fetch made, or with nothing, and a caller that gets nothing asks the origin as it did before this change. + +**Where the code is.** + +| File | What it holds | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/source_cache.rs` | `get_or_fetch`, which finds the copy for a request through the record and the copy caches, and the record of which request headers an address varies on | +| `crates/trusted-server-core/src/asset_cache.rs` | `AssetCacheKey`, `AssetCopy` and `AssetEntry`, which say what is stored, how it is written and read, and how a copy is served | +| `crates/trusted-server-core/src/publisher.rs` | The gate in `handle_publisher_request`, then `asset_from_cache`, `load_asset`, `asset_cache_ttl`, `HandBack` and `origin_response_may_be_kept`, which `template_cache_ttl` now calls as well | +| `crates/trusted-server-core/src/proxy.rs` | `asset_route_copy_key`, and the lookup in `handle_asset_proxy_request` | +| `crates/trusted-server-core/src/settings.rs` | `CacheSettings.assets`, `ASSETS_CACHE`, `ASSETS_VARY_CACHE`, and `CacheSettings::loading_caches`, which now lists both names | +| `crates/trusted-server-core/src/integrations/mod.rs` | `declared_caches`, which declares both names as core's own | +| `crates/trusted-server-core/src/platform/` | `hash_length_prefixed` in `template_cache.rs`, which `TemplateCacheKey::to_cache_key` and `AssetCacheKey::to_cache_key` share, and `MemoryLoadingCache::held` and `asset_copy_lifetimes` in `test_support.rs` | + +**The key.** `AssetCacheKey` has five inputs, and the two callers fill them differently. + +| Input | Publisher path | Asset route | +| ----------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `url` | The URL asked of the origin, with its query | The route's target URL, after the route's query policy | +| `request_host` | The host the reader asked | The host of the request URI, else its `Host` header | +| `request_scheme` | The scheme the reader asked over | The scheme of the request URI, `https` when it has none | +| `origin_identity` | The publisher's `origin_url` and the `Host` sent to the origin, joined by a NUL byte | The route's `origin_url` and `prefix`, joined by a NUL byte | +| `fingerprint` | `template_fingerprint`, a digest of the build, the script bundle, the public prefix and the serialized settings | Empty | + +`AssetCacheKey::to_cache_key` writes a schema version and the five inputs into one SHA-256, each with its length before it, and returns `ts-asset-cache-v1-` and the digest in hex. No input appears in the key. The route's `origin_identity` carries the route's prefix, so two routes to one address keep their own copies. The reader's host and scheme are in the key although the copy is kept as the origin sent it, so two hosts that serve one publisher each keep a copy. + +`asset_address` joins that digest to the publisher's prefix (`services.server_key_prefix()`) as a `SourceAddress`. Every key in the store starts with the prefix, so two publishers sharing a store never share a record or a copy. + +An address has one record, in `assets-vary`, and any number of copies, in `assets`. `SourceVary::of` reads the origin's `Vary` into the record as lowercase header names, sorted and each named once. A copy's key is the address and a 128-bit digest (`variant` in `source_cache.rs`) of the request's values for exactly those headers, as RFC 9111 section 4.1 describes. The digest tells apart an absent header, an empty one, and repeated values in a different order. The request headers are those sent to the origin. On the publisher path they are the headers after `rewrite_origin_request` has restricted `Accept-Encoding`. On an asset route they are the headers built in `handle_asset_proxy_request`, which include the route's own credentials when the route has `auth`. + +**When a stored asset is used.** The publisher path looks for a copy only when every one of these holds, as `request_can_use_asset_copy` in `handle_publisher_request` computes them. + +1. The method is `GET`. +2. The request is not for a document. `is_html_document_request` reads `Sec-Fetch-Dest`, where `document`, `embed`, `fencedframe`, `frame`, `iframe` and `object` count as documents, and falls back to the navigation check when the header is absent. +3. The request names a host. +4. The request carries no `Authorization` header. +5. `request_bypasses_template_cache` finds none of `Range`, `If-Range`, `If-Match`, `If-None-Match`, `If-Modified-Since` or `If-Unmodified-Since`, and no `Cache-Control` that asks for the origin, being `no-cache`, `no-store`, `min-fresh`, an unreadable value, or a `max-age` that is not zero. +6. No module left state on the request, and no integration marked the response as personalized. +7. The loading cache that `[cache.assets]` selects keeps values (`PlatformLoadingCache::keeps_values`). `NoLoadingCache` answers false. + +An asset route looks for a copy when `asset_route_copy_key` finds the method `GET`, no `Authorization`, none of the conditions in item 5, a host in the request URI or the `Host` header, and a cache that keeps values, and when no image optimizer applies to the request. The route does not check items 2 and 6. A request that fails any check is served exactly as before this change. + +**What is stored.** `load_asset` fetches the file with `GET` and the prepared request's headers, then tests the response in this order. The first test the response fails ends the checks. The response is then not stored for other readers, and the request whose load fetched it is served that response. + +1. `Vary` is `*`, names `Cookie`, or cannot be read. +2. `asset_cache_ttl` refuses the response, which happens when the status is not `200`, when the response sets a cookie, when it carries an edge cache header other than `Surrogate-Control`, when its content type is HTML, or when `origin_shared_ttl` finds no usable lifetime. That function refuses `private`, `no-store` and `no-cache`, a repeated or unreadable `max-age` or `s-maxage`, and a response with no lifetime left. Otherwise the lifetime comes from `Surrogate-Control`, else `s-maxage`, else `max-age`, else `Expires` measured from `Date`. The current age is taken off, being the larger of the `Age` header and the age `Date` implies, and `store_seconds` caps the result. The response does not need `public`. +3. `Content-Length` is over 1 MiB (`MAX_ASSET_COPY_BYTES`). +4. A header value is not visible text and so cannot be written down (`AssetCopy::kept_headers`). +5. The body outgrows 1 MiB, or fails, while `read_whole_or_hand_back` reads it. + +A stored copy is the origin's response, headers and body, as the origin sent it, with its `Content-Encoding` left on. `AssetCopy::decoded` is always false. The headers `age`, `connection`, `content-length`, `keep-alive`, `proxy-authenticate`, `proxy-authorization`, `proxy-connection`, `set-cookie`, `te`, `trailer`, `transfer-encoding` and `upgrade` are never written. The copy and the record are held for the lifetime from item 2, less the time the fetch took. A response that may not be shared is held as the marker `AssetEntry::NotShareable` for 60 seconds (`ASSET_NOT_SHAREABLE_LIFETIME`), so the requests that follow do not wait on a load that would end the same way. Each of those requests asks the origin itself. + +**What is served.** `AssetCopy::into_response` builds a `200` response from the kept headers, then sets `Age` to the origin's age at the time of the fetch plus the seconds since, and sets `Content-Length`. The kept headers include `Vary`, so a cache further along separates readers as the origin asked. On both paths the copy takes the place of the origin's response, and everything after that point is unchanged. On the publisher path the copy is therefore processed as an origin response is, and the copy is the origin's bytes and not a rewritten body. + +**One fetch for many requests.** `get_or_fetch` loads the address's record through `assets-vary`. The request that leads that load fetches the origin and writes its own copy under its own values before the record is published, so a waiting request with the same values finds the copy. A waiting request with other values loads its own copy, and the requests waiting on those values share that load. Both caches are built with `detachable: false`, so a load runs in the request that started it. + +**Serving the request whose load fetched.** A response that cannot be shared is not stored for others, yet the request that fetched it must not fetch again. `HandBack` carries the response from the load to that request. `asset_from_cache` makes one `HandBack` for each lookup, `load_asset` puts a response that cannot be shared in the slot by its id, and the lookup ends by taking the slot's response when it finds `AssetEntry::NotShareable` or `Found::NoCopies`. A request that did not lead the load finds the slot empty and asks the origin. Two other cases use the same path. A body that outgrows a copy while it is read is handed back as what was read so far followed by the rest of the stream. A response whose `Vary` no longer matches its record is handled by `copy_loader`, which removes the record, writes the marker with no time to live, and serves the request what its own fetch returned, so the next request learns the new `Vary`. + +**Configuration.** One optional table selects and sizes both caches. + +```toml +[cache.assets] +module = "51degrees" +memory_seconds = 5 +store_seconds = 3600 +store_idle_seconds = 86400 +``` + +| Field | Default | Range in the guide | Meaning | +| -------------------- | --------------- | ------------------ | ---------------------------------------------------------- | +| `module` | none | | The loading cache module that holds both caches | +| `memory_seconds` | `5` | 0 to 31536000 | How long a copy in process memory is used. Zero keeps none | +| `store_seconds` | `3600`, an hour | 1 to 31536000 | The longest a copy in the shared store is used | +| `store_idle_seconds` | `86400`, a day | 1 to 31536000 | How long a copy in the shared store lasts unused | + +The defaults are `ASSET_DEFAULT_LIFETIMES` in `publisher.rs`. The memory layer holds at most 256 records and 32 copies of at most 1 MiB each. Those sizes are constants in `asset_cache` and not settings. `LoadingCacheConfig` refuses a field it does not know. + +`loading_caches_from` in `integrations/mod.rs` makes the selection. `CacheSettings::loading_caches` lists `robots-txt`, then `assets` and `assets-vary`, both with the settings of `[cache.assets]`, then the tables modules declare. A table that names a module takes that module, by short or full name. A table that names none, and an absent table, take the one loading cache module the build links, and hold nothing when the build links none or several. A table that names a module no linked crate offers is refused when the service starts, with the modules the build has. `declared_caches` declares `assets` and `assets-vary` as core's own, so a module that declares either name is refused, and the error that lists a build's caches now reads `robots-txt, assets, assets-vary` first. + +The range check, `LoadingCacheConfig::check`, runs in `CacheSettings::prepare_runtime` for `[cache.robots-txt]` and for each table a module declares. The check does not run for `[cache.assets]`. The ranges in the table are those the guide gives, and nothing in this change enforces them for `[cache.assets]`. There is no `[cache.assets-vary]` field. A table of that name would be read as one a module declares, and its lifetimes would not be read. + +**How each adapter takes it.** No adapter code changes. Each adapter calls `loading_caches_from` when it builds its application state and hands the result to each request's services, so the selection reaches `asset_from_cache` through `RuntimeServices::loading_cache_for`. + +| Adapter | Where the selection is made and handed on | Shared store the stock module uses | How the origin body arrives | +| ---------- | ------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------- | +| Fastly | `crates/trusted-server-adapter-fastly/src/app.rs`, handed on with `.loading_caches(...)` | Fastly's cache, through `FastlyValueCache` | Streamed, and read up to 1 MiB | +| Axum | `crates/trusted-server-adapter-axum/src/app.rs`, handed on with `.with_loading_caches(...)` | The host's key-value store | Whole, as the adapter does not stream | +| Cloudflare | `crates/trusted-server-adapter-cloudflare/src/app.rs`, with `.with_loading_caches(...)` | The host's key-value store | Whole, as the adapter does not stream | +| Spin | `crates/trusted-server-adapter-spin/src/app.rs`, handed on with `.with_loading_caches(...)` | The host's key-value store | Whole, as the adapter does not stream | + +The stock module, `crates/loading-cache/51degrees`, uses a host's value cache when the services carry one and the key-value store otherwise (`through_store` in `stores.rs`). Only the Fastly adapter registers a value cache. The module's memory layer lives as long as the application state, except on Cloudflare, where one cache lives as long as the isolate. Only the Fastly adapter overrides `supports_streaming_responses`, so the others hand `load_asset` a body that is already whole. + +**Consent and privacy.** The asset caches read no consent signal and set none, and nothing in them identifies a reader. + +- **What is stored** is the origin's response for a `200`, the record of header names, and the marker. No reader's request is stored. The values of the request headers that `Vary` names enter the key only as the 128-bit digest, and no URL, host or header value appears in a key. +- **What is never stored for another reader** is a response that sets a cookie, a response whose policy is `private`, `no-store` or `no-cache` or gives no lifetime, a response that varies on `*` or on `Cookie`, and any response to a request that carries `Authorization`, a conditional header, a range, or a `Cache-Control` that asks for the origin, and on the publisher path any response to a request that carries state a module left or a personalized marker. Those requests also never read a copy. +- **An asset route sends no cookie.** `ASSET_PROXY_FORWARD_HEADERS` lists the eleven request headers an asset origin receives, and `Cookie` and `Authorization` are not among them. An answer from a route's origin therefore cannot depend on a cookie that origin never received. Any reader who asks the route for a path is already given the origin's answer for that path, so a shared copy gives no reader a file the route would not. +- **The publisher path sends the reader's headers on.** `rewrite_origin_request` removes `fastly-ssl`, restricts `Accept-Encoding` and sets `Host`, and `handle_publisher_request` itself does not remove `Cookie`. The key holds only the headers that `Vary` names. An origin that answers a cookie or a header differently without naming it in `Vary`, and gives that answer a lifetime another reader may share, has one reader's answer kept and served to others until the lifetime ends. The cache cannot see such an input, and this change has no setting to declare one (see section 3). +- **Logs.** The lines `asset_cache bypass`, `asset_cache stored` and `asset_cache invalid entry` carry a fixed reason and a byte count, and no URL, key or header. The line `asset_cache load failed` carries the error text of the failed fetch, which includes the platform's error report as the platform formats it, and this change does not filter that text. + +**Failure handling.** Each failure ends with the caller asking the origin, as before this change, or with the reader served the response the request's own fetch made. + +- **The origin cannot be reached during a load.** `load_asset` returns a `LoadError`. Every request waiting on that load receives it, nothing is stored, `asset_from_cache` logs `asset_cache load failed` at warn level, and each of those requests then asks the origin itself. A failing origin is therefore asked once more by each request that waited. +- **A stored entry cannot be read.** `AssetEntry::decode` refuses bytes that are short, unreadable or written in another format. A record this build did not write is refused in the same way. In both cases the lookup logs at warn level, removes the entry, and the request asks the origin. A format version is in the key, in the entry and in the record, so a build that changes the format does not read the old entries. +- **The shared store fails.** The key-value layer treats a failed read as a miss, so the value is loaded, and logs a failed read, write or removal at debug level. The value cache that Fastly provides reports no errors to the module. +- **An error status.** Only a `200` is stored, so an origin outage or a `404` is not held. +- **A body that cannot be read whole.** The reader is served the whole body from the one fetch, as described above. + +**Rollout for existing deployments.** + +1. `[cache.assets]` is optional and is left out when written back (`skip_serializing_if = "LoadingCacheConfig::is_default"`), so settings written before this change read as they did. +2. A build that links no loading cache module holds nothing, and a build that links several and names none holds nothing. Both behave as before. +3. A stock build links exactly one loading cache module, `loading-cache.51degrees`, so it starts holding shareable assets with no change to its settings. A change at the origin then shows after the lifetime the origin gave the file, and never later than `store_seconds`, which defaults to one hour. +4. No setting turns the cache off while exactly one module is linked. `loading_caches_from` has no spelling for "none" and refuses a module name that is not linked. A deployment that does not want its assets held builds its module list without the loading cache module. +5. Every change to the settings and every new build changes the fingerprint, so a publisher path copy made earlier is not found again and ends by its lifetime. Nothing purges it. A route's key has no fingerprint, so a route's copy outlives a settings change, including a change to the route's credentials. +6. The deploy check lists the new cache names. The error that names a misspelt table now includes `assets` and `assets-vary`. + +## 3. What is not in this change + +- **The publisher's declared rule.** An origin can vary on an input it does not name in `Vary`, and only the publisher knows which. `Lookup::declared` in `source_cache.rs` takes a token that enters the copy's key, and one `source_cache` test covers it. Both callers pass `None`, and no setting supplies a value. The rule, as `[publisher.origin_personalization]`, is a later change with its own tests, and until then the origin's own `Vary` alone separates copies. +- **A rewritten body kept once.** The fetch phase runs on each reader's copy. `AssetCopy::decoded` and `SourceVary::without` are for a later change that keeps the rewritten body. Production code leaves `decoded` false and calls `without` nowhere. +- **Pages.** A page stays on the template cache. +- **The `/proxy` first-party route.** The route is unchanged and does not use the asset caches. +- **Answers made by the platform's image optimizer.** The platform holds those answers, so core does not keep them. +- **Requests a copy cannot answer.** `HEAD`, other methods, `Authorization`, ranges and conditional requests always go to the origin. +- **Files over 1 MiB.** A file over 1 MiB is served as it streams from the origin. +- **A way to purge one asset.** This change adds no purge for the asset caches. +- **A setting that turns the asset caches off**, and settings for the memory layer's sizes. +- **Enforcing the ranges for `[cache.assets]`**, as section 2 records. + +## 4. Acceptance + +The tests below exist and use `MemoryLoadingCache` from `crates/trusted-server-core/src/platform/test_support.rs` as the loading cache and `StubHttpClient` as the origin. The pull request records the fork's CI at `f6e598fda` as 19 of 19 checks green. + +| Where | Tests | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `asset_cache.rs` | Every input changes the key and none appears in it. An entry reads back as written. A short, foreign or other-format entry is not read. A copy keeps no connection, cookie or length header. A served copy counts its age from when it was made | +| `source_cache.rs` | Record and copy keys follow the publisher. `Vary` names are lowercase, sorted and named once. `*`, `Cookie` and an unreadable `Vary` allow no copies. A record reads back, and a foreign one does not. A variant tells absent, empty and reordered values apart. The first response records its `Vary` and each value gets its own copy. A response that allows no copies has every request fetch its own. A source that changes its `Vary` has its record dropped. A declared token separates copies | +| `publisher.rs`, module `asset_cache_tests` | An image is kept as sent and one fetch serves three requests. A stylesheet is served from one fetch with its age. `Vary: *`, `Vary: Cookie`, `private`, `no-store` and `public, no-store` are never kept. `s-maxage` then `max-age` set the lifetime. A header named in `Vary` separates copies. A body over 1 MiB is served whole from one fetch, with the stub streaming and not. A response whose `Vary` changed is served from its one fetch. A request carrying `Authorization` is fetched for itself. A page navigation never reads a copy | +| `proxy.rs` | A route's answer is held and serves the next request, with its type, an `Age`, its bytes and the origin's lifetime. An answer with `Set-Cookie` is fetched each time. A range request is fetched. `no-cache`, `no-store` and `min-fresh=10` are fetched. Nothing is held while no cache keeps values | +| `settings.rs`, `integrations/mod.rs`, Axum `seam_probe.rs` | The list of core's caches now holds `assets` and `assets-vary`, in the settings listing, in the refusal that names a misspelt table, and in the Axum deploy check | + +Joins with no test: + +1. **Concurrent requests.** Every test of the asset paths sends requests one after another. The loading cache module has tests for many callers loading a missing key once (`many_callers_on_many_threads_load_a_missing_key_once_in_the_caller`) and for a failed load reaching every waiter (`a_failed_load_reaches_every_waiter_and_is_not_kept`), and those load a generic value and not an asset. +2. **A real shared store.** No test runs the asset caches against Fastly's cache, a key-value store, or two instances. The Axum, Cloudflare and Spin adapters have no test of asset holding. +3. **Selecting `[cache.assets]`.** No test names the table, or checks that both `assets` and `assets-vary` take the one linked module when the table is absent. +4. **The range check.** The code does not run the range check for `[cache.assets]`, and no test covers an out-of-range value. +5. **The publisher path with no loading cache.** No test in `asset_cache_tests` names that case. +6. **Publisher path cases.** No test covers an HTML response to a non-document request, a `Content-Length` over the limit, a response with `Set-Cookie`, a lifetime from `Expires` or `Surrogate-Control`, a request with a conditional header, a range or `Cache-Control`, request state left by a module, a personalized response, a request with no host, or a header that is not visible text. +7. **Asset route cases.** No test covers an image optimizer route, a route with `auth`, a request with `Authorization`, a method other than `GET`, or a request with no host. +8. **Streaming with a stored copy.** The route tests turn streaming off (`held_asset_stub`), and the publisher tests leave it off except in the oversized body test, which stores nothing. A stored copy served on an adapter that streams has no test. +9. **Two publishers in one cache.** The key test in `source_cache.rs` and the module's `two_publishers_never_share_an_entry` cover the prefix, and no test serves two publishers through either asset path. +10. **Reordered page checks.** `origin_response_may_be_kept` moves the status check ahead of the cookie and edge header checks in `template_cache_ttl`. A page response with a non-`200` status and a fault found by a later check is now reported as a non-`200` status, and no test was added for the changed order. + +## Review log + +No review rounds yet. + +## Revision record + +| Date | Change | +| ---------- | ------------------------------------ | +| 2026-10-15 | First draft, written after the code. | From d777cb0ae7c67198d7b0bdfce31942fabbdaa1cb Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 19:37:21 +0100 Subject: [PATCH 06/23] The asset cache design takes its round 1 review The design now says that the asset gate does not apply the cookie rules of the page path, so a request that carries a session cookie can read a stored copy or lead a load whose response is stored for every reader. It gives the reason the code gives, states that the cache is on by default with no off switch, and puts the cookie rule and an off switch under Open questions. The design also gains Pragma in the gate, the verdict kept for a response that fails a test, the module default, where the sizes and the assets-vary table stand, the key-value store each adapter uses, how far one fetch reaches, what the caches add on Fastly, the asset rules, the cost and limits, and the tests that are missing. The review log and the revision record name the round. --- .../specs/2026-10-15-asset-cache-design.md | 102 +++++++++++------- 1 file changed, 65 insertions(+), 37 deletions(-) diff --git a/docs/superpowers/specs/2026-10-15-asset-cache-design.md b/docs/superpowers/specs/2026-10-15-asset-cache-design.md index 0cf2999136..6d02d86313 100644 --- a/docs/superpowers/specs/2026-10-15-asset-cache-design.md +++ b/docs/superpowers/specs/2026-10-15-asset-cache-design.md @@ -2,7 +2,7 @@ **Status:** Proposed, 2026-10-15. Written after the code, for draft pull request IABTechLab/trusted-server#1375 **Author:** 51Degrees (contributed), for Tech Lab review -**Related specs:** `2026-10-12-loading-cache-design.md`, `2026-09-15-852-template-and-origin-caching-design.md`, `2026-04-28-multi-backend-asset-proxy-design.md`, `2026-05-19-asset-s3-auth-fastly-io-design.md` +**Related specs:** `2026-10-12-loading-cache-design.md`, `2026-09-15-852-template-and-origin-caching-design.md`, `2026-04-28-multi-backend-asset-proxy-design.md`, `2026-05-19-asset-s3-auth-fastly-io-design.md`, `2026-09-08-1138-per-cookie-template-cache-policy-design.md` **Related PRs:** IABTechLab/trusted-server#1375, reviewed on jwrosewell/trusted-server#29 **Issues:** IABTechLab/trusted-server#1324 **Last updated:** 2026-10-15 @@ -13,7 +13,7 @@ Every design document of the stack is in IABTechLab/trusted-server#1342, includi ## 1. Problem -`handle_publisher_request` in `crates/trusted-server-core/src/publisher.rs` keeps a page as a template in the template cache and sends every other request to the publisher's origin. `handle_asset_proxy_request` in `crates/trusted-server-core/src/proxy.rs` fetches each file of an asset route from the route's origin. Core keeps no copy of either kind of file. A deployment with a host cache in front of core already has its files held there. A deployment with nothing in front of core fetches every file from the origin for every reader, on every instance. +`handle_publisher_request` in `crates/trusted-server-core/src/publisher.rs` keeps a page as a template in the template cache and sends every other request to the publisher's origin. `handle_asset_proxy_request` in `crates/trusted-server-core/src/proxy.rs` fetches each file of an asset route from the route's origin. Core keeps no copy of either kind of file. A deployment with a host cache in front of core already has its files held there. A deployment with nothing in front of core fetches every file from the origin for every reader, on every instance. On Fastly the platform's readthrough cache answers the origin fetch that core makes, so a file the origin lets that cache hold is already held there (see "What the caches add on Fastly"). The loading cache that core declares (`2026-10-12-loading-cache-design.md`) loads a missing value once for all the requests that ask for it at the same moment, and gives each of them the value. Each feature that holds answers this way selects its cache in its own `[cache.]` table. The robots.txt answers were the first feature to do so. Assets are the second. @@ -49,13 +49,13 @@ Core holds the origin's response for a file in two loading caches, `assets-vary` An address has one record, in `assets-vary`, and any number of copies, in `assets`. `SourceVary::of` reads the origin's `Vary` into the record as lowercase header names, sorted and each named once. A copy's key is the address and a 128-bit digest (`variant` in `source_cache.rs`) of the request's values for exactly those headers, as RFC 9111 section 4.1 describes. The digest tells apart an absent header, an empty one, and repeated values in a different order. The request headers are those sent to the origin. On the publisher path they are the headers after `rewrite_origin_request` has restricted `Accept-Encoding`. On an asset route they are the headers built in `handle_asset_proxy_request`, which include the route's own credentials when the route has `auth`. -**When a stored asset is used.** The publisher path looks for a copy only when every one of these holds, as `request_can_use_asset_copy` in `handle_publisher_request` computes them. +**When a stored asset is used.** The publisher path looks for a copy only when every one of these holds, as `request_can_use_asset_copy` in `handle_publisher_request` computes them. Together they are the asset gate. 1. The method is `GET`. 2. The request is not for a document. `is_html_document_request` reads `Sec-Fetch-Dest`, where `document`, `embed`, `fencedframe`, `frame`, `iframe` and `object` count as documents, and falls back to the navigation check when the header is absent. 3. The request names a host. 4. The request carries no `Authorization` header. -5. `request_bypasses_template_cache` finds none of `Range`, `If-Range`, `If-Match`, `If-None-Match`, `If-Modified-Since` or `If-Unmodified-Since`, and no `Cache-Control` that asks for the origin, being `no-cache`, `no-store`, `min-fresh`, an unreadable value, or a `max-age` that is not zero. +5. `request_bypasses_template_cache` finds none of `Range`, `If-Range`, `If-Match`, `If-None-Match`, `If-Modified-Since` or `If-Unmodified-Since`, no `Cache-Control` that asks for the origin, being `no-cache`, `no-store`, `min-fresh`, an unreadable value, or a `max-age` that is not zero, and no `Pragma` that is `no-cache` or cannot be read. 6. No module left state on the request, and no integration marked the response as personalized. 7. The loading cache that `[cache.assets]` selects keeps values (`PlatformLoadingCache::keeps_values`). `NoLoadingCache` answers false. @@ -69,13 +69,17 @@ An asset route looks for a copy when `asset_route_copy_key` finds the method `GE 4. A header value is not visible text and so cannot be written down (`AssetCopy::kept_headers`). 5. The body outgrows 1 MiB, or fails, while `read_whole_or_hand_back` reads it. -A stored copy is the origin's response, headers and body, as the origin sent it, with its `Content-Encoding` left on. `AssetCopy::decoded` is always false. The headers `age`, `connection`, `content-length`, `keep-alive`, `proxy-authenticate`, `proxy-authorization`, `proxy-connection`, `set-cookie`, `te`, `trailer`, `transfer-encoding` and `upgrade` are never written. The copy and the record are held for the lifetime from item 2, less the time the fetch took. A response that may not be shared is held as the marker `AssetEntry::NotShareable` for 60 seconds (`ASSET_NOT_SHAREABLE_LIFETIME`), so the requests that follow do not wait on a load that would end the same way. Each of those requests asks the origin itself. +A stored copy is the origin's response, headers and body, as the origin sent it, with its `Content-Encoding` left on. `AssetCopy::decoded` is always false. The headers `age`, `connection`, `content-length`, `keep-alive`, `proxy-authenticate`, `proxy-authorization`, `proxy-connection`, `set-cookie`, `te`, `trailer`, `transfer-encoding` and `upgrade` are never written. The copy and the record are held for the lifetime from item 2, less the time the fetch took. The verdict on a response that fails a test is held for 60 seconds (`ASSET_NOT_SHAREABLE_LIFETIME`), as the marker `AssetEntry::NotShareable` under the copy's key, or as the record `NoCopies` when the `Vary` allows no copies, so the requests that follow do not wait on a load that would end the same way. Each of those requests asks the origin itself. A status other than `200` fails test 2, so the body of a `404` is never held, and its verdict is. For those 60 seconds no request that finds the marker stores a copy, even if the origin has begun to answer `200`. **What is served.** `AssetCopy::into_response` builds a `200` response from the kept headers, then sets `Age` to the origin's age at the time of the fetch plus the seconds since, and sets `Content-Length`. The kept headers include `Vary`, so a cache further along separates readers as the origin asked. On both paths the copy takes the place of the origin's response, and everything after that point is unchanged. On the publisher path the copy is therefore processed as an origin response is, and the copy is the origin's bytes and not a rewritten body. +**Asset rules.** The `[[cache.asset_rules]]` entries rewrite the `Cache-Control` and edge cache headers of the response a reader is sent, after a copy is read or the origin is asked (`apply_publisher_asset_cache_policy` on the publisher path and the end of `handle_asset_proxy_request` on a route). The asset gate and the copy's lifetime do not read the rules, so the lifetime comes from the origin's response alone, and a rule that gives a reader a longer lifetime does not lengthen the copy's. + **One fetch for many requests.** `get_or_fetch` loads the address's record through `assets-vary`. The request that leads that load fetches the origin and writes its own copy under its own values before the record is published, so a waiting request with the same values finds the copy. A waiting request with other values loads its own copy, and the requests waiting on those values share that load. Both caches are built with `detachable: false`, so a load runs in the request that started it. -**Serving the request whose load fetched.** A response that cannot be shared is not stored for others, yet the request that fetched it must not fetch again. `HandBack` carries the response from the load to that request. `asset_from_cache` makes one `HandBack` for each lookup, `load_asset` puts a response that cannot be shared in the slot by its id, and the lookup ends by taking the slot's response when it finds `AssetEntry::NotShareable` or `Found::NoCopies`. A request that did not lead the load finds the slot empty and asks the origin. Two other cases use the same path. A body that outgrows a copy while it is read is handed back as what was read so far followed by the rest of the stream. A response whose `Vary` no longer matches its record is handled by `copy_loader`, which removes the record, writes the marker with no time to live, and serves the request what its own fetch returned, so the next request learns the new `Vary`. +**How far one fetch reaches.** The memory layer shares a load among the requests that share the layer. The stock module's layer lives as long as the application state, or on Cloudflare as long as the isolate. Fastly serves one request per instance unless the opt-in reusable sandbox is on, and Spin runs a fresh instance per request (see `AppState` in the Fastly and Spin adapters' `app.rs`), so on those two the memory layer is not shared between requests. Across instances only Fastly makes requests share a load. Only the Fastly adapter registers a value cache, and a lookup in that cache makes every other instance at the same location wait for the instance that fills the key (`crates/trusted-server-adapter-fastly/src/value_cache.rs`), so a missing key is loaded once for each location and not once for the service. Cloudflare and Spin share copies only through a key-value store, which makes no instance wait, so each instance that misses at the same moment fetches the origin itself, and Axum has no shared store at all. + +**Serving the request whose load fetched.** A response that cannot be shared is not stored for others, yet the request that fetched it must not fetch again. `HandBack` carries the response from the load to that request. `asset_from_cache` makes one `HandBack` for each lookup, `load_asset` puts a response that cannot be shared in the slot by its id, and the lookup ends by taking the slot's response when it finds `AssetEntry::NotShareable` or `Found::NoCopies`. A request that did not lead the load finds the slot empty and asks the origin. Two other cases use the same path. A body that outgrows a copy while it is read is handed back as what was read so far followed by the rest of the stream. A response whose `Vary` no longer matches its record is handled by `copy_loader`, which removes the record, writes the marker with a lifetime of zero seconds, and serves the request what its own fetch returned, so the next request learns the new `Vary`. **Configuration.** One optional table selects and sizes both caches. @@ -87,36 +91,43 @@ store_seconds = 3600 store_idle_seconds = 86400 ``` -| Field | Default | Range in the guide | Meaning | -| -------------------- | --------------- | ------------------ | ---------------------------------------------------------- | -| `module` | none | | The loading cache module that holds both caches | -| `memory_seconds` | `5` | 0 to 31536000 | How long a copy in process memory is used. Zero keeps none | -| `store_seconds` | `3600`, an hour | 1 to 31536000 | The longest a copy in the shared store is used | -| `store_idle_seconds` | `86400`, a day | 1 to 31536000 | How long a copy in the shared store lasts unused | +In the table, the guide is the configuration guide, `docs/guide/configuration.md`, under Loading caches. -The defaults are `ASSET_DEFAULT_LIFETIMES` in `publisher.rs`. The memory layer holds at most 256 records and 32 copies of at most 1 MiB each. Those sizes are constants in `asset_cache` and not settings. `LoadingCacheConfig` refuses a field it does not know. +| Field | Default | Range in the guide | Meaning | +| -------------------- | ---------------------------------------------------------------------- | ------------------ | ---------------------------------------------------------- | +| `module` | The one module the build links, and none when it links none or several | | The loading cache module that holds both caches | +| `memory_seconds` | `5` | 0 to 31536000 | How long a copy in process memory is used. Zero keeps none | +| `store_seconds` | `3600`, an hour | 1 to 31536000 | The longest a copy in the shared store is used | +| `store_idle_seconds` | `86400`, a day | 1 to 31536000 | How long a copy in the shared store lasts unused | + +The defaults are `ASSET_DEFAULT_LIFETIMES` in `publisher.rs`. The memory layer holds at most 256 records and 32 copies. Those two counts are literals in `fn asset_cache` in `publisher.rs`, the limit of 1 MiB on a copy's body is the constant `MAX_ASSET_COPY_BYTES` in `asset_cache.rs`, and none of the three is a setting. `LoadingCacheConfig` refuses a field it does not know. `loading_caches_from` in `integrations/mod.rs` makes the selection. `CacheSettings::loading_caches` lists `robots-txt`, then `assets` and `assets-vary`, both with the settings of `[cache.assets]`, then the tables modules declare. A table that names a module takes that module, by short or full name. A table that names none, and an absent table, take the one loading cache module the build links, and hold nothing when the build links none or several. A table that names a module no linked crate offers is refused when the service starts, with the modules the build has. `declared_caches` declares `assets` and `assets-vary` as core's own, so a module that declares either name is refused, and the error that lists a build's caches now reads `robots-txt, assets, assets-vary` first. -The range check, `LoadingCacheConfig::check`, runs in `CacheSettings::prepare_runtime` for `[cache.robots-txt]` and for each table a module declares. The check does not run for `[cache.assets]`. The ranges in the table are those the guide gives, and nothing in this change enforces them for `[cache.assets]`. There is no `[cache.assets-vary]` field. A table of that name would be read as one a module declares, and its lifetimes would not be read. +The range check, `LoadingCacheConfig::check`, runs in `CacheSettings::prepare_runtime` for `[cache.robots-txt]` and for each table a module declares. The check does not run for `[cache.assets]`, so nothing enforces the ranges in the table for it. There is no `[cache.assets-vary]` field. A table of that name is read as one a module declares. The deploy check accepts it, because `assets-vary` is a name core declares, and `prepare_runtime` range checks its lifetimes. Its `module`, when it names one, replaces the module `[cache.assets]` selected for the record cache, because `loading_caches_from` handles the table after the entry made from `[cache.assets]` and the later entry wins. Its lifetimes are never used, because `asset_cache` builds both caches from `[cache.assets]`. **How each adapter takes it.** No adapter code changes. Each adapter calls `loading_caches_from` when it builds its application state and hands the result to each request's services, so the selection reaches `asset_from_cache` through `RuntimeServices::loading_cache_for`. -| Adapter | Where the selection is made and handed on | Shared store the stock module uses | How the origin body arrives | -| ---------- | ------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------- | -| Fastly | `crates/trusted-server-adapter-fastly/src/app.rs`, handed on with `.loading_caches(...)` | Fastly's cache, through `FastlyValueCache` | Streamed, and read up to 1 MiB | -| Axum | `crates/trusted-server-adapter-axum/src/app.rs`, handed on with `.with_loading_caches(...)` | The host's key-value store | Whole, as the adapter does not stream | -| Cloudflare | `crates/trusted-server-adapter-cloudflare/src/app.rs`, with `.with_loading_caches(...)` | The host's key-value store | Whole, as the adapter does not stream | -| Spin | `crates/trusted-server-adapter-spin/src/app.rs`, handed on with `.with_loading_caches(...)` | The host's key-value store | Whole, as the adapter does not stream | +| Adapter | Where the selection is made and handed on | Shared store the stock module uses | How the origin body arrives | +| ---------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------- | +| Fastly | `crates/trusted-server-adapter-fastly/src/app.rs`, handed on with `.loading_caches(...)` | Fastly's cache, through `FastlyValueCache` | Streamed, and read up to 1 MiB | +| Axum | `crates/trusted-server-adapter-axum/src/app.rs`, handed on with `.with_loading_caches(...)` | None. The adapter's key-value store is `UnavailableKvStore`, so only the memory layer holds a copy | Whole, as the adapter does not stream | +| Cloudflare | `crates/trusted-server-adapter-cloudflare/src/app.rs`, with `.with_loading_caches(...)` | The key-value store the host binds as `TRUSTED_SERVER_KV`, when it binds one | Whole, as the adapter does not stream | +| Spin | `crates/trusted-server-adapter-spin/src/app.rs`, handed on with `.with_loading_caches(...)` | The host's default key-value store, which gives an entry no lifetime | Whole, as the adapter does not stream | The stock module, `crates/loading-cache/51degrees`, uses a host's value cache when the services carry one and the key-value store otherwise (`through_store` in `stores.rs`). Only the Fastly adapter registers a value cache. The module's memory layer lives as long as the application state, except on Cloudflare, where one cache lives as long as the isolate. Only the Fastly adapter overrides `supports_streaming_responses`, so the others hand `load_asset` a body that is already whole. -**Consent and privacy.** The asset caches read no consent signal and set none, and nothing in them identifies a reader. +**What the caches add on Fastly.** The origin fetch asks for the platform's default cache behavior (`PlatformCacheIntent::Default`). The Fastly adapter leaves the request alone for that intent (`apply_fastly_cache_intent`), so Fastly's readthrough cache stays on, and `apply_origin_cache_intent` keeps subresources on that default. Fastly's own cache therefore already holds a subresource for the lifetime the origin gives it, and a load can be answered from that cache and not from the origin. The asset caches add a second copy, held in the value cache for at most `store_seconds`. This change makes no measurement of the gain over the platform's cache on Fastly. The Cloudflare adapter likewise leaves the Workers runtime's default cache behavior in place for that intent (`outbound_cache_mode`). + +**Consent and privacy.** The asset caches read no consent signal and set none, and they store no identifier of a reader. They do store the origin's response to the request that led a load, which can hold whatever the origin made for that reader (see "The asset gate does not apply the page path's cookie rules"). - **What is stored** is the origin's response for a `200`, the record of header names, and the marker. No reader's request is stored. The values of the request headers that `Vary` names enter the key only as the 128-bit digest, and no URL, host or header value appears in a key. -- **What is never stored for another reader** is a response that sets a cookie, a response whose policy is `private`, `no-store` or `no-cache` or gives no lifetime, a response that varies on `*` or on `Cookie`, and any response to a request that carries `Authorization`, a conditional header, a range, or a `Cache-Control` that asks for the origin, and on the publisher path any response to a request that carries state a module left or a personalized marker. Those requests also never read a copy. -- **An asset route sends no cookie.** `ASSET_PROXY_FORWARD_HEADERS` lists the eleven request headers an asset origin receives, and `Cookie` and `Authorization` are not among them. An answer from a route's origin therefore cannot depend on a cookie that origin never received. Any reader who asks the route for a path is already given the origin's answer for that path, so a shared copy gives no reader a file the route would not. -- **The publisher path sends the reader's headers on.** `rewrite_origin_request` removes `fastly-ssl`, restricts `Accept-Encoding` and sets `Host`, and `handle_publisher_request` itself does not remove `Cookie`. The key holds only the headers that `Vary` names. An origin that answers a cookie or a header differently without naming it in `Vary`, and gives that answer a lifetime another reader may share, has one reader's answer kept and served to others until the lifetime ends. The cache cannot see such an input, and this change has no setting to declare one (see section 3). +- **What is never stored for another reader** is a response that sets a cookie, a response whose policy is `private`, `no-store` or `no-cache` or gives no lifetime, a response that varies on `*` or on `Cookie`, and any response to a request that carries `Authorization`, a conditional header, a range, or a `Cache-Control` or `Pragma` that asks for the origin, and on the publisher path any response to a request that carries state a module left or a personalized marker. Those requests also never read a copy. +- **An asset route sends no cookie, but it sends other headers.** `ASSET_PROXY_FORWARD_HEADERS` lists the eleven request headers an asset origin receives, and `Cookie` and `Authorization` are not among them. An answer from a route's origin therefore cannot depend on a cookie that origin never received. The list does hold `User-Agent`, `Accept`, `Accept-Encoding`, `Accept-Language` and `Referer`. An origin that answers differently on one of those five without naming it in `Vary`, and gives that answer a lifetime another reader may share, has one reader's answer kept and served to others until the lifetime ends. A shared copy opens no path that the route keeps closed, because any reader who asks the route for a path is already given the origin's answer for that path. +- **The asset gate does not apply the page path's cookie rules.** The page path reads no stored page for a request that carries a `Cookie` header and stores none from its response. `evaluate_cookie_policy` in `crates/trusted-server-core/src/cookies/template_cache_policy.rs` makes that decision, and `handle_publisher_request` sets `cookie_disqualifies` from it. Three settings in `[creative_opportunities]` change the rule for pages, being `template_cache_key_cookies`, `template_cache_bypass_cookies` and `origin_is_cookie_independent` (`2026-09-08-1138-per-cookie-template-cache-policy-design.md`). The asset gate reads none of the three and does not look at `Cookie` at all. +- **What that means for a reader with a session cookie.** `rewrite_origin_request` removes `fastly-ssl`, restricts `Accept-Encoding` and sets `Host`, and `handle_publisher_request` itself does not remove `Cookie`, so the origin receives the reader's cookies. Such a request can be served a stored copy, and when no copy exists the request leads the load. The origin's response is then stored for later readers if the response passes the checks in "What is stored", which read the response and not the request's cookies. For example, a `200` that is not HTML, sets no cookie, does not name `Cookie` in `Vary`, is within the size limit and carries `max-age` alone passes, because `public` is not needed. The key holds only the headers that `Vary` names. An origin that answers a cookie or a header differently without naming it in `Vary`, and gives that answer a lifetime another reader may share, has one reader's answer kept and served to others until the lifetime ends. The cache cannot see such an input. No setting of this change lets a publisher declare such an input for assets (see section 3), and `template_cache_bypass_cookies`, which the page path honors, is not read by the asset gate. +- **The reason in the code.** The asset gate carries no comment on cookies. The nearest reason is a comment in `apply_origin_cache_intent` in `publisher.rs`. That function keeps subresources on the platform's default cache behavior and out of the readthrough gate for pages, which applies the page path's cookie and conditional request rules, and the comment gives two reasons. Judged by that gate, every cookie-bearing or conditional asset request would bypass the platform's cache, and browsers send first-party cookies on subresources, which makes that most repeat-visitor asset traffic. Every cached asset would also carry the `ts-template` key, so the purge for templates would flush every asset. +- **On by default.** Item 3 of the rollout starts the cache on a stock build with no change to its settings, and item 4 says no setting turns it off. A build that links the loading cache module therefore applies this rule to every eligible asset request from its first deploy. - **Logs.** The lines `asset_cache bypass`, `asset_cache stored` and `asset_cache invalid entry` carry a fixed reason and a byte count, and no URL, key or header. The line `asset_cache load failed` carries the error text of the failed fetch, which includes the platform's error report as the platform formats it, and this change does not filter that text. **Failure handling.** Each failure ends with the caller asking the origin, as before this change, or with the reader served the response the request's own fetch made. @@ -124,18 +135,33 @@ The stock module, `crates/loading-cache/51degrees`, uses a host's value cache wh - **The origin cannot be reached during a load.** `load_asset` returns a `LoadError`. Every request waiting on that load receives it, nothing is stored, `asset_from_cache` logs `asset_cache load failed` at warn level, and each of those requests then asks the origin itself. A failing origin is therefore asked once more by each request that waited. - **A stored entry cannot be read.** `AssetEntry::decode` refuses bytes that are short, unreadable or written in another format. A record this build did not write is refused in the same way. In both cases the lookup logs at warn level, removes the entry, and the request asks the origin. A format version is in the key, in the entry and in the record, so a build that changes the format does not read the old entries. - **The shared store fails.** The key-value layer treats a failed read as a miss, so the value is loaded, and logs a failed read, write or removal at debug level. The value cache that Fastly provides reports no errors to the module. -- **An error status.** Only a `200` is stored, so an origin outage or a `404` is not held. +- **An error status.** Only a `200` is stored, so the body of an origin error or of a `404` is never held. The verdict is held, as "What is stored" says. For 60 seconds the requests that find the marker ask the origin themselves and none of them stores a copy, so a file the origin begins to answer with a `200` is stored only once the marker ends. - **A body that cannot be read whole.** The reader is served the whole body from the one fetch, as described above. +**Cost and limits.** + +- **A slow origin.** For a response that passes the header checks, the request that leads a load waits for the whole body, up to 1 MiB, before its reader is served, and each request waiting on that load waits as long. An origin fetch has a first byte timeout and a between bytes timeout of 15 seconds, on the publisher path (`DEFAULT_PUBLISHER_FIRST_BYTE_TIMEOUT`) and on an asset route (`DEFAULT_FIRST_BYTE_TIMEOUT`). A failed load reaches every request waiting on it and each of them then fetches the origin itself, so a reader can wait for one failed load and then for a fetch of their own. +- **Removals.** When a response no longer varies as its record says, the request that fetched it removes the record, and a lookup that finds an entry it cannot read removes that entry. On Fastly a removal is a purge by surrogate key (`purge_surrogate_key` in the Fastly adapter's `value_cache.rs`), and on a key-value store it is a delete. An origin whose `Vary` differs from one response to the next causes a removal for each response that differs from the record. +- **Keys and variants without a limit.** Each query string, each host the reader asked, each scheme, and each combination of values for the headers `Vary` names is a key of its own. A miss writes the copy or the marker to the shared store, and the record too when the address has none. This change sets no limit on the number of keys. `store_seconds` limits how long a copy is used and `store_idle_seconds` how long it lasts unused. Spin's key-value store gives an entry no lifetime, so there an entry that nobody reads again stays in the store, because only a read past its drop time, or a removal, takes it out. +- **The fingerprint.** The publisher path computes `template_fingerprint`, a digest of all the serialized settings, for each eligible request, whether or not a copy is found. +- **Memory.** The memory layer holds at most 256 records and 32 copies, and a copy's body is at most 1 MiB, so the copies take up to about 32 MiB for as long as the layer lives. The request that leads a load also holds the whole body while it reads. +- **Reads and writes of the shared store.** A request that misses the memory layer reads the shared store up to twice, once for the record and once for the copy, and a load writes both. The memory layer keeps an entry for at most `memory_seconds`, 5 seconds by default, and it is not shared between requests on Spin or, by default, on Fastly, so there every request reads the store. On Cloudflare and Spin the store is the host's key-value store, when the host supplies one, and a write to Cloudflare's store is given a lifetime of at least a minute (`KV_SHORTEST`), because Workers KV refuses less, with the entry's own drop time holding the exact lifetime. On Axum the adapter's store is `UnavailableKvStore`, so every read and write fails and is logged at debug level, a failed read counts as a miss, and only the memory layer holds copies. +- **Purges.** `ts cache purge` does not reach a copy. It purges by the `ts-template` key or by a page's key, and a copy in Fastly's cache carries neither, only a key made of `vc-` and a hash of its own key (the Fastly adapter's `value_cache.rs`). A copy on another adapter sits in the key-value store or, on Axum, in memory, and `ts cache purge` touches neither. + **Rollout for existing deployments.** 1. `[cache.assets]` is optional and is left out when written back (`skip_serializing_if = "LoadingCacheConfig::is_default"`), so settings written before this change read as they did. 2. A build that links no loading cache module holds nothing, and a build that links several and names none holds nothing. Both behave as before. -3. A stock build links exactly one loading cache module, `loading-cache.51degrees`, so it starts holding shareable assets with no change to its settings. A change at the origin then shows after the lifetime the origin gave the file, and never later than `store_seconds`, which defaults to one hour. +3. A stock build links exactly one loading cache module, `loading-cache.51degrees`, so it starts holding shareable assets with no change to its settings. A change at the origin then shows after the lifetime the origin gave the file, and never later than `store_seconds`, which defaults to one hour. The checks in "What is stored" and the cookie rule under Consent and privacy then apply to its asset traffic from the first deploy. 4. No setting turns the cache off while exactly one module is linked. `loading_caches_from` has no spelling for "none" and refuses a module name that is not linked. A deployment that does not want its assets held builds its module list without the loading cache module. -5. Every change to the settings and every new build changes the fingerprint, so a publisher path copy made earlier is not found again and ends by its lifetime. Nothing purges it. A route's key has no fingerprint, so a route's copy outlives a settings change, including a change to the route's credentials. +5. Every change to the settings and every new build changes the fingerprint, so a publisher path copy made earlier is not found again and ends by its lifetime. Nothing purges it. The publisher path therefore starts with no copy of any asset after each build or settings change, and each asset is fetched from the origin again. A route's key has no fingerprint, so a route's copy outlives a settings change, including a change to the route's credentials. 6. The deploy check lists the new cache names. The error that names a misspelt table now includes `assets` and `assets-vary`. +**Open questions.** Two choices are open, and this change makes neither. + +1. The cookie rule for assets. Should the asset gate apply the page path's cookie rules, or keep the current rule, which reads no cookie (see "The asset gate does not apply the page path's cookie rules")? +2. An off switch. Should a setting turn the asset caches off for a build that links the loading cache module (see item 4 of the rollout)? + ## 3. What is not in this change - **The publisher's declared rule.** An origin can vary on an input it does not name in `Vary`, and only the publisher knows which. `Lookup::declared` in `source_cache.rs` takes a token that enters the copy's key, and one `source_cache` test covers it. Both callers pass `None`, and no setting supplies a value. The rule, as `[publisher.origin_personalization]`, is a later change with its own tests, and until then the origin's own `Vary` alone separates copies. @@ -143,15 +169,15 @@ The stock module, `crates/loading-cache/51degrees`, uses a host's value cache wh - **Pages.** A page stays on the template cache. - **The `/proxy` first-party route.** The route is unchanged and does not use the asset caches. - **Answers made by the platform's image optimizer.** The platform holds those answers, so core does not keep them. -- **Requests a copy cannot answer.** `HEAD`, other methods, `Authorization`, ranges and conditional requests always go to the origin. +- **Requests a copy cannot answer.** `HEAD`, other methods, `Authorization`, ranges, conditional requests, and a `Cache-Control` or `Pragma` that asks for the origin always go to the origin. - **Files over 1 MiB.** A file over 1 MiB is served as it streams from the origin. -- **A way to purge one asset.** This change adds no purge for the asset caches. -- **A setting that turns the asset caches off**, and settings for the memory layer's sizes. +- **A way to purge one asset.** This change adds no purge for the asset caches, and `ts cache purge` does not reach a copy (see Cost and limits). +- **A setting that turns the asset caches off**, which Open questions raises, and settings for the memory layer's sizes. - **Enforcing the ranges for `[cache.assets]`**, as section 2 records. ## 4. Acceptance -The tests below exist and use `MemoryLoadingCache` from `crates/trusted-server-core/src/platform/test_support.rs` as the loading cache and `StubHttpClient` as the origin. The pull request records the fork's CI at `f6e598fda` as 19 of 19 checks green. +The tests below exist. Those in `publisher.rs` and `proxy.rs` use `StubHttpClient` as the origin and `MemoryLoadingCache` from `crates/trusted-server-core/src/platform/test_support.rs` as the loading cache, apart from the `proxy.rs` test that has no cache. Those in `source_cache.rs` use `MemoryLoadingCache` and a counting function as the origin. Those in `asset_cache.rs`, `settings.rs`, `integrations/mod.rs` and the Axum `seam_probe.rs` use neither. The pull request records the fork's CI at `f6e598fda` as 19 of 19 checks green. | Where | Tests | | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -165,21 +191,23 @@ Joins with no test: 1. **Concurrent requests.** Every test of the asset paths sends requests one after another. The loading cache module has tests for many callers loading a missing key once (`many_callers_on_many_threads_load_a_missing_key_once_in_the_caller`) and for a failed load reaching every waiter (`a_failed_load_reaches_every_waiter_and_is_not_kept`), and those load a generic value and not an asset. 2. **A real shared store.** No test runs the asset caches against Fastly's cache, a key-value store, or two instances. The Axum, Cloudflare and Spin adapters have no test of asset holding. -3. **Selecting `[cache.assets]`.** No test names the table, or checks that both `assets` and `assets-vary` take the one linked module when the table is absent. +3. **Selecting `[cache.assets]`.** No test names the table, or checks that both `assets` and `assets-vary` take the one linked module when the table is absent, or writes a `[cache.assets-vary]` table. 4. **The range check.** The code does not run the range check for `[cache.assets]`, and no test covers an out-of-range value. 5. **The publisher path with no loading cache.** No test in `asset_cache_tests` names that case. -6. **Publisher path cases.** No test covers an HTML response to a non-document request, a `Content-Length` over the limit, a response with `Set-Cookie`, a lifetime from `Expires` or `Surrogate-Control`, a request with a conditional header, a range or `Cache-Control`, request state left by a module, a personalized response, a request with no host, or a header that is not visible text. +6. **Publisher path cases.** No test covers an HTML response to a non-document request, a `Content-Length` over the limit, a response with `Set-Cookie`, a lifetime from `Expires` or `Surrogate-Control`, a request with a conditional header, a range, `Cache-Control` or `Pragma`, a request that carries `Cookie`, request state left by a module, a personalized response, a request with no host, or a header that is not visible text. No test sends a `Cookie` header through the publisher path, so nothing pins the cookie rule under Consent and privacy. 7. **Asset route cases.** No test covers an image optimizer route, a route with `auth`, a request with `Authorization`, a method other than `GET`, or a request with no host. 8. **Streaming with a stored copy.** The route tests turn streaming off (`held_asset_stub`), and the publisher tests leave it off except in the oversized body test, which stores nothing. A stored copy served on an adapter that streams has no test. 9. **Two publishers in one cache.** The key test in `source_cache.rs` and the module's `two_publishers_never_share_an_entry` cover the prefix, and no test serves two publishers through either asset path. 10. **Reordered page checks.** `origin_response_may_be_kept` moves the status check ahead of the cookie and edge header checks in `template_cache_ttl`. A page response with a non-`200` status and a fault found by a later check is now reported as a non-`200` status, and no test was added for the changed order. +11. **A module declaring a cache of core's.** `a_builder_declaring_core_s_own_cache_is_refused` declares `robots-txt` only, so no test has a module declare `assets` or `assets-vary`. ## Review log -No review rounds yet. +1. **Round 1, 2026-10-15.** A review in a fresh session, against `split/34-asset-cache` at `67af14ea4`. Issues found, one of them blocking. The asset gate leaves out the page path's cookie rules, so a request that carries a session cookie can read a stored copy or lead a load whose response is stored for every reader, and the spec did not say so. This revision states the cookie rule as the code has it today, with the reason the code gives and the default-on state, puts the choice of cookie rules and an off switch under Open questions, fills the coverage gaps and corrects the partly true claims, while the fixes to the code, its comments and its tests that the review advised wait for code changes. ## Revision record -| Date | Change | -| ---------- | ------------------------------------ | -| 2026-10-15 | First draft, written after the code. | +| Date | Change | +| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-10-15 | First draft, written after the code. | +| 2026-10-15 | Fixes from review round 1. The cookie rules the asset gate leaves out and what that lets through, the reason the code gives, the default-on state and the open questions on cookie rules and an off switch, `Pragma` in the gate, the verdict a response that fails a test leaves for 60 seconds, the module default, where the sizes and the `[cache.assets-vary]` table stand, the key-value store each adapter uses, how far one fetch reaches, what the caches add on Fastly, `[[cache.asset_rules]]`, the headers a route forwards, the cost and limits, the test doubles and the missing tests. | From 32f9ee385e50cec9ddcdd135d45772420105980e Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 06:09:46 +0100 Subject: [PATCH 07/23] A request that sends the publisher's origin a cookie is fetched for itself The asset gate on the publisher path let a request that carries a Cookie header read a kept copy, and lead a load whose answer was then kept for every reader. The origin receives the reader's cookies and nothing in its response shows whether they shaped it, so such a request now neither reads nor leads a copy, and asks the origin as it did before the asset cache. A response that sets a cookie was already kept for no one. A test on the publisher path now shows that such an answer is served only to the request whose fetch made it, and another shows that two readers with different cookies each get their own answer while nothing is kept. An asset route sends its origin no cookie, so its answers stay shared, and a test pins that the route's origin never receives one. --- crates/trusted-server-core/src/proxy.rs | 39 ++++++++- crates/trusted-server-core/src/publisher.rs | 95 ++++++++++++++++++++- 2 files changed, 132 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 7b31ec20f0..13a51a83c9 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -1251,7 +1251,9 @@ pub async fn handle_asset_proxy_request( /// answer, one whose `Cache-Control` asks for the origin, as the publisher /// path reads it, one that names no host, and any request while the cache /// `[cache.assets]` selects keeps nothing. The route's origin and prefix are -/// part of the key, so two routes to one address keep their own copies. +/// part of the key, so two routes to one address keep their own copies. A +/// route sends its origin no cookie, see [`ASSET_PROXY_FORWARD_HEADERS`], so +/// a reader's cookies cannot shape the answer and do not keep it from a copy. fn asset_route_copy_key( services: &RuntimeServices, req: &Request, @@ -5226,6 +5228,41 @@ mod tests { }); } + #[test] + fn a_reader_s_cookie_never_reaches_an_asset_route_s_origin_so_its_answer_is_shared() { + futures::executor::block_on(async { + let stub = held_asset_stub(); + shareable_stylesheet(&stub); + let cache = Arc::new(crate::platform::test_support::MemoryLoadingCache::default()); + let services = asset_route_services(&stub, &cache); + let uri = "https://www.example.com/.images/site.css"; + + for reader in ["one", "two"] { + let mut request = build_http_request(Method::GET, uri); + request.headers_mut().insert( + header::COOKIE, + HeaderValue::from_str(&format!("session={reader}")) + .expect("should be a header value"), + ); + let answer = asset_route_answer(&services, request).await; + assert_eq!(answer.status(), StatusCode::OK); + } + + assert_eq!( + stub.recorded_request_uris().len(), + 1, + "should ask the origin once for both readers" + ); + assert!( + stub.recorded_request_headers() + .iter() + .flatten() + .all(|(name, _)| !name.eq_ignore_ascii_case("cookie")), + "should send the route's origin no cookie" + ); + }); + } + #[test] fn a_range_request_through_an_asset_route_is_not_served_a_copy() { futures::executor::block_on(async { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index bfa9d82c61..41f9e18d67 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -5401,11 +5401,14 @@ pub async fn handle_publisher_request( }); // Every other response the origin sends may be kept as it sent it, in // every assembly mode, keyed the way a page is and kept for as long as - // the origin allows, see `asset_cache`. + // the origin allows, see `asset_cache`. A request that sends the origin a + // cookie is fetched for itself, because nothing shows whether the cookie + // shaped the answer. let request_can_use_asset_copy = method_is_cacheable && !request_is_document && !request_host.is_empty() && !authorization_disqualifies + && !req.headers().contains_key(header::COOKIE) && !request_requires_origin && services .loading_cache_for(crate::settings::ASSETS_CACHE) @@ -8914,6 +8917,96 @@ mod tests { assert!(asset_copy_lifetimes(&cache).is_empty()); } + #[tokio::test] + async fn a_request_carrying_a_cookie_is_fetched_for_itself_and_kept_for_no_one() { + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryLoadingCache::default()); + let services = services(&stub, &cache); + let settings = settings(); + // The origin answers each reader from their cookie without naming + // it in `Vary`, which the cache cannot see. + for reader in ["one", "two"] { + queue( + &stub, + "application/json", + format!("{{\"reader\":\"{reader}\"}}").as_bytes(), + &[("cache-control", "max-age=300")], + ); + } + + for reader in ["one", "two"] { + let mut request = asset_request("/account.json", "empty"); + request.headers_mut().insert( + header::COOKIE, + HeaderValue::from_str(&format!("session={reader}")) + .expect("should be a header value"), + ); + let answer = body_of(run(&settings, &services, request).await).await; + assert_eq!( + answer, + format!("{{\"reader\":\"{reader}\"}}").into_bytes(), + "reader {reader} is served the answer made for their own cookie" + ); + } + + assert_eq!( + stub.recorded_request_uris().len(), + 2, + "each reader's request is fetched for itself" + ); + assert!( + asset_copy_lifetimes(&cache).is_empty(), + "no answer to a request carrying a cookie is kept" + ); + } + + #[tokio::test] + async fn an_answer_that_sets_a_cookie_is_served_only_to_the_reader_it_was_made_for() { + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryLoadingCache::default()); + let services = services(&stub, &cache); + let settings = settings(); + for reader in ["one", "two"] { + let cookie = format!("visitor={reader}"); + queue( + &stub, + "text/css", + format!("/* {reader} */").as_bytes(), + &[ + ("cache-control", "public, max-age=300"), + ("set-cookie", cookie.as_str()), + ], + ); + } + + for reader in ["one", "two"] { + let response = run(&settings, &services, asset_request("/site.css", "style")).await; + let cookie = format!("visitor={reader}"); + assert!( + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .any(|value| value.as_bytes() == cookie.as_bytes()), + "reader {reader} is served the cookie the origin set for them" + ); + assert_eq!( + body_of(response).await, + format!("/* {reader} */").into_bytes() + ); + } + + assert_eq!( + stub.recorded_request_uris().len(), + 2, + "each reader's request is fetched for itself" + ); + assert!( + asset_copy_lifetimes(&cache).is_empty(), + "an answer that sets a cookie is kept for no one" + ); + } + #[tokio::test] async fn a_page_navigation_never_reads_an_asset_copy() { let stub = Arc::new(StubHttpClient::new()); From 210d3e0ee8ba5714e5b49e492687a7b35e54eb5d Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 06:11:21 +0100 Subject: [PATCH 08/23] The asset caches are turned off when their table names the module none A build that links a loading cache module held assets with no way to stop it short of rebuilding without the module. Naming the module "none" in [cache.assets] now leaves both asset caches unselected, so the publisher path and the asset routes ask the origin for every request as they did before the asset cache, and the other caches are selected as before. Only [cache.assets] reads "none" this way, as [ec] and [geo] do for their own modules. The comments on loading_caches_from and on LoadingCacheConfig::module said a table that names no module keeps no copy. They now say it takes the one loading cache module the build links, and keeps no copy when the build links none or several, which is what the code does. One test selects the caches from settings that name no module and from settings that name none, and checks each asset cache keeps values in the first and nothing in the second, with robots.txt's cache left as it was. Another runs two asset requests through the publisher path with the caches an adapter would make, and counts one origin fetch with no module named and two with none. --- .../src/integrations/mod.rs | 49 ++++++++++++++++++- crates/trusted-server-core/src/publisher.rs | 44 +++++++++++++++++ crates/trusted-server-core/src/settings.rs | 9 +++- 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 16c3d3e8f5..fba10ad862 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -898,8 +898,10 @@ pub fn check_cache_tables( } /// The loading cache each `[cache.]` table selects, from the caches the -/// builders a deployment links offer. A table that names no module keeps no -/// copy, and a module's cache is made once however many tables select it. +/// builders a deployment links offer. A table that names no module takes the +/// one loading cache module the build links, and keeps no copy when it links +/// none or several. A module's cache is made once however many tables select +/// it. `[cache.assets]` naming `none` selects nothing for either asset cache. /// /// # Errors /// @@ -923,7 +925,20 @@ pub fn loading_caches_from( let declared = declared_caches(builders)?; let mut made: Vec<(&'static str, Arc)> = Vec::new(); let mut caches = crate::platform::LoadingCaches::default(); + let assets_off = + settings.cache.assets.module.as_deref() == Some(crate::settings::ASSETS_CACHE_OFF); for (table, config) in settings.cache.loading_caches() { + // `[cache.assets]` turned off leaves both asset caches unselected, so + // they keep nothing and each asset is fetched from its origin. + if assets_off + && [ + crate::settings::ASSETS_CACHE, + crate::settings::ASSETS_VARY_CACHE, + ] + .contains(&table) + { + continue; + } // A table naming no module takes the one loading cache module the // build links, so answers are held without a line in the document. // With none linked, or several, it holds nothing. @@ -1195,6 +1210,36 @@ mod loading_caches_from_tests { assert!(message.contains("core's own"), "{message}"); } + #[test] + fn assets_naming_none_turns_both_asset_caches_off_and_nothing_else() { + let builders = [offering("loading-cache.one")]; + let on = loading_caches_from(&settings_selecting(None), &builders) + .expect("no selection is not an error"); + let mut settings = settings_selecting(None); + settings.cache.assets.module = Some(crate::settings::ASSETS_CACHE_OFF.to_owned()); + + let off = loading_caches_from(&settings, &builders) + .expect("turning the asset caches off is not an error"); + + for name in [ + crate::settings::ASSETS_CACHE, + crate::settings::ASSETS_VARY_CACHE, + ] { + assert!( + on.for_feature(name).keeps_values(), + "{name} takes the one module offered" + ); + assert!( + !off.for_feature(name).keeps_values(), + "{name} keeps nothing once turned off" + ); + } + assert!( + off.for_feature(ROBOTS_TXT_CACHE).keeps_values(), + "turning the asset caches off leaves the other caches as they were" + ); + } + fn settings_selecting(module: Option<&str>) -> Settings { let mut settings = crate::test_support::tests::create_test_settings(); settings.cache.robots_txt.module = module.map(str::to_owned); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 41f9e18d67..4d92f011a5 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -9007,6 +9007,50 @@ mod tests { ); } + /// A loading cache module's cache, which keeps what it loads. + fn memory_module_cache() -> Arc { + Arc::new(MemoryLoadingCache::default()) + } + + #[tokio::test] + async fn assets_turned_off_in_the_settings_are_fetched_for_every_request() { + let module = crate::integrations::IntegrationBuilder::implementations( + "loading-cache.memory", + "a-crate", + ) + .with_loading_cache(memory_module_cache); + for (written, fetches) in [(None, 1), (Some(crate::settings::ASSETS_CACHE_OFF), 2)] { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.cache.assets.module = written.map(str::to_owned); + let caches = crate::integrations::loading_caches_from( + &settings, + std::slice::from_ref(&module), + ) + .expect("should select the asset caches"); + let settings = Arc::new(settings); + let stub = Arc::new(StubHttpClient::new()); + let services = build_services_with_http_client(Arc::clone(&stub) as _) + .with_loading_caches(caches); + for _ in 0..2 { + queue( + &stub, + "text/css", + b"body{}", + &[("cache-control", "public, max-age=300")], + ); + let response = + run(&settings, &services, asset_request("/site.css", "style")).await; + assert_eq!(body_of(response).await, b"body{}"); + } + + assert_eq!( + stub.recorded_request_uris().len(), + fetches, + "[cache.assets] module {written:?}" + ); + } + } + #[tokio::test] async fn a_page_navigation_never_reads_an_asset_copy() { let stub = Arc::new(StubHttpClient::new()); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 8b5fac687f..c00476ae77 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -3033,7 +3033,7 @@ pub struct CacheSettings { )] pub robots_txt: LoadingCacheConfig, /// How long the copies of the assets the publisher's origin sends last, - /// `[cache.assets]`. + /// `[cache.assets]`. A `module` of [`ASSETS_CACHE_OFF`] keeps none. #[serde(default, skip_serializing_if = "LoadingCacheConfig::is_default")] pub assets: LoadingCacheConfig, /// The caches modules declare and load through, each in its own @@ -3060,6 +3060,10 @@ pub const ASSETS_CACHE: &str = "assets"; /// copies, so one line selects both. pub const ASSETS_VARY_CACHE: &str = "assets-vary"; +/// The `module` that turns both asset caches off in `[cache.assets]`, so +/// every asset is fetched from its origin for each request. +pub const ASSETS_CACHE_OFF: &str = "none"; + /// How long one loading cache's copies last, each field defaulting to the /// cache's own default when absent. /// @@ -3071,7 +3075,8 @@ pub const ASSETS_VARY_CACHE: &str = "assets-vary"; pub struct LoadingCacheConfig { /// The module whose loading cache holds this cache's copies, as its short /// name or in full, for example `51degrees` or `loading-cache.51degrees`. - /// Absent, no copy is kept and every call loads. + /// Absent, the one loading cache module the build links holds them, and + /// no copy is kept when it links none or several. #[serde(default, skip_serializing_if = "Option::is_none")] pub module: Option, /// How long the brief copy in process memory is used, in seconds. Zero From 34010cad010f47ea101b2128589a6946f4b5447c Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 06:11:46 +0100 Subject: [PATCH 09/23] An asset cache lifetime out of range is refused when the settings load CacheSettings::prepare_runtime range checked [cache.robots-txt] and each table a module declares, and not [cache.assets], so a store lifetime of zero, or a lifetime over a year, loaded and was used. The check now runs for [cache.assets] too, wherever settings are loaded or validated for a deploy, and the error names the table and the field. The test of out of range lifetimes now writes each value in both [cache.robots-txt] and [cache.assets]. --- crates/trusted-server-core/src/settings.rs | 43 ++++++++++++---------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index c00476ae77..f005ebddbc 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -3183,6 +3183,7 @@ impl CacheSettings { /// invalid policy/matcher or cannot compile its regex/glob. pub fn prepare_runtime(&self) -> Result<(), Report> { self.robots_txt.check("cache.robots-txt")?; + self.assets.check("cache.assets")?; for (name, config) in &self.declared { config.check(&format!("cache.{name}"))?; } @@ -7031,28 +7032,30 @@ module = \"none\"", #[test] fn a_loading_cache_lifetime_out_of_range_is_refused_naming_it() { - for (field, value, least) in [ - ("store_seconds", "0", 1), - ("store_idle_seconds", "0", 1), - ("memory_seconds", "31536001", 0), - ] { - let toml_str = format!( - "{} - -[cache.robots-txt] + for table in ["robots-txt", "assets"] { + for (field, value, least) in [ + ("store_seconds", "0", 1), + ("store_idle_seconds", "0", 1), + ("memory_seconds", "31536001", 0), + ] { + let toml_str = format!( + "{} + +[cache.{table}] {field} = {value} ", - crate_test_settings_str() - ); - let error = Settings::from_toml(&toml_str) - .expect_err("a lifetime out of range should not load"); - let message = format!("{error:?}"); - assert!( - message.contains(&format!( - "[cache.robots-txt] {field} is {value}, and must be from {least} to 31536000" - )), - "{field}: {message}" - ); + crate_test_settings_str() + ); + let error = Settings::from_toml(&toml_str) + .expect_err("a lifetime out of range should not load"); + let message = format!("{error:?}"); + assert!( + message.contains(&format!( + "[cache.{table}] {field} is {value}, and must be from {least} to 31536000" + )), + "{table} {field}: {message}" + ); + } } } From 93520fb0538503875c52f8a38809defb0c3447b8 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 06:12:16 +0100 Subject: [PATCH 10/23] The guide and the changelog describe the asset cache's cookie rule, off switch and range check The configuration guide says that a request sending the publisher's origin a cookie always goes to the origin and its answer is not kept, that an asset route sends its origin no cookie, and that module = "none" in [cache.assets] keeps no asset. Its table gives the one linked module as the default module of each cache table, where it said none. The changelog entry for the asset cache says the same, and that a [cache.assets] lifetime outside the ranges of [cache.robots-txt] is refused when the settings load. --- CHANGELOG.md | 2 +- docs/guide/configuration.md | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a051bbc9c9..9cae7d4352 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,7 +90,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `[ad-tag.google] write_loader` writes the loader tag into the head, last among the module's inserts and asking for the loader on the module's own route, for a page that carries no loader of its own. Off by default, because a page that already carries the loader would otherwise load it twice. -- The publisher's assets and the answers of the asset routes are held through the loading cache, selected in `[cache.assets]`, so an asset is fetched once however many readers and instances ask for it at the same moment, and the copy in the host's shared store answers every instance until the origin's own lifetime ends. A copy is kept under what the origin says it varies on, served with the headers that describe it and an `Age` that counts from the origin's, and nothing is kept that may not be shared. A deployment that names no module takes the one loading cache module the build links, and a build that links none holds nothing, as before. +- The publisher's assets and the answers of the asset routes are held through the loading cache, selected in `[cache.assets]`, so an asset is fetched once however many readers and instances ask for it at the same moment, and the copy in the host's shared store answers every instance until the origin's own lifetime ends. A copy is kept under what the origin says it varies on, served with the headers that describe it and an `Age` that counts from the origin's, and nothing is kept that may not be shared. A request that sends the publisher's origin a cookie always goes to the origin, and its answer is not kept. A deployment that names no module takes the one loading cache module the build links, and `module = "none"`, or a build that links none, holds nothing, as before. A `[cache.assets]` lifetime outside the ranges of `[cache.robots-txt]` is refused when the settings load. - A Google tag module, `[tag] modules = ["google-tag"]` with `[tag.google-tag]`, from `crates/tag/google-tag`, which writes the consent mode defaults, Tag Manager's container script, the gtag.js snippet and the AdSense loader in the head immediately before the script bundle's tag, and Tag Manager's noscript iframe after the opening body tag, each copied from Google's documentation. The consent defaults come first, the four consent types Google requires for its own services default to denied, the tag waits `consent_defaults.wait_for_update` milliseconds (500 unless set) for a consent management platform's update before it fires with them, and `consent_defaults.region` scopes them to country or region codes. Where the Tag Manager module serves the same tag or container the loader names the first-party path, and a page's own loader for the configured tag is removed. - A middleware may write markup immediately after the opening body tag, with `body_open_inserts` on its action, once per document, beside the places it had for the head and for straight after the script bundle. - A creative-opportunity format may name `min_viewport_width` and `max_viewport_width`, and a slot with such a format asks for only the formats that fit each band of viewport widths. The slot's page data carries `viewport_sizes`, and the Google ad tag's head bootstrap and the bundle's slot definition apply it as a size mapping, and the bundle's refresh auction offers a bidder only the sizes of the band the viewport is in. A slot whose formats name no bound behaves as before. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 89cab12509..9291fc3eba 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1773,16 +1773,20 @@ says `private`, `no-store` or `no-cache` or gives no lifetime, one that varies on `*` or on `Cookie`, one larger than a megabyte, an answer the platform's image optimizer makes, and the answer to a request carrying `Authorization`, a conditional header, a range, or a `Cache-Control` of `no-cache`, `no-store`, -`min-fresh` or a nonzero `max-age`. A response that may not be shared is served to -the request whose load fetched it from that one fetch. +`min-fresh` or a nonzero `max-age`. A response that may not be shared is served +to the request whose load fetched it from that one fetch. A request that sends +the publisher's origin a cookie always goes to the origin, and its answer is not +kept, because the cache cannot see whether the cookie shaped it. An asset route +sends its origin no cookie. `[cache.assets] module = "none"` keeps no asset, so +every asset is fetched from its origin for each request. | Table | Field | Default | Range | Description | | -------------------- | -------------------- | ------------------ | ------------- | ------------------------------------------------------------------------------- | -| `[cache.robots-txt]` | `module` | none | | The module whose loading cache holds each robots.txt module's answer. | +| `[cache.robots-txt]` | `module` | the linked module | | The module whose loading cache holds each robots.txt module's answer. | | `[cache.robots-txt]` | `memory_seconds` | `60` | 0 to 31536000 | How long the copy in memory is used. Zero keeps none. | | `[cache.robots-txt]` | `store_seconds` | `604800`, a week | 1 to 31536000 | The longest a module's answer is used. The module's own refresh ends it sooner. | | `[cache.robots-txt]` | `store_idle_seconds` | `2592000`, 30 days | 1 to 31536000 | How long an answer in the shared store lasts unused. | -| `[cache.assets]` | `module` | none | | The module whose loading cache holds the publisher's assets and route answers. | +| `[cache.assets]` | `module` | the linked module | | The loading cache module for assets and route answers. `none` keeps none. | | `[cache.assets]` | `memory_seconds` | `5` | 0 to 31536000 | How long the copy in memory is used. Zero keeps none. | | `[cache.assets]` | `store_seconds` | `3600`, an hour | 1 to 31536000 | The longest a copy is used. The origin's own lifetime ends it sooner. | | `[cache.assets]` | `store_idle_seconds` | `86400`, a day | 1 to 31536000 | How long a copy in the shared store lasts unused. | From 10aacb039e8ee45c4920cb1d3e2749edb5567833 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 06:16:15 +0100 Subject: [PATCH 11/23] The asset cache design takes its round 2 review The design now states the three fixes to the code. A request on the publisher path that carries a Cookie header neither reads nor leads a copy, module = "none" in [cache.assets] turns both asset caches off, and the range check runs for [cache.assets]. The cookie rule and the off switch leave Open questions, and the question left asks whether a publisher may say that cookies do not shape its assets, as origin_is_cookie_independent says of its HTML. Section 3 drops the off switch and the range check, and Acceptance names the new tests and drops the joins they cover. From the round 2 review, the design says which entries each load writes, no longer says that no test sends a Cookie header through the publisher path, and gives the one linked module as the default. The review log and the revision record name the round. --- .../specs/2026-10-15-asset-cache-design.md | 110 +++++++++--------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/docs/superpowers/specs/2026-10-15-asset-cache-design.md b/docs/superpowers/specs/2026-10-15-asset-cache-design.md index 6d02d86313..f8a7e4e11e 100644 --- a/docs/superpowers/specs/2026-10-15-asset-cache-design.md +++ b/docs/superpowers/specs/2026-10-15-asset-cache-design.md @@ -5,7 +5,7 @@ **Related specs:** `2026-10-12-loading-cache-design.md`, `2026-09-15-852-template-and-origin-caching-design.md`, `2026-04-28-multi-backend-asset-proxy-design.md`, `2026-05-19-asset-s3-auth-fastly-io-design.md`, `2026-09-08-1138-per-cookie-template-cache-policy-design.md` **Related PRs:** IABTechLab/trusted-server#1375, reviewed on jwrosewell/trusted-server#29 **Issues:** IABTechLab/trusted-server#1324 -**Last updated:** 2026-10-15 +**Last updated:** 2026-10-16 > **Why this spec exists.** A file that is not a page, whether the publisher's origin sends it or an asset route fetches it, is asked of its origin on every request and by every instance, so a burst of readers for one file is a burst of fetches. @@ -23,15 +23,15 @@ Core holds the origin's response for a file in two loading caches, `assets-vary` **Where the code is.** -| File | What it holds | -| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `crates/trusted-server-core/src/source_cache.rs` | `get_or_fetch`, which finds the copy for a request through the record and the copy caches, and the record of which request headers an address varies on | -| `crates/trusted-server-core/src/asset_cache.rs` | `AssetCacheKey`, `AssetCopy` and `AssetEntry`, which say what is stored, how it is written and read, and how a copy is served | -| `crates/trusted-server-core/src/publisher.rs` | The gate in `handle_publisher_request`, then `asset_from_cache`, `load_asset`, `asset_cache_ttl`, `HandBack` and `origin_response_may_be_kept`, which `template_cache_ttl` now calls as well | -| `crates/trusted-server-core/src/proxy.rs` | `asset_route_copy_key`, and the lookup in `handle_asset_proxy_request` | -| `crates/trusted-server-core/src/settings.rs` | `CacheSettings.assets`, `ASSETS_CACHE`, `ASSETS_VARY_CACHE`, and `CacheSettings::loading_caches`, which now lists both names | -| `crates/trusted-server-core/src/integrations/mod.rs` | `declared_caches`, which declares both names as core's own | -| `crates/trusted-server-core/src/platform/` | `hash_length_prefixed` in `template_cache.rs`, which `TemplateCacheKey::to_cache_key` and `AssetCacheKey::to_cache_key` share, and `MemoryLoadingCache::held` and `asset_copy_lifetimes` in `test_support.rs` | +| File | What it holds | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/source_cache.rs` | `get_or_fetch`, which finds the copy for a request through the record and the copy caches, and the record of which request headers an address varies on | +| `crates/trusted-server-core/src/asset_cache.rs` | `AssetCacheKey`, `AssetCopy` and `AssetEntry`, which say what is stored, how it is written and read, and how a copy is served | +| `crates/trusted-server-core/src/publisher.rs` | The gate in `handle_publisher_request`, then `asset_from_cache`, `load_asset`, `asset_cache_ttl`, `HandBack` and `origin_response_may_be_kept`, which `template_cache_ttl` now calls as well | +| `crates/trusted-server-core/src/proxy.rs` | `asset_route_copy_key`, and the lookup in `handle_asset_proxy_request` | +| `crates/trusted-server-core/src/settings.rs` | `CacheSettings.assets`, `ASSETS_CACHE`, `ASSETS_VARY_CACHE`, `ASSETS_CACHE_OFF`, `CacheSettings::loading_caches`, which now lists both names, and the range check of `[cache.assets]` in `CacheSettings::prepare_runtime` | +| `crates/trusted-server-core/src/integrations/mod.rs` | `declared_caches`, which declares both names as core's own, and `loading_caches_from`, which selects no cache for either name when `[cache.assets]` names `none` | +| `crates/trusted-server-core/src/platform/` | `hash_length_prefixed` in `template_cache.rs`, which `TemplateCacheKey::to_cache_key` and `AssetCacheKey::to_cache_key` share, and `MemoryLoadingCache::held` and `asset_copy_lifetimes` in `test_support.rs` | **The key.** `AssetCacheKey` has five inputs, and the two callers fill them differently. @@ -55,11 +55,12 @@ An address has one record, in `assets-vary`, and any number of copies, in `asset 2. The request is not for a document. `is_html_document_request` reads `Sec-Fetch-Dest`, where `document`, `embed`, `fencedframe`, `frame`, `iframe` and `object` count as documents, and falls back to the navigation check when the header is absent. 3. The request names a host. 4. The request carries no `Authorization` header. -5. `request_bypasses_template_cache` finds none of `Range`, `If-Range`, `If-Match`, `If-None-Match`, `If-Modified-Since` or `If-Unmodified-Since`, no `Cache-Control` that asks for the origin, being `no-cache`, `no-store`, `min-fresh`, an unreadable value, or a `max-age` that is not zero, and no `Pragma` that is `no-cache` or cannot be read. -6. No module left state on the request, and no integration marked the response as personalized. -7. The loading cache that `[cache.assets]` selects keeps values (`PlatformLoadingCache::keeps_values`). `NoLoadingCache` answers false. +5. The request carries no `Cookie` header, because the origin receives the reader's cookies and nothing shows whether they shaped its answer (see "The cookie rule"). +6. `request_bypasses_template_cache` finds none of `Range`, `If-Range`, `If-Match`, `If-None-Match`, `If-Modified-Since` or `If-Unmodified-Since`, no `Cache-Control` that asks for the origin, being `no-cache`, `no-store`, `min-fresh`, an unreadable value, or a `max-age` that is not zero, and no `Pragma` that is `no-cache` or cannot be read. +7. No module left state on the request, and no integration marked the response as personalized. +8. The loading cache that `[cache.assets]` selects keeps values (`PlatformLoadingCache::keeps_values`). `NoLoadingCache` answers false. A build that links no loading cache module, a build that links several and names no module, and a `[cache.assets]` that names `none` all select it. -An asset route looks for a copy when `asset_route_copy_key` finds the method `GET`, no `Authorization`, none of the conditions in item 5, a host in the request URI or the `Host` header, and a cache that keeps values, and when no image optimizer applies to the request. The route does not check items 2 and 6. A request that fails any check is served exactly as before this change. +An asset route looks for a copy when `asset_route_copy_key` finds the method `GET`, no `Authorization`, none of the conditions in item 6, a host in the request URI or the `Host` header, and a cache that keeps values, and when no image optimizer applies to the request. The route does not check items 2, 5 and 7. A route sends its origin no cookie, so a reader's cookies cannot shape its answer. A request that fails any check is served exactly as before this change. **What is stored.** `load_asset` fetches the file with `GET` and the prepared request's headers, then tests the response in this order. The first test the response fails ends the checks. The response is then not stored for other readers, and the request whose load fetched it is served that response. @@ -91,20 +92,20 @@ store_seconds = 3600 store_idle_seconds = 86400 ``` -In the table, the guide is the configuration guide, `docs/guide/configuration.md`, under Loading caches. +The configuration guide, `docs/guide/configuration.md`, lists the same fields under Loading caches. -| Field | Default | Range in the guide | Meaning | -| -------------------- | ---------------------------------------------------------------------- | ------------------ | ---------------------------------------------------------- | -| `module` | The one module the build links, and none when it links none or several | | The loading cache module that holds both caches | -| `memory_seconds` | `5` | 0 to 31536000 | How long a copy in process memory is used. Zero keeps none | -| `store_seconds` | `3600`, an hour | 1 to 31536000 | The longest a copy in the shared store is used | -| `store_idle_seconds` | `86400`, a day | 1 to 31536000 | How long a copy in the shared store lasts unused | +| Field | Default | Range | Meaning | +| -------------------- | ---------------------------------------------------------------------- | ------------- | -------------------------------------------------------------------------------- | +| `module` | The one module the build links, and none when it links none or several | | The loading cache module that holds both caches, or `none`, which turns both off | +| `memory_seconds` | `5` | 0 to 31536000 | How long a copy in process memory is used. Zero keeps none | +| `store_seconds` | `3600`, an hour | 1 to 31536000 | The longest a copy in the shared store is used | +| `store_idle_seconds` | `86400`, a day | 1 to 31536000 | How long a copy in the shared store lasts unused | The defaults are `ASSET_DEFAULT_LIFETIMES` in `publisher.rs`. The memory layer holds at most 256 records and 32 copies. Those two counts are literals in `fn asset_cache` in `publisher.rs`, the limit of 1 MiB on a copy's body is the constant `MAX_ASSET_COPY_BYTES` in `asset_cache.rs`, and none of the three is a setting. `LoadingCacheConfig` refuses a field it does not know. -`loading_caches_from` in `integrations/mod.rs` makes the selection. `CacheSettings::loading_caches` lists `robots-txt`, then `assets` and `assets-vary`, both with the settings of `[cache.assets]`, then the tables modules declare. A table that names a module takes that module, by short or full name. A table that names none, and an absent table, take the one loading cache module the build links, and hold nothing when the build links none or several. A table that names a module no linked crate offers is refused when the service starts, with the modules the build has. `declared_caches` declares `assets` and `assets-vary` as core's own, so a module that declares either name is refused, and the error that lists a build's caches now reads `robots-txt, assets, assets-vary` first. +`loading_caches_from` in `integrations/mod.rs` makes the selection. `CacheSettings::loading_caches` lists `robots-txt`, then `assets` and `assets-vary`, both with the settings of `[cache.assets]`, then the tables modules declare. A table that names a module takes that module, by short or full name. A table that names no module, and an absent table, take the one loading cache module the build links, and hold nothing when the build links none or several. A table that names a module no linked crate offers is refused when the service starts, with the modules the build has. `[cache.assets]` may also name `none`, which selects no cache for either name, so `loading_cache_for` gives both `NoLoadingCache`, item 8 of the asset gate fails, and both paths ask the origin for every request as they did before this change. Only `[cache.assets]` reads `none` this way, and another table that names `none` is refused as naming a module no linked crate offers. `declared_caches` declares `assets` and `assets-vary` as core's own, so a module that declares either name is refused, and the error that lists a build's caches now reads `robots-txt, assets, assets-vary` first. -The range check, `LoadingCacheConfig::check`, runs in `CacheSettings::prepare_runtime` for `[cache.robots-txt]` and for each table a module declares. The check does not run for `[cache.assets]`, so nothing enforces the ranges in the table for it. There is no `[cache.assets-vary]` field. A table of that name is read as one a module declares. The deploy check accepts it, because `assets-vary` is a name core declares, and `prepare_runtime` range checks its lifetimes. Its `module`, when it names one, replaces the module `[cache.assets]` selected for the record cache, because `loading_caches_from` handles the table after the entry made from `[cache.assets]` and the later entry wins. Its lifetimes are never used, because `asset_cache` builds both caches from `[cache.assets]`. +The range check, `LoadingCacheConfig::check`, runs in `CacheSettings::prepare_runtime` for `[cache.robots-txt]`, for `[cache.assets]` and for each table a module declares, so settings with a lifetime outside the ranges in the table do not load, and the error names the table and the field, for example `[cache.assets] store_seconds is 0, and must be from 1 to 31536000`. There is no `[cache.assets-vary]` field. A table of that name is read as one a module declares. The deploy check accepts it, because `assets-vary` is a name core declares, and `prepare_runtime` range checks its lifetimes. Its `module`, when it names one, replaces the module `[cache.assets]` selected for the record cache, because `loading_caches_from` handles the table after the entry made from `[cache.assets]` and the later entry wins, unless `[cache.assets]` names `none`, which leaves the record cache unselected whatever the table names. Its lifetimes are never used, because `asset_cache` builds both caches from `[cache.assets]`. **How each adapter takes it.** No adapter code changes. Each adapter calls `loading_caches_from` when it builds its application state and hands the result to each request's services, so the selection reaches `asset_from_cache` through `RuntimeServices::loading_cache_for`. @@ -119,15 +120,16 @@ The stock module, `crates/loading-cache/51degrees`, uses a host's value cache wh **What the caches add on Fastly.** The origin fetch asks for the platform's default cache behavior (`PlatformCacheIntent::Default`). The Fastly adapter leaves the request alone for that intent (`apply_fastly_cache_intent`), so Fastly's readthrough cache stays on, and `apply_origin_cache_intent` keeps subresources on that default. Fastly's own cache therefore already holds a subresource for the lifetime the origin gives it, and a load can be answered from that cache and not from the origin. The asset caches add a second copy, held in the value cache for at most `store_seconds`. This change makes no measurement of the gain over the platform's cache on Fastly. The Cloudflare adapter likewise leaves the Workers runtime's default cache behavior in place for that intent (`outbound_cache_mode`). -**Consent and privacy.** The asset caches read no consent signal and set none, and they store no identifier of a reader. They do store the origin's response to the request that led a load, which can hold whatever the origin made for that reader (see "The asset gate does not apply the page path's cookie rules"). +**Consent and privacy.** The asset caches read no consent signal and set none, and they store no identifier of a reader. They do store the origin's response to the request that led a load, which can hold whatever the origin made for that reader from the headers it received (see "The cookie rule"). - **What is stored** is the origin's response for a `200`, the record of header names, and the marker. No reader's request is stored. The values of the request headers that `Vary` names enter the key only as the 128-bit digest, and no URL, host or header value appears in a key. -- **What is never stored for another reader** is a response that sets a cookie, a response whose policy is `private`, `no-store` or `no-cache` or gives no lifetime, a response that varies on `*` or on `Cookie`, and any response to a request that carries `Authorization`, a conditional header, a range, or a `Cache-Control` or `Pragma` that asks for the origin, and on the publisher path any response to a request that carries state a module left or a personalized marker. Those requests also never read a copy. +- **What is never stored for another reader** is a response that sets a cookie, a response whose policy is `private`, `no-store` or `no-cache` or gives no lifetime, a response that varies on `*` or on `Cookie`, and any response to a request that carries `Authorization`, a conditional header, a range, or a `Cache-Control` or `Pragma` that asks for the origin, and on the publisher path any response to a request that carries a `Cookie` header, state a module left or a personalized marker. Those requests also never read a copy. +- **The cookie rule.** No response that sets a cookie, and no answer to a request whose cookies may have shaped it, is stored and served to another reader. On the publisher path `rewrite_origin_request` removes `fastly-ssl`, restricts `Accept-Encoding` and sets `Host`, and `handle_publisher_request` does not remove `Cookie`, so the origin receives the reader's cookies and the cache cannot see whether they shaped the answer. A request that carries a `Cookie` header therefore fails item 5 of the asset gate, so it never reads a copy and never leads a load, and it asks the origin as it did before this change. A response that sets a cookie fails test 2 of "What is stored", so it is served to the request whose load fetched it and to no other, and `set-cookie` is never written with a copy. An asset route sends its origin no cookie (next point), so a route's answer cannot depend on one, and a request to a route may read and lead copies whatever cookies it carries. - **An asset route sends no cookie, but it sends other headers.** `ASSET_PROXY_FORWARD_HEADERS` lists the eleven request headers an asset origin receives, and `Cookie` and `Authorization` are not among them. An answer from a route's origin therefore cannot depend on a cookie that origin never received. The list does hold `User-Agent`, `Accept`, `Accept-Encoding`, `Accept-Language` and `Referer`. An origin that answers differently on one of those five without naming it in `Vary`, and gives that answer a lifetime another reader may share, has one reader's answer kept and served to others until the lifetime ends. A shared copy opens no path that the route keeps closed, because any reader who asks the route for a path is already given the origin's answer for that path. -- **The asset gate does not apply the page path's cookie rules.** The page path reads no stored page for a request that carries a `Cookie` header and stores none from its response. `evaluate_cookie_policy` in `crates/trusted-server-core/src/cookies/template_cache_policy.rs` makes that decision, and `handle_publisher_request` sets `cookie_disqualifies` from it. Three settings in `[creative_opportunities]` change the rule for pages, being `template_cache_key_cookies`, `template_cache_bypass_cookies` and `origin_is_cookie_independent` (`2026-09-08-1138-per-cookie-template-cache-policy-design.md`). The asset gate reads none of the three and does not look at `Cookie` at all. -- **What that means for a reader with a session cookie.** `rewrite_origin_request` removes `fastly-ssl`, restricts `Accept-Encoding` and sets `Host`, and `handle_publisher_request` itself does not remove `Cookie`, so the origin receives the reader's cookies. Such a request can be served a stored copy, and when no copy exists the request leads the load. The origin's response is then stored for later readers if the response passes the checks in "What is stored", which read the response and not the request's cookies. For example, a `200` that is not HTML, sets no cookie, does not name `Cookie` in `Vary`, is within the size limit and carries `max-age` alone passes, because `public` is not needed. The key holds only the headers that `Vary` names. An origin that answers a cookie or a header differently without naming it in `Vary`, and gives that answer a lifetime another reader may share, has one reader's answer kept and served to others until the lifetime ends. The cache cannot see such an input. No setting of this change lets a publisher declare such an input for assets (see section 3), and `template_cache_bypass_cookies`, which the page path honors, is not read by the asset gate. -- **The reason in the code.** The asset gate carries no comment on cookies. The nearest reason is a comment in `apply_origin_cache_intent` in `publisher.rs`. That function keeps subresources on the platform's default cache behavior and out of the readthrough gate for pages, which applies the page path's cookie and conditional request rules, and the comment gives two reasons. Judged by that gate, every cookie-bearing or conditional asset request would bypass the platform's cache, and browsers send first-party cookies on subresources, which makes that most repeat-visitor asset traffic. Every cached asset would also carry the `ts-template` key, so the purge for templates would flush every asset. -- **On by default.** Item 3 of the rollout starts the cache on a stock build with no change to its settings, and item 4 says no setting turns it off. A build that links the loading cache module therefore applies this rule to every eligible asset request from its first deploy. +- **Other headers on the publisher path.** The origin receives the reader's other headers as well. The key holds only the headers that `Vary` names, so an origin that answers differently on another header without naming it in `Vary`, and gives that answer a lifetime another reader may share, has one reader's answer kept and served to others until the lifetime ends. No setting of this change lets a publisher declare such an input (see section 3). +- **The page path's cookie settings are not read.** Three settings in `[creative_opportunities]` change the page path's cookie rule, being `template_cache_key_cookies`, `template_cache_bypass_cookies` and `origin_is_cookie_independent` (`2026-09-08-1138-per-cookie-template-cache-policy-design.md`), and `evaluate_cookie_policy` in `crates/trusted-server-core/src/cookies/template_cache_policy.rs` applies them. Each describes the origin's HTML, so the asset gate reads none of them. A publisher who sets `origin_is_cookie_independent` to say that cookies do not change its HTML still has every cookie-bearing asset request on the publisher path fetched for itself (see Open questions). +- **The cost.** Browsers send first-party cookies on subresources, which the comment in `apply_origin_cache_intent` in `publisher.rs` says is most repeat-visitor asset traffic. On the publisher path the asset caches therefore serve only requests that carry no cookie, while an asset route serves its eligible requests whatever cookies they carry. +- **On by default.** Item 3 of the rollout starts the cache on a stock build with no change to its settings, and `[cache.assets] module = "none"` turns it off (item 4). A build that links the loading cache module applies the cookie rule to every eligible asset request from its first deploy. - **Logs.** The lines `asset_cache bypass`, `asset_cache stored` and `asset_cache invalid entry` carry a fixed reason and a byte count, and no URL, key or header. The line `asset_cache load failed` carries the error text of the failed fetch, which includes the platform's error report as the platform formats it, and this change does not filter that text. **Failure handling.** Each failure ends with the caller asking the origin, as before this change, or with the reader served the response the request's own fetch made. @@ -142,25 +144,24 @@ The stock module, `crates/loading-cache/51degrees`, uses a host's value cache wh - **A slow origin.** For a response that passes the header checks, the request that leads a load waits for the whole body, up to 1 MiB, before its reader is served, and each request waiting on that load waits as long. An origin fetch has a first byte timeout and a between bytes timeout of 15 seconds, on the publisher path (`DEFAULT_PUBLISHER_FIRST_BYTE_TIMEOUT`) and on an asset route (`DEFAULT_FIRST_BYTE_TIMEOUT`). A failed load reaches every request waiting on it and each of them then fetches the origin itself, so a reader can wait for one failed load and then for a fetch of their own. - **Removals.** When a response no longer varies as its record says, the request that fetched it removes the record, and a lookup that finds an entry it cannot read removes that entry. On Fastly a removal is a purge by surrogate key (`purge_surrogate_key` in the Fastly adapter's `value_cache.rs`), and on a key-value store it is a delete. An origin whose `Vary` differs from one response to the next causes a removal for each response that differs from the record. -- **Keys and variants without a limit.** Each query string, each host the reader asked, each scheme, and each combination of values for the headers `Vary` names is a key of its own. A miss writes the copy or the marker to the shared store, and the record too when the address has none. This change sets no limit on the number of keys. `store_seconds` limits how long a copy is used and `store_idle_seconds` how long it lasts unused. Spin's key-value store gives an entry no lifetime, so there an entry that nobody reads again stays in the store, because only a read past its drop time, or a removal, takes it out. +- **Keys and variants without a limit.** Each query string, each host the reader asked, each scheme, and each combination of values for the headers `Vary` names is a key of its own. A miss writes the copy or the marker to the shared store, and the record too when the address has none, or the record alone when the origin's `Vary` allows no copies. This change sets no limit on the number of keys. `store_seconds` limits how long a copy is used and `store_idle_seconds` how long it lasts unused. Spin's key-value store gives an entry no lifetime, so there an entry that nobody reads again stays in the store, because only a read past its drop time, or a removal, takes it out. - **The fingerprint.** The publisher path computes `template_fingerprint`, a digest of all the serialized settings, for each eligible request, whether or not a copy is found. - **Memory.** The memory layer holds at most 256 records and 32 copies, and a copy's body is at most 1 MiB, so the copies take up to about 32 MiB for as long as the layer lives. The request that leads a load also holds the whole body while it reads. -- **Reads and writes of the shared store.** A request that misses the memory layer reads the shared store up to twice, once for the record and once for the copy, and a load writes both. The memory layer keeps an entry for at most `memory_seconds`, 5 seconds by default, and it is not shared between requests on Spin or, by default, on Fastly, so there every request reads the store. On Cloudflare and Spin the store is the host's key-value store, when the host supplies one, and a write to Cloudflare's store is given a lifetime of at least a minute (`KV_SHORTEST`), because Workers KV refuses less, with the entry's own drop time holding the exact lifetime. On Axum the adapter's store is `UnavailableKvStore`, so every read and write fails and is logged at debug level, a failed read counts as a miss, and only the memory layer holds copies. +- **Reads and writes of the shared store.** A request that misses the memory layer reads the shared store up to twice, once for the record and once for the copy. The record's load writes the record with the copy or the marker, or the record alone when the origin's `Vary` allows no copies, and a copy's load writes only the copy or the marker. The memory layer keeps an entry for at most `memory_seconds`, 5 seconds by default, and it is not shared between requests on Spin or, by default, on Fastly, so there every request reads the store. On Cloudflare and Spin the store is the host's key-value store, when the host supplies one, and a write to Cloudflare's store is given a lifetime of at least a minute (`KV_SHORTEST`), because Workers KV refuses less, with the entry's own drop time holding the exact lifetime. On Axum the adapter's store is `UnavailableKvStore`, so every read and write fails and is logged at debug level, a failed read counts as a miss, and only the memory layer holds copies. - **Purges.** `ts cache purge` does not reach a copy. It purges by the `ts-template` key or by a page's key, and a copy in Fastly's cache carries neither, only a key made of `vc-` and a hash of its own key (the Fastly adapter's `value_cache.rs`). A copy on another adapter sits in the key-value store or, on Axum, in memory, and `ts cache purge` touches neither. **Rollout for existing deployments.** 1. `[cache.assets]` is optional and is left out when written back (`skip_serializing_if = "LoadingCacheConfig::is_default"`), so settings written before this change read as they did. -2. A build that links no loading cache module holds nothing, and a build that links several and names none holds nothing. Both behave as before. +2. A build that links no loading cache module holds nothing, and a build that links several and names no module holds nothing. Both behave as before. 3. A stock build links exactly one loading cache module, `loading-cache.51degrees`, so it starts holding shareable assets with no change to its settings. A change at the origin then shows after the lifetime the origin gave the file, and never later than `store_seconds`, which defaults to one hour. The checks in "What is stored" and the cookie rule under Consent and privacy then apply to its asset traffic from the first deploy. -4. No setting turns the cache off while exactly one module is linked. `loading_caches_from` has no spelling for "none" and refuses a module name that is not linked. A deployment that does not want its assets held builds its module list without the loading cache module. +4. `[cache.assets] module = "none"` turns both caches off while a module is linked, and both paths then ask the origin for every asset as they did before this change. A build that links no loading cache module holds nothing as well. 5. Every change to the settings and every new build changes the fingerprint, so a publisher path copy made earlier is not found again and ends by its lifetime. Nothing purges it. The publisher path therefore starts with no copy of any asset after each build or settings change, and each asset is fetched from the origin again. A route's key has no fingerprint, so a route's copy outlives a settings change, including a change to the route's credentials. 6. The deploy check lists the new cache names. The error that names a misspelt table now includes `assets` and `assets-vary`. -**Open questions.** Two choices are open, and this change makes neither. +**Open questions.** One choice is open, and this change does not make it. -1. The cookie rule for assets. Should the asset gate apply the page path's cookie rules, or keep the current rule, which reads no cookie (see "The asset gate does not apply the page path's cookie rules")? -2. An off switch. Should a setting turn the asset caches off for a build that links the loading cache module (see item 4 of the rollout)? +1. A publisher's word for assets. Should a setting let a publisher say that cookies do not shape its assets, as `origin_is_cookie_independent` says of its HTML, so that a request on the publisher path that carries a cookie may read and lead copies (see "The page path's cookie settings are not read" and "The cost")? ## 3. What is not in this change @@ -172,38 +173,36 @@ The stock module, `crates/loading-cache/51degrees`, uses a host's value cache wh - **Requests a copy cannot answer.** `HEAD`, other methods, `Authorization`, ranges, conditional requests, and a `Cache-Control` or `Pragma` that asks for the origin always go to the origin. - **Files over 1 MiB.** A file over 1 MiB is served as it streams from the origin. - **A way to purge one asset.** This change adds no purge for the asset caches, and `ts cache purge` does not reach a copy (see Cost and limits). -- **A setting that turns the asset caches off**, which Open questions raises, and settings for the memory layer's sizes. -- **Enforcing the ranges for `[cache.assets]`**, as section 2 records. +- **Settings for the memory layer's sizes.** The counts of records and copies are literals in `fn asset_cache`. ## 4. Acceptance -The tests below exist. Those in `publisher.rs` and `proxy.rs` use `StubHttpClient` as the origin and `MemoryLoadingCache` from `crates/trusted-server-core/src/platform/test_support.rs` as the loading cache, apart from the `proxy.rs` test that has no cache. Those in `source_cache.rs` use `MemoryLoadingCache` and a counting function as the origin. Those in `asset_cache.rs`, `settings.rs`, `integrations/mod.rs` and the Axum `seam_probe.rs` use neither. The pull request records the fork's CI at `f6e598fda` as 19 of 19 checks green. +The tests below exist. Those in `publisher.rs` and `proxy.rs` use `StubHttpClient` as the origin and `MemoryLoadingCache` from `crates/trusted-server-core/src/platform/test_support.rs` as the loading cache, apart from the `proxy.rs` test that has no cache and the `publisher.rs` test that selects the caches from the settings with `loading_caches_from`, as an adapter does. Those in `source_cache.rs` use `MemoryLoadingCache` and a counting function as the origin. Those in `asset_cache.rs`, `settings.rs`, `integrations/mod.rs` and the Axum `seam_probe.rs` use neither. The pull request records the fork's CI at `f6e598fda` as 19 of 19 checks green. -| Where | Tests | -| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `asset_cache.rs` | Every input changes the key and none appears in it. An entry reads back as written. A short, foreign or other-format entry is not read. A copy keeps no connection, cookie or length header. A served copy counts its age from when it was made | -| `source_cache.rs` | Record and copy keys follow the publisher. `Vary` names are lowercase, sorted and named once. `*`, `Cookie` and an unreadable `Vary` allow no copies. A record reads back, and a foreign one does not. A variant tells absent, empty and reordered values apart. The first response records its `Vary` and each value gets its own copy. A response that allows no copies has every request fetch its own. A source that changes its `Vary` has its record dropped. A declared token separates copies | -| `publisher.rs`, module `asset_cache_tests` | An image is kept as sent and one fetch serves three requests. A stylesheet is served from one fetch with its age. `Vary: *`, `Vary: Cookie`, `private`, `no-store` and `public, no-store` are never kept. `s-maxage` then `max-age` set the lifetime. A header named in `Vary` separates copies. A body over 1 MiB is served whole from one fetch, with the stub streaming and not. A response whose `Vary` changed is served from its one fetch. A request carrying `Authorization` is fetched for itself. A page navigation never reads a copy | -| `proxy.rs` | A route's answer is held and serves the next request, with its type, an `Age`, its bytes and the origin's lifetime. An answer with `Set-Cookie` is fetched each time. A range request is fetched. `no-cache`, `no-store` and `min-fresh=10` are fetched. Nothing is held while no cache keeps values | -| `settings.rs`, `integrations/mod.rs`, Axum `seam_probe.rs` | The list of core's caches now holds `assets` and `assets-vary`, in the settings listing, in the refusal that names a misspelt table, and in the Axum deploy check | +| Where | Tests | +| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `asset_cache.rs` | Every input changes the key and none appears in it. An entry reads back as written. A short, foreign or other-format entry is not read. A copy keeps no connection, cookie or length header. A served copy counts its age from when it was made | +| `source_cache.rs` | Record and copy keys follow the publisher. `Vary` names are lowercase, sorted and named once. `*`, `Cookie` and an unreadable `Vary` allow no copies. A record reads back, and a foreign one does not. A variant tells absent, empty and reordered values apart. The first response records its `Vary` and each value gets its own copy. A response that allows no copies has every request fetch its own. A source that changes its `Vary` has its record dropped. A declared token separates copies | +| `publisher.rs`, module `asset_cache_tests` | An image is kept as sent and one fetch serves three requests. A stylesheet is served from one fetch with its age. `Vary: *`, `Vary: Cookie`, `private`, `no-store` and `public, no-store` are never kept. `s-maxage` then `max-age` set the lifetime. A header named in `Vary` separates copies. A body over 1 MiB is served whole from one fetch, with the stub streaming and not. A response whose `Vary` changed is served from its one fetch. A request carrying `Authorization` is fetched for itself. A request carrying a cookie is fetched for itself and its answer kept for no one. An answer that sets a cookie is served only to the reader it was made for. Assets turned off in the settings are fetched for every request, and one fetch serves two requests when the settings name no module. A page navigation never reads a copy | +| `proxy.rs` | A route's answer is held and serves the next request, with its type, an `Age`, its bytes and the origin's lifetime. An answer with `Set-Cookie` is fetched each time. A reader's cookie never reaches a route's origin, so the answer is shared. A range request is fetched. `no-cache`, `no-store` and `min-fresh=10` are fetched. Nothing is held while no cache keeps values | +| `settings.rs`, `integrations/mod.rs`, Axum `seam_probe.rs` | The list of core's caches now holds `assets` and `assets-vary`, in the settings listing, in the refusal that names a misspelt table, and in the Axum deploy check. A lifetime out of range in `[cache.robots-txt]` or `[cache.assets]` is refused, naming the table and the field. Both asset caches take the one module offered when `[cache.assets]` names no module, and `none` turns both off and leaves `robots-txt` as it was | Joins with no test: 1. **Concurrent requests.** Every test of the asset paths sends requests one after another. The loading cache module has tests for many callers loading a missing key once (`many_callers_on_many_threads_load_a_missing_key_once_in_the_caller`) and for a failed load reaching every waiter (`a_failed_load_reaches_every_waiter_and_is_not_kept`), and those load a generic value and not an asset. 2. **A real shared store.** No test runs the asset caches against Fastly's cache, a key-value store, or two instances. The Axum, Cloudflare and Spin adapters have no test of asset holding. -3. **Selecting `[cache.assets]`.** No test names the table, or checks that both `assets` and `assets-vary` take the one linked module when the table is absent, or writes a `[cache.assets-vary]` table. -4. **The range check.** The code does not run the range check for `[cache.assets]`, and no test covers an out-of-range value. -5. **The publisher path with no loading cache.** No test in `asset_cache_tests` names that case. -6. **Publisher path cases.** No test covers an HTML response to a non-document request, a `Content-Length` over the limit, a response with `Set-Cookie`, a lifetime from `Expires` or `Surrogate-Control`, a request with a conditional header, a range, `Cache-Control` or `Pragma`, a request that carries `Cookie`, request state left by a module, a personalized response, a request with no host, or a header that is not visible text. No test sends a `Cookie` header through the publisher path, so nothing pins the cookie rule under Consent and privacy. -7. **Asset route cases.** No test covers an image optimizer route, a route with `auth`, a request with `Authorization`, a method other than `GET`, or a request with no host. -8. **Streaming with a stored copy.** The route tests turn streaming off (`held_asset_stub`), and the publisher tests leave it off except in the oversized body test, which stores nothing. A stored copy served on an adapter that streams has no test. -9. **Two publishers in one cache.** The key test in `source_cache.rs` and the module's `two_publishers_never_share_an_entry` cover the prefix, and no test serves two publishers through either asset path. -10. **Reordered page checks.** `origin_response_may_be_kept` moves the status check ahead of the cookie and edge header checks in `template_cache_ttl`. A page response with a non-`200` status and a fault found by a later check is now reported as a non-`200` status, and no test was added for the changed order. -11. **A module declaring a cache of core's.** `a_builder_declaring_core_s_own_cache_is_refused` declares `robots-txt` only, so no test has a module declare `assets` or `assets-vary`. +3. **A `[cache.assets-vary]` table.** No test writes one. +4. **Publisher path cases.** No test covers an HTML response to a non-document request, a `Content-Length` over the limit, a lifetime from `Expires` or `Surrogate-Control`, a request with a conditional header, a range, `Cache-Control` or `Pragma`, request state left by a module, a personalized response, a request with no host, or a header that is not visible text. +5. **Asset route cases.** No test covers an image optimizer route, a route with `auth`, a request with `Authorization`, a method other than `GET`, or a request with no host. +6. **Streaming with a stored copy.** The route tests turn streaming off (`held_asset_stub`), and the publisher tests leave it off except in the oversized body test, which stores nothing. A stored copy served on an adapter that streams has no test. +7. **Two publishers in one cache.** The key test in `source_cache.rs` and the module's `two_publishers_never_share_an_entry` cover the prefix, and no test serves two publishers through either asset path. +8. **Reordered page checks.** `origin_response_may_be_kept` moves the status check ahead of the cookie and edge header checks in `template_cache_ttl`. A page response with a non-`200` status and a fault found by a later check is now reported as a non-`200` status, and no test was added for the changed order. +9. **A module declaring a cache of core's.** `a_builder_declaring_core_s_own_cache_is_refused` declares `robots-txt` only, so no test has a module declare `assets` or `assets-vary`. ## Review log 1. **Round 1, 2026-10-15.** A review in a fresh session, against `split/34-asset-cache` at `67af14ea4`. Issues found, one of them blocking. The asset gate leaves out the page path's cookie rules, so a request that carries a session cookie can read a stored copy or lead a load whose response is stored for every reader, and the spec did not say so. This revision states the cookie rule as the code has it today, with the reason the code gives and the default-on state, puts the choice of cookie rules and an off switch under Open questions, fills the coverage gaps and corrects the partly true claims, while the fixes to the code, its comments and its tests that the review advised wait for code changes. +2. **Round 2, 2026-10-15.** A lighter review in a fresh session, checking the round 1 fixes against `split/34-asset-cache` at `e9832a39e`. Issues found, none blocking. This revision states the cookie rule, the off switch and the range check of `[cache.assets]` as the code now has them, says which entries each load writes, removes the claim that no test sends a `Cookie` header through the publisher path, which navigation tests and now the asset gate's tests do, and gives the one linked module as the default in the guide and the code comments. ## Revision record @@ -211,3 +210,4 @@ Joins with no test: | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 2026-10-15 | First draft, written after the code. | | 2026-10-15 | Fixes from review round 1. The cookie rules the asset gate leaves out and what that lets through, the reason the code gives, the default-on state and the open questions on cookie rules and an off switch, `Pragma` in the gate, the verdict a response that fails a test leaves for 60 seconds, the module default, where the sizes and the `[cache.assets-vary]` table stand, the key-value store each adapter uses, how far one fetch reaches, what the caches add on Fastly, `[[cache.asset_rules]]`, the headers a route forwards, the cost and limits, the test doubles and the missing tests. | +| 2026-10-16 | Fixes to the code, and from review round 2. A request that sends the publisher's origin a cookie neither reads a copy nor leads a load, `[cache.assets] module = "none"` turns both asset caches off, and a `[cache.assets]` lifetime out of range is refused when the settings load, each with its tests. Which entries each load writes, the tests that send a cookie, and the module default in the guide and the code comments. | From a75e5dc371d4ccff417425590713a65258288061 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 20:41:49 +0100 Subject: [PATCH 12/23] The design header names the pull request that carries it --- docs/superpowers/specs/2026-10-15-asset-cache-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-10-15-asset-cache-design.md b/docs/superpowers/specs/2026-10-15-asset-cache-design.md index 0c3e67a93f..80d53e4abf 100644 --- a/docs/superpowers/specs/2026-10-15-asset-cache-design.md +++ b/docs/superpowers/specs/2026-10-15-asset-cache-design.md @@ -3,7 +3,7 @@ **Status:** Proposed, 2026-10-15. Written after the code, for draft pull request IABTechLab/trusted-server#1375 **Author:** 51Degrees (contributed), for Tech Lab review **Related specs:** `2026-10-12-loading-cache-design.md`, `2026-09-15-852-template-and-origin-caching-design.md`, `2026-04-28-multi-backend-asset-proxy-design.md`, `2026-05-19-asset-s3-auth-fastly-io-design.md`, `2026-09-08-1138-per-cookie-template-cache-policy-design.md` -**Related PRs:** IABTechLab/trusted-server#1375, reviewed on jwrosewell/trusted-server#29 +**Related PRs:** IABTechLab/trusted-server#1375, reviewed on jwrosewell/trusted-server#29 (closed) **Issues:** IABTechLab/trusted-server#1324 **Last updated:** 2026-10-16 From a1e43d8f8a3079057dc1df7825eb4655f98c3140 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 17 Oct 2026 11:09:56 +0100 Subject: [PATCH 13/23] The changelog and guide say how far one asset fetch reaches Both said an asset is fetched once however many readers and instances ask for it at the same moment. Readers share one fetch where they share a memory copy, or on Fastly a location, because Fastly's cache makes the other instances at a location wait. On Cloudflare and Spin a memory copy lasts one request, as each builds its state for every request, and the key-value store makes no request wait, so each request that misses at the same moment fetches the asset and later requests read the stored copy. That stored copy is kept once the two adapters declare their key-value stores on the loading cache branch. --- CHANGELOG.md | 2 +- docs/guide/configuration.md | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa473d443f..ca7182a14c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,7 +94,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `[ad-tag.google] write_loader` writes the loader tag, for a page that carries no loader of its own, through the serve middleware `ad-tag.google.loader`, which a `[[serve]]` entry names and `ts audit` writes where it finds GPT. The tag goes in the head after the bootstrap and asks for the loader on the module's own route. Only a reader whose permissions allow storage on the device (`necessary.operations.storage`) gets it, because GPT loaded from Google's standard address may use the device's storage at any time. That is TCF Purpose 1, which core's server-side auction requires where GDPR applies and which the Google tag module's loaders require. Off by default, because a page that already carries the loader would otherwise load it twice. -- The publisher's assets and the answers of the asset routes are held through the loading cache, selected in `[cache.assets]`, so an asset is fetched once however many readers and instances ask for it at the same moment, and the copy in the host's shared store answers every instance until the origin's own lifetime ends. A copy is kept under what the origin says it varies on, served with the headers that describe it and an `Age` that counts from the origin's, and nothing is kept that may not be shared. A request that sends the publisher's origin a cookie always goes to the origin, and its answer is not kept. A deployment that names no module takes the one loading cache module the build links, and `module = "none"`, or a build that links none, holds nothing, as before. A `[cache.assets]` lifetime outside the ranges of `[cache.robots-txt]` is refused when the settings load. +- The publisher's assets and the answers of the asset routes are held through the loading cache, selected in `[cache.assets]`, so readers who share a memory copy, or on Fastly a location, share one fetch of an asset, and the copy in the host's shared store answers later requests until the origin's own lifetime ends. A memory copy lasts one request on Cloudflare and Spin, and their key-value stores make no request wait, so there each request that misses at the same moment fetches the asset. A copy is kept under what the origin says it varies on, served with the headers that describe it and an `Age` that counts from the origin's, and nothing is kept that may not be shared. A request that sends the publisher's origin a cookie always goes to the origin, and its answer is not kept. A deployment that names no module takes the one loading cache module the build links, and `module = "none"`, or a build that links none, holds nothing, as before. A `[cache.assets]` lifetime outside the ranges of `[cache.robots-txt]` is refused when the settings load. - A Google tag module, `[tag] modules = ["google-tag"]` with `[tag.google-tag]`, from `crates/tag/google-tag`, which writes the consent mode defaults, Tag Manager's container script, the gtag.js snippet and the AdSense loader in the head immediately before the script bundle's tag, and Tag Manager's noscript iframe after the opening body tag, each copied from Google's documentation. Every reader gets the consent defaults, and only a reader whose permissions allow storage on the device (`necessary.operations.storage`) gets the loaders and the noscript iframe. The consent defaults come first, the four consent types Google requires for its own services default to denied, the tag waits `consent_defaults.wait_for_update` milliseconds (500 unless set) for a consent management platform's update before it fires with them, and `consent_defaults.region` scopes them to country or region codes, with every other visitor given all four denied. Where the Tag Manager module serves the same tag or container the loader names the first-party path, and a page's own loader for the configured tag is removed for every reader. - A middleware may write markup immediately after the opening body tag, with `body_open_inserts` on its action, once per document, beside the places it had for the head and for straight after the script bundle. - A creative-opportunity format may name `min_viewport_width` and `max_viewport_width`, and a slot with such a format asks for only the formats that fit each band of viewport widths. The slot's page data carries `viewport_sizes`, and the Google ad tag's head bootstrap and the bundle's slot definition apply it as a size mapping, define no slot where the band asks for no size, and keep every format when the mapping fails. The bundle's refresh auction offers a bidder only the sizes of the band the viewport is in for a slot defined with that mapping, and offers a slot the publisher defined its formats as before. A winning bid with no size of its own takes the band's first size, and `ts config ad-templates --details` prints each format's bounds. A slot whose formats name no bound behaves as before. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 68a45851cd..6ec0ea83ff 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1786,8 +1786,11 @@ the service starts, with the names that could be written. Every response the publisher's origin sends that is not a page, and every answer of an asset route under `[[proxy.asset_routes]]`, is held the same way, selected by `[cache.assets]`, for the lifetime the origin's cache policy gives -it and under what the origin says it varies on, so an asset is fetched once -however many readers and instances ask for it at the same moment. A copy is +it and under what the origin says it varies on, so readers who share a memory +copy, or on Fastly a location, share one fetch of an asset. On Cloudflare and +Spin, where a memory copy lasts one request and the key-value store makes no +request wait, each request that misses at the same moment fetches it, and later +requests read the stored copy. A copy is served with the headers that describe it and an `Age` that counts from the origin's. Nothing is kept that may not be shared: anything but a `GET`, anything but a `200`, HTML, a response carrying `Set-Cookie`, one whose policy From b404a39fa3b513a589c7b27731cfce257102b47b Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 17 Oct 2026 11:10:08 +0100 Subject: [PATCH 14/23] The asset cache design says what Cloudflare and Spin keep between requests How far one fetch reaches, the adapters' shared stores, how long the memory layer lives and the reads and writes of the shared store now say that the Cloudflare and Spin adapters declare their key-value stores, TRUSTED_SERVER_KV and default, so copies are kept there between requests, and that a memory layer lives as long as its application state, which is one request on Cloudflare as on Spin. The same text goes to the copy of this design on the specs branch. --- .../specs/2026-10-15-asset-cache-design.md | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/specs/2026-10-15-asset-cache-design.md b/docs/superpowers/specs/2026-10-15-asset-cache-design.md index 80d53e4abf..6efbd308f8 100644 --- a/docs/superpowers/specs/2026-10-15-asset-cache-design.md +++ b/docs/superpowers/specs/2026-10-15-asset-cache-design.md @@ -5,7 +5,7 @@ **Related specs:** `2026-10-12-loading-cache-design.md`, `2026-09-15-852-template-and-origin-caching-design.md`, `2026-04-28-multi-backend-asset-proxy-design.md`, `2026-05-19-asset-s3-auth-fastly-io-design.md`, `2026-09-08-1138-per-cookie-template-cache-policy-design.md` **Related PRs:** IABTechLab/trusted-server#1375, reviewed on jwrosewell/trusted-server#29 (closed) **Issues:** IABTechLab/trusted-server#1324 -**Last updated:** 2026-10-16 +**Last updated:** 2026-10-17 > **Why this spec exists.** A file that is not a page, whether the publisher's origin sends it or an asset route fetches it, is asked of its origin on every request and by every instance, so a burst of readers for one file is a burst of fetches. @@ -78,7 +78,7 @@ A stored copy is the origin's response, headers and body, as the origin sent it, **One fetch for many requests.** `get_or_fetch` loads the address's record through `assets-vary`. The request that leads that load fetches the origin and writes its own copy under its own values before the record is published, so a waiting request with the same values finds the copy. A waiting request with other values loads its own copy, and the requests waiting on those values share that load. A load runs in the request that started it, as every load of the loading cache does. -**How far one fetch reaches.** The memory layer shares a load among the requests that share the layer. The stock module's layer lives as long as the application state, or on Cloudflare as long as the isolate. Fastly serves one request per instance unless the opt-in reusable sandbox is on, and Spin runs a fresh instance per request (see `AppState` in the Fastly and Spin adapters' `app.rs`), so on those two the memory layer is not shared between requests. Across instances only Fastly makes requests share a load. Only the Fastly adapter registers a value cache, and a lookup in that cache makes every other instance at the same location wait for the instance that fills the key (`crates/trusted-server-adapter-fastly/src/value_cache.rs`), so a missing key is loaded once for each location and not once for the service. Cloudflare and Spin share copies only through a key-value store, which makes no instance wait, so each instance that misses at the same moment fetches the origin itself, and Axum has no shared store at all. +**How far one fetch reaches.** The memory layer shares a load among the requests that share the layer. The stock module's layer lives as long as the application state. Fastly serves one request per instance unless the opt-in reusable sandbox is on, and Cloudflare and Spin build the application state for every request (see `AppState` in the three adapters' `app.rs`), so on those three the memory layer is not shared between requests. Across instances only Fastly makes requests share a load. Only the Fastly adapter registers a value cache, and a lookup in that cache makes every other instance at the same location wait for the instance that fills the key (`crates/trusted-server-adapter-fastly/src/value_cache.rs`), so a missing key is loaded once for each location and not once for the service. Cloudflare and Spin share copies only through the key-value store each adapter declares, `TRUSTED_SERVER_KV` and `default`, which makes no request wait, so each request that misses at the same moment fetches the origin itself and later requests read the stored copy, and Axum has no shared store at all. **Serving the request whose load fetched.** A response that cannot be shared is not stored for others, yet the request that fetched it must not fetch again. `HandBack` carries the response from the load to that request. `asset_from_cache` makes one `HandBack` for each lookup, `load_asset` puts a response that cannot be shared in the slot by its id, and the lookup ends by taking the slot's response when it finds `AssetEntry::NotShareable` or `Found::NoCopies`. A request that did not lead the load finds the slot empty and asks the origin. Two other cases use the same path. A body that outgrows a copy while it is read is handed back as what was read so far followed by the rest of the stream. A response whose `Vary` no longer matches its record is handled by `copy_loader`, which removes the record, writes the marker with a lifetime of zero seconds, and serves the request what its own fetch returned, so the next request learns the new `Vary`. @@ -109,14 +109,14 @@ The range check, `LoadingCacheConfig::check`, runs in `CacheSettings::prepare_ru **How each adapter takes it.** No adapter code changes. Each adapter calls `loading_caches_from` when it builds its application state and hands the result to each request's services, so the selection reaches `asset_from_cache` through `RuntimeServices::loading_cache_for`. -| Adapter | Where the selection is made and handed on | Shared store the stock module uses | How the origin body arrives | -| ---------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------- | -| Fastly | `crates/trusted-server-adapter-fastly/src/app.rs`, handed on with `.loading_caches(...)` | Fastly's cache, through `FastlyValueCache` | Streamed, and read up to 1 MiB | -| Axum | `crates/trusted-server-adapter-axum/src/app.rs`, handed on with `.with_loading_caches(...)` | None. The adapter's key-value store is `UnavailableKvStore`, so only the memory layer holds a copy | Whole, as the adapter does not stream | -| Cloudflare | `crates/trusted-server-adapter-cloudflare/src/app.rs`, with `.with_loading_caches(...)` | The key-value store the host binds as `TRUSTED_SERVER_KV`, when it binds one | Whole, as the adapter does not stream | -| Spin | `crates/trusted-server-adapter-spin/src/app.rs`, handed on with `.with_loading_caches(...)` | The host's default key-value store, which gives an entry no lifetime | Whole, as the adapter does not stream | +| Adapter | Where the selection is made and handed on | Shared store the stock module uses | How the origin body arrives | +| ---------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------- | +| Fastly | `crates/trusted-server-adapter-fastly/src/app.rs`, handed on with `.loading_caches(...)` | Fastly's cache, through `FastlyValueCache` | Streamed, and read up to 1 MiB | +| Axum | `crates/trusted-server-adapter-axum/src/app.rs`, handed on with `.with_loading_caches(...)` | None. The adapter's key-value store is `UnavailableKvStore`, so only the memory layer holds a copy | Whole, as the adapter does not stream | +| Cloudflare | `crates/trusted-server-adapter-cloudflare/src/app.rs`, with `.with_loading_caches(...)` | The `TRUSTED_SERVER_KV` namespace, which the adapter declares, so a Worker without the binding fails every request | Whole, as the adapter does not stream | +| Spin | `crates/trusted-server-adapter-spin/src/app.rs`, handed on with `.with_loading_caches(...)` | The `default` key-value store, which the adapter declares and which gives an entry no lifetime | Whole, as the adapter does not stream | -The stock module, `crates/loading-cache/51degrees`, uses a host's value cache when the services carry one and the key-value store otherwise (`through_store` in `stores.rs`). Only the Fastly adapter registers a value cache. The module's memory layer lives as long as the application state, except on Cloudflare, where one cache lives as long as the isolate. Only the Fastly adapter overrides `supports_streaming_responses`, so the others hand `load_asset` a body that is already whole. +The stock module, `crates/loading-cache/51degrees`, uses a host's value cache when the services carry one and the key-value store otherwise (`through_store` in `stores.rs`). Only the Fastly adapter registers a value cache. The module's memory layer lives as long as the application state, which is one request on Cloudflare and Spin. Only the Fastly adapter overrides `supports_streaming_responses`, so the others hand `load_asset` a body that is already whole. **What the caches add on Fastly.** The origin fetch asks for the platform's default cache behavior (`PlatformCacheIntent::Default`). The Fastly adapter leaves the request alone for that intent (`apply_fastly_cache_intent`), so Fastly's readthrough cache stays on, and `apply_origin_cache_intent` keeps subresources on that default. Fastly's own cache therefore already holds a subresource for the lifetime the origin gives it, and a load can be answered from that cache and not from the origin. The asset caches add a second copy, held in the value cache for at most `store_seconds`. This change makes no measurement of the gain over the platform's cache on Fastly. The Cloudflare adapter likewise leaves the Workers runtime's default cache behavior in place for that intent (`outbound_cache_mode`). @@ -144,10 +144,10 @@ The stock module, `crates/loading-cache/51degrees`, uses a host's value cache wh - **A slow origin.** For a response that passes the header checks, the request that leads a load waits for the whole body, up to 1 MiB, before its reader is served, and each request waiting on that load waits as long. An origin fetch has a first byte timeout and a between bytes timeout of 15 seconds, on the publisher path (`DEFAULT_PUBLISHER_FIRST_BYTE_TIMEOUT`) and on an asset route (`DEFAULT_FIRST_BYTE_TIMEOUT`). A failed load reaches every request waiting on it and each of them then fetches the origin itself, so a reader can wait for one failed load and then for a fetch of their own. - **Removals.** When a response no longer varies as its record says, the request that fetched it removes the record, and a lookup that finds an entry it cannot read removes that entry. On Fastly a removal is a purge by surrogate key (`purge_surrogate_key` in the Fastly adapter's `value_cache.rs`), and on a key-value store it is a delete. An origin whose `Vary` differs from one response to the next causes a removal for each response that differs from the record. -- **Keys and variants without a limit.** Each query string, each host the reader asked, each scheme, and each combination of values for the headers `Vary` names is a key of its own. A miss writes the copy or the marker to the shared store, and the record too when the address has none, or the record alone when the origin's `Vary` allows no copies. This change sets no limit on the number of keys. `store_seconds` limits how long a copy is used and `store_idle_seconds` how long it lasts unused. Spin's key-value store gives an entry no lifetime, so there an entry that nobody reads again stays in the store, because only a read past its drop time, or a removal, takes it out. +- **Keys and variants without a limit.** Each query string, each host the reader asked, each scheme, and each combination of values for the headers `Vary` names is a key of its own. A miss writes the copy or the marker to the shared store, and the record too when the address has none, or the record alone when the origin's `Vary` allows no copies. This change sets no limit on the number of keys. `store_seconds` limits how long a copy is used and `store_idle_seconds` how long it lasts unused. Spin's `default` store, which the Spin adapter declares, gives an entry no lifetime, so there an entry that nobody reads again stays in the store, because only a read past its drop time, or a removal, takes it out. - **The fingerprint.** The publisher path computes `template_fingerprint`, a digest of all the serialized settings, for each eligible request, whether or not a copy is found. - **Memory.** The memory layer holds at most 256 records and 32 copies, and a copy's body is at most 1 MiB, so the copies take up to about 32 MiB for as long as the layer lives. The request that leads a load also holds the whole body while it reads. -- **Reads and writes of the shared store.** A request that misses the memory layer reads the shared store up to twice, once for the record and once for the copy. The record's load writes the record with the copy or the marker, or the record alone when the origin's `Vary` allows no copies, and a copy's load writes only the copy or the marker. The memory layer keeps an entry for at most `memory_seconds`, 5 seconds by default, and it is not shared between requests on Spin or, by default, on Fastly, so there every request reads the store. On Cloudflare and Spin the store is the host's key-value store, when the host supplies one, and a write to Cloudflare's store is given a lifetime of at least a minute (`KV_SHORTEST`), because Workers KV refuses less, with the entry's own drop time holding the exact lifetime. On Axum the adapter's store is `UnavailableKvStore`, so every read and write fails and is logged at debug level, a failed read counts as a miss, and only the memory layer holds copies. +- **Reads and writes of the shared store.** A request that misses the memory layer reads the shared store up to twice, once for the record and once for the copy. The record's load writes the record with the copy or the marker, or the record alone when the origin's `Vary` allows no copies, and a copy's load writes only the copy or the marker. The memory layer keeps an entry for at most `memory_seconds`, 5 seconds by default, and it is not shared between requests on Cloudflare, on Spin or, by default, on Fastly, so there every request reads the store. On Cloudflare and Spin the store is the key-value store the adapter declares, `TRUSTED_SERVER_KV` or `default`, and a write to Cloudflare's store is given a lifetime of at least a minute (`KV_SHORTEST`), because Workers KV refuses less, with the entry's own drop time holding the exact lifetime. On Axum the adapter's store is `UnavailableKvStore`, so every read and write fails and is logged at debug level, a failed read counts as a miss, and only the memory layer holds copies. - **Purges.** `ts cache purge` does not reach a copy. It purges by the `ts-template` key or by a page's key, and a copy in Fastly's cache carries neither, only a key made of `vc-` and a hash of its own key (the Fastly adapter's `value_cache.rs`). A copy on another adapter sits in the key-value store or, on Axum, in memory, and `ts cache purge` touches neither. **Rollout for existing deployments.** @@ -211,3 +211,4 @@ Joins with no test: | 2026-10-15 | First draft, written after the code. | | 2026-10-15 | Fixes from review round 1. The cookie rules the asset gate leaves out and what that lets through, the reason the code gives, the default-on state and the open questions on cookie rules and an off switch, `Pragma` in the gate, the verdict a response that fails a test leaves for 60 seconds, the module default, where the sizes and the `[cache.assets-vary]` table stand, the key-value store each adapter uses, how far one fetch reaches, what the caches add on Fastly, `[[cache.asset_rules]]`, the headers a route forwards, the cost and limits, the test doubles and the missing tests. | | 2026-10-16 | Fixes to the code, and from review round 2. A request that sends the publisher's origin a cookie neither reads a copy nor leads a load, `[cache.assets] module = "none"` turns both asset caches off, and a `[cache.assets]` lifetime out of range is refused when the settings load, each with its tests. Which entries each load writes, the tests that send a cookie, and the module default in the guide and the code comments. | +| 2026-10-17 | The code fix on row 24, where the Cloudflare and Spin adapters declare their key-value stores, `TRUSTED_SERVER_KV` and `default`, so copies are kept there between requests, and where each application state has its own memory layer, Cloudflare included. How far one fetch reaches, the adapters' shared stores, how long the memory layer lives, and the reads and writes of the shared store say so. | From c0392902982b317a836e0d70e7c7a77e096fed7f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 18 Oct 2026 21:51:59 +0100 Subject: [PATCH 15/23] A failed asset load is the answer, so the origin is asked once When the load a request ran or waited on failed asking the origin, asset_from_cache answered nothing, and the publisher path and the asset route then sent the same request to the origin again. A failure that could not succeed, such as a response over the platform's body size limit, was asked for twice, and an origin that was already failing was asked once more by every request that had waited on the load. asset_from_cache now hands the load's failure to its caller, which answers as it does when its own origin request fails, with a 502. A test on each path counts one origin request. --- crates/trusted-server-core/src/proxy.rs | 47 ++++++++--- crates/trusted-server-core/src/publisher.rs | 87 +++++++++++++++++---- 2 files changed, 111 insertions(+), 23 deletions(-) diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 13a51a83c9..4339be11cd 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -1204,8 +1204,9 @@ pub async fn handle_asset_proxy_request( .await; } - let platform_resp = if let Some(response) = from_asset_cache { - PlatformResponse::new(response) + // A load that failed asking the origin is this request's origin failure. + let origin_result = if let Some(answer) = from_asset_cache { + answer.map(PlatformResponse::new) } else { let mut platform_req = build_asset_platform_request( req.method(), @@ -1219,14 +1220,11 @@ pub async fn handle_asset_proxy_request( if stream_response { platform_req = platform_req.with_stream_response(); } - services - .http_client() - .send(platform_req) - .await - .change_context(TrustedServerError::Proxy { - message: "Failed to proxy asset request".to_string(), - })? + services.http_client().send(platform_req).await }; + let platform_resp = origin_result.change_context(TrustedServerError::Proxy { + message: "Failed to proxy asset request".to_string(), + })?; let mut response = if stream_response { platform_response_to_fastly_asset(platform_resp) @@ -5338,6 +5336,37 @@ mod tests { }); } + #[test] + fn an_asset_route_load_that_fails_is_the_answer_so_the_origin_is_asked_once() { + futures::executor::block_on(async { + let stub = held_asset_stub(); + let cache = Arc::new(crate::platform::test_support::MemoryLoadingCache::default()); + let services = asset_route_services(&stub, &cache); + let settings = create_test_settings(); + let route = ProxyAssetRoute::new("/.images/", "https://assets.example.com"); + let request = + build_http_request(Method::GET, "https://www.example.com/.images/site.css"); + + // Nothing is queued, so the stub fails the origin request. + let Err(error) = + handle_asset_proxy_request(&settings, &services, request, &route).await + else { + panic!("should fail when the origin request fails"); + }; + + assert_eq!( + error.current_context().status_code(), + StatusCode::BAD_GATEWAY, + "should answer as a failed origin request does" + ); + assert_eq!( + stub.recorded_request_uris().len(), + 1, + "should ask the origin once, because the failed load is the answer" + ); + }); + } + #[test] fn handle_asset_proxy_request_forwards_asset_headers_and_host() { futures::executor::block_on(async { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 2b552c970d..dfbf1e0389 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -5968,6 +5968,7 @@ pub async fn handle_publisher_request( // origin TTFB tracks origin latency rather than the auction timeout. // Any other response is looked for the same way, before the origin is // fetched, and a copy found is served as the origin's response would be. + // A load that failed asking the origin is this request's origin failure. let mut from_asset_cache = None; if let Some(key) = asset_cache_key.as_ref() { let prepared = origin_request.as_ref().ok_or_else(|| { @@ -5986,8 +5987,8 @@ pub async fn handle_publisher_request( }) .await; } - let origin_result = if let Some(response) = from_asset_cache { - Ok(crate::platform::PlatformResponse::new(response)) + let origin_result = if let Some(answer) = from_asset_cache { + answer.map(crate::platform::PlatformResponse::new) } else if let Some(pending) = pending_origin { services.http_client().wait(pending).await } else { @@ -8353,10 +8354,14 @@ pub(crate) struct AssetRequest<'a> { pub(crate) stream_response: bool, } -/// The asset cache's answer for `request`, being a kept copy served as the -/// origin's response would be, or the response this request's own load -/// fetched and may not share, or `None` when the origin is to be asked. -pub(crate) async fn asset_from_cache(request: &AssetRequest<'_>) -> Option> { +/// The asset cache's answer for `request`, or `None` when the origin is to be +/// asked. The answer is a kept copy served as the origin's response would be, +/// the response this request's own load fetched and may not share, or the +/// failure of the load this request ran or waited on, which the caller treats +/// as its own origin request failing, so the origin is not asked again. +pub(crate) async fn asset_from_cache( + request: &AssetRequest<'_>, +) -> Option, Report>> { let services = request.services; let cache = asset_cache(request.settings); let address = asset_address(services, request.key); @@ -8388,11 +8393,11 @@ pub(crate) async fn asset_from_cache(request: &AssetRequest<'_>) -> Option match crate::asset_cache::AssetEntry::decode(&value) { Ok(crate::asset_cache::AssetEntry::Copy(copy)) => { - Some(copy.into_response(unix_seconds())) + Some(Ok(copy.into_response(unix_seconds()))) } // This request's own load fetched the asset, so it is served from // that fetch rather than a second one. - Ok(crate::asset_cache::AssetEntry::NotShareable) => hand_back.take(), + Ok(crate::asset_cache::AssetEntry::NotShareable) => hand_back.take().map(Ok), Err(miss) => { log::warn!("asset_cache invalid entry: {miss}; removing it"); services @@ -8402,10 +8407,14 @@ pub(crate) async fn asset_from_cache(request: &AssetRequest<'_>) -> Option hand_back.take(), + Ok(Found::NoCopies) => hand_back.take().map(Ok), + // The load failed asking the origin, and every request waiting on it + // is given that failure, so none of them asks the origin again. Err(error) => { - log::warn!("asset_cache load failed: {error}; fetching the origin"); - None + log::warn!("asset_cache load failed: {error}"); + Some(Err( + Report::new(error).change_context(crate::platform::PlatformError::HttpClient) + )) } } } @@ -8755,6 +8764,18 @@ mod tests { services: &RuntimeServices, request: Request, ) -> Response { + try_run(settings, services, request) + .await + .expect("should proxy the asset request") + } + + /// The publisher path's finished response to `request`, or the error + /// it answers with. + async fn try_run( + settings: &Arc, + services: &RuntimeServices, + request: Request, + ) -> Result, Report> { let registry = IntegrationRegistry::new(settings).expect("should create integration registry"); let orchestrator = AuctionOrchestrator::new( @@ -8781,8 +8802,7 @@ mod tests { request, EdgeCacheHeader::SMaxageFallback, ) - .await - .expect("should proxy the asset request"); + .await?; buffer_publisher_response_async( publisher_response, &Method::GET, @@ -8792,7 +8812,6 @@ mod tests { services, ) .await - .expect("should finalize the asset response") } async fn body_of(response: Response) -> Vec { @@ -9046,6 +9065,46 @@ mod tests { ); } + #[tokio::test] + async fn a_load_that_fails_is_the_answer_so_the_origin_is_asked_once() { + use crate::error::IntoHttpResponse as _; + + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryLoadingCache::default()); + let services = services(&stub, &cache); + let settings = settings(); + + // Nothing is queued, so the stub fails the origin request. + let request = asset_request("/site.css", "style"); + let Err(error) = try_run(&settings, &services, request).await else { + panic!("should fail when the origin request fails"); + }; + assert_eq!( + error.current_context().status_code(), + StatusCode::BAD_GATEWAY, + "a failed load answers as a failed origin request does" + ); + assert_eq!( + stub.recorded_request_uris().len(), + 1, + "the failed load is the request's answer, so the origin is asked once" + ); + assert!(cache.keys().is_empty(), "nothing is kept for a failed load"); + + queue( + &stub, + "text/css", + b"body{}", + &[("cache-control", "public, max-age=300")], + ); + let response = run(&settings, &services, asset_request("/site.css", "style")).await; + assert_eq!( + body_of(response).await, + b"body{}", + "the next request loads the asset again" + ); + } + #[tokio::test] async fn a_request_carrying_authorization_is_fetched_for_itself() { let stub = Arc::new(StubHttpClient::new()); From cc32b22e57e3c3b19b9ae609f411ad3d9f61be2b Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 18 Oct 2026 21:52:01 +0100 Subject: [PATCH 16/23] The asset cache design says a failed load is the answer The design, failure handling, the cost of a slow origin and the tests now say that a load that fails asking the origin is the answer to every request that ran or waited on it, a 502, so the origin is asked once and the next request loads again. --- .../specs/2026-10-15-asset-cache-design.md | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/specs/2026-10-15-asset-cache-design.md b/docs/superpowers/specs/2026-10-15-asset-cache-design.md index 6efbd308f8..cc6fa52727 100644 --- a/docs/superpowers/specs/2026-10-15-asset-cache-design.md +++ b/docs/superpowers/specs/2026-10-15-asset-cache-design.md @@ -5,7 +5,7 @@ **Related specs:** `2026-10-12-loading-cache-design.md`, `2026-09-15-852-template-and-origin-caching-design.md`, `2026-04-28-multi-backend-asset-proxy-design.md`, `2026-05-19-asset-s3-auth-fastly-io-design.md`, `2026-09-08-1138-per-cookie-template-cache-policy-design.md` **Related PRs:** IABTechLab/trusted-server#1375, reviewed on jwrosewell/trusted-server#29 (closed) **Issues:** IABTechLab/trusted-server#1324 -**Last updated:** 2026-10-17 +**Last updated:** 2026-10-18 > **Why this spec exists.** A file that is not a page, whether the publisher's origin sends it or an asset route fetches it, is asked of its origin on every request and by every instance, so a burst of readers for one file is a burst of fetches. @@ -19,7 +19,7 @@ The loading cache that core declares (`2026-10-12-loading-cache-design.md`) load ## 2. Design -Core holds the origin's response for a file in two loading caches, `assets-vary` and `assets`, and the one table `[cache.assets]` selects both. The publisher path and the asset routes both call `asset_from_cache` in `publisher.rs`. That function answers with a stored copy, or with the response its own fetch made, or with nothing, and a caller that gets nothing asks the origin as it did before this change. +Core holds the origin's response for a file in two loading caches, `assets-vary` and `assets`, and the one table `[cache.assets]` selects both. The publisher path and the asset routes both call `asset_from_cache` in `publisher.rs`. That function answers with a stored copy, with the response its own fetch made, with the failure of the load the request ran or waited on, or with nothing. A caller given the failure answers as it does when its own origin request fails, and a caller that gets nothing asks the origin as it did before this change. **Where the code is.** @@ -132,9 +132,9 @@ The stock module, `crates/loading-cache/51degrees`, uses a host's value cache wh - **On by default.** Item 3 of the rollout starts the cache on a stock build with no change to its settings, and `[cache.assets] module = "none"` turns it off (item 4). A build that links the loading cache module applies the cookie rule to every eligible asset request from its first deploy. - **Logs.** The lines `asset_cache bypass`, `asset_cache stored` and `asset_cache invalid entry` carry a fixed reason and a byte count, and no URL, key or header. The line `asset_cache load failed` carries the error text of the failed fetch, which includes the platform's error report as the platform formats it, and this change does not filter that text. -**Failure handling.** Each failure ends with the caller asking the origin, as before this change, or with the reader served the response the request's own fetch made. +**Failure handling.** A load that fails asking the origin ends with every request that ran or waited on it answering with that failure. Each other failure ends with the caller asking the origin, as before this change, or with the reader served the response the request's own fetch made. -- **The origin cannot be reached during a load.** `load_asset` returns a `LoadError`. Every request waiting on that load receives it, nothing is stored, `asset_from_cache` logs `asset_cache load failed` at warn level, and each of those requests then asks the origin itself. A failing origin is therefore asked once more by each request that waited. +- **The origin cannot be reached during a load.** `load_asset` returns a `LoadError`. Every request waiting on that load receives it and nothing is stored. `asset_from_cache` logs `asset_cache load failed` at warn level and hands the failure to its caller, which answers as it does when its own origin request fails, with `Failed to proxy request to origin` on the publisher path and `Failed to proxy asset request` on an asset route, both a `502`. A failing origin is therefore asked once for all the requests that share the load, and the next request loads again. - **A stored entry cannot be read.** `AssetEntry::decode` refuses bytes that are short, unreadable or written in another format. A record this build did not write is refused in the same way. In both cases the lookup logs at warn level, removes the entry, and the request asks the origin. A format version is in the key, in the entry and in the record, so a build that changes the format does not read the old entries. - **The shared store fails.** The key-value layer treats a failed read as a miss, so the value is loaded, and logs a failed read, write or removal at debug level. The value cache that Fastly provides reports no errors to the module. - **An error status.** Only a `200` is stored, so the body of an origin error or of a `404` is never held. The verdict is held, as "What is stored" says. For 60 seconds the requests that find the marker ask the origin themselves and none of them stores a copy, so a file the origin begins to answer with a `200` is stored only once the marker ends. @@ -142,7 +142,7 @@ The stock module, `crates/loading-cache/51degrees`, uses a host's value cache wh **Cost and limits.** -- **A slow origin.** For a response that passes the header checks, the request that leads a load waits for the whole body, up to 1 MiB, before its reader is served, and each request waiting on that load waits as long. An origin fetch has a first byte timeout and a between bytes timeout of 15 seconds, on the publisher path (`DEFAULT_PUBLISHER_FIRST_BYTE_TIMEOUT`) and on an asset route (`DEFAULT_FIRST_BYTE_TIMEOUT`). A failed load reaches every request waiting on it and each of them then fetches the origin itself, so a reader can wait for one failed load and then for a fetch of their own. +- **A slow origin.** For a response that passes the header checks, the request that leads a load waits for the whole body, up to 1 MiB, before its reader is served, and each request waiting on that load waits as long. An origin fetch has a first byte timeout and a between bytes timeout of 15 seconds, on the publisher path (`DEFAULT_PUBLISHER_FIRST_BYTE_TIMEOUT`) and on an asset route (`DEFAULT_FIRST_BYTE_TIMEOUT`). A failed load reaches every request waiting on it, and each of them answers with that failure rather than fetching the origin itself. - **Removals.** When a response no longer varies as its record says, the request that fetched it removes the record, and a lookup that finds an entry it cannot read removes that entry. On Fastly a removal is a purge by surrogate key (`purge_surrogate_key` in the Fastly adapter's `value_cache.rs`), and on a key-value store it is a delete. An origin whose `Vary` differs from one response to the next causes a removal for each response that differs from the record. - **Keys and variants without a limit.** Each query string, each host the reader asked, each scheme, and each combination of values for the headers `Vary` names is a key of its own. A miss writes the copy or the marker to the shared store, and the record too when the address has none, or the record alone when the origin's `Vary` allows no copies. This change sets no limit on the number of keys. `store_seconds` limits how long a copy is used and `store_idle_seconds` how long it lasts unused. Spin's `default` store, which the Spin adapter declares, gives an entry no lifetime, so there an entry that nobody reads again stays in the store, because only a read past its drop time, or a removal, takes it out. - **The fingerprint.** The publisher path computes `template_fingerprint`, a digest of all the serialized settings, for each eligible request, whether or not a copy is found. @@ -179,13 +179,13 @@ The stock module, `crates/loading-cache/51degrees`, uses a host's value cache wh The tests below exist. Those in `publisher.rs` and `proxy.rs` use `StubHttpClient` as the origin and `MemoryLoadingCache` from `crates/trusted-server-core/src/platform/test_support.rs` as the loading cache, apart from the `proxy.rs` test that has no cache and the `publisher.rs` test that selects the caches from the settings with `loading_caches_from`, as an adapter does. Those in `source_cache.rs` use `MemoryLoadingCache` and a counting function as the origin. Those in `asset_cache.rs`, `settings.rs`, `integrations/mod.rs` and the Axum `seam_probe.rs` use neither. The pull request records the fork's CI at `f6e598fda` as 19 of 19 checks green. -| Where | Tests | -| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `asset_cache.rs` | Every input changes the key and none appears in it. An entry reads back as written. A short, foreign or other-format entry is not read. A copy keeps no connection, cookie or length header. A served copy counts its age from when it was made | -| `source_cache.rs` | Record and copy keys follow the publisher. `Vary` names are lowercase, sorted and named once. `*`, `Cookie` and an unreadable `Vary` allow no copies. A record reads back, and a foreign one does not. A variant tells absent, empty and reordered values apart. The first response records its `Vary` and each value gets its own copy. A response that allows no copies has every request fetch its own. A source that changes its `Vary` has its record dropped. A declared token separates copies | -| `publisher.rs`, module `asset_cache_tests` | An image is kept as sent and one fetch serves three requests. A stylesheet is served from one fetch with its age. `Vary: *`, `Vary: Cookie`, `private`, `no-store` and `public, no-store` are never kept. `s-maxage` then `max-age` set the lifetime. A header named in `Vary` separates copies. A body over 1 MiB is served whole from one fetch, with the stub streaming and not. A response whose `Vary` changed is served from its one fetch. A request carrying `Authorization` is fetched for itself. A request carrying a cookie is fetched for itself and its answer kept for no one. An answer that sets a cookie is served only to the reader it was made for. Assets turned off in the settings are fetched for every request, and one fetch serves two requests when the settings name no module. A page navigation never reads a copy | -| `proxy.rs` | A route's answer is held and serves the next request, with its type, an `Age`, its bytes and the origin's lifetime. An answer with `Set-Cookie` is fetched each time. A reader's cookie never reaches a route's origin, so the answer is shared. A range request is fetched. `no-cache`, `no-store` and `min-fresh=10` are fetched. Nothing is held while no cache keeps values | -| `settings.rs`, `integrations/mod.rs`, Axum `seam_probe.rs` | The list of core's caches now holds `assets` and `assets-vary`, in the settings listing, in the refusal that names a misspelt table, and in the Axum deploy check. A lifetime out of range in `[cache.robots-txt]` or `[cache.assets]` is refused, naming the table and the field. Both asset caches take the one module offered when `[cache.assets]` names no module, and `none` turns both off and leaves `robots-txt` as it was | +| Where | Tests | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `asset_cache.rs` | Every input changes the key and none appears in it. An entry reads back as written. A short, foreign or other-format entry is not read. A copy keeps no connection, cookie or length header. A served copy counts its age from when it was made | +| `source_cache.rs` | Record and copy keys follow the publisher. `Vary` names are lowercase, sorted and named once. `*`, `Cookie` and an unreadable `Vary` allow no copies. A record reads back, and a foreign one does not. A variant tells absent, empty and reordered values apart. The first response records its `Vary` and each value gets its own copy. A response that allows no copies has every request fetch its own. A source that changes its `Vary` has its record dropped. A declared token separates copies | +| `publisher.rs`, module `asset_cache_tests` | An image is kept as sent and one fetch serves three requests. A stylesheet is served from one fetch with its age. `Vary: *`, `Vary: Cookie`, `private`, `no-store` and `public, no-store` are never kept. `s-maxage` then `max-age` set the lifetime. A header named in `Vary` separates copies. A body over 1 MiB is served whole from one fetch, with the stub streaming and not. A response whose `Vary` changed is served from its one fetch. A load that fails asking the origin is the answer, a `502` after one origin request, and the next request loads again. A request carrying `Authorization` is fetched for itself. A request carrying a cookie is fetched for itself and its answer kept for no one. An answer that sets a cookie is served only to the reader it was made for. Assets turned off in the settings are fetched for every request, and one fetch serves two requests when the settings name no module. A page navigation never reads a copy | +| `proxy.rs` | A route's answer is held and serves the next request, with its type, an `Age`, its bytes and the origin's lifetime. An answer with `Set-Cookie` is fetched each time. A reader's cookie never reaches a route's origin, so the answer is shared. A range request is fetched. `no-cache`, `no-store` and `min-fresh=10` are fetched. Nothing is held while no cache keeps values. A load that fails asking the origin is the answer, a `502` after one origin request | +| `settings.rs`, `integrations/mod.rs`, Axum `seam_probe.rs` | The list of core's caches now holds `assets` and `assets-vary`, in the settings listing, in the refusal that names a misspelt table, and in the Axum deploy check. A lifetime out of range in `[cache.robots-txt]` or `[cache.assets]` is refused, naming the table and the field. Both asset caches take the one module offered when `[cache.assets]` names no module, and `none` turns both off and leaves `robots-txt` as it was | Joins with no test: @@ -212,3 +212,4 @@ Joins with no test: | 2026-10-15 | Fixes from review round 1. The cookie rules the asset gate leaves out and what that lets through, the reason the code gives, the default-on state and the open questions on cookie rules and an off switch, `Pragma` in the gate, the verdict a response that fails a test leaves for 60 seconds, the module default, where the sizes and the `[cache.assets-vary]` table stand, the key-value store each adapter uses, how far one fetch reaches, what the caches add on Fastly, `[[cache.asset_rules]]`, the headers a route forwards, the cost and limits, the test doubles and the missing tests. | | 2026-10-16 | Fixes to the code, and from review round 2. A request that sends the publisher's origin a cookie neither reads a copy nor leads a load, `[cache.assets] module = "none"` turns both asset caches off, and a `[cache.assets]` lifetime out of range is refused when the settings load, each with its tests. Which entries each load writes, the tests that send a cookie, and the module default in the guide and the code comments. | | 2026-10-17 | The code fix on row 24, where the Cloudflare and Spin adapters declare their key-value stores, `TRUSTED_SERVER_KV` and `default`, so copies are kept there between requests, and where each application state has its own memory layer, Cloudflare included. How far one fetch reaches, the adapters' shared stores, how long the memory layer lives, and the reads and writes of the shared store say so. | +| 2026-10-18 | A fix to the code. A load that fails asking the origin is the answer to every request that ran or waited on it, so the origin is asked once, with a test on each path. The design, failure handling, the cost of a slow origin and the tests say so. | From a5b62b168a7e39464152bee40b3a59cfdfdf1c22 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:29:51 +0000 Subject: [PATCH 17/23] Hold no copy of a file in core The publisher's assets and the answers of the asset routes were held in core, in asset_cache.rs and source_cache.rs and in a gate and a loader in publisher.rs and proxy.rs. That holding moves to a module, behind an interface that core gains next, so core goes back to asking the origin for every file, as it did before. [cache.assets] and [cache.assets-vary] leave with it, with module = "none", their place in declared_caches, the changelog line and the configuration guide's section. The seam probe expects only robots-txt and its own cache again. Tests show that two requests for a file the origin lets be kept are two origin requests, on the publisher path and on an asset route, with a loading cache that keeps values linked, that core declares only robots-txt, and that a [cache.assets] table is refused. MemoryLoadingCache::held stays, for the module's tests. --- CHANGELOG.md | 1 - .../tests/seam_probe.rs | 2 +- crates/trusted-server-core/src/asset_cache.rs | 426 ------- .../src/integrations/mod.rs | 82 +- crates/trusted-server-core/src/lib.rs | 2 - .../trusted-server-core/src/platform/mod.rs | 1 - .../src/platform/template_cache.rs | 18 +- .../src/platform/test_support.rs | 19 - crates/trusted-server-core/src/proxy.rs | 360 +----- crates/trusted-server-core/src/publisher.rs | 1024 +---------------- crates/trusted-server-core/src/settings.rs | 111 +- .../trusted-server-core/src/source_cache.rs | 754 ------------ docs/guide/configuration.md | 26 - 13 files changed, 169 insertions(+), 2657 deletions(-) delete mode 100644 crates/trusted-server-core/src/asset_cache.rs delete mode 100644 crates/trusted-server-core/src/source_cache.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 397931df3a..08d5daf42d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,7 +97,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `[ad-tag.google] write_loader` writes the loader tag, for a page that carries no loader of its own, through the serve middleware `ad-tag.google.loader`, which a `[[serve]]` entry names and `ts audit` writes where it finds GPT. The tag goes in the head after the bootstrap and asks for the loader on the module's own route. Only a reader whose permissions allow storage on the device (`necessary.operations.storage`) gets it, because GPT loaded from Google's standard address may use the device's storage at any time. That is TCF Purpose 1, which core's server-side auction requires where GDPR applies and which the Google tag module's loaders require. Off by default, because a page that already carries the loader would otherwise load it twice. -- The publisher's assets and the answers of the asset routes are held through the loading cache, selected in `[cache.assets]`, so readers who share a memory copy, or on Fastly a location, share one fetch of an asset, and the copy in the host's shared store answers later requests until the origin's own lifetime ends. A memory copy lasts one request on Cloudflare and Spin, and their key-value stores make no request wait, so there each request that misses at the same moment fetches the asset. A copy is kept under what the origin says it varies on, served with the headers that describe it and an `Age` that counts from the origin's, and nothing is kept that may not be shared. A request that sends the publisher's origin a cookie always goes to the origin, and its answer is not kept. A deployment that names no module takes the one loading cache module the build links, and `module = "none"`, or a build that links none, holds nothing, as before. A `[cache.assets]` lifetime outside the ranges of `[cache.robots-txt]` is refused when the settings load. - A Google tag module, `[tag] modules = ["google-tag"]` with `[tag.google-tag]`, from `crates/tag/google-tag`, which writes the consent mode defaults, Tag Manager's container script, the gtag.js snippet and the AdSense loader in the head immediately before the script bundle's tag, and Tag Manager's noscript iframe after the opening body tag, each copied from Google's documentation. Every reader gets the consent defaults, and only a reader whose permissions allow storage on the device (`necessary.operations.storage`) gets the loaders and the noscript iframe. The consent defaults come first, the four consent types Google requires for its own services default to denied, the tag waits `consent_defaults.wait_for_update` milliseconds (500 unless set) for a consent management platform's update before it fires with them, and `consent_defaults.region` scopes them to country or region codes, with every other visitor given all four denied. Where the Tag Manager module serves the same tag or container the loader names the first-party path, and a page's own loader for the configured tag is removed for every reader. - A middleware may write markup immediately after the opening body tag, with `body_open_inserts` on its action, once per document, beside the places it had for the head and for straight after the script bundle. - A creative-opportunity format may name `min_viewport_width` and `max_viewport_width`, and a slot with such a format asks for only the formats that fit each band of viewport widths. The slot's page data carries `viewport_sizes`, and the Google ad tag's head bootstrap and the bundle's slot definition apply it as a size mapping, define no slot where the band asks for no size, and keep every format when the mapping fails. The bundle's refresh auction offers a bidder only the sizes of the band the viewport is in for a slot defined with that mapping, and offers a slot the publisher defined its formats as before. A winning bid with no size of its own takes the band's first size, and `ts config ad-templates --details` prints each format's bounds. A slot whose formats name no bound behaves as before. diff --git a/crates/trusted-server-adapter-axum/tests/seam_probe.rs b/crates/trusted-server-adapter-axum/tests/seam_probe.rs index 85fa1afc99..2f48ff1d3b 100644 --- a/crates/trusted-server-adapter-axum/tests/seam_probe.rs +++ b/crates/trusted-server-adapter-axum/tests/seam_probe.rs @@ -626,7 +626,7 @@ async fn a_cache_table_no_module_declares_is_refused_by_the_deploy_check_and_at_ "should name the table: {message}" ); assert!( - message.contains("robots-txt, assets, assets-vary, seam-probe-answers"), + message.contains("robots-txt, seam-probe-answers"), "should list the caches this build declares: {message}" ); assert_eq!( diff --git a/crates/trusted-server-core/src/asset_cache.rs b/crates/trusted-server-core/src/asset_cache.rs deleted file mode 100644 index 97680605b9..0000000000 --- a/crates/trusted-server-core/src/asset_cache.rs +++ /dev/null @@ -1,426 +0,0 @@ -//! What the asset cache keeps for a response that is not a page, being the -//! response as the fetch phase left it, and the address it is kept under. -//! -//! A copy holds the headers a reader is served and the body, as the origin -//! sent them. Every copy is kept under what the origin's own `Vary` names, see -//! `crate::source_cache`. - -use edgezero_core::body::Body as EdgeBody; -use http::{HeaderName, HeaderValue, Response, StatusCode, header}; -use sha2::{Digest as _, Sha256}; - -/// Version of what a kept copy holds and how it is written. Bump on any change -/// to either, so a copy written by another build is never read. -pub const ASSET_SCHEMA_VERSION: u32 = 1; - -/// The most bytes a kept copy's body holds. A larger response is served as it -/// streams from the origin, because every copy is held whole, in memory and in -/// the shared store. -pub const MAX_ASSET_COPY_BYTES: usize = 1024 * 1024; - -/// Response headers never kept with a copy, being those describing one -/// connection, one reader's cookies, or the length and age, which are set again -/// for each reader. -const NOT_KEPT: &[&str] = &[ - "age", - "connection", - "content-length", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "proxy-connection", - "set-cookie", - "te", - "trailer", - "transfer-encoding", - "upgrade", -]; - -/// Inputs that select one asset's address. -/// -/// Every field changes the bytes the fetch phase makes of the same response. -/// What the origin's response varies on is not here, because it is read from -/// the address's record once the origin has said it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AssetCacheKey { - /// The URL asked of the origin, with its query. - pub url: String, - /// The host the reader asked, which the fetch phase writes into the body - /// in place of the origin's. - pub request_host: String, - /// The scheme the reader asked over, written into the body with the host. - pub request_scheme: String, - /// The origin URL and the `Host` it is asked for. - pub origin_identity: String, - /// Digest of every setting and module that can shape the fetch phase, - /// and of the build, so a build that rewrites bodies differently never - /// serves a copy an earlier one made. Empty for an asset route's answer. - pub fingerprint: String, -} - -impl AssetCacheKey { - /// A fixed-size key naming no part of the request. - /// - /// Each input is written with its length before it, so no two sets of - /// inputs run together into the same key. - #[must_use] - pub fn to_cache_key(&self) -> String { - use crate::platform::hash_length_prefixed as push; - - let mut hasher = Sha256::new(); - push(&mut hasher, b"ts-asset-cache"); - push(&mut hasher, &ASSET_SCHEMA_VERSION.to_be_bytes()); - push(&mut hasher, self.request_scheme.as_bytes()); - push(&mut hasher, self.request_host.as_bytes()); - push(&mut hasher, self.origin_identity.as_bytes()); - push(&mut hasher, self.url.as_bytes()); - push(&mut hasher, self.fingerprint.as_bytes()); - format!( - "ts-asset-cache-v{ASSET_SCHEMA_VERSION}-{}", - hex::encode(hasher.finalize()) - ) - } -} - -/// One kept response. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AssetCopy { - /// The headers a reader is served, as the fetch phase left them. - pub headers: Vec<(String, String)>, - /// Whether the body is kept without its `Content-Encoding`. Every copy is - /// kept as the origin sent it today, so this is false. - pub decoded: bool, - /// When the copy was made, in seconds since the Unix epoch. - pub made_at: u64, - /// The response's age when the copy was made, in seconds. - pub age_at_made: u64, - /// The body. - pub body: Vec, -} - -impl AssetCopy { - /// The headers of `response_headers` a copy keeps, or `None` when one - /// cannot be written down, as a value that is not visible text cannot. - /// - /// `Content-Encoding` is left out of a decoded copy, which holds identity - /// bytes. - #[must_use] - pub fn kept_headers( - response_headers: &http::HeaderMap, - decoded: bool, - ) -> Option> { - let mut kept = Vec::new(); - for (name, value) in response_headers { - if NOT_KEPT.contains(&name.as_str()) || (decoded && name == header::CONTENT_ENCODING) { - continue; - } - let value = value.to_str().ok()?; - kept.push((name.as_str().to_owned(), value.to_owned())); - } - Some(kept) - } - - /// The response this copy is served as, `now` seconds since the Unix - /// epoch, its `Age` counting the time it has been kept. - pub fn into_response(self, now: u64) -> Response { - let mut response = Response::new(EdgeBody::empty()); - *response.status_mut() = StatusCode::OK; - for (name, value) in &self.headers { - // Both were checked when the copy was read. - if let (Ok(name), Ok(value)) = ( - HeaderName::from_bytes(name.as_bytes()), - HeaderValue::from_str(value), - ) { - response.headers_mut().append(name, value); - } - } - let age = self - .age_at_made - .saturating_add(now.saturating_sub(self.made_at)); - response - .headers_mut() - .insert(header::AGE, HeaderValue::from(age)); - response.headers_mut().insert( - header::CONTENT_LENGTH, - HeaderValue::from(self.body.len() as u64), - ); - *response.body_mut() = EdgeBody::from(self.body); - response - } -} - -/// What the asset cache holds for one request's values. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AssetEntry { - /// A response every request with the same values may be served. - Copy(AssetCopy), - /// The origin's response for these values may not be shared, so each - /// request fetches its own. Held briefly, so the requests that follow do - /// not wait on a load that ends the same way. - NotShareable, -} - -/// The first byte of a held [`AssetEntry`]. -const ENTRY_NOT_SHAREABLE: u8 = 0; -const ENTRY_COPY: u8 = 1; - -/// Why a held asset entry could not be read. -#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] -pub enum AssetCacheMiss { - /// Written by a build keeping another format. - #[display("the kept asset was written in another format")] - OtherFormat, - /// Unreadable, or shorter than it says it is. - #[display("the kept asset is unreadable")] - Unreadable, -} - -impl core::error::Error for AssetCacheMiss {} - -impl AssetEntry { - /// The bytes the cache holds for this entry, being a kind byte, then for a - /// copy the length of its description as a little-endian `u32`, the - /// description, one line per field, and the body. - #[must_use] - pub fn encode(&self) -> Vec { - match self { - Self::NotShareable => vec![ENTRY_NOT_SHAREABLE], - Self::Copy(copy) => { - let mut description = format!( - "v={ASSET_SCHEMA_VERSION}\nd={}\nt={}\na={}\nlen={}", - u8::from(copy.decoded), - copy.made_at, - copy.age_at_made, - copy.body.len() - ); - for (name, value) in ©.headers { - description.push_str(&format!("\nh={name}:{value}")); - } - let length = u32::try_from(description.len()).unwrap_or(u32::MAX); - let mut bytes = Vec::with_capacity(5 + description.len() + copy.body.len()); - bytes.push(ENTRY_COPY); - bytes.extend_from_slice(&length.to_le_bytes()); - bytes.extend_from_slice(description.as_bytes()); - bytes.extend_from_slice(©.body); - bytes - } - } - } - - /// The entry `bytes` hold. - /// - /// # Errors - /// - /// When the bytes are not an entry this build wrote. - pub fn decode(bytes: &[u8]) -> Result { - match bytes.split_first() { - Some((&ENTRY_NOT_SHAREABLE, [])) => Ok(Self::NotShareable), - Some((&ENTRY_COPY, rest)) => { - let (length, rest) = rest - .split_first_chunk::<4>() - .ok_or(AssetCacheMiss::Unreadable)?; - let length = usize::try_from(u32::from_le_bytes(*length)) - .map_err(|_| AssetCacheMiss::Unreadable)?; - if rest.len() < length { - return Err(AssetCacheMiss::Unreadable); - } - let (description, body) = rest.split_at(length); - let description = - std::str::from_utf8(description).map_err(|_| AssetCacheMiss::Unreadable)?; - decode_copy(description, body).map(Self::Copy) - } - _ => Err(AssetCacheMiss::Unreadable), - } - } -} - -fn decode_copy(description: &str, body: &[u8]) -> Result { - let unreadable = AssetCacheMiss::Unreadable; - let mut version = None; - let mut decoded = None; - let mut made_at = None; - let mut age_at_made = None; - let mut length = None; - let mut headers = Vec::new(); - for line in description.split('\n') { - let (field, value) = line.split_once('=').ok_or(unreadable)?; - let once = |held: Option| held.is_none().then_some(()).ok_or(unreadable); - match field { - "v" => { - once(version)?; - version = Some(value.parse::().map_err(|_| unreadable)?); - } - "d" => { - decoded = Some(match value { - "0" if decoded.is_none() => false, - "1" if decoded.is_none() => true, - _ => return Err(unreadable), - }); - } - "t" => { - once(made_at)?; - made_at = Some(value.parse().map_err(|_| unreadable)?); - } - "a" => { - once(age_at_made)?; - age_at_made = Some(value.parse().map_err(|_| unreadable)?); - } - "len" => { - once(length)?; - length = Some(value.parse().map_err(|_| unreadable)?); - } - "h" => { - let (name, header_value) = value.split_once(':').ok_or(unreadable)?; - let parsed = HeaderName::from_bytes(name.as_bytes()).map_err(|_| unreadable)?; - if parsed.as_str() != name || NOT_KEPT.contains(&name) { - return Err(unreadable); - } - HeaderValue::from_str(header_value).map_err(|_| unreadable)?; - headers.push((name.to_owned(), header_value.to_owned())); - } - _ => return Err(unreadable), - } - } - if version.ok_or(unreadable)? != u64::from(ASSET_SCHEMA_VERSION) { - return Err(AssetCacheMiss::OtherFormat); - } - if length.ok_or(unreadable)? != body.len() as u64 { - return Err(unreadable); - } - Ok(AssetCopy { - headers, - decoded: decoded.ok_or(unreadable)?, - made_at: made_at.ok_or(unreadable)?, - age_at_made: age_at_made.ok_or(unreadable)?, - body: body.to_vec(), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn key() -> AssetCacheKey { - AssetCacheKey { - url: "https://origin.example.com/assets/site.css".to_owned(), - request_host: "www.example.com".to_owned(), - request_scheme: "https".to_owned(), - origin_identity: "https://origin.example.com\0origin.example.com".to_owned(), - fingerprint: "abc123".to_owned(), - } - } - - fn copy() -> AssetCopy { - AssetCopy { - headers: vec![ - ("content-type".to_owned(), "text/css".to_owned()), - ("vary".to_owned(), "Accept-Encoding".to_owned()), - ], - decoded: true, - made_at: 1_000, - age_at_made: 5, - body: b"body { color: red }".to_vec(), - } - } - - #[test] - fn every_input_changes_the_key_and_none_appears_in_it() { - let base = key().to_cache_key(); - let changes: [fn(&mut AssetCacheKey); 5] = [ - |key| key.url.push_str("?v=2"), - |key| key.request_host = "other.example.com".to_owned(), - |key| key.request_scheme = "http".to_owned(), - |key| key.origin_identity.push('x'), - |key| key.fingerprint = "def456".to_owned(), - ]; - for change in changes { - let mut changed = key(); - change(&mut changed); - assert_ne!(changed.to_cache_key(), base); - } - assert!(base.starts_with("ts-asset-cache-v1-")); - for named in ["example.com", "site.css", "abc123"] { - assert!(!base.contains(named), "the key names `{named}`: {base}"); - } - } - - #[test] - fn an_entry_reads_back_as_written() { - for entry in [AssetEntry::Copy(copy()), AssetEntry::NotShareable] { - assert_eq!(AssetEntry::decode(&entry.encode()), Ok(entry)); - } - } - - #[test] - fn a_short_foreign_or_other_format_entry_is_not_read() { - let bytes = AssetEntry::Copy(copy()).encode(); - assert_eq!( - AssetEntry::decode(&bytes[..bytes.len() - 1]), - Err(AssetCacheMiss::Unreadable) - ); - for foreign in [&b""[..], b"\x07", b"\x01\xff\xff", b"\x00extra"] { - assert!(AssetEntry::decode(foreign).is_err(), "{foreign:?}"); - } - let other_version = String::from_utf8(bytes.clone()) - .expect("the test copy is text") - .replacen("v=1", "v=9", 1); - assert_eq!( - AssetEntry::decode(other_version.as_bytes()), - Err(AssetCacheMiss::OtherFormat) - ); - } - - #[test] - fn a_copy_keeps_no_connection_cookie_or_length_header() { - let mut response = http::HeaderMap::new(); - for (name, value) in [ - ("content-type", "image/png"), - ("content-encoding", "gzip"), - ("set-cookie", "a=b"), - ("content-length", "10"), - ("age", "3"), - ("connection", "keep-alive"), - ("cache-control", "public, max-age=60"), - ] { - response.append( - HeaderName::from_static(name), - HeaderValue::from_static(value), - ); - } - - let raw = AssetCopy::kept_headers(&response, false).expect("should keep the headers"); - let decoded = AssetCopy::kept_headers(&response, true).expect("should keep the headers"); - - let names = |kept: &[(String, String)]| -> Vec { - kept.iter().map(|(name, _)| name.clone()).collect() - }; - assert_eq!( - names(&raw), - ["content-type", "content-encoding", "cache-control"] - ); - assert_eq!( - names(&decoded), - ["content-type", "cache-control"], - "a decoded copy's encoding is set for each reader" - ); - } - - #[test] - fn a_served_copy_counts_its_age_from_when_it_was_made() { - let response = copy().into_response(1_030); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::AGE), - Some(&HeaderValue::from_static("35")) - ); - assert_eq!( - response.headers().get(header::CONTENT_LENGTH), - Some(&HeaderValue::from_static("19")) - ); - assert_eq!( - response.headers().get(header::CONTENT_TYPE), - Some(&HeaderValue::from_static("text/css")) - ); - } -} diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 9438567b46..f798ffb857 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -832,17 +832,14 @@ pub(crate) fn all_builders( /// /// # Errors /// -/// When a builder declares one of core's own caches, or two builders declare +/// When a builder declares core's own `robots-txt`, or two builders declare /// one name, naming the builders, since the two would otherwise share one /// cache and its keys without a word. pub(crate) fn declared_caches( builders: &[IntegrationBuilder], ) -> Result, Report> { - let mut declared: Vec<(&'static str, &'static str)> = vec![ - (crate::settings::ROBOTS_TXT_CACHE, "core"), - (crate::settings::ASSETS_CACHE, "core"), - (crate::settings::ASSETS_VARY_CACHE, "core"), - ]; + let mut declared: Vec<(&'static str, &'static str)> = + vec![(crate::settings::ROBOTS_TXT_CACHE, "core")]; for builder in builders { for name in builder.cache_features() { if let Some((_, by)) = declared.iter().find(|(declared, _)| declared == name) { @@ -902,8 +899,7 @@ pub(crate) fn check_cache_tables( /// builders a deployment links offer. A table that names no module takes the /// one loading cache module the build links, and keeps no copy when the build /// links none or several. A module's cache is made once however many tables -/// select it. `[cache.assets]` naming `none` selects nothing for either asset -/// cache. +/// select it. /// /// # Errors /// @@ -921,20 +917,7 @@ pub fn loading_caches_from( let declared = declared_caches(builders)?; let mut made: Vec<(&'static str, Arc)> = Vec::new(); let mut caches = crate::platform::LoadingCaches::default(); - let assets_off = - settings.cache.assets.module.as_deref() == Some(crate::settings::ASSETS_CACHE_OFF); for (table, config) in settings.cache.loading_caches() { - // `[cache.assets]` turned off leaves both asset caches unselected, so - // they keep nothing and each asset is fetched from its origin. - if assets_off - && [ - crate::settings::ASSETS_CACHE, - crate::settings::ASSETS_VARY_CACHE, - ] - .contains(&table) - { - continue; - } // A table naming no module takes the one loading cache module the // build links, so answers are held without a line in the document. // With none linked, or several, it holds nothing. @@ -1193,12 +1176,33 @@ mod loading_caches_from_tests { let message = format!("{refused:?}"); assert!(message.contains("[cache.answres]"), "{message}"); - assert!( - message.contains("robots-txt, assets, assets-vary, answers"), - "{message}" + assert!(message.contains("robots-txt, answers"), "{message}"); + } + + #[test] + fn core_declares_only_the_robots_txt_cache() { + assert_eq!( + declared_caches(&[]).expect("core's own declaration is not refused"), + [ROBOTS_TXT_CACHE], + "a cache of the files the origin sends is a module's to declare" ); } + #[test] + fn a_cache_assets_table_is_refused() { + let toml_str = format!( + "{}\n[cache.assets]\nstore_seconds = 3600\n", + crate::test_support::tests::crate_test_settings_str() + ); + let settings = Settings::from_toml(&toml_str).expect("should parse the settings"); + + let refused = loading_caches_from(&settings, &[offering("loading-cache.one")]) + .expect_err("no linked module declares a cache named `assets`"); + + let message = format!("{refused:?}"); + assert!(message.contains("[cache.assets]"), "{message}"); + } + #[test] fn the_deploy_check_refuses_the_table_the_cache_build_refuses() { let settings = settings_declaring("answres", Some("one")); @@ -1259,36 +1263,6 @@ mod loading_caches_from_tests { assert!(message.contains("core's own"), "{message}"); } - #[test] - fn assets_naming_none_turns_both_asset_caches_off_and_nothing_else() { - let builders = [offering("loading-cache.one")]; - let on = loading_caches_from(&settings_selecting(None), &builders) - .expect("no selection is not an error"); - let mut settings = settings_selecting(None); - settings.cache.assets.module = Some(crate::settings::ASSETS_CACHE_OFF.to_owned()); - - let off = loading_caches_from(&settings, &builders) - .expect("turning the asset caches off is not an error"); - - for name in [ - crate::settings::ASSETS_CACHE, - crate::settings::ASSETS_VARY_CACHE, - ] { - assert!( - on.for_feature(name).keeps_values(), - "{name} takes the one module offered" - ); - assert!( - !off.for_feature(name).keeps_values(), - "{name} keeps nothing once turned off" - ); - } - assert!( - off.for_feature(ROBOTS_TXT_CACHE).keeps_values(), - "turning the asset caches off leaves the other caches as they were" - ); - } - fn settings_selecting(module: Option<&str>) -> Settings { let mut settings = crate::test_support::tests::create_test_settings(); settings.cache.robots_txt.module = module.map(str::to_owned); diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 39a19e91e7..ddd367e1a8 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -28,7 +28,6 @@ ) )] -pub mod asset_cache; pub(crate) mod asset_image_optimizer; pub mod attestation; pub mod auction; @@ -79,7 +78,6 @@ pub mod secret_resolution; pub mod service_version; pub mod settings; pub mod settings_data; -pub mod source_cache; pub mod streaming_processor; pub mod streaming_replacer; pub mod tdl; diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index e00331317f..db4640b4ee 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -84,7 +84,6 @@ pub use template_assembly::{ PlatformTemplateAssembler, TemplateAssemblyError, UnavailableTemplateAssembler, contains_publisher_esi_directive, }; -pub(crate) use template_cache::hash_length_prefixed; pub use template_cache::{ PlatformTemplateCache, PlatformTemplateCacheReservation, REPLAYABLE_POLICY_HEADERS, TEMPLATE_CACHE_PURGE_ALL_SURROGATE_KEY, TEMPLATE_SCHEMA_VERSION, TemplateCacheError, diff --git a/crates/trusted-server-core/src/platform/template_cache.rs b/crates/trusted-server-core/src/platform/template_cache.rs index 1fe5a0c81c..62873d58d2 100644 --- a/crates/trusted-server-core/src/platform/template_cache.rs +++ b/crates/trusted-server-core/src/platform/template_cache.rs @@ -89,15 +89,6 @@ pub struct TemplateCacheKey { pub schema_version: u32, } -/// Writes `part` into `hasher` with its length before it, so no two lists of -/// parts run together into the same digest. -pub(crate) fn hash_length_prefixed(hasher: &mut sha2::Sha256, part: &[u8]) { - use sha2::Digest as _; - - hasher.update((part.len() as u64).to_be_bytes()); - hasher.update(part); -} - impl TemplateCacheKey { /// Render a fixed-size opaque key for the platform cache. /// @@ -107,9 +98,12 @@ impl TemplateCacheKey { pub fn to_cache_key(&self) -> String { use sha2::Digest as _; - use hash_length_prefixed as push; + fn push(out: &mut Vec, part: &[u8]) { + out.extend_from_slice(&(part.len() as u64).to_be_bytes()); + out.extend_from_slice(part); + } - let mut canonical = sha2::Sha256::new(); + let mut canonical = Vec::new(); push(&mut canonical, b"ts-template-cache"); push(&mut canonical, &self.schema_version.to_be_bytes()); push( @@ -161,7 +155,7 @@ impl TemplateCacheKey { } } - let digest = canonical.finalize(); + let digest = sha2::Sha256::digest(canonical); format!( "ts-template-cache-v{}-{}", self.schema_version, diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 4151b94c69..86af796f0d 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -1462,25 +1462,6 @@ impl MemoryLoadingCache { } } -/// The time left to each asset copy `cache` holds, leaving out the records -/// and the marks of what may not be shared, in no order. -pub fn asset_copy_lifetimes(cache: &MemoryLoadingCache) -> Vec { - let copies = format!("/{}/", crate::settings::ASSETS_CACHE); - cache - .keys() - .iter() - .filter(|key| key.contains(&copies)) - .filter_map(|key| cache.held(key)) - .filter(|(value, _)| { - matches!( - crate::asset_cache::AssetEntry::decode(value), - Ok(crate::asset_cache::AssetEntry::Copy(_)) - ) - }) - .map(|(_, left)| left.unwrap_or(Duration::MAX)) - .collect() -} - #[async_trait::async_trait(?Send)] impl super::PlatformLoadingCache for MemoryLoadingCache { async fn get_or_load( diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 4339be11cd..b3656c8350 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -1171,60 +1171,23 @@ pub async fn handle_asset_proxy_request( apply_asset_origin_auth(req.method(), &target_url, &mut outbound_headers, auth)?; } + let mut platform_req = + build_asset_platform_request(req.method(), &target_url, &outbound_headers, &backend_name)?; + if let Some(image_optimizer) = image_optimizer { + platform_req = platform_req.with_image_optimizer(image_optimizer); + } let stream_response = services.http_client().supports_streaming_responses(); - - // The route's answer is looked for among the copies the loading cache - // holds before the origin is asked, and the origin's answer is kept the - // way the publisher path keeps an asset, under the same two caches. An - // answer the platform's image optimizer makes is not kept, because the - // platform holds it. - let copy_key = if image_optimizer.is_none() { - asset_route_copy_key(services, &req, route, &target_url) - } else { - None - }; - let mut from_asset_cache = None; - if let Some(key) = copy_key.as_ref() { - let uri: http::Uri = - target_url - .as_str() - .parse() - .change_context(TrustedServerError::Proxy { - message: "invalid asset target URL".to_string(), - })?; - from_asset_cache = crate::publisher::asset_from_cache(&crate::publisher::AssetRequest { - settings, - services, - key, - uri: &uri, - headers: &outbound_headers, - backend_name: &backend_name, - stream_response, - }) - .await; + if stream_response { + platform_req = platform_req.with_stream_response(); } - // A load that failed asking the origin is this request's origin failure. - let origin_result = if let Some(answer) = from_asset_cache { - answer.map(PlatformResponse::new) - } else { - let mut platform_req = build_asset_platform_request( - req.method(), - &target_url, - &outbound_headers, - &backend_name, - )?; - if let Some(image_optimizer) = image_optimizer { - platform_req = platform_req.with_image_optimizer(image_optimizer); - } - if stream_response { - platform_req = platform_req.with_stream_response(); - } - services.http_client().send(platform_req).await - }; - let platform_resp = origin_result.change_context(TrustedServerError::Proxy { - message: "Failed to proxy asset request".to_string(), - })?; + let platform_resp = services + .http_client() + .send(platform_req) + .await + .change_context(TrustedServerError::Proxy { + message: "Failed to proxy asset request".to_string(), + })?; let mut response = if stream_response { platform_response_to_fastly_asset(platform_resp) @@ -1243,53 +1206,6 @@ pub async fn handle_asset_proxy_request( Ok(response) } -/// The key an asset route's answer is held under, or nothing for a request -/// whose answer is not kept: one that is not a `GET`, one carrying -/// `Authorization`, a conditional header or a range, which a copy cannot -/// answer, one whose `Cache-Control` asks for the origin, as the publisher -/// path reads it, one that names no host, and any request while the cache -/// `[cache.assets]` selects keeps nothing. The route's origin and prefix are -/// part of the key, so two routes to one address keep their own copies. A -/// route sends its origin no cookie, see [`ASSET_PROXY_FORWARD_HEADERS`], so -/// a reader's cookies cannot shape the answer and do not keep it from a copy. -fn asset_route_copy_key( - services: &RuntimeServices, - req: &Request, - route: &ProxyAssetRoute, - target_url: &url::Url, -) -> Option { - if req.method() != Method::GET - || req.headers().contains_key(header::AUTHORIZATION) - || crate::publisher::request_bypasses_template_cache(req.headers()) - { - return None; - } - let request_host = req - .uri() - .host() - .map(str::to_owned) - .or_else(|| { - req.headers() - .get(header::HOST) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned) - }) - .filter(|host| !host.is_empty())?; - if !services - .loading_cache_for(crate::settings::ASSETS_CACHE) - .keeps_values() - { - return None; - } - Some(crate::asset_cache::AssetCacheKey { - url: target_url.to_string(), - request_host, - request_scheme: req.uri().scheme_str().unwrap_or("https").to_owned(), - origin_identity: format!("{}\0{}", route.origin_url, route.prefix), - fingerprint: String::new(), - }) -} - /// Upserts the `ts-ec` query parameter on a URL, replacing any existing value. fn upsert_ec_query_param(url: &mut url::Url, ec_id: &str) { let mut pairs: Vec<(String, String)> = url @@ -5104,24 +5020,8 @@ mod tests { ); } - /// The services an asset route's tests use, with every loading cache - /// served by one memory cache, so a copy kept by one request is there - /// for the next. - fn asset_route_services( - stub: &Arc, - cache: &Arc, - ) -> RuntimeServices { - build_services_with_http_client(Arc::clone(stub) as _) - .with_loading_cache(Arc::clone(cache) as _) - } - - fn held_asset_stub() -> Arc { - let stub = Arc::new(crate::platform::test_support::StubHttpClient::new()); - stub.set_streaming_responses_supported(false); - stub - } - - fn shareable_stylesheet(stub: &crate::platform::test_support::StubHttpClient) { + /// A stylesheet the route's origin lets be kept for five minutes. + fn queue_shareable_stylesheet(stub: &StubHttpClient) { stub.push_response_with_headers( 200, b"body { color: red }".to_vec(), @@ -5132,237 +5032,41 @@ mod tests { ); } - async fn asset_route_answer( + /// A route's answer to `request`, through `/.images/` to its origin. + async fn route_answer( services: &RuntimeServices, request: HttpRequest, - ) -> Response { + ) -> Result, Report> { let settings = create_test_settings(); let route = ProxyAssetRoute::new("/.images/", "https://assets.example.com"); handle_asset_proxy_request(&settings, services, request, &route) - .await - .expect("should answer the asset request") + .await? .into_response() - .expect("should return a buffered asset response") } #[test] - fn an_asset_route_answer_is_held_and_serves_the_next_request() { + fn every_file_is_asked_of_a_routes_origin() { futures::executor::block_on(async { - let stub = held_asset_stub(); - shareable_stylesheet(&stub); - let cache = Arc::new(crate::platform::test_support::MemoryLoadingCache::default()); - let services = asset_route_services(&stub, &cache); + let stub = Arc::new(StubHttpClient::new()); + stub.set_streaming_responses_supported(false); + let services = build_services_with_http_client(Arc::clone(&stub) as _) + .with_loading_cache(Arc::new( + crate::platform::test_support::MemoryLoadingCache::default(), + )); let uri = "https://www.example.com/.images/site.css"; - let first = asset_route_answer(&services, build_http_request(Method::GET, uri)).await; - let second = asset_route_answer(&services, build_http_request(Method::GET, uri)).await; - - assert_eq!(first.status(), StatusCode::OK); - assert_eq!( - stub.recorded_request_uris().len(), - 1, - "should ask the origin once for two requests of one asset" - ); - assert_eq!(second.status(), StatusCode::OK); - assert_eq!( - second - .headers() - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()), - Some("text/css"), - "should serve the copy with the headers that describe it" - ); - assert!( - second.headers().contains_key(header::AGE), - "should serve the copy with an Age" - ); - assert!( - matches!(second.into_body(), EdgeBody::Once(bytes) if bytes.as_ref() == b"body { color: red }"), - "should serve the copy's bytes" - ); - let lifetimes = crate::platform::test_support::asset_copy_lifetimes(&cache); - assert!( - lifetimes.len() == 1 - && lifetimes[0] > std::time::Duration::from_secs(295) - && lifetimes[0] <= std::time::Duration::from_secs(300), - "should keep the copy for the lifetime the origin gave it, less the time since, got {lifetimes:?}" - ); - }); - } - - #[test] - fn an_asset_route_answer_with_a_cookie_is_not_held() { - futures::executor::block_on(async { - let stub = held_asset_stub(); for _ in 0..2 { - stub.push_response_with_headers( - 200, - b"body { color: red }".to_vec(), - vec![ - ("content-type", "text/css"), - ("cache-control", "public, max-age=300"), - ("set-cookie", "session=1"), - ], - ); - } - let cache = Arc::new(crate::platform::test_support::MemoryLoadingCache::default()); - let services = asset_route_services(&stub, &cache); - let uri = "https://www.example.com/.images/site.css"; - - let first = asset_route_answer(&services, build_http_request(Method::GET, uri)).await; - let second = asset_route_answer(&services, build_http_request(Method::GET, uri)).await; - - assert_eq!(first.status(), StatusCode::OK); - assert_eq!(second.status(), StatusCode::OK); - assert_eq!( - stub.recorded_request_uris().len(), - 2, - "should ask the origin for each request of an answer that set a cookie" - ); - assert!( - crate::platform::test_support::asset_copy_lifetimes(&cache).is_empty(), - "should keep no copy" - ); - }); - } - - #[test] - fn a_reader_s_cookie_never_reaches_an_asset_route_s_origin_so_its_answer_is_shared() { - futures::executor::block_on(async { - let stub = held_asset_stub(); - shareable_stylesheet(&stub); - let cache = Arc::new(crate::platform::test_support::MemoryLoadingCache::default()); - let services = asset_route_services(&stub, &cache); - let uri = "https://www.example.com/.images/site.css"; - - for reader in ["one", "two"] { - let mut request = build_http_request(Method::GET, uri); - request.headers_mut().insert( - header::COOKIE, - HeaderValue::from_str(&format!("session={reader}")) - .expect("should be a header value"), - ); - let answer = asset_route_answer(&services, request).await; + queue_shareable_stylesheet(&stub); + let answer = route_answer(&services, build_http_request(Method::GET, uri)) + .await + .expect("should answer the file request"); assert_eq!(answer.status(), StatusCode::OK); } - assert_eq!( - stub.recorded_request_uris().len(), - 1, - "should ask the origin once for both readers" - ); - assert!( - stub.recorded_request_headers() - .iter() - .flatten() - .all(|(name, _)| !name.eq_ignore_ascii_case("cookie")), - "should send the route's origin no cookie" - ); - }); - } - - #[test] - fn a_range_request_through_an_asset_route_is_not_served_a_copy() { - futures::executor::block_on(async { - let stub = held_asset_stub(); - shareable_stylesheet(&stub); - shareable_stylesheet(&stub); - let cache = Arc::new(crate::platform::test_support::MemoryLoadingCache::default()); - let services = asset_route_services(&stub, &cache); - let uri = "https://www.example.com/.images/site.css"; - - asset_route_answer(&services, build_http_request(Method::GET, uri)).await; - let mut ranged = build_http_request(Method::GET, uri); - ranged - .headers_mut() - .insert(header::RANGE, HeaderValue::from_static("bytes=0-3")); - asset_route_answer(&services, ranged).await; - - assert_eq!( - stub.recorded_request_uris().len(), - 2, - "should ask the origin for a range, which a copy cannot answer" - ); - }); - } - - #[test] - fn a_request_asking_for_the_origin_through_an_asset_route_is_not_served_a_copy() { - futures::executor::block_on(async { - for cache_control in ["no-cache", "no-store", "min-fresh=10"] { - let stub = held_asset_stub(); - shareable_stylesheet(&stub); - shareable_stylesheet(&stub); - let cache = Arc::new(crate::platform::test_support::MemoryLoadingCache::default()); - let services = asset_route_services(&stub, &cache); - let uri = "https://www.example.com/.images/site.css"; - - asset_route_answer(&services, build_http_request(Method::GET, uri)).await; - let mut reload = build_http_request(Method::GET, uri); - reload.headers_mut().insert( - header::CACHE_CONTROL, - HeaderValue::from_static(cache_control), - ); - asset_route_answer(&services, reload).await; - - assert_eq!( - stub.recorded_request_uris().len(), - 2, - "should ask the origin for a request carrying Cache-Control {cache_control}" - ); - } - }); - } - - #[test] - fn an_asset_route_holds_nothing_while_no_cache_keeps_values() { - futures::executor::block_on(async { - let stub = held_asset_stub(); - shareable_stylesheet(&stub); - shareable_stylesheet(&stub); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - let uri = "https://www.example.com/.images/site.css"; - - asset_route_answer(&services, build_http_request(Method::GET, uri)).await; - asset_route_answer(&services, build_http_request(Method::GET, uri)).await; - assert_eq!( stub.recorded_request_uris().len(), 2, - "should ask the origin for each request while no loading cache keeps values" - ); - }); - } - - #[test] - fn an_asset_route_load_that_fails_is_the_answer_so_the_origin_is_asked_once() { - futures::executor::block_on(async { - let stub = held_asset_stub(); - let cache = Arc::new(crate::platform::test_support::MemoryLoadingCache::default()); - let services = asset_route_services(&stub, &cache); - let settings = create_test_settings(); - let route = ProxyAssetRoute::new("/.images/", "https://assets.example.com"); - let request = - build_http_request(Method::GET, "https://www.example.com/.images/site.css"); - - // Nothing is queued, so the stub fails the origin request. - let Err(error) = - handle_asset_proxy_request(&settings, &services, request, &route).await - else { - panic!("should fail when the origin request fails"); - }; - - assert_eq!( - error.current_context().status_code(), - StatusCode::BAD_GATEWAY, - "should answer as a failed origin request does" - ); - assert_eq!( - stub.recorded_request_uris().len(), - 1, - "should ask the origin once, because the failed load is the answer" + "two requests for a file the origin lets be kept are two origin requests" ); }); } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 4ef5ca770a..f275392dfe 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -95,7 +95,6 @@ use crate::settings::{ AUCTION_DEBUG_METADATA_ALLOWLIST, AUCTION_DEBUG_UPSTREAM_METADATA_KEYS, AuctionDebugCommentFormat, AuctionDebugCommentOptions, AuctionDebugCommentVerbosity, Settings, }; -use crate::source_cache::{Found, Lookup, SourceAddress, SourceCache, SourceVary}; use crate::streaming_processor::{ BodyStreamDecoder, BodyStreamEncoder, Compression, GzipDecodeReader, PipelineConfig, STREAM_CHUNK_SIZE, StreamProcessor, StreamingPipeline, @@ -5535,27 +5534,6 @@ pub async fn handle_publisher_request( ), schema_version: crate::platform::TEMPLATE_SCHEMA_VERSION, }); - // Every other response the origin sends may be kept as it sent it, in - // every assembly mode, keyed the way a page is and kept for as long as - // the origin allows, see `asset_cache`. A request that sends the origin a - // cookie is fetched for itself, because nothing shows whether the cookie - // shaped the answer. - let request_can_use_asset_copy = method_is_cacheable - && !request_is_document - && !request_host.is_empty() - && !authorization_disqualifies - && !req.headers().contains_key(header::COOKIE) - && !request_requires_origin - && services - .loading_cache_for(crate::settings::ASSETS_CACHE) - .keeps_values(); - let asset_cache_key = request_can_use_asset_copy.then(|| crate::asset_cache::AssetCacheKey { - url: target_uri.to_string(), - request_host: request_host.to_string(), - request_scheme: request_scheme.to_string(), - origin_identity: format!("{}\0{}", settings.publisher.origin_url, origin_host_header), - fingerprint: template_fingerprint(settings, integration_registry, services.public_prefix()), - }); let mut template_cache_response_state = matches!(assembly_mode, AssemblyMode::Esi) .then_some(TemplateCacheResponseState::BypassRequest); rewrite_origin_request(&mut req, target_uri, &origin_host_header)?; @@ -5592,7 +5570,6 @@ pub async fn handle_publisher_request( // every request in the default `Inline` assembly mode — keep the overlap. if should_preload_ec && template_cache_key.is_none() - && asset_cache_key.is_none() && services.http_client().supports_concurrent_fanout() && services .http_client() @@ -5964,30 +5941,7 @@ pub async fn handle_publisher_request( // SSP requests are already racing through the platform HTTP client, so // origin TTFB tracks origin latency rather than the auction timeout. - // Any other response is looked for the same way, before the origin is - // fetched, and a copy found is served as the origin's response would be. - // A load that failed asking the origin is this request's origin failure. - let mut from_asset_cache = None; - if let Some(key) = asset_cache_key.as_ref() { - let prepared = origin_request.as_ref().ok_or_else(|| { - Report::new(TrustedServerError::Proxy { - message: "publisher origin request was already consumed".to_owned(), - }) - })?; - from_asset_cache = asset_from_cache(&AssetRequest { - settings, - services, - key, - uri: prepared.uri(), - headers: prepared.headers(), - backend_name: &backend_name, - stream_response: services.http_client().supports_streaming_responses(), - }) - .await; - } - let origin_result = if let Some(answer) = from_asset_cache { - answer.map(crate::platform::PlatformResponse::new) - } else if let Some(pending) = pending_origin { + let origin_result = if let Some(pending) = pending_origin { services.http_client().wait(pending).await } else { let origin_req = origin_request.take().ok_or_else(|| { @@ -7181,7 +7135,7 @@ pub(crate) enum TemplateCacheBypassReason { MalformedPolicyHeader, } -pub(crate) fn request_bypasses_template_cache(headers: &edgezero_core::http::HeaderMap) -> bool { +fn request_bypasses_template_cache(headers: &edgezero_core::http::HeaderMap) -> bool { const CONDITIONAL_OR_PARTIAL: &[&str] = &[ "range", "if-range", @@ -7559,32 +7513,6 @@ fn replayable_policy_headers( Ok(captured) } -/// Whether an origin response may be kept for other readers at all. A status -/// other than `200`, a cookie it sets, or an edge cache header this layer does -/// not read keeps it from them, whatever it holds. -fn origin_response_may_be_kept( - status: StatusCode, - headers: &edgezero_core::http::HeaderMap, -) -> Result<(), TemplateCacheBypassReason> { - if status != StatusCode::OK { - return Err(TemplateCacheBypassReason::NonOkStatus); - } - if headers.contains_key(header::SET_COOKIE) { - return Err(TemplateCacheBypassReason::OriginSetCookie); - } - // Core Cache has no HTTP semantics. Fastly's documented Surrogate-Control subset - // is parsed by `origin_shared_ttl`; every other vendor-specific policy remains a - // bypass rather than guessing that unrelated CDNs share its grammar or precedence. - if crate::cache_policy::EDGE_CACHE_HEADER_NAMES - .iter() - .filter(|name| **name != "surrogate-control") - .any(|name| headers.contains_key(*name)) - { - return Err(TemplateCacheBypassReason::OriginNotShareable); - } - Ok(()) -} - fn template_cache_ttl( mode: AssemblyMode, authorization_disqualifies: bool, @@ -7603,7 +7531,19 @@ fn template_cache_ttl( if cookie_disqualifies { return Err(TemplateCacheBypassReason::CookiePolicy); } - origin_response_may_be_kept(status, response_headers)?; + if response_headers.contains_key(header::SET_COOKIE) { + return Err(TemplateCacheBypassReason::OriginSetCookie); + } + // Core Cache has no HTTP semantics. Fastly's documented Surrogate-Control subset + // is parsed by `origin_shared_ttl`; every other vendor-specific policy remains a + // bypass rather than guessing that unrelated CDNs share its grammar or precedence. + if crate::cache_policy::EDGE_CACHE_HEADER_NAMES + .iter() + .filter(|name| **name != "surrogate-control") + .any(|name| response_headers.contains_key(*name)) + { + return Err(TemplateCacheBypassReason::OriginNotShareable); + } // Checked here, among the leak vectors, because storing under a key that does not // cover the origin's Vary is cross-serving rather than mere ineligibility: a request // differing only in the uncovered header would read this template. @@ -7634,6 +7574,9 @@ fn template_cache_ttl( uncovered, ))); } + if status != StatusCode::OK { + return Err(TemplateCacheBypassReason::NonOkStatus); + } let declared_content_type = single_representation_header_value(response_headers, header::CONTENT_TYPE)?; if declared_content_type.is_some_and(|declared| declared != content_type) @@ -8318,460 +8261,51 @@ fn mark_deprecated_alias(response: &mut Response, is_legacy_alias: boo ); } -/// The lifetimes `[cache.assets]` leaves unset. An asset's own lifetime ends -/// each copy sooner than the store lifetime. -const ASSET_DEFAULT_LIFETIMES: crate::platform::CacheLifetimes = crate::platform::CacheLifetimes { - memory: Duration::from_secs(5), - store: Duration::from_secs(60 * 60), - store_idle: Duration::from_secs(24 * 60 * 60), -}; - -/// How long an asset the origin says may not be shared is held as such, so -/// the requests that follow fetch their own rather than waiting on a load. -const ASSET_NOT_SHAREABLE_LIFETIME: Duration = Duration::from_secs(60); - -/// The asset caches, being one holding each address's record of what its -/// response varies on and one holding the kept copies, both with the -/// lifetimes `[cache.assets]` sets. A load runs in the request that started -/// it, which is served the response that load fetched, see [`HandBack`]. -fn asset_cache(settings: &Settings) -> SourceCache { - let lifetimes = settings.cache.assets.lifetimes(ASSET_DEFAULT_LIFETIMES); - let spec = |name, memory_entries| crate::platform::CacheSpec { - name, - lifetimes, - memory_entries, - }; - SourceCache { - records: spec(crate::settings::ASSETS_VARY_CACHE, 256), - copies: spec(crate::settings::ASSETS_CACHE, 32), - } -} - -/// The address one asset's copies are held under, made of the publisher's -/// prefix the request's services carry, so two publishers never share one, -/// and the asset's own key. -fn asset_address( - services: &RuntimeServices, - key: &crate::asset_cache::AssetCacheKey, -) -> SourceAddress { - SourceAddress::new(services.server_key_prefix().clone(), key.to_cache_key()) -} - -/// One request the asset cache may answer, already prepared for the origin. -pub(crate) struct AssetRequest<'a> { - pub(crate) settings: &'a Settings, - pub(crate) services: &'a RuntimeServices, - /// The address the request's answer is kept under. - pub(crate) key: &'a crate::asset_cache::AssetCacheKey, - pub(crate) uri: &'a http::Uri, - pub(crate) headers: &'a http::HeaderMap, - pub(crate) backend_name: &'a str, - pub(crate) stream_response: bool, -} - -/// The asset cache's answer for `request`, or `None` when the origin is to be -/// asked. The answer is a kept copy served as the origin's response would be, -/// the response this request's own load fetched and may not share, or the -/// failure of the load this request ran or waited on, which the caller treats -/// as its own origin request failing, so the origin is not asked again. -pub(crate) async fn asset_from_cache( - request: &AssetRequest<'_>, -) -> Option, Report>> { - let services = request.services; - let cache = asset_cache(request.settings); - let address = asset_address(services, request.key); - let hand_back = HandBack::new(); - let fetch = asset_fetch( - Arc::new(AssetLoad { - services: services.clone(), - uri: request.uri.clone(), - headers: request.headers.clone(), - backend_name: request.backend_name.to_owned(), - stream_response: request.stream_response, - max_lifetime: cache.copies.lifetimes.store, - }), - hand_back.id, - ); - let not_shareable = bytes::Bytes::from(crate::asset_cache::AssetEntry::NotShareable.encode()); - let found = crate::source_cache::get_or_fetch( - &Lookup { - services, - cache: &cache, - address: &address, - headers: request.headers, - declared: None, - not_shareable: ¬_shareable, - }, - fetch, - ) - .await; - match found { - Ok(Found::Copy { value, key }) => match crate::asset_cache::AssetEntry::decode(&value) { - Ok(crate::asset_cache::AssetEntry::Copy(copy)) => { - Some(Ok(copy.into_response(unix_seconds()))) - } - // This request's own load fetched the asset, so it is served from - // that fetch rather than a second one. - Ok(crate::asset_cache::AssetEntry::NotShareable) => hand_back.take().map(Ok), - Err(miss) => { - log::warn!("asset_cache invalid entry: {miss}; removing it"); - services - .loading_cache_for(cache.copies.name) - .remove(services, &cache.copies, &key) - .await; - None - } - }, - Ok(Found::NoCopies) => hand_back.take().map(Ok), - // The load failed asking the origin, and every request waiting on it - // is given that failure, so none of them asks the origin again. - Err(error) => { - log::warn!("asset_cache load failed: {error}"); - Some(Err( - Report::new(error).change_context(crate::platform::PlatformError::HttpClient) - )) - } - } -} - -/// Why an asset's response is not kept. -#[derive(Debug, derive_more::Display)] -enum AssetCacheBypassReason { - /// A page is kept as a template, by the template cache, or not at all. - #[display("the response is HTML, which is not kept as an asset")] - Html, - /// The body is larger than a copy holds. - #[display("the response is larger than a kept asset holds")] - TooLarge, - /// The body outgrew a copy, or failed, while it was read. - #[display("the response could not be read whole")] - NotReadWhole, - /// A header cannot be written down to be served again. - #[display("a response header cannot be kept")] - UnkeptHeader, - /// The response varies on every request header. - #[display("origin response varies on *")] - VaryWildcard, - /// The response varies on the reader's cookies. - #[display("origin response varies on Cookie")] - VaryCookie, - /// The response's `Vary` cannot be read. - #[display("origin response Vary cannot be read")] - VaryUnreadable, - /// The origin's status, cookies or cache policy do not allow a shared - /// copy, or the policy cannot be read. - #[display("{_0}")] - Policy(TemplateCacheBypassReason), -} - -/// Why a response that varies this way is served to no other request. -fn vary_bypass_reason(vary: &SourceVary) -> Option { - match vary { - SourceVary::Headers(_) => None, - SourceVary::Any => Some(AssetCacheBypassReason::VaryWildcard), - SourceVary::Cookie => Some(AssetCacheBypassReason::VaryCookie), - SourceVary::Unreadable => Some(AssetCacheBypassReason::VaryUnreadable), - } -} - -/// How long an asset's response may be kept, from its own cache policy and -/// capped at `max_lifetime`, or why it is not kept at all. -fn asset_cache_ttl( - status: StatusCode, - content_type: &str, - headers: &edgezero_core::http::HeaderMap, - max_lifetime: Duration, -) -> Result { - origin_response_may_be_kept(status, headers).map_err(AssetCacheBypassReason::Policy)?; - if is_html_content_type(content_type) { - return Err(AssetCacheBypassReason::Html); - } - origin_shared_ttl(headers, max_lifetime).map_err(AssetCacheBypassReason::Policy) -} - -/// Seconds since the Unix epoch, from the wasm-safe clock. -pub(crate) fn unix_seconds() -> u64 { - crate::ec::std_system_time_now() - .duration_since(SystemTime::UNIX_EPOCH) - .map_or(0, |since| since.as_secs()) -} - -type HandBackSlot = std::cell::RefCell>>; - -thread_local! { - static HANDED_BACK: std::cell::RefCell>> = - std::cell::RefCell::new(std::collections::HashMap::new()); -} - -static NEXT_LOAD: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - -/// Where a load leaves a response the request that started it is served, -/// because the origin answered with something no other request may be -/// served, so that request is served from the one fetch rather than a -/// second one. The slot lives as long as the request's `HandBack`, on the -/// thread that runs the request. -struct HandBack { - id: u64, - slot: std::rc::Rc, -} - -impl HandBack { - fn new() -> Self { - let id = NEXT_LOAD.fetch_add(1, Ordering::Relaxed); - let slot = std::rc::Rc::new(std::cell::RefCell::new(None)); - HANDED_BACK.with(|handed| { - handed - .borrow_mut() - .insert(id, std::rc::Rc::downgrade(&slot)); - }); - Self { id, slot } - } - - fn take(&self) -> Option> { - self.slot.borrow_mut().take() - } -} - -impl Drop for HandBack { - fn drop(&mut self) { - HANDED_BACK.with(|handed| { - handed.borrow_mut().remove(&self.id); - }); - } -} - -fn hand_back(id: u64, response: Response) { - let slot = HANDED_BACK.with(|handed| handed.borrow().get(&id).and_then(std::rc::Weak::upgrade)); - if let Some(slot) = slot { - *slot.borrow_mut() = Some(response); - } -} - -/// What a load owns to fetch one asset, because a loader owns everything it -/// uses. -struct AssetLoad { - services: RuntimeServices, - /// The origin request, already prepared for the origin. Only a `GET` is - /// kept, which carries no body. - uri: http::Uri, - headers: http::HeaderMap, - backend_name: String, - stream_response: bool, - /// The longest a copy may be kept, whatever the origin asks. - max_lifetime: Duration, -} - -/// What one fetch of an asset made. -struct AssetLoaded { - /// What the origin's response varies on. - vary: SourceVary, - /// The copy with the time it may be used for, or `None` when the response - /// may not be shared. - copy: Option<(crate::asset_cache::AssetCopy, Duration)>, -} - -/// The asset cache's fetch for one request, which fetches the asset and, when -/// it may be shared, keeps it as the origin sent it. -/// -/// An asset that may not be shared is held as -/// [`AssetEntry::NotShareable`](crate::asset_cache::AssetEntry::NotShareable) -/// for [`ASSET_NOT_SHAREABLE_LIFETIME`], and handed back to the request whose -/// load fetched it through `hand_back_id`, see [`HandBack`]. -fn asset_fetch(load: Arc, hand_back_id: u64) -> crate::source_cache::Fetch { - Arc::new(move || { - let load = Arc::clone(&load); - Box::pin(async move { - let AssetLoaded { vary, copy } = load_asset(&load, hand_back_id).await?; - let (entry, lifetime) = match copy { - Some((copy, lifetime)) => (crate::asset_cache::AssetEntry::Copy(copy), lifetime), - None => ( - crate::asset_cache::AssetEntry::NotShareable, - ASSET_NOT_SHAREABLE_LIFETIME, - ), - }; - Ok(crate::source_cache::Fetched { - vary, - copy: entry.encode().into(), - lifetime, - }) - }) - }) -} - -/// `body` read whole when it holds at most `limit` bytes, or else the body to -/// serve in its place, being what was read so far and then the rest. -async fn read_whole_or_hand_back(body: EdgeBody, limit: usize) -> Result { - let mut stream = match body { - EdgeBody::Once(bytes) if bytes.len() > limit => return Err(EdgeBody::Once(bytes)), - EdgeBody::Once(bytes) => return Ok(bytes), - EdgeBody::Stream(stream) => stream, - }; - let mut read = Vec::new(); - while let Some(chunk) = stream.next().await { - let failed = match chunk { - Ok(chunk) => { - read.extend_from_slice(&chunk); - if read.len() <= limit { - continue; - } - None - } - Err(error) => Some(Err(error)), - }; - let head = std::iter::once(Ok(bytes::Bytes::from(read))).chain(failed); - return Err(EdgeBody::Stream( - futures::stream::iter(head).chain(stream).boxed_local(), - )); - } - Ok(read.into()) -} - -/// Fetches an asset and keeps it as the origin sent it, with the time it may -/// be used for, or no copy when it may not be shared. -/// -/// # Errors -/// -/// When the origin cannot be reached. -async fn load_asset( - load: &AssetLoad, - hand_back_id: u64, -) -> Result { - let load_error = |what: &str, report: &dyn core::fmt::Debug| { - crate::platform::LoadError::new(format!("{what}: {report:?}")) - }; - let mut request = http::Request::builder() - .method(Method::GET) - .uri(load.uri.clone()) - .body(EdgeBody::empty()) - .map_err(|error| load_error("the origin request could not be built", &error))?; - *request.headers_mut() = load.headers.clone(); - let mut platform_request = PlatformHttpRequest::new(request, load.backend_name.clone()); - if load.stream_response { - platform_request = platform_request.with_stream_response(); - } - let response = load - .services - .http_client() - .send(platform_request) - .await - .map_err(|report| load_error("the origin could not be reached", &report))? - .response; - let made = Instant::now(); - let (parts, body) = response.into_parts(); - let content_type = parts - .headers - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let vary = SourceVary::of(&parts.headers); - let not_shareable = |vary, parts, body| { - hand_back(hand_back_id, Response::from_parts(parts, body)); - Ok(AssetLoaded { vary, copy: None }) - }; - if let Some(reason) = vary_bypass_reason(&vary) { - log::debug!("asset_cache bypass: {reason}"); - return not_shareable(vary, parts, body); - } - let ttl = match asset_cache_ttl( - parts.status, - &content_type, - &parts.headers, - load.max_lifetime, - ) { - Ok(ttl) => ttl, - Err(reason) => { - log::debug!("asset_cache bypass: {reason}"); - return not_shareable(vary, parts, body); - } - }; - let declared_length = parts - .headers - .get(header::CONTENT_LENGTH) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.trim().parse::().ok()); - if declared_length - .is_some_and(|length| length > crate::asset_cache::MAX_ASSET_COPY_BYTES as u64) - { - log::debug!("asset_cache bypass: {}", AssetCacheBypassReason::TooLarge); - return not_shareable(vary, parts, body); - } - let Some(headers) = crate::asset_cache::AssetCopy::kept_headers(&parts.headers, false) else { - log::debug!( - "asset_cache bypass: {}", - AssetCacheBypassReason::UnkeptHeader - ); - return not_shareable(vary, parts, body); - }; - let age_at_made = single_header_value(&parts.headers, header::AGE) - .ok() - .flatten() - .and_then(|value| parse_delta_seconds(value).ok()) - .unwrap_or(0); - let raw = match read_whole_or_hand_back(body, crate::asset_cache::MAX_ASSET_COPY_BYTES).await { - Ok(raw) => raw, - Err(body) => { - log::debug!( - "asset_cache bypass: {}", - AssetCacheBypassReason::NotReadWhole - ); - return not_shareable(vary, parts, body); - } - }; - let lifetime = ttl.saturating_sub(made.elapsed()); - if !lifetime.is_zero() { - log::debug!("asset_cache stored {} bytes", raw.len()); - } - Ok(AssetLoaded { - vary, - copy: Some(( - crate::asset_cache::AssetCopy { - headers, - decoded: false, - made_at: unix_seconds(), - age_at_made, - body: raw.to_vec(), - }, - lifetime, - )), - }) -} - #[cfg(test)] mod tests { - mod asset_cache_tests { + /// The files the publisher's origin sends that are not pages. + mod origin_file_tests { use super::*; use crate::platform::test_support::{ - MemoryLoadingCache, StubHttpClient, asset_copy_lifetimes, - build_services_with_http_client, + MemoryLoadingCache, StubHttpClient, build_services_with_http_client, }; - fn services( - stub: &Arc, - cache: &Arc, - ) -> RuntimeServices { - build_services_with_http_client(Arc::clone(stub) as _) - .with_loading_cache(Arc::clone(cache) as _) - } + /// The lifetime the origin gives every file these tests queue. + const FILE_CACHE_CONTROL: &str = "public, max-age=300"; + + /// The most bytes a test reads of a response body. + const MAX_BODY_BYTES: usize = 4 * 1024 * 1024; fn settings() -> Arc { Arc::new(crate::test_support::tests::create_test_settings()) } - fn asset_request(path_and_query: &str, destination: &str) -> Request { + /// The services of a deployment that links a loading cache module, + /// so that a value a load makes is kept. + fn services(stub: &Arc) -> RuntimeServices { + build_services_with_http_client(Arc::clone(stub) as _) + .with_loading_cache(Arc::new(MemoryLoadingCache::default())) + } + + fn file_request(path_and_query: &str, destination: &str) -> Request { Request::builder() .method(Method::GET) .uri(format!("https://ts.example.com{path_and_query}")) .header(header::HOST, "ts.example.com") .header("sec-fetch-dest", destination) .body(EdgeBody::empty()) - .expect("should build the asset request") + .expect("should build the file request") } - fn queue(stub: &StubHttpClient, content_type: &str, body: &[u8], extra: &[(&str, &str)]) { - let mut headers = vec![("content-type", content_type)]; - headers.extend_from_slice(extra); - stub.push_response_with_headers(200, body.to_vec(), headers); + fn queue(stub: &StubHttpClient, content_type: &str, body: &[u8]) { + stub.push_response_with_headers( + 200, + body.to_vec(), + vec![ + ("content-type", content_type), + ("cache-control", FILE_CACHE_CONTROL), + ], + ); } async fn run( @@ -8781,7 +8315,7 @@ mod tests { ) -> Response { try_run(settings, services, request) .await - .expect("should proxy the asset request") + .expect("should proxy the file request") } /// The publisher path's finished response to `request`, or the error @@ -8832,483 +8366,29 @@ mod tests { async fn body_of(response: Response) -> Vec { response .into_body() - .into_bytes_bounded(4 * 1024 * 1024) + .into_bytes_bounded(MAX_BODY_BYTES) .await .expect("should read the body") .to_vec() } #[tokio::test] - async fn an_image_is_kept_as_the_origin_sent_it() { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryLoadingCache::default()); - let services = services(&stub, &cache); - let settings = settings(); - let image = [0x89_u8, b'P', b'N', b'G', 0, 1, 2, 3]; - queue( - &stub, - "image/png", - &image, - &[("cache-control", "public, max-age=300")], - ); - - for _ in 0..3 { - let response = run(&settings, &services, asset_request("/a.png", "image")).await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(body_of(response).await, image); - } - - assert_eq!( - stub.recorded_request_uris().len(), - 1, - "one fetch serves all three" - ); - } - - #[tokio::test] - async fn a_stylesheet_is_served_from_one_fetch_with_its_age() { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryLoadingCache::default()); - let services = services(&stub, &cache); - let settings = settings(); - queue( - &stub, - "text/css", - b"body{color:red}", - &[("cache-control", "public, max-age=300")], - ); - - let first = - body_of(run(&settings, &services, asset_request("/site.css", "style")).await).await; - let second = run(&settings, &services, asset_request("/site.css", "style")).await; - assert!( - second.headers().contains_key(header::AGE), - "a kept copy says how long it has been kept" - ); - let second = body_of(second).await; - - assert_eq!( - stub.recorded_request_uris().len(), - 1, - "one fetch serves both" - ); - assert_eq!( - second, first, - "the kept copy is what the first reader was served" - ); - } - - #[tokio::test] - async fn a_wildcard_vary_a_private_or_a_no_store_response_is_never_kept() { - for (cache_control, vary, kept) in [ - ("public, max-age=300", None, true), - ("public, max-age=300", Some("*"), false), - ("public, max-age=300", Some("Cookie"), false), - ("private, max-age=300", None, false), - ("no-store", None, false), - ("public, max-age=300, no-store", None, false), - ] { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryLoadingCache::default()); - let services = services(&stub, &cache); - let settings = settings(); - for _ in 0..2 { - let mut extra = vec![("cache-control", cache_control)]; - extra.extend(vary.map(|vary| ("vary", vary))); - queue(&stub, "text/css", b"body{}", &extra); - let response = - run(&settings, &services, asset_request("/site.css", "style")).await; - assert_eq!(body_of(response).await, b"body{}"); - } - assert_eq!( - stub.recorded_request_uris().len(), - if kept { 1 } else { 2 }, - "Cache-Control {cache_control} and Vary {vary:?}" - ); - assert_eq!( - asset_copy_lifetimes(&cache).is_empty(), - !kept, - "Cache-Control {cache_control} and Vary {vary:?}" - ); - } - } - - #[tokio::test] - async fn s_maxage_then_max_age_set_how_long_a_copy_is_kept() { - for (cache_control, seconds) in [ - ("public, max-age=300", 300), - ("public, max-age=300, s-maxage=60", 60), - ("public, s-maxage=600, max-age=30", 600), - ] { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryLoadingCache::default()); - let services = services(&stub, &cache); - queue( - &stub, - "text/css", - b"body{}", - &[("cache-control", cache_control)], - ); - - let response = - run(&settings(), &services, asset_request("/site.css", "style")).await; - let _ = body_of(response).await; - - let lifetimes = asset_copy_lifetimes(&cache); - assert_eq!(lifetimes.len(), 1, "{cache_control}"); - let expected = Duration::from_secs(seconds); - assert!( - lifetimes[0] <= expected && lifetimes[0] > expected - Duration::from_secs(5), - "{cache_control} should keep a copy for {expected:?}, not {:?}", - lifetimes[0] - ); - } - } - - #[tokio::test] - async fn a_header_the_origin_varies_on_separates_kept_copies() { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryLoadingCache::default()); - let services = services(&stub, &cache); - let settings = settings(); - for edition in ["red", "blue"] { - queue( - &stub, - "text/css", - format!("/* {edition} */").as_bytes(), - &[ - ("cache-control", "public, max-age=300"), - ("vary", "X-Edition"), - ], - ); - } - let request = |edition: &str| { - let mut request = asset_request("/site.css", "style"); - request.headers_mut().insert( - "x-edition", - HeaderValue::from_str(edition).expect("should be a header value"), - ); - request - }; - - for edition in ["red", "blue", "red", "blue"] { - let css = body_of(run(&settings, &services, request(edition)).await).await; - assert_eq!(css, format!("/* {edition} */").into_bytes()); - } - - assert_eq!( - stub.recorded_request_uris().len(), - 2, - "each edition is fetched once" - ); - } - - #[tokio::test] - async fn a_body_that_outgrows_a_copy_is_served_from_the_one_fetch() { - for streaming in [false, true] { - let stub = Arc::new(StubHttpClient::new()); - stub.set_streaming_responses_supported(streaming); - let cache = Arc::new(MemoryLoadingCache::default()); - let services = services(&stub, &cache); - let settings = settings(); - let large = vec![b'a'; crate::asset_cache::MAX_ASSET_COPY_BYTES + 1]; - for _ in 0..2 { - queue( - &stub, - "text/plain", - &large, - &[("cache-control", "public, max-age=300")], - ); - } - - let response = run(&settings, &services, asset_request("/big.txt", "empty")).await; - - assert_eq!( - body_of(response).await, - large, - "streaming {streaming}: the reader is served the whole body" - ); - assert_eq!( - stub.recorded_request_uris().len(), - 1, - "streaming {streaming}: a body with no Content-Length that outgrows a copy \ - is served from the one fetch" - ); - assert!(asset_copy_lifetimes(&cache).is_empty()); - } - } - - #[tokio::test] - async fn a_response_whose_vary_changed_is_served_from_the_one_fetch() { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryLoadingCache::default()); - let services = services(&stub, &cache); - let settings = settings(); - for (edition, vary) in [ - ("red", "X-Edition"), - ("blue", "X-Region"), - ("blue", "X-Region"), - ] { - queue( - &stub, - "text/css", - format!("/* {edition} */").as_bytes(), - &[("cache-control", "public, max-age=300"), ("vary", vary)], - ); - } - let request = |edition: &str| { - let mut request = asset_request("/site.css", "style"); - request.headers_mut().insert( - "x-edition", - HeaderValue::from_str(edition).expect("should be a header value"), - ); - request - }; - - let red = body_of(run(&settings, &services, request("red")).await).await; - let blue = body_of(run(&settings, &services, request("blue")).await).await; - - assert_eq!(red, b"/* red */"); - assert_eq!( - blue, b"/* blue */", - "the request whose fetch found the new Vary is served that fetch" - ); - assert_eq!( - stub.recorded_request_uris().len(), - 2, - "each request fetches the origin once" - ); - } - - #[tokio::test] - async fn a_load_that_fails_is_the_answer_so_the_origin_is_asked_once() { - use crate::error::IntoHttpResponse as _; - - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryLoadingCache::default()); - let services = services(&stub, &cache); - let settings = settings(); - - // Nothing is queued, so the stub fails the origin request. - let request = asset_request("/site.css", "style"); - let Err(error) = try_run(&settings, &services, request).await else { - panic!("should fail when the origin request fails"); - }; - assert_eq!( - error.current_context().status_code(), - StatusCode::BAD_GATEWAY, - "a failed load answers as a failed origin request does" - ); - assert_eq!( - stub.recorded_request_uris().len(), - 1, - "the failed load is the request's answer, so the origin is asked once" - ); - assert!(cache.keys().is_empty(), "nothing is kept for a failed load"); - - queue( - &stub, - "text/css", - b"body{}", - &[("cache-control", "public, max-age=300")], - ); - let response = run(&settings, &services, asset_request("/site.css", "style")).await; - assert_eq!( - body_of(response).await, - b"body{}", - "the next request loads the asset again" - ); - } - - #[tokio::test] - async fn a_request_carrying_authorization_is_fetched_for_itself() { + async fn every_file_is_asked_of_the_publishers_origin() { let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryLoadingCache::default()); - let services = services(&stub, &cache); + let services = services(&stub); let settings = settings(); - for _ in 0..2 { - queue( - &stub, - "image/png", - b"png", - &[("cache-control", "public, max-age=300")], - ); - let mut request = asset_request("/a.png", "image"); - request.headers_mut().insert( - header::AUTHORIZATION, - HeaderValue::from_static("Bearer reader-token"), - ); - let _ = body_of(run(&settings, &services, request).await).await; - } - assert_eq!( - stub.recorded_request_uris().len(), - 2, - "an authenticated request never shares a copy" - ); - assert!(asset_copy_lifetimes(&cache).is_empty()); - } - - #[tokio::test] - async fn a_request_carrying_a_cookie_is_fetched_for_itself_and_kept_for_no_one() { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryLoadingCache::default()); - let services = services(&stub, &cache); - let settings = settings(); - // The origin answers each reader from their cookie without naming - // it in `Vary`, which the cache cannot see. - for reader in ["one", "two"] { - queue( - &stub, - "application/json", - format!("{{\"reader\":\"{reader}\"}}").as_bytes(), - &[("cache-control", "max-age=300")], - ); - } - - for reader in ["one", "two"] { - let mut request = asset_request("/account.json", "empty"); - request.headers_mut().insert( - header::COOKIE, - HeaderValue::from_str(&format!("session={reader}")) - .expect("should be a header value"), - ); - let answer = body_of(run(&settings, &services, request).await).await; - assert_eq!( - answer, - format!("{{\"reader\":\"{reader}\"}}").into_bytes(), - "reader {reader} is served the answer made for their own cookie" - ); - } - - assert_eq!( - stub.recorded_request_uris().len(), - 2, - "each reader's request is fetched for itself" - ); - assert!( - asset_copy_lifetimes(&cache).is_empty(), - "no answer to a request carrying a cookie is kept" - ); - } - - #[tokio::test] - async fn an_answer_that_sets_a_cookie_is_served_only_to_the_reader_it_was_made_for() { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryLoadingCache::default()); - let services = services(&stub, &cache); - let settings = settings(); - for reader in ["one", "two"] { - let cookie = format!("visitor={reader}"); - queue( - &stub, - "text/css", - format!("/* {reader} */").as_bytes(), - &[ - ("cache-control", "public, max-age=300"), - ("set-cookie", cookie.as_str()), - ], - ); - } - - for reader in ["one", "two"] { - let response = run(&settings, &services, asset_request("/site.css", "style")).await; - let cookie = format!("visitor={reader}"); - assert!( - response - .headers() - .get_all(header::SET_COOKIE) - .iter() - .any(|value| value.as_bytes() == cookie.as_bytes()), - "reader {reader} is served the cookie the origin set for them" - ); - assert_eq!( - body_of(response).await, - format!("/* {reader} */").into_bytes() - ); - } - - assert_eq!( - stub.recorded_request_uris().len(), - 2, - "each reader's request is fetched for itself" - ); - assert!( - asset_copy_lifetimes(&cache).is_empty(), - "an answer that sets a cookie is kept for no one" - ); - } - - /// A loading cache module's cache, which keeps what it loads. - fn memory_module_cache() -> Arc { - Arc::new(MemoryLoadingCache::default()) - } - - #[tokio::test] - async fn assets_turned_off_in_the_settings_are_fetched_for_every_request() { - let module = crate::integrations::IntegrationBuilder::implementations( - "loading-cache.memory", - "a-crate", - ) - .with_loading_cache(memory_module_cache); - for (written, fetches) in [(None, 1), (Some(crate::settings::ASSETS_CACHE_OFF), 2)] { - let mut settings = crate::test_support::tests::create_test_settings(); - settings.cache.assets.module = written.map(str::to_owned); - let caches = crate::integrations::loading_caches_from( - &settings, - std::slice::from_ref(&module), - ) - .expect("should select the asset caches"); - let settings = Arc::new(settings); - let stub = Arc::new(StubHttpClient::new()); - let services = build_services_with_http_client(Arc::clone(&stub) as _) - .with_loading_caches(caches); - for _ in 0..2 { - queue( - &stub, - "text/css", - b"body{}", - &[("cache-control", "public, max-age=300")], - ); - let response = - run(&settings, &services, asset_request("/site.css", "style")).await; - assert_eq!(body_of(response).await, b"body{}"); - } - - assert_eq!( - stub.recorded_request_uris().len(), - fetches, - "[cache.assets] module {written:?}" - ); - } - } - - #[tokio::test] - async fn a_page_navigation_never_reads_an_asset_copy() { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryLoadingCache::default()); - let services = services(&stub, &cache); - let settings = settings(); for _ in 0..2 { - queue( - &stub, - "text/html; charset=utf-8", - b"page", - &[("cache-control", "public, max-age=300")], - ); - let mut request = asset_request("/page", "document"); - request - .headers_mut() - .insert("sec-fetch-mode", HeaderValue::from_static("navigate")); - let _ = body_of(run(&settings, &services, request).await).await; + queue(&stub, "text/css", b"body{}"); + let response = run(&settings, &services, file_request("/site.css", "style")).await; + assert_eq!(body_of(response).await, b"body{}"); } assert_eq!( stub.recorded_request_uris().len(), 2, - "an inline page is fetched for each reader" + "two requests for a file the origin lets be kept are two origin requests" ); - assert!(asset_copy_lifetimes(&cache).is_empty()); } } diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index d77836b5ec..142ab17ada 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -3085,10 +3085,6 @@ pub struct CacheSettings { skip_serializing_if = "LoadingCacheConfig::is_default" )] pub robots_txt: LoadingCacheConfig, - /// How long the copies of the assets the publisher's origin sends last, - /// `[cache.assets]`. A `module` of [`ASSETS_CACHE_OFF`] keeps none. - #[serde(default, skip_serializing_if = "LoadingCacheConfig::is_default")] - pub assets: LoadingCacheConfig, /// The caches modules declare and load through, each in its own /// `[cache.]` table with the same settings as `[cache.robots-txt]`. /// A name no linked module declares is refused when the deployment's @@ -3104,19 +3100,6 @@ pub const MAX_LOADING_CACHE_SECONDS: u32 = 365 * 24 * 60 * 60; /// settings are `[cache.robots-txt]`. pub const ROBOTS_TXT_CACHE: &str = "robots-txt"; -/// The name of the cache that holds the copies of the assets the publisher's -/// origin sends, whose settings are `[cache.assets]`. -pub const ASSETS_CACHE: &str = "assets"; - -/// The name of the cache that holds each asset's record of what its -/// responses vary on, selected by the same `[cache.assets]` table as the -/// copies, so one line selects both. -pub const ASSETS_VARY_CACHE: &str = "assets-vary"; - -/// The `module` that turns both asset caches off in `[cache.assets]`, so -/// every asset is fetched from its origin for each request. -pub const ASSETS_CACHE_OFF: &str = "none"; - /// How long one loading cache's copies last, each field defaulting to the /// cache's own default when absent. /// @@ -3207,18 +3190,13 @@ impl CacheSettings { /// modules declare, in name order. #[must_use] pub fn loading_caches(&self) -> Vec<(&str, &LoadingCacheConfig)> { - [ - (ROBOTS_TXT_CACHE, &self.robots_txt), - (ASSETS_CACHE, &self.assets), - (ASSETS_VARY_CACHE, &self.assets), - ] - .into_iter() - .chain( - self.declared - .iter() - .map(|(name, config)| (name.as_str(), config)), - ) - .collect() + std::iter::once((ROBOTS_TXT_CACHE, &self.robots_txt)) + .chain( + self.declared + .iter() + .map(|(name, config)| (name.as_str(), config)), + ) + .collect() } fn normalize(&mut self) { @@ -3236,7 +3214,6 @@ impl CacheSettings { /// invalid policy/matcher or cannot compile its regex/glob. pub fn prepare_runtime(&self) -> Result<(), Report> { self.robots_txt.check("cache.robots-txt")?; - self.assets.check("cache.assets")?; for (name, config) in &self.declared { config.check(&format!("cache.{name}"))?; } @@ -7079,30 +7056,28 @@ module = \"none\"", #[test] fn a_loading_cache_lifetime_out_of_range_is_refused_naming_it() { - for table in ["robots-txt", "assets"] { - for (field, value, least) in [ - ("store_seconds", "0", 1), - ("store_idle_seconds", "0", 1), - ("memory_seconds", "31536001", 0), - ] { - let toml_str = format!( - "{} + for (field, value, least) in [ + ("store_seconds", "0", 1), + ("store_idle_seconds", "0", 1), + ("memory_seconds", "31536001", 0), + ] { + let toml_str = format!( + "{} -[cache.{table}] +[cache.robots-txt] {field} = {value} ", - crate_test_settings_str() - ); - let error = Settings::from_toml(&toml_str) - .expect_err("a lifetime out of range should not load"); - let message = format!("{error:?}"); - assert!( - message.contains(&format!( - "[cache.{table}] {field} is {value}, and must be from {least} to 31536000" - )), - "{table} {field}: {message}" - ); - } + crate_test_settings_str() + ); + let error = Settings::from_toml(&toml_str) + .expect_err("a lifetime out of range should not load"); + let message = format!("{error:?}"); + assert!( + message.contains(&format!( + "[cache.robots-txt] {field} is {value}, and must be from {least} to 31536000" + )), + "{field}: {message}" + ); } } @@ -7125,14 +7100,32 @@ module = \"none\"", let caches = settings.cache.loading_caches(); - assert_eq!(caches.len(), 4); - assert_eq!(caches[3].0, "answers"); - assert_eq!(caches[3].1.module.as_deref(), Some("51degrees")); - assert_eq!(caches[3].1.store_seconds, Some(3600)); + assert_eq!(caches.len(), 2); + assert_eq!(caches[1].0, "answers"); + assert_eq!(caches[1].1.module.as_deref(), Some("51degrees")); + assert_eq!(caches[1].1.store_seconds, Some(3600)); let written = serde_json::to_value(&settings.cache).expect("should serialize"); assert_eq!(written["answers"]["module"], "51degrees"); } + #[test] + fn a_cache_assets_table_is_read_as_a_cache_a_module_declares() { + let toml_str = format!( + "{}\n[cache.assets]\nstore_seconds = 3600\n", + crate_test_settings_str() + ); + let settings = Settings::from_toml(&toml_str).expect("should parse the table"); + + assert_eq!( + settings.cache.loading_caches(), + vec![ + (super::ROBOTS_TXT_CACHE, &settings.cache.robots_txt), + ("assets", &settings.cache.declared["assets"]), + ], + "core has no `[cache.assets]` of its own, so a module would declare it" + ); + } + #[test] fn a_loading_cache_module_is_read_and_written_back() { let toml_str = format!( @@ -7152,12 +7145,8 @@ module = \"none\"", assert!(!settings.cache.robots_txt.is_default()); assert_eq!( settings.cache.loading_caches(), - vec![ - (super::ROBOTS_TXT_CACHE, &settings.cache.robots_txt), - (super::ASSETS_CACHE, &settings.cache.assets), - (super::ASSETS_VARY_CACHE, &settings.cache.assets), - ], - "should list core's own caches by name, robots.txt then the two of assets" + vec![(super::ROBOTS_TXT_CACHE, &settings.cache.robots_txt)], + "should list the robots.txt cache by its name" ); let written = serde_json::to_value(&settings.cache).expect("should serialize"); assert_eq!( diff --git a/crates/trusted-server-core/src/source_cache.rs b/crates/trusted-server-core/src/source_cache.rs deleted file mode 100644 index 6c6f0914c1..0000000000 --- a/crates/trusted-server-core/src/source_cache.rs +++ /dev/null @@ -1,754 +0,0 @@ -//! The cache that holds what the fetch phase made of a source's response, -//! keyed the way the source says its responses vary. -//! -//! A request's address is what it asks the source for. The first response for -//! an address records the request headers its `Vary` names, and that record is -//! held in the loading cache like any other value. A later request for the -//! address reads the record and is served the copy kept under its own values -//! for those headers, as RFC 9111 section 4.1 describes. -//! -//! Requests for an address no record covers share one load. The request that -//! leads it fetches the source and writes its copy under its own values before -//! the record is published, so a waiting request with the same values is -//! served that copy, and one with other values loads its own, sharing that -//! load with every request waiting on the same values. The request whose fetch -//! ran is served what it fetched, whatever the cache kept, so no request -//! fetches the source twice. -//! -//! A response whose `Vary` is `*`, names `Cookie`, or cannot be read can be -//! served to no other request, so its record says no copy is kept, and every -//! request for the address fetches its own response until the record ends. - -use std::future::Future; -use std::pin::Pin; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use bytes::Bytes; -use http::HeaderMap; -use http::header::{self, HeaderName}; -use sha2::{Digest as _, Sha256}; - -use crate::platform::{ - CacheSpec, LoadError, LoadedValue, Loader, RuntimeServices, ServerKeyPrefix, StoreKey, - hash_length_prefixed as push, -}; - -/// What a source's response says it varies on, read from its `Vary`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SourceVary { - /// These request headers, in lowercase, sorted and each named once. - /// Empty when the response varies on nothing. - Headers(Vec), - /// `Vary: *`, which no other request matches. - Any, - /// `Cookie` is named, which keys a copy on every cookie a reader carries, - /// so each reader would have a copy of their own. - Cookie, - /// A `Vary` that is not a list of header names. - Unreadable, -} - -impl SourceVary { - /// What a response with `headers` says it varies on. - pub fn of(headers: &HeaderMap) -> Self { - let mut names = Vec::new(); - for value in headers.get_all(header::VARY) { - let Ok(value) = value.to_str() else { - return Self::Unreadable; - }; - for name in value - .split(',') - .map(str::trim) - .filter(|name| !name.is_empty()) - { - if name == "*" { - return Self::Any; - } - let Ok(name) = HeaderName::from_bytes(name.as_bytes()) else { - return Self::Unreadable; - }; - if name == header::COOKIE { - return Self::Cookie; - } - names.push(name.as_str().to_owned()); - } - } - names.sort_unstable(); - names.dedup(); - Self::Headers(names) - } - - /// The same, leaving out `name`, a header whose value never selects a - /// different copy, such as `Accept-Encoding` for a copy kept decoded and - /// encoded for each reader as it is served. - #[must_use] - pub fn without(self, name: &HeaderName) -> Self { - match self { - Self::Headers(mut names) => { - names.retain(|kept| kept != name.as_str()); - Self::Headers(names) - } - other => other, - } - } -} - -/// What the cache holds for an address. -#[derive(Debug, Clone, PartialEq, Eq)] -enum AddressRecord { - /// Each copy at the address is found by a request's values for these - /// headers. - VariesOn(Vec), - /// No copy at the address is served to another request. - NoCopies, -} - -impl AddressRecord { - fn encode(&self) -> Bytes { - match self { - Self::VariesOn(names) => Bytes::from(format!("v1:{}", names.join(","))), - Self::NoCopies => Bytes::from_static(b"v1*"), - } - } - - /// The record `bytes` hold, or `None` for bytes this build did not write. - fn decode(bytes: &[u8]) -> Option { - let text = std::str::from_utf8(bytes).ok()?; - if text == "v1*" { - return Some(Self::NoCopies); - } - let names = text.strip_prefix("v1:")?; - if names.is_empty() { - return Some(Self::VariesOn(Vec::new())); - } - names - .split(',') - .map(|name| { - HeaderName::from_bytes(name.as_bytes()) - .ok() - .filter(|parsed| parsed.as_str() == name) - .map(|_| name.to_owned()) - }) - .collect::>>() - .map(Self::VariesOn) - } -} - -/// One address, made of the publisher's prefix, so two publishers never share -/// a record or a copy, and a digest of what the request asks the source for. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SourceAddress { - prefix: ServerKeyPrefix, - key: String, -} - -impl SourceAddress { - /// The address `key` names for the publisher `prefix` names. The key - /// should be short, such as a digest, because a store refuses a long key. - #[must_use] - pub fn new(prefix: ServerKeyPrefix, key: String) -> Self { - Self { prefix, key } - } - - fn record_key(&self, cache: &SourceCache) -> StoreKey { - StoreKey::new(&self.prefix, cache.records.name, self.key.clone()) - } - - fn copy_key(&self, cache: &SourceCache, variant: &str) -> StoreKey { - StoreKey::new( - &self.prefix, - cache.copies.name, - format!("{}-{variant}", self.key), - ) - } - - /// The key this address's record is held under in `cache`, for a test. - #[cfg(test)] - #[must_use] - pub fn record_key_for_test(&self, cache: &SourceCache) -> StoreKey { - self.record_key(cache) - } - - /// The key one of this address's copies is held under in `cache`, for a - /// test. - #[cfg(test)] - #[must_use] - pub fn copy_key_for_test(&self, cache: &SourceCache, variant: &str) -> StoreKey { - self.copy_key(cache, variant) - } -} - -/// Which copy a request is served, as a digest of its values for `names` and -/// of what the publisher's rule declared for it. -/// -/// An absent header and one present with an empty value are different requests -/// to the source, so they give different digests, as do repeated values in a -/// different order. -fn variant(names: &[String], headers: &HeaderMap, declared: Option<&str>) -> String { - let mut hasher = Sha256::new(); - push(&mut hasher, b"torque-variant-v1"); - push(&mut hasher, &(names.len() as u64).to_be_bytes()); - for name in names { - push(&mut hasher, name.as_bytes()); - let values: Vec<&[u8]> = headers - .get_all(name.as_str()) - .iter() - .map(http::HeaderValue::as_bytes) - .collect(); - if values.is_empty() { - push(&mut hasher, b"absent"); - } else { - push(&mut hasher, b"present"); - push(&mut hasher, &(values.len() as u64).to_be_bytes()); - for value in values { - push(&mut hasher, value); - } - } - } - match declared { - None => push(&mut hasher, b"undeclared"), - Some(token) => { - push(&mut hasher, b"declared"); - push(&mut hasher, token.as_bytes()); - } - } - // Half the digest. 128 bits keeps two variants of one address from ever - // sharing a key, and a key is read by stores that refuse long ones. - hex::encode(&hasher.finalize()[..16]) -} - -/// The two loading caches one kind of response uses, one holding each -/// address's record, and one holding the copies. -#[derive(Debug, Clone)] -pub struct SourceCache { - /// Holds each address's record of what its responses vary on. - pub records: CacheSpec, - /// Holds the copies, each under its address and a request's values. - pub copies: CacheSpec, -} - -/// What one fetch of the source gave the request that made it. -#[derive(Debug, Clone)] -pub struct Fetched { - /// What the source's response varies on. - pub vary: SourceVary, - /// The copy cache's value for the request that fetched it, in the caller's - /// own form, which may say the response may not be served to another - /// request. - pub copy: Bytes, - /// How long the copy may be used. The record lasts as long. - pub lifetime: Duration, -} - -/// The work of one [`Fetch`]. -pub type FetchFuture = Pin>>>; - -/// Fetches the source for one request, run at most once for its record and -/// once for its copy, by whichever of the two loads this request leads. -pub type Fetch = Arc FetchFuture + Send + Sync>; - -/// One request's lookup. -pub struct Lookup<'a> { - /// The request's services, whose loading cache holds the records and the - /// copies. - pub services: &'a RuntimeServices, - /// The caches this kind of response uses. - pub cache: &'a SourceCache, - /// What the request asks the source for. - pub address: &'a SourceAddress, - /// The request's headers, as they are sent to the source. - pub headers: &'a HeaderMap, - /// What the publisher's rule says separates this request's copy, beyond - /// the source's own `Vary`. - pub declared: Option<&'a str>, - /// The copy cache's value saying a response may not be served to another - /// request, in the caller's form. - pub not_shareable: &'a Bytes, -} - -/// Where a load leaves the copy its own fetch made, for the request whose -/// fetch it was. -type Own = Arc>>; - -fn keep_own(own: &Own, copy: &Bytes) { - *own.lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(copy.clone()); -} - -fn take_own(own: &Own) -> Option { - own.lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take() -} - -/// What a lookup found. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Found { - /// The copy cache's value for this request's values, in the caller's form. - Copy { - /// The value. - value: Bytes, - /// Where it is kept, for a caller that finds it cannot use it. - key: StoreKey, - }, - /// No copy at the address is served to another request, so this request - /// fetches its own response. - NoCopies, -} - -/// The copy for `lookup`'s request, from the record and copy caches or from -/// `fetch`. When this request's own fetch ran, it is served what that fetch -/// made, even when the cache keeps it for nobody. -/// -/// # Errors -/// -/// The error of a fetch this request ran or waited on. Every request waiting -/// on that fetch receives it, and nothing is kept. -pub async fn get_or_fetch(lookup: &Lookup<'_>, fetch: Fetch) -> Result { - let own = Own::default(); - let record_key = lookup.address.record_key(lookup.cache); - let record = lookup - .services - .load_cached( - &lookup.cache.records, - &record_key, - record_loader(lookup, Arc::clone(&fetch), Arc::clone(&own)), - ) - .await?; - let Some(record) = AddressRecord::decode(&record) else { - log::warn!("source_cache: a record this build did not write was dropped"); - lookup - .services - .loading_cache_for(lookup.cache.records.name) - .remove(lookup.services, &lookup.cache.records, &record_key) - .await; - return Ok(Found::NoCopies); - }; - let names = match record { - AddressRecord::NoCopies => return Ok(Found::NoCopies), - AddressRecord::VariesOn(names) => names, - }; - let copy_key = lookup.address.copy_key( - lookup.cache, - &variant(&names, lookup.headers, lookup.declared), - ); - if let Some(value) = take_own(&own) { - return Ok(Found::Copy { - value, - key: copy_key, - }); - } - let value = lookup - .services - .load_cached( - &lookup.cache.copies, - ©_key, - copy_loader(lookup, names, fetch, Arc::clone(&own)), - ) - .await?; - Ok(Found::Copy { - value: take_own(&own).unwrap_or(value), - key: copy_key, - }) -} - -/// The record's load, which fetches the source and records what its response -/// varies on. The fetching request's copy is written under its own values -/// first, so every request waiting on the record finds it. -fn record_loader(lookup: &Lookup<'_>, fetch: Fetch, own: Own) -> Loader { - let services = lookup.services.clone(); - let cache = lookup.cache.clone(); - let address = lookup.address.clone(); - let headers = lookup.headers.clone(); - let declared = lookup.declared.map(str::to_owned); - Box::new(move || { - Box::pin(async move { - let fetched = fetch().await?; - keep_own(&own, &fetched.copy); - let SourceVary::Headers(names) = fetched.vary else { - return Ok(LoadedValue::new(AddressRecord::NoCopies.encode()) - .with_lifetime(fetched.lifetime)); - }; - let copy_key = - address.copy_key(&cache, &variant(&names, &headers, declared.as_deref())); - let copy = fetched.copy; - let lifetime = fetched.lifetime; - services - .load_cached( - &cache.copies, - ©_key, - Box::new(move || { - Box::pin(async move { Ok(LoadedValue::new(copy).with_lifetime(lifetime)) }) - }), - ) - .await?; - Ok(LoadedValue::new(AddressRecord::VariesOn(names).encode()) - .with_lifetime(fetched.lifetime)) - }) - }) -} - -/// A copy's load, which fetches the source for this request's values. -/// -/// A response that no longer varies on what the record names cannot be kept -/// under a key the record made, so only the request that fetched it is served -/// it, and the record is dropped so the next request learns what the source -/// varies on now. -fn copy_loader(lookup: &Lookup<'_>, names: Vec, fetch: Fetch, own: Own) -> Loader { - let services = lookup.services.clone(); - let cache = lookup.cache.clone(); - let address = lookup.address.clone(); - let not_shareable = lookup.not_shareable.clone(); - Box::new(move || { - Box::pin(async move { - let fetched = fetch().await?; - keep_own(&own, &fetched.copy); - if fetched.vary != SourceVary::Headers(names) { - log::debug!( - "source_cache: the source no longer varies as its record says, so the \ - record is dropped" - ); - services - .loading_cache_for(cache.records.name) - .remove(&services, &cache.records, &address.record_key(&cache)) - .await; - return Ok(LoadedValue::new(not_shareable).with_lifetime(Duration::ZERO)); - } - Ok(LoadedValue::new(fetched.copy).with_lifetime(fetched.lifetime)) - }) - }) -} - -#[cfg(test)] -mod tests { - use std::sync::atomic::{AtomicUsize, Ordering}; - - use http::HeaderValue; - - use super::*; - use crate::platform::{ - CacheLifetimes, - test_support::{MemoryLoadingCache, StubHttpClient, build_services_with_http_client}, - }; - - fn services(cache: Arc) -> RuntimeServices { - build_services_with_http_client(Arc::new(StubHttpClient::new())).with_loading_cache(cache) - } - - fn spec(name: &'static str) -> CacheSpec { - CacheSpec { - name, - lifetimes: CacheLifetimes { - memory: Duration::from_secs(5), - store: Duration::from_secs(60), - store_idle: Duration::from_secs(60), - }, - memory_entries: 8, - } - } - - fn cache() -> SourceCache { - SourceCache { - records: spec("test-vary"), - copies: spec("test"), - } - } - - fn headers(pairs: &[(&'static str, &str)]) -> HeaderMap { - let mut headers = HeaderMap::new(); - for (name, value) in pairs { - headers.append( - HeaderName::from_static(name), - HeaderValue::from_str(value).expect("should be a header value"), - ); - } - headers - } - - /// A source answering with `vary`, whose copy names the edition the - /// request asked for, counting its fetches. - fn source(vary: SourceVary, edition: &str, fetches: &Arc) -> Fetch { - let fetches = Arc::clone(fetches); - let copy = Bytes::from(format!("edition {edition}")); - Arc::new(move || { - let fetches = Arc::clone(&fetches); - let vary = vary.clone(); - let copy = copy.clone(); - Box::pin(async move { - fetches.fetch_add(1, Ordering::SeqCst); - Ok(Fetched { - vary, - copy, - lifetime: Duration::from_secs(30), - }) - }) - }) - } - - fn copy(found: Found) -> Option { - match found { - Found::Copy { value, .. } => Some(value), - Found::NoCopies => None, - } - } - - async fn look_up( - services: &RuntimeServices, - request: &HeaderMap, - declared: Option<&str>, - fetch: Fetch, - ) -> Found { - let address = SourceAddress::new( - ServerKeyPrefix::new("ts", "example.com"), - "address".to_owned(), - ); - get_or_fetch( - &Lookup { - services, - cache: &cache(), - address: &address, - headers: request, - declared, - not_shareable: &Bytes::from_static(b"not shareable"), - }, - fetch, - ) - .await - .expect("the lookup should succeed") - } - - #[test] - fn record_and_copy_keys_follow_the_publisher() { - crate::prefix::test_support::assert_keys_follow_the_publisher(|domain| { - let address = - SourceAddress::new(ServerKeyPrefix::new("ts", domain), "address".to_owned()); - format!( - "{} {}", - address.record_key(&cache()), - address.copy_key(&cache(), "variant") - ) - }); - } - - #[test] - fn vary_names_are_lowercase_sorted_and_named_once() { - let response = headers(&[ - ("vary", "X-Edition, Accept-Encoding"), - ("vary", "x-edition"), - ]); - - assert_eq!( - SourceVary::of(&response), - SourceVary::Headers(vec!["accept-encoding".to_owned(), "x-edition".to_owned()]) - ); - assert_eq!( - SourceVary::of(&response).without(&header::ACCEPT_ENCODING), - SourceVary::Headers(vec!["x-edition".to_owned()]), - "a header that never selects a copy is left out" - ); - assert_eq!( - SourceVary::of(&HeaderMap::new()), - SourceVary::Headers(Vec::new()) - ); - } - - #[test] - fn a_wildcard_a_cookie_or_an_unreadable_vary_allows_no_copies() { - for (vary, expected) in [ - ("*", SourceVary::Any), - ("x-edition, *", SourceVary::Any), - ("Cookie", SourceVary::Cookie), - ("x edition", SourceVary::Unreadable), - ] { - assert_eq!( - SourceVary::of(&headers(&[("vary", vary)])), - expected, - "{vary}" - ); - } - } - - #[test] - fn a_record_reads_back_as_written_and_a_foreign_one_does_not_read() { - for record in [ - AddressRecord::VariesOn(Vec::new()), - AddressRecord::VariesOn(vec!["accept-language".to_owned(), "x-edition".to_owned()]), - AddressRecord::NoCopies, - ] { - assert_eq!(AddressRecord::decode(&record.encode()), Some(record)); - } - for foreign in [&b"v2:x-edition"[..], b"", b"v1:X-Edition", b"v1:x edition"] { - assert_eq!(AddressRecord::decode(foreign), None, "{foreign:?}"); - } - } - - #[test] - fn a_variant_tells_absent_empty_and_reordered_values_apart() { - let names = vec!["x-edition".to_owned()]; - let digest = |pairs: &[(&'static str, &str)]| variant(&names, &headers(pairs), None); - - let absent = digest(&[]); - let empty = digest(&[("x-edition", "")]); - let one_then_two = digest(&[("x-edition", "one"), ("x-edition", "two")]); - let two_then_one = digest(&[("x-edition", "two"), ("x-edition", "one")]); - - assert_ne!(absent, empty); - assert_ne!(one_then_two, two_then_one); - assert_eq!( - digest(&[("x-edition", "one"), ("x-other", "a")]), - digest(&[("x-edition", "one"), ("x-other", "b")]), - "a header the source does not vary on does not separate copies" - ); - assert_ne!( - variant(&names, &HeaderMap::new(), Some("token")), - variant(&names, &HeaderMap::new(), None), - "what the publisher's rule declares separates copies" - ); - } - - #[tokio::test] - async fn the_first_response_records_its_vary_and_each_value_gets_its_own_copy() { - let memory = Arc::new(MemoryLoadingCache::default()); - let services = services(Arc::clone(&memory)); - let fetches = Arc::new(AtomicUsize::new(0)); - let vary = SourceVary::Headers(vec!["x-edition".to_owned()]); - let red = headers(&[("x-edition", "red")]); - let blue = headers(&[("x-edition", "blue")]); - - let first = look_up(&services, &red, None, source(vary.clone(), "red", &fetches)).await; - let again = look_up(&services, &red, None, source(vary.clone(), "red", &fetches)).await; - let other = look_up( - &services, - &blue, - None, - source(vary.clone(), "blue", &fetches), - ) - .await; - let other_again = look_up(&services, &blue, None, source(vary, "blue", &fetches)).await; - - assert_eq!(copy(first), Some(Bytes::from("edition red"))); - assert_eq!(copy(again), Some(Bytes::from("edition red"))); - assert_eq!(copy(other), Some(Bytes::from("edition blue"))); - assert_eq!(copy(other_again), Some(Bytes::from("edition blue"))); - assert_eq!( - fetches.load(Ordering::SeqCst), - 2, - "each value is fetched once, and the first fetch also made the record" - ); - } - - #[tokio::test] - async fn a_response_that_allows_no_copies_has_every_request_fetch_its_own() { - let memory = Arc::new(MemoryLoadingCache::default()); - let services = services(Arc::clone(&memory)); - let fetches = Arc::new(AtomicUsize::new(0)); - let request = headers(&[("x-edition", "red")]); - - for _ in 0..2 { - let found = look_up( - &services, - &request, - None, - source(SourceVary::Any, "red", &fetches), - ) - .await; - assert_eq!(found, Found::NoCopies); - } - assert_eq!( - fetches.load(Ordering::SeqCst), - 1, - "the record saying no copies are kept is itself kept" - ); - } - - #[tokio::test] - async fn a_source_that_changes_its_vary_has_its_record_dropped() { - let memory = Arc::new(MemoryLoadingCache::default()); - let services = services(Arc::clone(&memory)); - let fetches = Arc::new(AtomicUsize::new(0)); - let red = headers(&[("x-edition", "red")]); - let blue = headers(&[("x-edition", "blue")]); - look_up( - &services, - &red, - None, - source( - SourceVary::Headers(vec!["x-edition".to_owned()]), - "red", - &fetches, - ), - ) - .await; - - let changed = look_up( - &services, - &blue, - None, - source( - SourceVary::Headers(vec!["x-region".to_owned()]), - "blue", - &fetches, - ), - ) - .await; - - assert_eq!( - copy(changed), - Some(Bytes::from("edition blue")), - "the request that fetched a copy made under another Vary is served it" - ); - assert_eq!( - fetches.load(Ordering::SeqCst), - 2, - "that request fetches the source once" - ); - let keys = memory.keys(); - assert!( - keys.iter() - .filter_map(|key| memory.held(key)) - .all(|(value, _)| value != "edition blue"), - "no other request is served that copy" - ); - assert!( - keys.iter().all(|key| !key.contains("/test-vary/")), - "the record is dropped so the next request learns the new Vary: {keys:?}" - ); - } - - #[tokio::test] - async fn the_publishers_rule_separates_copies_the_source_does_not() { - let memory = Arc::new(MemoryLoadingCache::default()); - let services = services(Arc::clone(&memory)); - let fetches = Arc::new(AtomicUsize::new(0)); - let request = HeaderMap::new(); - let nothing = SourceVary::Headers(Vec::new()); - - let uk = look_up( - &services, - &request, - Some("uk"), - source(nothing.clone(), "uk", &fetches), - ) - .await; - let us = look_up( - &services, - &request, - Some("us"), - source(nothing.clone(), "us", &fetches), - ) - .await; - let uk_again = look_up( - &services, - &request, - Some("uk"), - source(nothing, "x", &fetches), - ) - .await; - - assert_eq!(copy(uk), Some(Bytes::from("edition uk"))); - assert_eq!(copy(us), Some(Bytes::from("edition us"))); - assert_eq!(copy(uk_again), Some(Bytes::from("edition uk"))); - assert_eq!(fetches.load(Ordering::SeqCst), 2); - } -} diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 4188a53c2a..aad7c9b0d1 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1858,38 +1858,12 @@ or several, making each answer every time it is asked for. A name that no linked crate offers a cache from is refused by `ts config validate` and when the service starts, with the names that could be written. -Every response the publisher's origin sends that is not a page, and every -answer of an asset route under `[[proxy.asset_routes]]`, is held the same way, -selected by `[cache.assets]`, for the lifetime the origin's cache policy gives -it and under what the origin says it varies on, so readers who share a memory -copy, or on Fastly a location, share one fetch of an asset. On Cloudflare and -Spin, where a memory copy lasts one request and the key-value store makes no -request wait, each request that misses at the same moment fetches it, and later -requests read the stored copy. A copy is -served with the headers that describe it and an `Age` that counts from the -origin's. Nothing is kept that may not be shared: anything but a `GET`, -anything but a `200`, HTML, a response carrying `Set-Cookie`, one whose policy -says `private`, `no-store` or `no-cache` or gives no lifetime, one that varies -on `*` or on `Cookie`, one larger than a megabyte, an answer the platform's -image optimizer makes, and the answer to a request carrying `Authorization`, a -conditional header, a range, or a `Cache-Control` of `no-cache`, `no-store`, -`min-fresh` or a nonzero `max-age`. A response that may not be shared is served -to the request whose load fetched it from that one fetch. A request that sends -the publisher's origin a cookie always goes to the origin, and its answer is not -kept, because the cache cannot see whether the cookie shaped it. An asset route -sends its origin no cookie. `[cache.assets] module = "none"` keeps no asset, so -every asset is fetched from its origin for each request. - | Table | Field | Default | Range | Description | | -------------------- | -------------------- | ----------------------- | ------------- | ------------------------------------------------------------------------------- | | `[cache.robots-txt]` | `module` | the one the build links | | The module whose loading cache holds each robots.txt module's answer. | | `[cache.robots-txt]` | `memory_seconds` | `60` | 0 to 31536000 | How long the copy in memory is used. Zero keeps none. | | `[cache.robots-txt]` | `store_seconds` | `604800`, a week | 1 to 31536000 | The longest a module's answer is used. The module's own refresh ends it sooner. | | `[cache.robots-txt]` | `store_idle_seconds` | `2592000`, 30 days | 1 to 31536000 | How long an answer in the shared store lasts unused. | -| `[cache.assets]` | `module` | the linked module | | The loading cache module for assets and route answers. `none` keeps none. | -| `[cache.assets]` | `memory_seconds` | `5` | 0 to 31536000 | How long the copy in memory is used. Zero keeps none. | -| `[cache.assets]` | `store_seconds` | `3600`, an hour | 1 to 31536000 | The longest a copy is used. The origin's own lifetime ends it sooner. | -| `[cache.assets]` | `store_idle_seconds` | `86400`, a day | 1 to 31536000 | How long a copy in the shared store lasts unused. | A module may declare caches of its own, named by the module, and a deployment selects a loading cache for each in its own `[cache.]` table with the From d411eb773bd8f97809ef33091919da86603946cf Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:29:52 +0000 Subject: [PATCH 18/23] Offer files to the selected asset cache module Core offers each GET for a file, on the publisher path and on an asset route, to the asset cache module that [asset-cache] selects, at the one point where it is about to ask the file's origin. The module answers Serve, which takes the place of the origin's response, OriginFailed, which core answers as its own failed origin request, or AskOrigin, and core then sends the request it prepared. With no module selected, nothing changes. asset_cache.rs holds the interface: AssetCacheModule, AssetAnswer, AssetVia, AssetFile, which carries what core knows of the reader's request, and OriginRequest, the request core would send, read from the request it then sends. A registration supplies a module with with_asset_cache_module. The registry takes it only from the registration of the builder that [asset-cache] selects. It refuses one from any other builder, and one in what any builder registers for the auction plan, which is made whatever [asset-cache] selects. It refuses an [asset-cache] that selects two modules, and one that selects a builder that supplies none, naming that builder. Each adapter hands the module to each request's services. Core offers only a GET that describes no body, because a module's load sends the request with none. A route offers no request the platform's image optimizer answers, and the publisher path offers a file only while no origin request has started, which happens only for a navigation. Tests use a module that records what it is offered. A served answer keeps the origin from being asked and is processed as the origin's response is, a file passed back is asked of the origin once, and a failure is a 502. The reader's facts are reported, among them the scheme, a route's fallback to the Host header and to https, and asks_for_origin as it stands once a middleware that needs the whole file has removed the range. A HEAD, a GET that describes a body and an optimized file are not offered, and the request a module is handed is the one core sends with no module. Registry tests cover each refusal, the auction plan's both when [asset-cache] selects its builder and when it selects another. --- crates/trusted-server-adapter-axum/src/app.rs | 5 +- .../src/app.rs | 5 +- .../trusted-server-adapter-fastly/src/app.rs | 14 +- crates/trusted-server-adapter-spin/src/app.rs | 5 +- crates/trusted-server-core/src/asset_cache.rs | 293 +++++++++++ .../src/integrations/registry.rs | 395 +++++++++++++++ crates/trusted-server-core/src/lib.rs | 1 + .../trusted-server-core/src/platform/types.rs | 27 ++ crates/trusted-server-core/src/proxy.rs | 453 +++++++++++++++++- crates/trusted-server-core/src/publisher.rs | 392 ++++++++++++++- docs/guide/integration-guide.md | 27 ++ 11 files changed, 1599 insertions(+), 18 deletions(-) create mode 100644 crates/trusted-server-core/src/asset_cache.rs diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index d7d4c825cd..4bd15ec820 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -268,7 +268,7 @@ fn refuse_platform_geo(settings: &Settings) -> Result<(), Report RuntimeServices { let client_info = ctx .request() @@ -489,6 +490,9 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime if let Some(module) = state.registry.device_module() { services = services.with_device_module(module); } + if let Some(module) = state.registry.asset_cache_module() { + services = services.with_asset_cache_module(module); + } // The publisher this request resolved to, its tenant included, names // every key in a shared store and every name the browser holds. let prefixes = state.registry.prefixes( diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 396cf9a156..7ea2436922 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -322,7 +322,7 @@ impl AppState { /// Build per-request [`RuntimeServices`], carrying the Edge Cookie module /// the composition root already resolved and applying the module-supplied /// geo, Edge Cookie and device modules selected by `[geo]`, `[ec]` and - /// `[device] module`. + /// `[device] module`, and the asset cache module `[asset-cache]` selects. /// /// No Edge Cookie module is carried when the composition root found /// nothing safe to keep, so the request path resolves the selection for @@ -351,6 +351,9 @@ impl AppState { if let Some(module) = self.registry.device_module() { services = services.with_device_module(module); } + if let Some(module) = self.registry.asset_cache_module() { + services = services.with_asset_cache_module(module); + } // The publisher this request resolved to names every key in a shared // store and every name the browser holds. let prefixes = self.registry.prefixes( diff --git a/crates/trusted-server-core/src/asset_cache.rs b/crates/trusted-server-core/src/asset_cache.rs new file mode 100644 index 0000000000..25a0ac804b --- /dev/null +++ b/crates/trusted-server-core/src/asset_cache.rs @@ -0,0 +1,293 @@ +//! Where a module may answer for a file before core asks the file's origin. +//! +//! The publisher path and the asset routes each offer a `GET` for a file to +//! the asset cache module that `[asset-cache]` selects, at the one point +//! where core is about to ask the file's origin. The module answers with a +//! response, with the failure of the origin request it made, or by passing +//! the file back to core. Core keeps no copy, no key and no gate of its own, +//! so a deployment that selects no asset cache module has every file asked +//! of its origin. + +use edgezero_core::body::Body as EdgeBody; +use error_stack::Report; +use http::{HeaderMap, Method, Request, Response, Uri, header}; + +use crate::platform::{PlatformCacheIntent, PlatformError, PlatformHttpRequest, RuntimeServices}; + +/// The type folder, and the section, that selects the asset cache module. +pub const MODULE_TYPE: &str = "asset-cache"; + +/// A module that may answer for a file before core asks the file's origin. +#[async_trait::async_trait(?Send)] +pub trait AssetCacheModule: Send + Sync { + /// The answer for `file`, at the point core is about to ask its origin. + async fn answer(&self, services: &RuntimeServices, file: AssetFile<'_>) -> AssetAnswer; +} + +/// What core does with the module's answer. +#[derive(Debug)] +pub enum AssetAnswer { + /// Serves this in place of the origin's response, processed as that + /// response would be. Where the adapter does not stream responses, the + /// body must be whole, as the origin's would be. + Serve(Response), + /// The module asked the origin through [`AssetFile::origin`] and could + /// not reach it, so core answers as it does when its own origin request + /// fails. + OriginFailed(Report), + /// Core asks the origin itself, as it does with no module selected. + AskOrigin, +} + +/// Which of core's two paths offers a file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AssetVia { + /// [`handle_publisher_request`](crate::publisher::handle_publisher_request), + /// for a file the publisher's origin sends. + Publisher, + /// [`handle_asset_proxy_request`](crate::proxy::handle_asset_proxy_request), + /// for a file an asset route fetches. + Route, +} + +/// A `GET` core is about to send to a file's origin, with what core knows of +/// the reader's request. Core makes it, and a module reads its fields. +#[derive(Debug)] +#[non_exhaustive] +pub struct AssetFile<'a> { + /// The path that offers the file. + pub via: AssetVia, + /// Whether the reader's request carries `Authorization`. + pub carries_authorization: bool, + /// Whether the reader's request carries `Cookie`. + pub carries_cookie: bool, + /// The host the reader asked, empty when the request names none. + pub host: &'a str, + /// The scheme the reader asked over. + pub scheme: &'a str, + /// Whether the reader's request is for a document, such as a page or a + /// frame. + pub is_document: bool, + /// Whether the request, as core will send it, asks for the origin's own + /// answer, by a range, a conditional header, or a `Cache-Control` or + /// `Pragma` that asks for the origin. On the publisher path also when a + /// module left state on the request or an integration marked its + /// response personalized. + pub asks_for_origin: bool, + /// The URL core asks of the origin, with its query. + pub origin_url: &'a str, + /// What names the origin beyond the URL, so one origin's file is never + /// taken for another's. + pub origin_identity: &'a str, + /// The request core would send to the origin. + pub origin: OriginRequest, +} + +/// The request core would send to the file's origin, a `GET` with no body. +#[derive(Debug, Clone)] +pub struct OriginRequest { + uri: Uri, + headers: HeaderMap, + backend_name: String, + cache_intent: PlatformCacheIntent, + stream_response: bool, +} + +impl OriginRequest { + /// What `request` asks for, being the request core sends itself when the + /// module passes the file back, or `None` when it is not a `GET` with no + /// body. + /// + /// A request that describes a body, by `Content-Length` or + /// `Transfer-Encoding`, is never offered, because a module's load sends + /// it with no body, and a load the origin refuses is the answer to every + /// request waiting on it. + pub(crate) fn of(request: &PlatformHttpRequest) -> Option { + let headers = request.request.headers(); + if request.request.method() != Method::GET + || headers.contains_key(header::CONTENT_LENGTH) + || headers.contains_key(header::TRANSFER_ENCODING) + { + return None; + } + Some(Self { + uri: request.request.uri().clone(), + headers: headers.clone(), + backend_name: request.backend_name.clone(), + cache_intent: request.cache_intent.clone(), + stream_response: request.stream_response, + }) + } + + /// The request core would send, built anew for each call. + #[must_use] + pub fn platform_request(&self) -> PlatformHttpRequest { + let mut request = Request::new(EdgeBody::empty()); + *request.method_mut() = Method::GET; + *request.uri_mut() = self.uri.clone(); + *request.headers_mut() = self.headers.clone(); + let mut platform_request = PlatformHttpRequest::new(request, self.backend_name.clone()); + platform_request.cache_intent = self.cache_intent.clone(); + platform_request.stream_response = self.stream_response; + platform_request + } + + /// The URI the request asks for. + #[must_use] + pub fn uri(&self) -> &Uri { + &self.uri + } + + /// The headers the request carries, which hold the values `Vary` names. + #[must_use] + pub fn headers(&self) -> &HeaderMap { + &self.headers + } +} + +#[cfg(any(test, feature = "test-utils"))] +impl<'a> AssetFile<'a> { + /// A file offered on `via` for `origin_url`, with nothing set that keeps + /// it from a copy, which a module's test changes field by field. + #[must_use] + pub fn for_test( + via: AssetVia, + host: &'a str, + origin_url: &'a str, + origin: OriginRequest, + ) -> Self { + Self { + via, + carries_authorization: false, + carries_cookie: false, + host, + scheme: "https", + is_document: false, + asks_for_origin: false, + origin_url, + origin_identity: origin_url, + origin, + } + } +} + +#[cfg(any(test, feature = "test-utils"))] +impl OriginRequest { + /// A `GET` of `uri` with `headers` to `backend_name`, with the platform's + /// default cache behavior, for a module's tests. + #[must_use] + pub fn for_test( + uri: Uri, + headers: HeaderMap, + backend_name: &str, + stream_response: bool, + ) -> Self { + Self { + uri, + headers, + backend_name: backend_name.to_owned(), + cache_intent: PlatformCacheIntent::Default, + stream_response, + } + } +} + +/// Asset cache modules for core's own tests of the two paths. +#[cfg(test)] +pub(crate) mod test_support { + use std::sync::{Arc, Mutex}; + + use super::*; + + /// What a module was offered, kept beyond the call that offered it. + #[derive(Debug, Clone)] + pub(crate) struct Offered { + pub(crate) via: AssetVia, + pub(crate) carries_authorization: bool, + pub(crate) carries_cookie: bool, + pub(crate) host: String, + pub(crate) scheme: String, + pub(crate) is_document: bool, + pub(crate) asks_for_origin: bool, + pub(crate) origin_url: String, + pub(crate) origin_identity: String, + pub(crate) origin: OriginRequest, + } + + impl Offered { + fn of(file: &AssetFile<'_>) -> Self { + Self { + via: file.via, + carries_authorization: file.carries_authorization, + carries_cookie: file.carries_cookie, + host: file.host.to_owned(), + scheme: file.scheme.to_owned(), + is_document: file.is_document, + asks_for_origin: file.asks_for_origin, + origin_url: file.origin_url.to_owned(), + origin_identity: file.origin_identity.to_owned(), + origin: file.origin.clone(), + } + } + } + + /// How a test module answers every file it is offered. + enum Answering { + AskOrigin, + Serve(fn() -> Response), + OriginFailed, + } + + /// An asset cache module that records each file it is offered and + /// answers every one the same way. + pub(crate) struct TestAssetCache { + answering: Answering, + offered: Mutex>, + } + + impl TestAssetCache { + fn answering(answering: Answering) -> Arc { + Arc::new(Self { + answering, + offered: Mutex::new(Vec::new()), + }) + } + + /// A module that passes every file back to core. + pub(crate) fn asking_the_origin() -> Arc { + Self::answering(Answering::AskOrigin) + } + + /// A module that serves what `response` makes for every file. + pub(crate) fn serving(response: fn() -> Response) -> Arc { + Self::answering(Answering::Serve(response)) + } + + /// A module whose own request to the origin fails for every file. + pub(crate) fn failing_to_reach_the_origin() -> Arc { + Self::answering(Answering::OriginFailed) + } + + /// Every file offered so far, in order. + pub(crate) fn offered(&self) -> Vec { + self.offered.lock().expect("should lock the offers").clone() + } + } + + #[async_trait::async_trait(?Send)] + impl AssetCacheModule for TestAssetCache { + async fn answer(&self, _services: &RuntimeServices, file: AssetFile<'_>) -> AssetAnswer { + self.offered + .lock() + .expect("should lock the offers") + .push(Offered::of(&file)); + match self.answering { + Answering::AskOrigin => AssetAnswer::AskOrigin, + Answering::Serve(response) => AssetAnswer::Serve(response()), + Answering::OriginFailed => { + AssetAnswer::OriginFailed(Report::new(PlatformError::HttpClient)) + } + } + } + } +} diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index fcde4823f7..f0b282700e 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -9,6 +9,7 @@ use http::{Method, Request, Response}; use matchit::Router; use sha2::{Digest as _, Sha256}; +use crate::asset_cache::AssetCacheModule; use crate::attestation::PlatformIdentity; use crate::auction::AuctionPlan; use crate::constants::HEADER_X_TS_EC; @@ -683,6 +684,11 @@ pub struct IntegrationRegistration { /// Declaring them does not put them in the file, because the file is only /// built from the contributors `[robots-txt] modules` names. pub robots_txt_contributor: Option<(&'static str, Arc)>, + /// Asset cache module this registration supplies, under its name. + /// + /// Taken only from the registration of the builder `[asset-cache]` + /// selects, and refused from any other. + pub asset_cache_module: Option<(&'static str, Arc)>, } impl IntegrationRegistration { @@ -714,6 +720,7 @@ impl IntegrationRegistrationBuilder { device_module: None, prefix_module: None, robots_txt_contributor: None, + asset_cache_module: None, }, } } @@ -822,6 +829,22 @@ impl IntegrationRegistrationBuilder { self } + /// Declare the asset cache module this registration supplies, under its + /// `name`, which may answer for a file before core asks its origin, see + /// [`crate::asset_cache`]. + /// + /// The registry takes it only when `[asset-cache]` selects the builder + /// that made this registration, and refuses it from any other builder. + #[must_use] + pub fn with_asset_cache_module( + mut self, + name: &'static str, + module: Arc, + ) -> Self { + self.registration.asset_cache_module = Some((name, module)); + self + } + /// Mark this integration's JS module for deferred loading via /// `