From bfeacdaae9e1e461b3d9026e0b970c2150fbfc23 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 10 Oct 2026 23:03:13 +0100 Subject: [PATCH 01/31] Declare a loading cache in core `PlatformLoadingCache` in `platform/loading_cache.rs` takes a key and a loader and returns the value, running the loader at most once per key at a time, so however many requests ask for a missing value at the same moment the source is asked once and every one of them continues with its value or its error. A failed load is not stored, so the next caller loads again. `NoLoadingCache` holds nothing and runs every load, and is what `RuntimeServices` carries when no module supplies a cache. Every key is a `StoreKey`, being the publisher's `ServerKeyPrefix`, the cache's name and the caller's key. The prefix is made in one place, `platform/server_key.rs`, from the publisher's domain in the loaded settings with a single leading `www.` folded and `ts.` in front, so two publishers sharing a store never share an entry. `PlatformValueCache` is the host's shared cache of small values with lifetimes, which a host supplies through `RuntimeServices` when it has one, with `lookup_or_reserve` handing the caller a `ValueReservation` where the host can make other callers for a key wait, and `remove`. A loading cache keeps its shared copies there where the host has one and in the key-value store otherwise. An `IntegrationBuilder` offers a loading cache with `with_loading_cache`, and `loading_cache_from` gives an adapter the one its linked builders offer, refusing two, so no adapter names the crate that supplies it. Core's tests get `MemoryLoadingCache`, which keeps what it loads for the lifetime the load gave and records every call. --- .../src/integrations/mod.rs | 150 +++++++++ .../src/platform/loading_cache.rs | 303 ++++++++++++++++++ .../trusted-server-core/src/platform/mod.rs | 11 + .../src/platform/server_key.rs | 79 +++++ .../src/platform/test_support.rs | 83 +++++ .../trusted-server-core/src/platform/types.rs | 69 ++++ .../src/platform/value_cache.rs | 80 +++++ 7 files changed, 775 insertions(+) create mode 100644 crates/trusted-server-core/src/platform/loading_cache.rs create mode 100644 crates/trusted-server-core/src/platform/server_key.rs create mode 100644 crates/trusted-server-core/src/platform/value_cache.rs diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 6e342339e3..b3b293ccc9 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -1,5 +1,6 @@ //! Integration module registry and sample implementations. +use std::sync::Arc; use std::time::Duration; use edgezero_core::body::Body as EdgeBody; @@ -422,8 +423,13 @@ pub struct IntegrationBuilder { adserver: Option<&'static AdServerImplementation>, module: Option<&'static str>, section: Option<&'static str>, + loading_cache: Option, } +/// Gives the loading cache a module supplies, see +/// [`IntegrationBuilder::with_loading_cache`]. +pub type LoadingCacheFactory = fn() -> Arc; + /// The build function of a builder that supplies no page integration. fn no_registration( _settings: &Settings, @@ -462,6 +468,7 @@ impl IntegrationBuilder { adserver: None, module: None, section: None, + loading_cache: None, } } @@ -489,6 +496,7 @@ impl IntegrationBuilder { adserver: None, module: None, section: None, + loading_cache: None, } } @@ -618,6 +626,25 @@ impl IntegrationBuilder { self.module } + /// Offers the loading cache this crate supplies, see + /// [`crate::platform::PlatformLoadingCache`]. + /// + /// Offered whatever the settings say, because the loading cache is how a + /// deployment caches rather than a module a document selects. An adapter + /// takes it with [`loading_cache_from`] when it builds its state, and a + /// deployment offering none asks each source itself. + #[must_use] + pub const fn with_loading_cache(mut self, factory: LoadingCacheFactory) -> Self { + self.loading_cache = Some(factory); + self + } + + /// The loading cache this builder offers, when it offers one. + #[must_use] + pub const fn loading_cache(&self) -> Option { + self.loading_cache + } + /// Names the section that selects this module, for one selected outside /// the section of its type, such as a module of core's own in a section /// of core's. @@ -756,3 +783,126 @@ pub(crate) fn all_builders( ) -> impl Iterator + '_ { builders().iter().copied().chain(extra.iter().copied()) } + +/// The loading cache the builders a deployment links offer, or +/// [`NoLoadingCache`](crate::platform::NoLoadingCache) when none offers one. +/// +/// # Errors +/// +/// When more than one builder offers a loading cache, naming where each came +/// from, because a deployment caches one way. +pub fn loading_cache_from( + builders: &[IntegrationBuilder], +) -> Result, Report> { + let offered: Vec<(&str, LoadingCacheFactory)> = builders + .iter() + .filter_map(|builder| { + builder + .loading_cache() + .map(|factory| (builder.source(), factory)) + }) + .collect(); + match offered.as_slice() { + [] => Ok(Arc::new(crate::platform::NoLoadingCache)), + [(_, factory)] => Ok(factory()), + several => Err(Report::new(TrustedServerError::Configuration { + message: format!( + "more than one linked crate offers a loading cache: {}. A deployment \ + links one", + several + .iter() + .map(|(source, _)| *source) + .collect::>() + .join(", ") + ), + })), + } +} + +#[cfg(test)] +mod loading_cache_from_tests { + use bytes::Bytes; + + use super::*; + use crate::platform::{CacheSpec, LoadError, Loader, PlatformLoadingCache, StoreKey}; + + fn no_build( + _settings: &Settings, + ) -> Result, Report> { + Ok(None) + } + + fn no_validate(_settings: &Settings) -> Result> { + Ok(true) + } + + /// A cache that says it keeps values, so a test can tell it from the + /// default, which keeps none. + struct Keeping; + + #[async_trait::async_trait(?Send)] + impl PlatformLoadingCache for Keeping { + async fn get_or_load( + &self, + _services: &crate::platform::RuntimeServices, + _spec: &CacheSpec, + _key: &StoreKey, + load: Loader, + ) -> Result { + load().await.map(|loaded| loaded.bytes) + } + + async fn remove( + &self, + _services: &crate::platform::RuntimeServices, + _spec: &CacheSpec, + _key: &StoreKey, + ) { + } + } + + fn keeping() -> Arc { + Arc::new(Keeping) + } + + fn builder(source: &'static str) -> IntegrationBuilder { + IntegrationBuilder::new("an_id", source, no_build, no_validate) + } + + #[test] + fn with_none_offered_the_deployment_keeps_nothing() { + let cache = loading_cache_from(&[builder("first"), builder("second")]) + .expect("none offered is not an error"); + + assert!(!cache.keeps_values()); + } + + #[test] + fn the_one_offered_is_the_one_taken() { + let cache = loading_cache_from(&[ + builder("first"), + builder("second").with_loading_cache(keeping), + ]) + .expect("one offered should be taken"); + + assert!(cache.keeps_values()); + } + + #[test] + fn two_offered_are_refused_naming_both() { + let error = loading_cache_from(&[ + builder("first").with_loading_cache(keeping), + builder("second").with_loading_cache(keeping), + ]) + .expect_err("a deployment caches one way"); + + let message = format!("{error:?}"); + assert!( + message.contains( + "more than one linked crate offers a loading cache: first, second. A deployment \ + links one" + ), + "{message}" + ); + } +} diff --git a/crates/trusted-server-core/src/platform/loading_cache.rs b/crates/trusted-server-core/src/platform/loading_cache.rs new file mode 100644 index 0000000000..be87b7be41 --- /dev/null +++ b/crates/trusted-server-core/src/platform/loading_cache.rs @@ -0,0 +1,303 @@ +//! A cache that loads a missing value once, however many callers ask for it +//! at the same time. +//! +//! The first caller for a key that no layer holds runs the [`Loader`]. Every +//! caller that asks for the key while it runs waits for that load, and all of +//! them continue with its value or its error. A failed load is not stored, so +//! the next caller loads again. +//! +//! Core declares the interface and [`NoLoadingCache`], which holds nothing. A +//! module outside core supplies the implementation through +//! [`IntegrationBuilder::with_loading_cache`](crate::integrations::IntegrationBuilder::with_loading_cache), +//! keeping a brief copy in memory over the host's shared store, being its +//! value cache where it has one and its key-value store otherwise. +//! +//! Every key is a [`StoreKey`], which starts with the publisher's +//! [`ServerKeyPrefix`], so two publishers sharing a store never share an +//! entry. + +use core::fmt; +use std::future::Future; +use std::pin::Pin; +use std::time::Duration; + +use bytes::Bytes; + +use super::{RuntimeServices, ServerKeyPrefix}; + +/// A value a [`Loader`] produced. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoadedValue { + /// The value. + pub bytes: Bytes, + /// How long the value may be used from now, or `None` for as long as the + /// cache's own lifetimes allow. Every copy of it ends by then. + pub lifetime: Option, +} + +impl LoadedValue { + /// A value with no lifetime of its own. + #[must_use] + pub fn new(bytes: impl Into) -> Self { + Self { + bytes: bytes.into(), + lifetime: None, + } + } + + /// Sets how long the value may be used from now. + #[must_use] + pub fn with_lifetime(mut self, lifetime: Duration) -> Self { + self.lifetime = Some(lifetime); + self + } +} + +/// Why a load produced no value. +/// +/// Every caller waiting on the load receives it, and nothing is stored, so the +/// next caller loads again. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoadError { + message: std::sync::Arc, +} + +impl LoadError { + /// An error saying `message`. + #[must_use] + pub fn new(message: impl Into) -> Self { + Self { + message: message.into().into(), + } + } + + /// What went wrong. + #[must_use] + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for LoadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl core::error::Error for LoadError {} + +/// The work a [`Loader`] does. +/// +/// Not `Send`, because the platform's own futures are not. It is made on the +/// thread that runs it. +pub type LoadFuture = Pin>>>; + +/// What a cache runs when no layer holds a key. +/// +/// It owns everything it uses, and is `Send`, so a host that can run work on +/// its own can start it on another thread and keep it running after the +/// caller that started it has gone. +pub type Loader = Box LoadFuture + Send>; + +/// How long one cache's copies last. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CacheLifetimes { + /// How long the brief copy in memory is used. + pub memory: Duration, + /// How long the copy in the shared store is used, at most. A value with a + /// shorter lifetime of its own ends sooner. + pub store: Duration, + /// How long the copy in the shared store lasts unused. + pub store_idle: Duration, +} + +/// One cache: its name, the lifetimes its copies get, and how its loads may +/// run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CacheSpec { + /// The cache's name, which is part of every key it writes. + pub name: &'static str, + /// How long its copies last. + pub lifetimes: CacheLifetimes, + /// The most values the brief copy in memory holds. + pub memory_entries: usize, + /// Whether a load may run apart from the caller that started it, on a + /// host that can run work on its own. False for a load that blocks the + /// thread it runs on, which only the caller's own thread can afford. + pub detachable: bool, +} + +/// A key in a shared store, being the publisher's prefix and the cache's name +/// as a namespace, and the caller's own key below it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StoreKey { + namespace: String, + key: String, +} + +impl StoreKey { + /// The key `key` in the cache `cache`, for the publisher `prefix` names. + /// + /// The caller's key should be short, such as a digest, because a store + /// refuses a long key. + #[must_use] + pub fn new(prefix: &ServerKeyPrefix, cache: &str, key: impl Into) -> Self { + Self { + namespace: format!("{}/{cache}", prefix.as_str()), + key: key.into(), + } + } + + /// The publisher's prefix and the cache's name, as `/`. + #[must_use] + pub fn namespace(&self) -> &str { + &self.namespace + } + + /// The caller's own key. + #[must_use] + pub fn key(&self) -> &str { + &self.key + } +} + +impl fmt::Display for StoreKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}/{}", self.namespace, self.key) + } +} + +/// A cache that loads a missing value once per key at a time. +/// +/// `Send + Sync` on the trait and `?Send` on the futures, as for the other +/// platform services, because [`RuntimeServices`] crosses threads while the +/// platform layer's futures never do. +#[async_trait::async_trait(?Send)] +pub trait PlatformLoadingCache: Send + Sync { + /// The value for `key` in the cache `spec` describes, from a copy that is + /// still fresh or from `load`. + /// + /// `load` runs at most once per key at a time, and a caller arriving while + /// it runs waits for it. The copies are kept in the stores `services` + /// supplies. + /// + /// # Errors + /// + /// The error of the load this caller waited on, which every caller waiting + /// on that load receives. + async fn get_or_load( + &self, + services: &RuntimeServices, + spec: &CacheSpec, + key: &StoreKey, + load: Loader, + ) -> Result; + + /// Removes `key` from the memory copy and the shared store. + async fn remove(&self, services: &RuntimeServices, spec: &CacheSpec, key: &StoreKey); + + /// Whether this cache keeps what it loads. A caller whose load pays off only + /// when its value is kept does without it on a cache that keeps nothing. + fn keeps_values(&self) -> bool { + true + } +} + +impl fmt::Debug for dyn PlatformLoadingCache { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("PlatformLoadingCache") + } +} + +/// The loading cache of a deployment that links no module supplying one. +/// +/// It holds nothing and runs every load, so each caller asks the source +/// itself. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoLoadingCache; + +#[async_trait::async_trait(?Send)] +impl PlatformLoadingCache for NoLoadingCache { + async fn get_or_load( + &self, + _services: &RuntimeServices, + _spec: &CacheSpec, + _key: &StoreKey, + load: Loader, + ) -> Result { + load().await.map(|loaded| loaded.bytes) + } + + async fn remove(&self, _services: &RuntimeServices, _spec: &CacheSpec, _key: &StoreKey) {} + + fn keeps_values(&self) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + #[test] + fn two_publishers_never_share_a_key() { + let key = |domain: &str| { + StoreKey::new(&ServerKeyPrefix::for_domain(domain), "page", "abc").to_string() + }; + + assert_ne!(key("example.com"), key("example.org")); + assert_ne!(key("clients.example.com"), key("public.example.com")); + assert_eq!(key("www.example.com"), key("example.com")); + } + + #[test] + fn a_key_names_the_publisher_then_the_cache() { + let key = StoreKey::new( + &ServerKeyPrefix::for_domain("www.example.com"), + "robots-txt", + "k1", + ); + + assert_eq!(key.namespace(), "ts.example.com/robots-txt"); + assert_eq!(key.key(), "k1"); + assert_eq!(key.to_string(), "ts.example.com/robots-txt/k1"); + } + + #[test] + fn the_default_cache_runs_every_load() { + let services = super::super::test_support::noop_services(); + let spec = CacheSpec { + name: "test", + lifetimes: CacheLifetimes { + memory: Duration::from_secs(1), + store: Duration::from_secs(1), + store_idle: Duration::from_secs(1), + }, + memory_entries: 1, + detachable: false, + }; + let key = StoreKey::new(&ServerKeyPrefix::for_domain("example.com"), "test", "k"); + let runs = Arc::new(AtomicUsize::new(0)); + for _ in 0..2 { + let counted = Arc::clone(&runs); + let value = futures::executor::block_on(NoLoadingCache.get_or_load( + &services, + &spec, + &key, + Box::new(move || { + Box::pin(async move { + counted.fetch_add(1, Ordering::SeqCst); + Ok(LoadedValue::new(Bytes::from_static(b"v"))) + }) + }), + )) + .expect("the load should succeed"); + assert_eq!(value, Bytes::from_static(b"v")); + } + + assert_eq!(runs.load(Ordering::SeqCst), 2); + } +} diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 826eff7fc2..66922a1860 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -14,6 +14,8 @@ //! - [`PlatformGeo`] — geographic information lookup //! - [`PlatformTemplateAssembler`] — cold-response shared-template assembly //! - [`PlatformTemplateCache`] — shared transformed-template caching +//! - [`PlatformValueCache`], a shared cache of small values with lifetimes +//! - [`PlatformLoadingCache`], a cache that loads a missing value once per key //! //! ## Platform-Agnostic Components //! @@ -40,6 +42,8 @@ mod geo; mod http; mod image_optimizer; mod kv; +mod loading_cache; +mod server_key; mod template_assembly; mod template_cache; #[cfg(any(test, feature = "test-utils"))] @@ -53,6 +57,7 @@ mod template_cache; pub mod test_support; mod traits; mod types; +mod value_cache; pub use backend_naming::{ AuctionTargetCapabilities, AuctionTargetDescriptor, AuctionTargetId, BackendNamingError, @@ -70,6 +75,11 @@ pub use image_optimizer::{ PlatformImageOptimizerParams, PlatformImageOptimizerRegion, }; pub use kv::UnavailableKvStore; +pub use loading_cache::{ + CacheLifetimes, CacheSpec, LoadError, LoadFuture, LoadedValue, Loader, NoLoadingCache, + PlatformLoadingCache, StoreKey, +}; +pub use server_key::ServerKeyPrefix; pub use template_assembly::{ PlatformTemplateAssembler, TemplateAssemblyError, UnavailableTemplateAssembler, contains_publisher_esi_directive, @@ -86,6 +96,7 @@ pub use types::{ ClientInfo, GeoInfo, PlatformBackendSpec, RuntimeServices, RuntimeServicesBuilder, StoreId, StoreName, }; +pub use value_cache::{PlatformValueCache, ValueLookup, ValueReservation}; /// Default first-byte timeout for platform backends. pub(crate) const DEFAULT_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(15); diff --git a/crates/trusted-server-core/src/platform/server_key.rs b/crates/trusted-server-core/src/platform/server_key.rs new file mode 100644 index 0000000000..9058588956 --- /dev/null +++ b/crates/trusted-server-core/src/platform/server_key.rs @@ -0,0 +1,79 @@ +//! The start of every key a publisher's entries take in a shared store. + +use core::fmt; + +use crate::settings::Settings; + +/// The start of every key a publisher's entries take in a shared store, so +/// two publishers sharing a store never share a key. +/// +/// The publisher's domain from the loaded settings, never from the request, +/// because a `Host` header can say anything and a prefix built from one would +/// let any request start a key space of its own. A single leading `www.` is +/// folded, so `www.example.com` and `example.com` share keys, and every other +/// label is kept, so `clients.example.com` and `public.example.com` do not. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerKeyPrefix(String); + +impl ServerKeyPrefix { + /// The prefix for the publisher the settings describe. + #[must_use] + pub fn for_settings(settings: &Settings) -> Self { + Self::for_domain(&settings.publisher.domain) + } + + /// The prefix for a publisher's domain. Every shared store key's prefix is + /// made here. + #[must_use] + pub fn for_domain(domain: &str) -> Self { + let domain = domain.trim().to_ascii_lowercase(); + let folded = domain.strip_prefix("www.").unwrap_or(&domain); + Self(format!("ts.{folded}")) + } + + /// The prefix as text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for ServerKeyPrefix { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_leading_www_is_folded_and_every_other_label_kept() { + for (domain, prefix) in [ + ("example.com", "ts.example.com"), + ("www.example.com", "ts.example.com"), + ("WWW.Example.COM", "ts.example.com"), + ("clients.example.com", "ts.clients.example.com"), + ("public.example.com", "ts.public.example.com"), + ("www.www.example.com", "ts.www.example.com"), + ] { + assert_eq!( + ServerKeyPrefix::for_domain(domain).as_str(), + prefix, + "{domain}" + ); + } + } + + #[test] + fn the_prefix_comes_from_the_loaded_settings() { + let mut settings = Settings::default(); + settings.publisher.domain = "www.example.com".to_owned(); + + assert_eq!( + ServerKeyPrefix::for_settings(&settings).as_str(), + "ts.example.com" + ); + } +} diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 768d3d5235..90348e16a0 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -1280,6 +1280,89 @@ pub fn build_services_with_secret_and_http_client( build_services_with_config_secret_and_http_client(NoopConfigStore, secret_store, http_client) } +/// A loading cache for tests that cannot link the module supplying the real +/// one. +/// +/// It keeps each value until the lifetime its load gave runs out, records the +/// key and cache of every call, and runs a load for every key it does not +/// hold. Loads are not joined, because these tests ask one at a time, and +/// the module's own tests prove the joining. +#[derive(Default)] +pub struct MemoryLoadingCache { + entries: Mutex)>>, + calls: Mutex>, +} + +impl MemoryLoadingCache { + /// The cache name and key of every call, in order. + pub fn calls(&self) -> Vec<(&'static str, String)> { + self.calls.lock().expect("should lock the calls").clone() + } + + /// Every key held, sorted. + pub fn keys(&self) -> Vec { + let mut keys: Vec = self + .entries + .lock() + .expect("should lock the entries") + .keys() + .cloned() + .collect(); + keys.sort(); + keys + } +} + +#[async_trait::async_trait(?Send)] +impl super::PlatformLoadingCache for MemoryLoadingCache { + async fn get_or_load( + &self, + _services: &RuntimeServices, + spec: &super::CacheSpec, + key: &super::StoreKey, + load: super::Loader, + ) -> Result { + let key = key.to_string(); + self.calls + .lock() + .expect("should lock the calls") + .push((spec.name, key.clone())); + let held = self + .entries + .lock() + .expect("should lock the entries") + .get(&key) + .filter(|(_, end)| end.is_none_or(|end| web_time::Instant::now() < end)) + .map(|(bytes, _)| bytes.clone()); + if let Some(bytes) = held { + return Ok(bytes); + } + let loaded = load().await?; + let end = loaded + .lifetime + .map(|lifetime| web_time::Instant::now() + lifetime); + if loaded.lifetime.is_none_or(|lifetime| !lifetime.is_zero()) { + self.entries + .lock() + .expect("should lock the entries") + .insert(key, (loaded.bytes.clone(), end)); + } + Ok(loaded.bytes) + } + + async fn remove( + &self, + _services: &RuntimeServices, + _spec: &super::CacheSpec, + key: &super::StoreKey, + ) { + self.entries + .lock() + .expect("should lock the entries") + .remove(&key.to_string()); + } +} + #[cfg(test)] mod tests { use crate::platform::{DEFAULT_FIRST_BYTE_TIMEOUT, PlatformImageOptimizerParams}; diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 837873359c..d1028483bd 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -173,6 +173,14 @@ pub struct RuntimeServices { /// per-request basis by cloning [`RuntimeServices`] with /// [`RuntimeServices::with_kv_store`]. pub(crate) kv_store: Arc, + /// The host's shared cache of small values, or `None` where the host has + /// none, in which case a caller holds what it pays for in the key-value + /// store instead. + pub(crate) value_cache: Option>, + /// The cache that loads a missing value once per key, keeping its copies + /// in [`value_cache`](Self::value_cache) or the key-value store. Defaults + /// to [`super::NoLoadingCache`], which holds nothing. + pub(crate) loading_cache: Arc, /// Shared transformed-template cache. Defaults to /// [`UnavailableTemplateCache`], so adapters without one degrade to transforming /// per request rather than failing. See @@ -257,6 +265,34 @@ impl RuntimeServices { &*self.kv_store } + /// The host's shared cache of small values, or `None` where it has none. + #[must_use] + pub fn value_cache(&self) -> Option<&dyn super::PlatformValueCache> { + self.value_cache.as_deref() + } + + /// The cache that loads a missing value once per key. + #[must_use] + pub fn loading_cache(&self) -> &dyn super::PlatformLoadingCache { + &*self.loading_cache + } + + /// The value for `key` in the cache `spec` describes, from a fresh copy + /// or from `load`, kept in this request's stores. See + /// [`super::PlatformLoadingCache::get_or_load`]. + /// + /// # Errors + /// + /// The error of the load this caller waited on. + pub async fn load_cached( + &self, + spec: &super::CacheSpec, + key: &super::StoreKey, + load: super::Loader, + ) -> Result { + self.loading_cache.get_or_load(self, spec, key, load).await + } + /// The shared transformed-template cache. #[must_use] pub fn template_cache(&self) -> &dyn super::PlatformTemplateCache { @@ -433,6 +469,15 @@ impl RuntimeServices { } } + /// Returns a clone of this instance with the loading cache replaced. + #[must_use] + pub fn with_loading_cache(self, cache: Arc) -> Self { + Self { + loading_cache: cache, + ..self + } + } + /// Returns a clone of this instance with the template cache replaced. #[must_use] pub fn with_template_cache(self, cache: Arc) -> Self { @@ -471,6 +516,8 @@ pub struct RuntimeServicesBuilder { config_store: Option>, secret_store: Option>, kv_store: Option>, + value_cache: Option>, + loading_cache: Option>, template_cache: Option>, template_assembler: Option>, backend: Option>, @@ -490,6 +537,8 @@ impl RuntimeServicesBuilder { config_store: None, secret_store: None, kv_store: None, + value_cache: None, + loading_cache: None, template_cache: None, template_assembler: None, backend: None, @@ -518,6 +567,20 @@ impl RuntimeServicesBuilder { self } + /// Set the host's shared cache of small values. + #[must_use] + pub fn value_cache(mut self, cache: Arc) -> Self { + self.value_cache = Some(cache); + self + } + + /// Set the cache that loads a missing value once per key. + #[must_use] + pub fn loading_cache(mut self, cache: Arc) -> Self { + self.loading_cache = Some(cache); + self + } + /// Set the shared transformed-template cache. #[must_use] pub fn template_cache(mut self, cache: Arc) -> Self { @@ -639,6 +702,12 @@ impl RuntimeServicesBuilder { kv_store: self .kv_store .expect("should set kv_store before building RuntimeServices"), + value_cache: self.value_cache, + // Defaulted rather than required: a deployment that links no + // module supplying one asks each source itself. + loading_cache: self + .loading_cache + .unwrap_or_else(|| Arc::new(super::NoLoadingCache)), // Defaulted rather than required: an adapter with no template cache // should degrade to transforming per request, not fail to build. template_cache: self diff --git a/crates/trusted-server-core/src/platform/value_cache.rs b/crates/trusted-server-core/src/platform/value_cache.rs new file mode 100644 index 0000000000..0e617b77fb --- /dev/null +++ b/crates/trusted-server-core/src/platform/value_cache.rs @@ -0,0 +1,80 @@ +//! A shared cache of small values with lifetimes, which a host supplies when it +//! has one. +//! +//! A module that pays for a remote answer holds it here, so the next +//! equivalent request, or a second lookup in the same request, is answered +//! without paying again. An entry can go at any time, which costs the caller one +//! fetch and never an error, so nothing durable belongs here. + +use std::time::Duration; + +use bytes::Bytes; + +/// Values keyed by text, shared by every request the host serves from the same +/// place. The caller owns its key space, so a key starts with a prefix naming +/// the module that wrote it. +/// +/// `Send + Sync` on the trait and `?Send` on the futures, as for the other +/// platform services, because [`super::RuntimeServices`] crosses threads while +/// the platform layer's futures never do. +#[async_trait::async_trait(?Send)] +pub trait PlatformValueCache: Send + Sync { + /// The value held under `key`, or `None` when there is none or it cannot be + /// read. + async fn get(&self, key: &str) -> Option; + + /// Holds `value` under `key` for `lifetime`. A failure is the host's to + /// log and is not returned, because the caller's only cost is fetching + /// again. + async fn put(&self, key: &str, value: Bytes, lifetime: Duration); + + /// The value held under `key`, or, when there is none and the host can + /// make other callers for the key wait, a [`ValueReservation`] obliging + /// this caller to fill it. + /// + /// The default never reserves, for a host whose cache cannot make callers + /// wait. + async fn lookup_or_reserve(&self, key: &str) -> ValueLookup { + match self.get(key).await { + Some(value) => ValueLookup::Hit(value), + None => ValueLookup::Miss, + } + } + + /// Removes the value held under `key`. The default does nothing, for a + /// host whose cache cannot remove an entry before its lifetime ends. + async fn remove(&self, _key: &str) {} +} + +/// What [`PlatformValueCache::lookup_or_reserve`] found. +pub enum ValueLookup { + /// The value held for the key. + Hit(Bytes), + /// Nothing held, and this caller is the one to fill the key. The host + /// makes other callers for the key wait until the reservation is filled + /// or dropped. + Reserved(Box), + /// Nothing held, and no reservation. + Miss, +} + +impl std::fmt::Debug for ValueLookup { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Hit(value) => f.debug_tuple("Hit").field(&value.len()).finish(), + Self::Reserved(_) => f.write_str("Reserved"), + Self::Miss => f.write_str("Miss"), + } + } +} + +/// The obligation to fill a key that other callers are waiting for. +/// +/// Dropping it unfilled releases the callers waiting on it, and one of them +/// is given the obligation, so a failed or canceled load never leaves them +/// waiting. +pub trait ValueReservation { + /// Holds `value` for `lifetime` and releases the callers waiting on it. A + /// failure is the host's to log, as for [`PlatformValueCache::put`]. + fn fill(self: Box, value: Bytes, lifetime: Duration); +} From b51e896c33d557d8cc3c1b8e02e4aeaee44be099 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 10 Oct 2026 23:05:27 +0100 Subject: [PATCH 02/31] Each robots.txt contributor's answer is held by the loading cache A contributor's answer is loaded through `RuntimeServices::load_cached`, so the contributor is asked once however many requests want its rules at the same moment, and the copy in the host's shared store answers every instance until the contributor's refresh is due. The answer's lifetime in the cache is the contributor's refresh, and `[cache.robots-txt]` sets how long the copies last with `memory_seconds`, `store_seconds` and `store_idle_seconds`, defaulting to a minute, a week and 30 days. A memory lifetime may be 0, the other two must be at least 1, and none may be more than a year, which `LoadingCacheConfig::check` refuses as the settings load, naming the setting. The last answer a contributor gave is also kept apart from the cache for 30 days, in the host's value cache where it has one and in the key-value store otherwise, and read only when the contributor fails, because yesterday's rules are a better answer than none. Every key starts with the publisher's `ServerKeyPrefix`, so two publishers sharing a store never share an answer. The handler hands the settings through in place of the publisher's domain and the time, and a selection carries the name the contributor was declared under. The tests use `MemoryLoadingCache` and cover the one load for a held answer, the key naming the publisher, changed settings asking again, the kept answer served when the contributor fails, in a second instance and from a value cache, and a failure with nothing kept being an error that is not held. --- .../src/robots_txt/holding.rs | 792 ++++++++++-------- .../trusted-server-core/src/robots_txt/mod.rs | 66 +- crates/trusted-server-core/src/settings.rs | 159 +++- 3 files changed, 618 insertions(+), 399 deletions(-) diff --git a/crates/trusted-server-core/src/robots_txt/holding.rs b/crates/trusted-server-core/src/robots_txt/holding.rs index e81c78b56b..5967f3b41d 100644 --- a/crates/trusted-server-core/src/robots_txt/holding.rs +++ b/crates/trusted-server-core/src/robots_txt/holding.rs @@ -1,204 +1,219 @@ -//! Each contributor's answer, held in the key-value store between requests. +//! Each contributor's answer, held between requests by the loading cache. //! -//! A contributor is asked when nothing of its is held, or when what is held -//! is older than the contributor's own refresh. Its answer is then stored and -//! serves every request until the refresh is due again. +//! A contributor's answer is loaded through +//! [`RuntimeServices::load_cached`], so the contributor is asked once however +//! many requests want its rules at the same moment, and the copy in the host's +//! shared store answers every instance until the contributor's refresh is due. //! -//! What is held is kept far longer than any refresh, and is served past its -//! age when the contributor fails, because yesterday's rules are a better -//! answer than none. A `404` or an empty file reads to a crawler as permission -//! to crawl everything, which is the opposite of what a publisher who refused -//! anything asked for. -//! -//! A store that cannot give an entry a lifetime is written without one. An -//! answer held for settings that have since changed then stays in that store, -//! one small entry for each change, until it is deleted there. -//! -//! A deployment with no key-value store holds nothing. Every request for the -//! file then asks each contributor, and a contributor that fails fails the -//! file. +//! The last answer a contributor gave is also kept apart from the cache, for +//! far longer than any refresh, and read only when the contributor fails. It is +//! then served past its age, because yesterday's rules are a better answer than +//! none. A `404` or an empty file reads to a crawler as permission to crawl +//! everything, which is the opposite of what a publisher who refused anything +//! asked for. It is kept in the host's value cache when the host supplies one, +//! and in the key-value store otherwise, because a host that starts an instance +//! for each request may give its requests a value cache and no key-value store. +//! A value cache may drop it sooner, so on such a host a copy past its age is +//! likely to be there rather than certain. use core::time::Duration; use std::sync::Arc; -use bytes::Bytes; use error_stack::Report; -use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; use super::{Contribution, RobotsTxtContributor}; use crate::error::TrustedServerError; use crate::module_context::{ModuleContext, ResolvedRequest}; -use crate::platform::{KvError, RuntimeServices}; +use crate::platform::{ + CacheLifetimes, CacheSpec, LoadError, LoadedValue, Loader, RuntimeServices, ServerKeyPrefix, + StoreKey, +}; +use crate::settings::Settings; -/// The start of every key an answer is held under, which nothing else that -/// writes the store uses. -const KEY_PREFIX: &str = "robots-txt:"; +/// The cache contributors' answers are held in. +pub(crate) const CACHE: &str = "robots-txt"; -/// How long an answer is kept, far past any refresh, so it is there to serve -/// when the contributor cannot answer. +/// Where each contributor's last answer is kept apart from the cache. +const KEPT: &str = "robots-txt-kept"; + +/// How long a contributor's last answer is kept, far past any refresh, so it +/// is there to serve when the contributor cannot answer. const KEPT_LIFETIME: Duration = Duration::from_secs(30 * 24 * 3600); -/// One contributor's answer as it is held. -#[derive(Debug, Serialize, Deserialize)] -struct Held { - /// When the contributor gave it, in seconds since the Unix epoch. - given_at: u64, - contribution: Contribution, -} +/// The lifetimes `[cache.robots-txt]` leaves unset. A contributor's own +/// refresh ends each copy sooner than the store lifetime. +const DEFAULT_LIFETIMES: CacheLifetimes = CacheLifetimes { + memory: Duration::from_secs(60), + store: Duration::from_secs(7 * 24 * 3600), + store_idle: Duration::from_secs(30 * 24 * 3600), +}; -/// Who an answer is for and who gave it, which together name where it is -/// held. -pub(crate) struct Holder<'a> { - /// The publisher the file is for. One store may serve several, and one - /// publisher's rules are never another's. - pub(crate) publisher: &'a str, - /// The name `[robots-txt] modules` selects the contributor by. - pub(crate) id: &'static str, +/// The cache contributors' answers are held in, with the lifetimes +/// `[cache.robots-txt]` sets. +/// +/// Its loads run in the caller, because a contributor may block the thread it +/// runs on while it asks its service. +pub(crate) fn cache_spec(settings: &Settings) -> CacheSpec { + CacheSpec { + name: CACHE, + lifetimes: settings.cache.robots_txt.lifetimes(DEFAULT_LIFETIMES), + memory_entries: 64, + detachable: false, + } } -/// The contribution of `contributor` from a held answer young enough to -/// serve, and from the contributor otherwise, which is asked for `request`. -/// -/// `now` is the time in seconds since the Unix epoch. +/// The contribution of `contributor`, selected as `id`, from a copy young +/// enough to serve, and from the contributor otherwise, which is asked for +/// `request`. /// /// # Errors /// -/// When the contributor fails and no answer of its is held. A held answer is +/// When the contributor fails and no answer of its is kept. A kept answer is /// returned past its age rather than an error. pub(crate) async fn contribution_for( - holder: &Holder<'_>, + id: &'static str, contributor: &Arc, request: &ResolvedRequest, services: &RuntimeServices, - now: u64, + settings: &Settings, ) -> Result> { - let key = held_key(holder, &contributor.fingerprint()); - let held = held_answer(services, &key).await; - if let Some(held) = &held - && now.saturating_sub(held.given_at) < contributor.refresh().as_secs() - { - return Ok(held.contribution.clone()); - } - let context = ModuleContext::new(request.view()).with_services(services); - let asked = contributor - .contribute(context.call(holder.id, contributor.required_permissions())) - .await; - match asked { - Ok(contribution) => { - let answer = Held { - given_at: now, - contribution, - }; - hold_answer(services, &key, &answer).await; - Ok(answer.contribution) - } - Err(report) => match held { - Some(held) => { - log::warn!( - "Serving the held robots.txt rules of `{}` past their age: {report:?}", - holder.id - ); - Ok(held.contribution) + let prefix = ServerKeyPrefix::for_settings(settings); + let held = held_key(id, &contributor.fingerprint()); + let key = StoreKey::new(&prefix, CACHE, held.clone()); + let kept = StoreKey::new(&prefix, KEPT, held); + let ask = Ask { + id, + contributor: Arc::clone(contributor), + request: request.clone(), + services: services.clone(), + }; + let loaded = services + .load_cached(&cache_spec(settings), &key, load(ask, kept.clone())) + .await + .and_then(|bytes| { + serde_json::from_slice::(&bytes) + .map_err(|error| LoadError::new(format!("the held answer is unreadable: {error}"))) + }); + match loaded { + Ok(contribution) => Ok(contribution), + Err(error) => match kept_answer(services, &kept).await { + Some(contribution) => { + log::warn!("Serving the held robots.txt rules of `{id}` past their age: {error}"); + Ok(contribution) } - None => Err(report.change_context(TrustedServerError::Integration { - integration: holder.id.to_owned(), - message: "the robots.txt contributor did not answer and none of its answers \ - is held" - .to_owned(), + None => Err(Report::new(TrustedServerError::Integration { + integration: id.to_owned(), + message: format!("the robots.txt contributor did not answer: {error}"), })), }, } } -/// The key one contributor's answer for one publisher and one fingerprint is -/// held under. A hash, so that the fingerprint's contents, whatever they are, -/// never appear in the store. -fn held_key(holder: &Holder<'_>, fingerprint: &str) -> String { +/// What a load owns to ask one contributor, because it may outlive the +/// request that started it. +struct Ask { + id: &'static str, + contributor: Arc, + request: ResolvedRequest, + services: RuntimeServices, +} + +/// Asks the contributor, keeps its answer apart for when it next fails, and +/// gives the answer the contributor's refresh as its lifetime. +fn load(ask: Ask, kept: StoreKey) -> Loader { + Box::new(move || { + Box::pin(async move { + let Ask { + id, + contributor, + request, + services, + } = ask; + let context = ModuleContext::new(request.view()).with_services(&services); + let contribution = contributor + .contribute(context.call(id, contributor.required_permissions())) + .await + .map_err(|report| LoadError::new(format!("{report:?}")))?; + let bytes = serde_json::to_vec(&contribution) + .map_err(|error| LoadError::new(format!("the answer cannot be held: {error}")))?; + keep_answer(&services, &kept, &bytes).await; + Ok(LoadedValue::new(bytes).with_lifetime(contributor.refresh())) + }) + }) +} + +/// The key one contributor's answer for one fingerprint is held under, a hash +/// so the fingerprint's contents, whatever they are, never appear in a store. +fn held_key(id: &str, fingerprint: &str) -> String { let mut hasher = Sha256::new(); - for part in [holder.publisher, holder.id, fingerprint] { - // Each part with its length, so that no two different triples run - // together into the same bytes. - hasher.update((part.len() as u64).to_be_bytes()); - hasher.update(part.as_bytes()); - } - let mut key = String::with_capacity(KEY_PREFIX.len() + 64); - key.push_str(KEY_PREFIX); + hasher.update(id.as_bytes()); + hasher.update(b"\n"); + hasher.update(fingerprint.as_bytes()); + let mut key = String::with_capacity(64); for byte in hasher.finalize() { key.push_str(&format!("{byte:02x}")); } key } -/// The answer held under `key`. +/// The answer kept for when the contributor fails, from the host's value +/// cache when it supplies one and from the key-value store otherwise. /// -/// Nothing read, for whatever reason, is no held answer, and the contributor -/// is asked. -async fn held_answer(services: &RuntimeServices, key: &str) -> Option { - let bytes = match services.kv_store().get_bytes(key).await { - Ok(bytes) => bytes?, - Err(error) => { - log::debug!("No held robots.txt answer could be read: {error}"); - return None; - } +/// Nothing read, for whatever reason, is no kept answer, and the failure that +/// led here is reported instead. +async fn kept_answer(services: &RuntimeServices, kept: &StoreKey) -> Option { + let key = kept.to_string(); + let bytes = match services.value_cache() { + Some(cache) => cache.get(&key).await?, + None => match services.kv_store().get_bytes(&key).await { + Ok(bytes) => bytes?, + Err(error) => { + log::debug!("No kept robots.txt answer could be read: {error}"); + return None; + } + }, }; serde_json::from_slice(&bytes) - .inspect_err(|error| log::debug!("The held robots.txt answer is unreadable: {error}")) + .inspect_err(|error| log::debug!("The kept robots.txt answer is unreadable: {error}")) .ok() } -/// Holds `answer` under `key`. An answer that cannot be held is still served, -/// so a failure here is logged and nothing more. -/// -/// A store that cannot give an entry a lifetime is written without one, so -/// the answer is still there to serve when the contributor fails. -async fn hold_answer(services: &RuntimeServices, key: &str, answer: &Held) { - let bytes = match serde_json::to_vec(answer) { - Ok(bytes) => Bytes::from(bytes), - Err(error) => { - log::debug!("The robots.txt answer cannot be held: {error}"); - return; - } - }; - let store = services.kv_store(); - let written = match store - .put_bytes_with_ttl(key, bytes.clone(), KEPT_LIFETIME) +/// Keeps an answer for when the contributor next fails, in the host's value +/// cache when it supplies one and in the key-value store otherwise. +async fn keep_answer(services: &RuntimeServices, kept: &StoreKey, bytes: &[u8]) { + let key = kept.to_string(); + let bytes = bytes::Bytes::copy_from_slice(bytes); + if let Some(cache) = services.value_cache() { + cache.put(&key, bytes, KEPT_LIFETIME).await; + return; + } + if let Err(error) = services + .kv_store() + .put_bytes_with_ttl(&key, bytes, KEPT_LIFETIME) .await { - Err(KvError::Unsupported { .. }) => store.put_bytes(key, bytes).await, - written => written, - }; - if let Err(error) = written { - log::debug!("The robots.txt answer was not held: {error}"); + log::debug!("The robots.txt answer was not kept for a later failure: {error}"); } } #[cfg(test)] pub(crate) mod tests { - use std::collections::HashMap; use std::sync::Mutex; use edgezero_core::body::Body as EdgeBody; - use edgezero_core::key_value_store::{KvError, KvPage, KvStore as PlatformKvStore}; use super::*; use crate::evidence::RequestInfo; use crate::module_context::{ModuleCall, ModuleRequest}; - use crate::platform::ClientInfo; - use crate::platform::test_support::noop_services; - - /// The publisher the tests hold answers for. - pub(crate) const PUBLISHER: &str = "www.publisher.example"; - - /// A time to ask at, in seconds since the Unix epoch. - pub(crate) const NOW: u64 = 1_800_000_000; + use crate::platform::test_support::{MemoryLoadingCache, StubHttpClient}; /// A contributor whose answers a test decides, counting how often it is /// asked. #[derive(Debug)] pub(crate) struct Scripted { answers: Mutex>>, - asked: Mutex, + pub(crate) asked: Mutex, pub(crate) refresh: Duration, pub(crate) fingerprint: Mutex, } @@ -251,14 +266,15 @@ pub(crate) mod tests { pub(crate) fn robots_request() -> http::Request { http::Request::builder() .uri("https://www.publisher.example/robots.txt?crawler=one") - .header(http::header::HOST, PUBLISHER) + .header(http::header::HOST, "www.publisher.example") .body(EdgeBody::empty()) .expect("should build the request") } /// The request the contributors are asked for, as the handler resolves it. pub(crate) fn request() -> ResolvedRequest { - ResolvedRequest::of(&robots_request(), &ClientInfo::default()).without_query() + ResolvedRequest::of(&robots_request(), &crate::platform::ClientInfo::default()) + .without_query() } /// A contributor whose rules name what it was handed, so a test can read @@ -315,21 +331,34 @@ pub(crate) mod tests { pub(crate) const ECHOED: &str = "User-Agent: EchoBot\nDisallow: /www.publisher.example/robots.txt?/no-evidence/services"; + /// The answer is shared by every crawler, so the contributor is handed + /// the request it was asked for and its services, and nothing about who + /// asked, not even the query. + #[tokio::test] + async fn the_contributor_is_asked_for_the_request_and_not_who_asked() { + let echoing: Arc = Arc::new(Echoing); + let services = services_with(Arc::default(), Arc::default()); + + let answer = contribution_for("echo", &echoing, &request(), &services, &settings()) + .await + .expect("should ask the contributor"); + + assert_eq!(answer, rules(ECHOED)); + } + /// A store held in memory for the life of one test, so that what one - /// request wrote is what the next one reads. + /// instance wrote is what the next one reads. #[derive(Debug, Default)] pub(crate) struct MemoryKvStore { - pub(crate) entries: Mutex>, - /// The lifetime each entry was last written with. - pub(crate) lifetimes: Mutex>, - /// Whether a write with a lifetime is refused, as it is by a store - /// that has none to give. - pub(crate) refuses_lifetimes: bool, + pub(crate) entries: Mutex>, } #[async_trait::async_trait(?Send)] - impl PlatformKvStore for MemoryKvStore { - async fn get_bytes(&self, key: &str) -> Result, KvError> { + impl crate::platform::PlatformKvStore for MemoryKvStore { + async fn get_bytes( + &self, + key: &str, + ) -> Result, crate::platform::KvError> { Ok(self .entries .lock() @@ -338,7 +367,11 @@ pub(crate) mod tests { .cloned()) } - async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + async fn put_bytes( + &self, + key: &str, + value: bytes::Bytes, + ) -> Result<(), crate::platform::KvError> { self.entries .lock() .expect("should lock the test store") @@ -349,22 +382,13 @@ pub(crate) mod tests { async fn put_bytes_with_ttl( &self, key: &str, - value: Bytes, - ttl: Duration, - ) -> Result<(), KvError> { - if self.refuses_lifetimes { - return Err(KvError::Unsupported { - operation: "put_bytes_with_ttl".to_owned(), - }); - } - self.lifetimes - .lock() - .expect("should lock the test store") - .insert(key.to_owned(), ttl); + value: bytes::Bytes, + _ttl: Duration, + ) -> Result<(), crate::platform::KvError> { self.put_bytes(key, value).await } - async fn delete(&self, key: &str) -> Result<(), KvError> { + async fn delete(&self, key: &str) -> Result<(), crate::platform::KvError> { self.entries .lock() .expect("should lock the test store") @@ -377,274 +401,332 @@ pub(crate) mod tests { _prefix: &str, _cursor: Option<&str>, _limit: usize, - ) -> Result { - Ok(KvPage::default()) + ) -> Result { + Ok(edgezero_core::key_value_store::KvPage::default()) } } - /// Services that hold answers in `store`. - pub(crate) fn services_with(store: Arc) -> RuntimeServices { - noop_services().with_kv_store(store) - } - - fn contributor(scripted: &Arc) -> Arc { - Arc::clone(scripted) as Arc + /// Services keeping answers in `cache` and kept answers in `store`, as one + /// instance of a host with no value cache. + pub(crate) fn services_with( + cache: Arc, + store: Arc, + ) -> RuntimeServices { + crate::platform::test_support::build_services_with_http_client(Arc::new( + StubHttpClient::new(), + )) + .with_kv_store(store) + .with_loading_cache(cache) } - fn holder(id: &'static str) -> Holder<'static> { - Holder { - publisher: PUBLISHER, - id, - } + pub(crate) fn settings() -> Settings { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.publisher.domain = "www.publisher.example".to_owned(); + settings } - async fn ask( - scripted: &Arc, - services: &RuntimeServices, - now: u64, - ) -> Result> { - contribution_for( - &holder("a"), - &contributor(scripted), - &request(), - services, - now, - ) - .await - } - - /// The answer is shared by every crawler, so the contributor is handed - /// the request it was asked for and its services, and nothing about who - /// asked, not even the query. - #[tokio::test] - async fn the_contributor_is_asked_for_the_request_and_not_who_asked() { - let echoing: Arc = Arc::new(Echoing); - let services = services_with(Arc::default()); - - let answer = contribution_for(&holder("echo"), &echoing, &request(), &services, NOW) - .await - .expect("should ask the contributor"); - - assert_eq!(answer, rules(ECHOED)); + fn contributor(scripted: &Arc) -> Arc { + Arc::clone(scripted) as Arc } #[tokio::test] async fn a_contributor_is_asked_once_and_its_answer_is_held_after_that() { let scripted = Arc::new(Scripted::new(vec![Ok(rules("User-Agent: *\nAllow: /"))])); - let services = services_with(Arc::default()); + let cache = Arc::new(MemoryLoadingCache::default()); + let services = services_with(Arc::clone(&cache), Arc::default()); - let first = ask(&scripted, &services, NOW) - .await - .expect("should ask the contributor"); - let second = ask(&scripted, &services, NOW + 3599) - .await - .expect("should serve the held answer"); + let first = contribution_for( + "a", + &contributor(&scripted), + &request(), + &services, + &settings(), + ) + .await + .expect("should ask the contributor"); + let second = contribution_for( + "a", + &contributor(&scripted), + &request(), + &services, + &settings(), + ) + .await + .expect("should serve the held copy"); assert_eq!(first, second); assert_eq!(scripted.times_asked(), 1); + assert!( + cache.calls().iter().all(|(name, _)| *name == CACHE), + "every call should be to the robots.txt cache" + ); } #[tokio::test] - async fn a_contributor_is_asked_again_when_its_refresh_is_due() { + async fn the_held_answer_is_keyed_by_the_publisher() { let scripted = Arc::new(Scripted::new(vec![ Ok(rules("User-Agent: OneBot\nDisallow: /")), - Ok(rules("User-Agent: TwoBot\nDisallow: /")), + Ok(rules("User-Agent: OtherBot\nDisallow: /")), ])); - let services = services_with(Arc::default()); + let cache = Arc::new(MemoryLoadingCache::default()); + let services = services_with(Arc::clone(&cache), Arc::default()); + let mut other = settings(); + other.publisher.domain = "other.example".to_owned(); - ask(&scripted, &services, NOW) - .await - .expect("should ask the contributor"); - let later = ask(&scripted, &services, NOW + 3600) + contribution_for( + "a", + &contributor(&scripted), + &request(), + &services, + &settings(), + ) + .await + .expect("should answer the first publisher"); + let answer = contribution_for("a", &contributor(&scripted), &request(), &services, &other) .await - .expect("should ask the contributor again"); + .expect("should answer the second publisher"); - assert_eq!(later, rules("User-Agent: TwoBot\nDisallow: /")); - assert_eq!(scripted.times_asked(), 2); + assert_eq!(answer, rules("User-Agent: OtherBot\nDisallow: /")); + assert_eq!(scripted.times_asked(), 2, "each publisher is asked for"); + let keys = cache.keys(); + assert!( + keys[0].starts_with("ts.other.example/robots-txt/"), + "{keys:?}" + ); + assert!( + keys[1].starts_with("ts.publisher.example/robots-txt/"), + "{keys:?}" + ); } - /// Yesterday's rules are a better answer than none. #[tokio::test] - async fn a_held_answer_is_served_past_its_age_when_the_contributor_fails() { + async fn changed_settings_are_not_answered_from_the_old_copy() { let scripted = Arc::new(Scripted::new(vec![ - Ok(rules("User-Agent: OneBot\nDisallow: /")), - Err("cannot answer".to_owned()), + Ok(rules("User-Agent: FirstBot\nDisallow: /")), + Ok(rules("User-Agent: SecondBot\nDisallow: /")), ])); - let services = services_with(Arc::default()); + let services = services_with(Arc::default(), Arc::default()); - ask(&scripted, &services, NOW) - .await - .expect("should ask the contributor"); - let later = ask(&scripted, &services, NOW + 7 * 24 * 3600) - .await - .expect("should serve the held answer past its age"); + contribution_for( + "a", + &contributor(&scripted), + &request(), + &services, + &settings(), + ) + .await + .expect("should ask for the first settings"); + *scripted.fingerprint.lock().expect("should lock") = "second settings".to_owned(); + let answer = contribution_for( + "a", + &contributor(&scripted), + &request(), + &services, + &settings(), + ) + .await + .expect("should ask again for the new settings"); - assert_eq!(later, rules("User-Agent: OneBot\nDisallow: /")); - assert_eq!(scripted.times_asked(), 2, "should have asked first"); + assert_eq!(answer, rules("User-Agent: SecondBot\nDisallow: /")); } #[tokio::test] - async fn with_nothing_held_a_failure_is_an_error() { - let scripted = Arc::new(Scripted::new(vec![Err("cannot answer".to_owned())])); - let services = services_with(Arc::default()); + async fn the_kept_answer_is_served_when_the_contributor_fails() { + let mut scripted = Scripted::new(vec![ + Ok(rules("User-Agent: HeldBot\nDisallow: /")), + Err("down".to_owned()), + ]); + // A refresh of zero gives each answer no time in the cache, so the + // second request asks again on any machine at any moment. + scripted.refresh = Duration::ZERO; + let scripted = Arc::new(scripted); + let services = services_with(Arc::default(), Arc::default()); - let error = ask(&scripted, &services, NOW) - .await - .expect_err("there is nothing to serve"); + contribution_for( + "a", + &contributor(&scripted), + &request(), + &services, + &settings(), + ) + .await + .expect("should ask the contributor"); + let answer = contribution_for( + "a", + &contributor(&scripted), + &request(), + &services, + &settings(), + ) + .await + .expect("should serve yesterday's rules"); - assert!( - format!("{error:?}").contains("cannot answer"), - "should keep what the contributor said: {error:?}" - ); + assert_eq!(answer, rules("User-Agent: HeldBot\nDisallow: /")); + assert_eq!(scripted.times_asked(), 2, "should have asked again first"); } - /// An answer given for other settings is never served for these. #[tokio::test] - async fn an_answer_is_held_for_the_fingerprint_it_was_given_for() { + async fn the_kept_answer_outlives_the_instance_that_kept_it() { let scripted = Arc::new(Scripted::new(vec![ - Ok(rules("User-Agent: OneBot\nDisallow: /")), - Ok(rules("User-Agent: TwoBot\nDisallow: /")), + Ok(rules("User-Agent: KeptBot\nDisallow: /")), + Err("down".to_owned()), ])); - let services = services_with(Arc::default()); + let store = Arc::new(MemoryKvStore::default()); - ask(&scripted, &services, NOW) - .await - .expect("should ask the contributor"); - *scripted.fingerprint.lock().expect("should lock") = "second settings".to_owned(); - let after = ask(&scripted, &services, NOW + 1) - .await - .expect("should ask for the new settings"); + contribution_for( + "a", + &contributor(&scripted), + &request(), + &services_with(Arc::default(), Arc::clone(&store)), + &settings(), + ) + .await + .expect("should ask the contributor"); + // A second instance, with a cache of its own and the same store. + let answer = contribution_for( + "a", + &contributor(&scripted), + &request(), + &services_with(Arc::default(), Arc::clone(&store)), + &settings(), + ) + .await + .expect("should serve the kept answer"); - assert_eq!(after, rules("User-Agent: TwoBot\nDisallow: /")); + assert_eq!(answer, rules("User-Agent: KeptBot\nDisallow: /")); + assert_eq!(scripted.times_asked(), 2); + let keys: Vec = store + .entries + .lock() + .expect("should lock the test store") + .keys() + .cloned() + .collect(); + assert_eq!(keys.len(), 1, "{keys:?}"); + assert!( + keys[0].starts_with("ts.publisher.example/robots-txt-kept/"), + "{keys:?}" + ); } - /// One store may serve several publishers, and one publisher's rules are - /// never another's. #[tokio::test] - async fn an_answer_is_held_for_the_publisher_it_was_given_for() { + async fn with_nothing_kept_a_failure_is_an_error_and_is_not_held() { let scripted = Arc::new(Scripted::new(vec![ - Ok(rules("User-Agent: OneBot\nDisallow: /")), - Ok(rules("User-Agent: TwoBot\nDisallow: /")), + Err("down".to_owned()), + Ok(rules("User-Agent: LateBot\nDisallow: /")), ])); - let services = services_with(Arc::default()); - let other = Holder { - publisher: "www.another.example", - id: "a", - }; + let services = services_with(Arc::default(), Arc::default()); - ask(&scripted, &services, NOW) + assert!( + contribution_for( + "a", + &contributor(&scripted), + &request(), + &services, + &settings() + ) .await - .expect("should ask the contributor"); - let for_other = contribution_for( - &other, + .is_err() + ); + let answer = contribution_for( + "a", &contributor(&scripted), &request(), &services, - NOW + 1, + &settings(), ) .await - .expect("should ask for the other publisher"); + .expect("the next request should ask again"); - assert_eq!(for_other, rules("User-Agent: TwoBot\nDisallow: /")); + assert_eq!(answer, rules("User-Agent: LateBot\nDisallow: /")); + assert_eq!(scripted.times_asked(), 2); } - #[test] - fn a_key_holds_nothing_of_what_it_was_made_from() { - let key = held_key(&holder("a_contributor"), "a secret an implementer left in"); - - assert!(key.starts_with(KEY_PREFIX), "{key}"); - assert_eq!(key.len(), KEY_PREFIX.len() + 64, "{key}"); - for part in [PUBLISHER, "a_contributor", "secret"] { - assert!(!key.contains(part), "{key} should not hold {part}"); - } + /// A value cache held in memory for the life of one test. + #[derive(Debug, Default)] + struct MemoryValueCache { + entries: Mutex>, } - #[test] - fn parts_that_run_together_do_not_share_a_key() { - let one = held_key( - &Holder { - publisher: "ab", - id: "c", - }, - "d", - ); - let other = held_key( - &Holder { - publisher: "a", - id: "bc", - }, - "d", - ); + #[async_trait::async_trait(?Send)] + impl crate::platform::PlatformValueCache for MemoryValueCache { + async fn get(&self, key: &str) -> Option { + self.entries + .lock() + .expect("should lock the test cache") + .get(key) + .cloned() + } - assert_ne!(one, other); + async fn put(&self, key: &str, value: bytes::Bytes, _lifetime: Duration) { + self.entries + .lock() + .expect("should lock the test cache") + .insert(key.to_owned(), value); + } } + /// A host that gives its requests a value cache and no key-value store + /// keeps the answer in the value cache, and serves it from there when the + /// contributor fails. #[tokio::test] - async fn an_answer_is_kept_far_longer_than_any_refresh() { - let scripted = Arc::new(Scripted::new(vec![Ok(rules("User-Agent: *\nAllow: /"))])); - let store = Arc::new(MemoryKvStore::default()); - let services = services_with(Arc::clone(&store)); + async fn the_kept_answer_is_in_the_value_cache_where_the_host_has_one() { + let mut scripted = Scripted::new(vec![ + Ok(rules("User-Agent: CachedBot\nDisallow: /")), + Err("down".to_owned()), + ]); + scripted.refresh = Duration::ZERO; + let scripted = Arc::new(scripted); + let value_cache = Arc::new(MemoryValueCache::default()); + let services = crate::platform::RuntimeServices::builder() + .config_store(Arc::new(crate::platform::test_support::NoopConfigStore)) + .secret_store(Arc::new(crate::platform::test_support::NoopSecretStore)) + .kv_store(Arc::new(crate::platform::UnavailableKvStore)) + .backend(Arc::new(crate::platform::test_support::NoopBackend)) + .http_client(Arc::new(StubHttpClient::new())) + .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .value_cache(Arc::clone(&value_cache) as Arc) + .loading_cache(Arc::new(MemoryLoadingCache::default())) + .client_info(crate::platform::ClientInfo::default()) + .build(); - ask(&scripted, &services, NOW) - .await - .expect("should ask the contributor"); + contribution_for( + "a", + &contributor(&scripted), + &request(), + &services, + &settings(), + ) + .await + .expect("should ask the contributor"); + let answer = contribution_for( + "a", + &contributor(&scripted), + &request(), + &services, + &settings(), + ) + .await + .expect("should serve the kept answer from the value cache"); - let lifetimes = store.lifetimes.lock().expect("should lock the test store"); + assert_eq!(answer, rules("User-Agent: CachedBot\nDisallow: /")); assert_eq!( - lifetimes.values().copied().collect::>(), - vec![KEPT_LIFETIME] - ); - } - - /// A store with no lifetime to give still holds the answer, so it is - /// there to serve when the contributor fails. - #[tokio::test] - async fn a_store_without_lifetimes_still_holds_the_answer() { - let scripted = Arc::new(Scripted::new(vec![ - Ok(rules("User-Agent: OneBot\nDisallow: /")), - Err("cannot answer".to_owned()), - ])); - let store = Arc::new(MemoryKvStore { - refuses_lifetimes: true, - ..MemoryKvStore::default() - }); - let services = services_with(Arc::clone(&store)); - - ask(&scripted, &services, NOW) - .await - .expect("should ask the contributor"); - let later = ask(&scripted, &services, NOW + 3600) - .await - .expect("should serve the held answer"); - - assert_eq!(later, rules("User-Agent: OneBot\nDisallow: /")); - assert!( - store - .lifetimes + value_cache + .entries .lock() - .expect("should lock the test store") - .is_empty(), - "the entry was written without a lifetime" + .expect("should lock the test cache") + .len(), + 1, + "the answer should be kept in the value cache" ); } - /// A deployment with no key-value store still serves the file, by asking - /// every time. - #[tokio::test] - async fn with_no_store_the_contributor_is_asked_every_time() { - let scripted = Arc::new(Scripted::new(vec![ - Ok(rules("User-Agent: *\nAllow: /")), - Ok(rules("User-Agent: *\nAllow: /")), - ])); - let services = noop_services(); - - ask(&scripted, &services, NOW) - .await - .expect("should ask the contributor"); - ask(&scripted, &services, NOW + 1) - .await - .expect("should ask the contributor again"); + #[test] + fn the_key_carries_nothing_of_the_fingerprint() { + let key = held_key("a", "license=secret-value"); - assert_eq!(scripted.times_asked(), 2); + assert_eq!(key.len(), 64); + assert!(!key.contains("secret"), "{key}"); + assert_ne!(key, held_key("b", "license=secret-value")); } } diff --git a/crates/trusted-server-core/src/robots_txt/mod.rs b/crates/trusted-server-core/src/robots_txt/mod.rs index 86d0f3b77b..e6b8f52e95 100644 --- a/crates/trusted-server-core/src/robots_txt/mod.rs +++ b/crates/trusted-server-core/src/robots_txt/mod.rs @@ -338,55 +338,45 @@ async fn answer( let mut selected: Vec = Vec::new(); for id in &config.modules { if id == ALLOW_ALL { - selected.push(None); + selected.push((ALLOW_ALL, None)); continue; } - let Some(contributor) = registry.robots_txt_contributor(id) else { + let Some((declared, contributor)) = registry.robots_txt_contributor(id) else { // The registry refuses a selection nothing supplies when it is // built, so this is a wiring fault, and it still fails closed. return Ok(respond(Err(Report::new(configuration(format!( "[robots-txt] modules names `{id}`, which no module in this deployment supplies" )))))); }; - selected.push(Some(contributor)); + selected.push((declared, Some(contributor))); } // The answers are held and served to every crawler, so the contributors // are handed the request with nothing one crawler put in the address. let request = ResolvedRequest::of(req, services.client_info()).without_query(); Ok(respond( - file_for( - config, - &selected, - &request, - services, - &settings.publisher.domain, - crate::ec::current_timestamp(), - ) - .await, + file_for(config, &selected, &request, services, settings).await, )) } -/// One entry of `modules` as the handler resolved it. `None` is `allow_all`, -/// and anything else is a contributor with the name it was declared under. -type Selected = Option<(&'static str, Arc)>; +/// One entry of `modules` as the handler resolved it: the name a contributor +/// was declared under and the contributor, or `allow_all` and none. +type Selected = (&'static str, Option>); /// The file for `config`, from the contributions of `selected` in order, -/// each asked for `request` and held for `publisher`. +/// each asked for `request`. async fn file_for( config: &RobotsTxtConfig, selected: &[Selected], request: &ResolvedRequest, services: &RuntimeServices, - publisher: &str, - now: u64, + settings: &Settings, ) -> Result> { let mut contributions = Vec::with_capacity(selected.len()); - for entry in selected { - contributions.push(match entry { + for (id, contributor) in selected { + contributions.push(match contributor { None => document::allow_all(), - Some((id, contributor)) => { - let holder = holding::Holder { publisher, id }; - holding::contribution_for(&holder, contributor, request, services, now).await? + Some(contributor) => { + holding::contribution_for(id, contributor, request, services, settings).await? } }); } @@ -430,8 +420,8 @@ mod tests { use std::sync::Arc; use super::holding::tests::{ - ECHOED, Echoing, MemoryKvStore, NOW, PUBLISHER, Scripted, request, robots_request, rules, - services_with, + ECHOED, Echoing, MemoryKvStore, Scripted, request, robots_request, rules, services_with, + settings, }; use super::*; use crate::integrations::{IntegrationBuilder, IntegrationRegistration}; @@ -440,12 +430,6 @@ mod tests { serde_json::from_value(document).expect("should read the robots.txt settings") } - fn settings() -> Settings { - let mut settings = crate::test_support::tests::create_test_settings(); - settings.publisher.domain = PUBLISHER.to_owned(); - settings - } - #[test] fn a_single_module_reads_as_a_list_of_one() { let config = parsed(serde_json::json!({ "modules": "refuse_all" })); @@ -616,11 +600,10 @@ mod tests { let file = file_for( &config, - &[Some(("first", first)), None], + &[("first", Some(first)), (ALLOW_ALL, None)], &request(), - &services_with(Arc::default()), - PUBLISHER, - NOW, + &services_with(Arc::default(), Arc::default()), + &settings(), ) .await .expect("should assemble the file"); @@ -643,11 +626,10 @@ mod tests { let response = respond( file_for( &config, - &[Some(("failing", failing)), None], + &[("failing", Some(failing)), (ALLOW_ALL, None)], &request(), - &services_with(Arc::default()), - PUBLISHER, - NOW, + &services_with(Arc::default(), Arc::default()), + &settings(), ) .await, ); @@ -678,7 +660,7 @@ mod tests { let response = handle_robots_txt( &settings, - &services_with(Arc::clone(&store)), + &services_with(Arc::default(), Arc::clone(&store)), ®istry, robots_request(), ) @@ -722,7 +704,7 @@ mod tests { let response = handle_robots_txt( &settings, - &services_with(Arc::default()), + &services_with(Arc::default(), Arc::default()), ®istry, request, ) @@ -787,7 +769,7 @@ mod tests { let response = handle_robots_txt( &settings, - &services_with(Arc::default()), + &services_with(Arc::default(), Arc::default()), ®istry, robots_request(), ) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 7b5f11251c..9ecb56465a 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -2874,6 +2874,95 @@ pub struct CacheSettings { /// Ordered static/rehosted asset rules. The first enabled matching rule wins. #[serde(default)] pub asset_rules: Vec, + /// How long the copies of each robots.txt contributor's answer last, + /// `[cache.robots-txt]`. + #[serde( + rename = "robots-txt", + default, + skip_serializing_if = "LoadingCacheConfig::is_default" + )] + pub robots_txt: LoadingCacheConfig, +} + +/// The most seconds a loading cache lifetime setting accepts, a year. +pub const MAX_LOADING_CACHE_SECONDS: u32 = 365 * 24 * 60 * 60; + +/// How long one loading cache's copies last, each field defaulting to the +/// cache's own default when absent. +/// +/// See [`crate::platform::PlatformLoadingCache`]. The memory copy is the brief +/// one a process keeps in front of the shared store, and the shared store's +/// copy is the one every instance reads. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LoadingCacheConfig { + /// How long the brief copy in process memory is used, in seconds. Zero + /// keeps no memory copy, and a missing value is still loaded once. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory_seconds: Option, + /// How long the copy in the shared store is used at most, in seconds. An + /// answer with a shorter lifetime of its own ends sooner. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub store_seconds: Option, + /// How long the copy in the shared store lasts unused, in seconds, after + /// which it leaves the store. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub store_idle_seconds: Option, +} + +impl LoadingCacheConfig { + /// Whether nothing is set, so every lifetime is the cache's default. + #[must_use] + pub fn is_default(&self) -> bool { + *self == Self::default() + } + + /// The lifetimes these settings give, each from `defaults` where unset. + #[must_use] + pub fn lifetimes( + &self, + defaults: crate::platform::CacheLifetimes, + ) -> crate::platform::CacheLifetimes { + let seconds = |value: Option, default: Duration| { + value.map_or(default, |value| Duration::from_secs(u64::from(value))) + }; + crate::platform::CacheLifetimes { + memory: seconds(self.memory_seconds, defaults.memory), + store: seconds(self.store_seconds, defaults.store), + store_idle: seconds(self.store_idle_seconds, defaults.store_idle), + } + } + + /// Refuses a lifetime out of range, naming the setting by `table`. + /// + /// # Errors + /// + /// When the memory lifetime is over [`MAX_LOADING_CACHE_SECONDS`], or the + /// store lifetime or idle time is zero or over it. + pub fn check(&self, table: &str) -> Result<(), Report> { + check_loading_cache_seconds(table, "memory_seconds", self.memory_seconds, 0)?; + check_loading_cache_seconds(table, "store_seconds", self.store_seconds, 1)?; + check_loading_cache_seconds(table, "store_idle_seconds", self.store_idle_seconds, 1) + } +} + +fn check_loading_cache_seconds( + table: &str, + field: &str, + value: Option, + least: u32, +) -> Result<(), Report> { + match value { + Some(value) if value < least || value > MAX_LOADING_CACHE_SECONDS => { + Err(Report::new(TrustedServerError::Configuration { + message: format!( + "[{table}] {field} is {value}, and must be from {least} to \ + {MAX_LOADING_CACHE_SECONDS}" + ), + })) + } + _ => Ok(()), + } } impl CacheSettings { @@ -2887,9 +2976,11 @@ impl CacheSettings { /// /// # Errors /// - /// Returns a configuration error if any rule ID is duplicate, or if an - /// enabled rule has an invalid policy/matcher or cannot compile its regex/glob. + /// Returns a configuration error if a loading cache lifetime is out of + /// range, if any rule ID is duplicate, or if an enabled rule has an + /// invalid policy/matcher or cannot compile its regex/glob. pub fn prepare_runtime(&self) -> Result<(), Report> { + self.robots_txt.check("cache.robots-txt")?; let mut seen_ids = HashSet::new(); for rule in &self.asset_rules { if rule.id.is_empty() { @@ -6432,6 +6523,70 @@ module = \"none\"", ); } + #[test] + fn loading_cache_lifetimes_are_read_with_defaults_for_what_is_unset() { + let toml_str = format!( + r#"{} + + [cache.robots-txt] + store_seconds = 600 + store_idle_seconds = 3600 + "#, + crate_test_settings_str() + ); + let settings = Settings::from_toml(&toml_str).expect("should parse the lifetimes"); + let defaults = crate::platform::CacheLifetimes { + memory: Duration::from_secs(5), + store: Duration::from_secs(60), + store_idle: Duration::from_secs(70), + }; + + assert_eq!( + settings.cache.robots_txt.lifetimes(defaults), + crate::platform::CacheLifetimes { + memory: Duration::from_secs(5), + store: Duration::from_secs(600), + store_idle: Duration::from_secs(3600), + } + ); + } + + #[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] +{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}" + ); + } + } + + #[test] + fn unset_loading_cache_lifetimes_are_not_written_back() { + let settings = + Settings::from_toml(&crate_test_settings_str()).expect("should parse the settings"); + let written = serde_json::to_value(&settings.cache).expect("should serialize"); + + assert_eq!(written, serde_json::json!({ "asset_rules": [] })); + } + #[test] fn cache_asset_rule_nextjs_preset_is_operator_controlled() { let toml_str = format!( From e0e4c7b7e6be52f4d0d8cedbf4d0862634ea6be5 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 10 Oct 2026 23:13:32 +0100 Subject: [PATCH 03/31] Every adapter takes the loading cache its builders offer Each adapter takes the cache from the builders it is given with `loading_cache_from` when it builds its state, and hands it to every request's services, so every request in a process shares the cache's memory copy and waits for one load. Axum, Cloudflare and Spin take it from the stock list followed by the builders a deployment passes, as they take every vendor module, and hold nothing without one. No adapter names the crate that supplies it. On Fastly the finishing steps' services carry the same cache as the request, so a lookup there shares the request's memory copy, and the entry point builds those services once from the state it has, or with the unavailable store and a cache that holds nothing when no state was built. Fastly supplies `FastlyValueCache` over Fastly's cache as the value cache, which needs nothing created or linked on the service. A transaction lookup reserves a key nothing holds for this instance and makes every other instance at the location wait for it to be filled, a stale entry is reserved for a fresh load rather than served, and a reservation dropped unfilled is released so a waiting instance takes it over. Each entry is written with a surrogate key, a hash of its key, which `remove` purges. Its tests run under Viceroy and cover the reservation, its release, a stale entry and the purge. --- crates/trusted-server-adapter-axum/src/app.rs | 7 + .../src/app.rs | 6 + .../trusted-server-adapter-fastly/src/app.rs | 21 +- .../src/app/seam_probe_tests.rs | 19 +- .../trusted-server-adapter-fastly/src/lib.rs | 56 ++-- .../src/middleware.rs | 1 + .../src/value_cache.rs | 280 ++++++++++++++++++ crates/trusted-server-adapter-spin/src/app.rs | 6 + 8 files changed, 373 insertions(+), 23 deletions(-) create mode 100644 crates/trusted-server-adapter-fastly/src/value_cache.rs diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 38e65c0c40..3dada50884 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -72,6 +72,9 @@ pub struct AppState { /// Services a caller supplied for every request, rather than services built /// from the request context. `None` in a deployment. services: Option, + /// The loading cache the linked builders offer, taken once here so every + /// request in the process shares its memory copy and waits for one load. + loading_cache: Arc, } /// The permission signal modules this adapter links, in the order they run @@ -207,6 +210,7 @@ fn build_state_with_registrations_and_services( &settings, &shipped_signal_modules(), )?; + let loading_cache = trusted_server_core::integrations::loading_cache_from(integrations)?; Ok(Arc::new(AppState { settings: Arc::new(settings), @@ -214,6 +218,7 @@ fn build_state_with_registrations_and_services( registry: Arc::new(registry), permission_signal_modules, services, + loading_cache, })) } @@ -234,6 +239,7 @@ impl AppState { fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { let mut services = self.services.clone().unwrap_or_else(|| { build_runtime_services(ctx, &self.settings, &self.permission_signal_modules) + .with_loading_cache(Arc::clone(&self.loading_cache)) }); if let Some(module) = self.registry.geo_module() { services = services.with_geo(module); @@ -913,6 +919,7 @@ mod tests { // This test drives the per-request path, which builds its services // from the request context. services: None, + loading_cache: Arc::new(trusted_server_core::platform::NoLoadingCache), } } diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 31a5356ec4..5c43059642 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -102,6 +102,9 @@ pub struct AppState { /// Services a caller supplied for every request, rather than services built /// from the request context. `None` in a deployment. services: Option, + /// The loading cache the builders this state was built with offer, or one + /// that holds nothing when none does, handed to every request's services. + loading_cache: Arc, } /// Build the application state, loading settings and constructing all per-application components. @@ -308,6 +311,7 @@ fn build_state_with_registrations_and_services( &settings, &shipped_signal_modules(), )?; + let loading_cache = trusted_server_core::integrations::loading_cache_from(integrations)?; Ok(Arc::new(AppState { settings: Arc::new(settings), @@ -316,6 +320,7 @@ fn build_state_with_registrations_and_services( ec_module, permission_signal_modules, services, + loading_cache, })) } @@ -360,6 +365,7 @@ impl AppState { .clone() .unwrap_or_else(|| { build_runtime_services(ctx, &self.settings, &self.permission_signal_modules) + .with_loading_cache(Arc::clone(&self.loading_cache)) }) .with_resolved_ec_module(self.ec_module.clone()); if let Some(module) = self.registry.geo_module() { diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 9f13334f4c..5870f344d7 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -127,7 +127,8 @@ use trusted_server_core::integrations::{ }; use trusted_server_core::permissions::PermissionState; use trusted_server_core::platform::{ - ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices, StoreName, build_geo_module, + ClientInfo, GeoInfo, PlatformKvStore, PlatformLoadingCache, RuntimeServices, StoreName, + build_geo_module, }; use trusted_server_core::proxy::{ AssetProxyCachePolicy, handle_asset_proxy_request, handle_first_party_click, @@ -209,6 +210,10 @@ pub(crate) struct AppState { /// than the first request, and handed to every request's services. pub(crate) permission_signal_modules: Arc<[Arc]>, + /// The loading cache the linked builders offer, taken once here and handed + /// to every request's services, so lookups in one request share its + /// memory copy. + pub(crate) loading_cache: Arc, } /// Build the application state, loading settings and constructing all per-application components. @@ -338,6 +343,7 @@ pub(crate) fn build_state_with_registrations( let auction_telemetry_sink = crate::tinybird::auction_sink_from_settings(&settings); let default_kv_store = Arc::new(UnavailableKvStore) as Arc; + let loading_cache = trusted_server_core::integrations::loading_cache_from(integrations)?; Ok(Arc::new(AppState { settings: Arc::new(settings), @@ -347,6 +353,7 @@ pub(crate) fn build_state_with_registrations( auction_telemetry_sink, ec_module, permission_signal_modules, + loading_cache, })) } @@ -431,6 +438,8 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime .config_store(Arc::new(FastlyPlatformConfigStore)) .secret_store(Arc::new(FastlyPlatformSecretStore)) .kv_store(Arc::clone(&state.default_kv_store)) + .value_cache(Arc::new(crate::value_cache::FastlyValueCache)) + .loading_cache(Arc::clone(&state.loading_cache)) // Constructed unconditionally, but only read when the assembly mode is a // shared-template one — which defaults to Inline, so this is inert until an // operator opts in. @@ -488,11 +497,14 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime pub(crate) fn build_finalize_services( settings: &Settings, kv_store: Arc, + loading_cache: Arc, ) -> RuntimeServices { RuntimeServices::builder() .config_store(Arc::new(FastlyPlatformConfigStore)) .secret_store(Arc::new(FastlyPlatformSecretStore)) .kv_store(kv_store) + .value_cache(Arc::new(crate::value_cache::FastlyValueCache)) + .loading_cache(loading_cache) .backend(Arc::new(FastlyPlatformBackend)) .http_client(Arc::new(FastlyPlatformHttpClient)) .geo(build_geo_module(settings, Arc::new(FastlyPlatformGeo))) @@ -1533,7 +1545,11 @@ impl TrustedServerApp { let mut router = RouterService::builder().middleware(FinalizeResponseMiddleware::new( Arc::clone(&state.settings), build_geo_module(&state.settings, Arc::new(FastlyPlatformGeo)), - build_finalize_services(&state.settings, Arc::clone(&state.default_kv_store)), + build_finalize_services( + &state.settings, + Arc::clone(&state.default_kv_store), + Arc::clone(&state.loading_cache), + ), )); let fallback_handler = fallback_route_handler(Arc::clone(state)); @@ -1868,6 +1884,7 @@ mod tests { default_kv_store, ec_module, permission_signal_modules: Arc::default(), + loading_cache: Arc::new(trusted_server_core::platform::NoLoadingCache), }); TrustedServerApp::routes_for_state(&state) } diff --git a/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs b/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs index 39ad2d99f7..107815fbd9 100644 --- a/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs @@ -639,7 +639,11 @@ fn ec_and_device_selectors_naming_a_module_resolve_the_modules_it_declares() { "the Edge Cookie module should be the one the module declared" ); - let services = build_finalize_services(&state.settings, Arc::clone(&state.default_kv_store)); + let services = build_finalize_services( + &state.settings, + Arc::clone(&state.default_kv_store), + Arc::new(trusted_server_core::platform::NoLoadingCache), + ); let request_info = OwnedRequestInfo::new("192.0.2.1".to_owned(), HeaderMap::new()); let method = Method::GET; let context = ModuleContext::new(ModuleRequest::new( @@ -683,8 +687,11 @@ fn device_selector_naming_a_module_classifies_a_request_with_it() { )), &[seam_probe::builder()], ); - let services = - build_finalize_services(&selected.settings, Arc::clone(&selected.default_kv_store)); + let services = build_finalize_services( + &selected.settings, + Arc::clone(&selected.default_kv_store), + Arc::new(trusted_server_core::platform::NoLoadingCache), + ); let signals = block_on(crate::derive_device_signals( &selected.settings, selected.registry.device_module(), @@ -701,7 +708,11 @@ fn device_selector_naming_a_module_classifies_a_request_with_it() { settings_with(&format!("{HMAC_BLOCK}{PROBE_BLOCK}")), &[seam_probe::builder()], ); - let services = build_finalize_services(&unset.settings, Arc::clone(&unset.default_kv_store)); + let services = build_finalize_services( + &unset.settings, + Arc::clone(&unset.default_kv_store), + Arc::new(trusted_server_core::platform::NoLoadingCache), + ); let signals = block_on(crate::derive_device_signals( &unset.settings, unset.registry.device_module(), diff --git a/crates/trusted-server-adapter-fastly/src/lib.rs b/crates/trusted-server-adapter-fastly/src/lib.rs index ff6e74fd8d..94f446c7cf 100644 --- a/crates/trusted-server-adapter-fastly/src/lib.rs +++ b/crates/trusted-server-adapter-fastly/src/lib.rs @@ -36,7 +36,7 @@ use trusted_server_core::integrations::RequestFilterEffects; use trusted_server_core::module_context::{ModuleContext, ModuleRequest}; use trusted_server_core::platform::build_geo_module; use trusted_server_core::platform::{ - ClientInfo, PlatformKvStore, RuntimeServices, UnavailableKvStore, + ClientInfo, NoLoadingCache, RuntimeServices, UnavailableKvStore, }; use trusted_server_core::proxy::{AssetProxyCachePolicy, stream_asset_body}; use trusted_server_core::response_privacy::TerminalPrivateResponse; @@ -56,6 +56,7 @@ mod rate_limiter; mod sandbox; mod template_cache; mod tinybird; +mod value_cache; use crate::app::{ AppState, EcFinalizeState, RuntimeStoreConfig, TrustedServerApp, build_finalize_services, @@ -589,7 +590,7 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re Some(settings) => { // The entry point is synchronous host code, so it drives the async // module seam at the same boundary it already drives the router. - let services = build_finalize_services(settings, entry_point_kv_store(&app_state)); + let services = entry_point_finalize_services(settings, &app_state); let registered = app_state .as_deref() .and_then(|state| state.registry.device_module()); @@ -638,7 +639,7 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re settings, &mut response, client_ip, - entry_point_kv_store(&app_state), + entry_point_finalize_services(settings, &app_state), )); } else { match load_settings_from_config_store(&runtime_stores) { @@ -647,7 +648,7 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re &settings, &mut response, client_ip, - entry_point_kv_store(&app_state), + entry_point_finalize_services(&settings, &app_state), )); } Err(e) => { @@ -742,31 +743,44 @@ fn take_finalize_sentinel(response: &mut HttpResponse) -> bool { .is_some() } -/// The key-value store a module called from the entry point is given. +/// The services a module called from the entry point is given, with the +/// state's key-value store and loading cache when the state was built. /// /// The entry-point paths run whether or not application state was built, so a -/// deployment whose state failed to build offers the unavailable store rather -/// than no services at all. A module that needs the store then fails its own -/// call instead of the entry point silently skipping the module. -fn entry_point_kv_store(app_state: &Option>) -> Arc { - app_state.as_ref().map_or_else( - || Arc::new(UnavailableKvStore) as Arc, - |state| Arc::clone(&state.default_kv_store), - ) +/// deployment whose state failed to build offers the unavailable store and a +/// cache that holds nothing rather than no services at all. A module that +/// needs the store then fails its own call instead of the entry point silently +/// skipping the module. +fn entry_point_finalize_services( + settings: &Settings, + app_state: &Option>, +) -> RuntimeServices { + match app_state { + Some(state) => build_finalize_services( + settings, + Arc::clone(&state.default_kv_store), + Arc::clone(&state.loading_cache), + ), + None => build_finalize_services( + settings, + Arc::new(UnavailableKvStore), + Arc::new(NoLoadingCache), + ), + } } async fn apply_entry_point_finalize_headers( settings: &Settings, response: &mut HttpResponse, client_ip: Option, - kv_store: Arc, + services: RuntimeServices, ) { // Route through the [geo] module selector, so a deployment that opts // out of geolocation makes no host geo call on the entry-point finalize // path either. let geo = build_geo_module(settings, Arc::new(FastlyPlatformGeo)); let geo_info = if geo_allowed_for_response(response) { - let services = build_finalize_services(settings, kv_store).with_client_info(ClientInfo { + let services = services.with_client_info(ClientInfo { client_ip, ..ClientInfo::default() }); @@ -1185,7 +1199,11 @@ mod tests { #[test] fn the_device_module_the_registry_resolved_classifies_the_request() { let settings = test_settings(); - let services = build_finalize_services(&settings, Arc::new(UnavailableKvStore)); + let services = build_finalize_services( + &settings, + Arc::new(UnavailableKvStore), + Arc::new(NoLoadingCache), + ); let registered: Arc = Arc::new(MarkedDeviceModule); let signals = futures::executor::block_on(derive_device_signals( @@ -1206,7 +1224,11 @@ mod tests { #[test] fn with_no_registered_module_the_built_in_module_classifies_the_request() { let settings = test_settings(); - let services = build_finalize_services(&settings, Arc::new(UnavailableKvStore)); + let services = build_finalize_services( + &settings, + Arc::new(UnavailableKvStore), + Arc::new(NoLoadingCache), + ); let req = page_request(); let signals = diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index d8fcf1033f..e486cf1a36 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -536,6 +536,7 @@ mod tests { crate::app::build_finalize_services( &test_settings(), Arc::new(trusted_server_core::platform::UnavailableKvStore), + Arc::new(trusted_server_core::platform::NoLoadingCache), ) } diff --git a/crates/trusted-server-adapter-fastly/src/value_cache.rs b/crates/trusted-server-adapter-fastly/src/value_cache.rs new file mode 100644 index 0000000000..df3376754c --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/value_cache.rs @@ -0,0 +1,280 @@ +//! Fastly's cache as the platform value cache. +//! +//! An entry is held at the point of presence that wrote it and shared by every +//! instance there, which is where a visitor's next request, and a second lookup +//! in the same request, arrive. Nothing needs creating or linking on the +//! service. +//! +//! A transaction lookup that finds nothing obliges this instance to fill the +//! key and makes every other instance that looks the key up wait for it, so a +//! loading cache over this store loads a missing key once per location rather +//! than once per instance. + +use std::io::{Read as _, Write as _}; +use std::time::Duration; + +use bytes::Bytes; +use fastly::cache::core::{CacheKey, Found, Transaction}; +use trusted_server_core::platform::{PlatformValueCache, ValueLookup, ValueReservation}; + +/// Values held in Fastly's cache. +#[derive(Debug, Default)] +pub struct FastlyValueCache; + +/// The surrogate key an entry is written with, so [`PlatformValueCache::remove`] +/// can purge it. A 64-bit FNV-1a hash of the key, in hex, because a cache key +/// may hold characters a surrogate key may not. +fn surrogate_key(key: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in key.bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("vc-{hash:016x}") +} + +fn cache_key(key: &str) -> CacheKey { + CacheKey::from(key.as_bytes().to_vec()) +} + +/// The bytes of a fresh entry, or `None` for a stale or unreadable one. +fn read(found: &Found) -> Option { + if found.is_stale() { + return None; + } + let mut body = Vec::new(); + found + .to_stream() + .inspect_err(|error| log::debug!("value cache body could not be opened: {error:?}")) + .ok()? + .read_to_end(&mut body) + .inspect_err(|error| log::debug!("value cache body could not be read: {error}")) + .ok()?; + Some(Bytes::from(body)) +} + +#[async_trait::async_trait(?Send)] +impl PlatformValueCache for FastlyValueCache { + async fn get(&self, key: &str) -> Option { + // A plain lookup rather than a transaction, because a caller that only + // reads must take no obligation it will not fill. + let found = fastly::cache::core::lookup(cache_key(key)) + .execute() + .inspect_err(|error| log::debug!("value cache lookup failed: {error:?}")) + .ok()??; + read(&found) + } + + async fn put(&self, key: &str, value: Bytes, lifetime: Duration) { + let mut writer = match fastly::cache::core::insert(cache_key(key), lifetime) + .surrogate_keys([surrogate_key(key).as_str()]) + .known_length(value.len() as u64) + .execute() + { + Ok(writer) => writer, + Err(error) => { + log::debug!("value cache insert could not begin: {error:?}"); + return; + } + }; + if let Err(error) = writer.write_all(&value) { + log::debug!("value cache insert could not be written: {error}"); + return; + } + if let Err(error) = writer.finish() { + log::debug!("value cache insert could not be finished: {error:?}"); + } + } + + async fn lookup_or_reserve(&self, key: &str) -> ValueLookup { + let transaction = match Transaction::lookup(cache_key(key)).execute() { + Ok(transaction) => transaction, + Err(error) => { + log::debug!("value cache transaction lookup failed: {error:?}"); + return ValueLookup::Miss; + } + }; + // Asked before `found`, because a stale entry comes with both, and + // reading it would leave the obligation unfilled and every other + // instance waiting until it times out. + if transaction.must_insert_or_update() { + return ValueLookup::Reserved(Box::new(FastlyReservation { + transaction: Some(transaction), + surrogate_key: surrogate_key(key), + })); + } + match transaction.found().as_ref().and_then(read) { + Some(value) => ValueLookup::Hit(value), + None => ValueLookup::Miss, + } + } + + async fn remove(&self, key: &str) { + if let Err(error) = fastly::http::purge::purge_surrogate_key(&surrogate_key(key)) { + log::debug!("value cache entry could not be purged: {error:?}"); + } + } +} + +/// This instance's obligation to fill a key every other instance waits for. +struct FastlyReservation { + transaction: Option, + surrogate_key: String, +} + +impl ValueReservation for FastlyReservation { + fn fill(mut self: Box, value: Bytes, lifetime: Duration) { + let Some(transaction) = self.transaction.take() else { + return; + }; + // `insert` takes the transaction, so a write that fails part way can no + // longer be canceled. The entry's own format checks its length when it + // is read, so a short entry is a miss rather than a wrong value. + let mut writer = match transaction + .insert(lifetime) + .surrogate_keys([self.surrogate_key.as_str()]) + .known_length(value.len() as u64) + .execute() + { + Ok(writer) => writer, + Err(error) => { + log::debug!("value cache reserved insert could not begin: {error:?}"); + return; + } + }; + if let Err(error) = writer.write_all(&value) { + log::debug!("value cache reserved insert could not be written: {error}"); + return; + } + if let Err(error) = writer.finish() { + log::debug!("value cache reserved insert could not be finished: {error:?}"); + } + } +} + +impl Drop for FastlyReservation { + fn drop(&mut self) { + // Unfilled, so the obligation passes to an instance waiting on it. + if let Some(transaction) = self.transaction.take() + && let Err(error) = transaction.cancel_insert_or_update() + { + log::debug!("value cache reservation could not be released: {error:?}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(future: F) -> F::Output { + futures::executor::block_on(future) + } + + #[test] + fn a_value_put_is_read_back() { + let cache = FastlyValueCache; + run(cache.put( + "test/value-cache/read-back", + Bytes::from_static(b"answer"), + Duration::from_secs(60), + )); + + assert_eq!( + run(cache.get("test/value-cache/read-back")).as_deref(), + Some(&b"answer"[..]), + "a value put should be read back" + ); + } + + #[test] + fn a_key_never_put_is_a_miss() { + assert_eq!( + run(FastlyValueCache.get("test/value-cache/never-put")), + None, + "a key never put should be a miss" + ); + } + + #[test] + fn a_missing_key_is_reserved_and_its_fill_is_then_a_hit() { + let key = "test/value-cache/reserve-then-fill"; + let ValueLookup::Reserved(reservation) = run(FastlyValueCache.lookup_or_reserve(key)) + else { + panic!("a missing key should oblige this caller to fill it"); + }; + reservation.fill(Bytes::from_static(b"filled"), Duration::from_secs(60)); + + match run(FastlyValueCache.lookup_or_reserve(key)) { + ValueLookup::Hit(value) => assert_eq!(value, Bytes::from_static(b"filled")), + other => panic!("the filled key should be a hit, got {other:?}"), + } + } + + #[test] + fn a_dropped_reservation_passes_the_obligation_on() { + let key = "test/value-cache/dropped-reservation"; + let ValueLookup::Reserved(first) = run(FastlyValueCache.lookup_or_reserve(key)) else { + panic!("a missing key should be reserved"); + }; + drop(first); + + let ValueLookup::Reserved(second) = run(FastlyValueCache.lookup_or_reserve(key)) else { + panic!("an unfilled reservation should be given to the next caller"); + }; + second.fill(Bytes::from_static(b"second"), Duration::from_secs(60)); + assert_eq!( + run(FastlyValueCache.get(key)).as_deref(), + Some(&b"second"[..]) + ); + } + + #[test] + fn a_stale_entry_is_reserved_rather_than_served() { + // Present and stale, which a revalidation window keeps readable after + // its lifetime has ended. + let key = "test/value-cache/stale"; + let mut writer = fastly::cache::core::insert(cache_key(key), Duration::from_secs(0)) + .stale_while_revalidate(Duration::from_secs(60)) + .execute() + .expect("should begin the insert"); + writer.write_all(b"stale").expect("should write the body"); + writer.finish().expect("should finish the insert"); + + assert_eq!( + run(FastlyValueCache.get(key)), + None, + "a plain read should not serve it" + ); + match run(FastlyValueCache.lookup_or_reserve(key)) { + ValueLookup::Reserved(reservation) => { + reservation.fill(Bytes::from_static(b"fresh"), Duration::from_secs(60)); + } + other => panic!("a stale entry should be reserved for a fresh load, got {other:?}"), + } + assert_eq!( + run(FastlyValueCache.get(key)).as_deref(), + Some(&b"fresh"[..]) + ); + } + + #[test] + fn remove_purges_the_entry() { + let key = "test/value-cache/remove"; + run(FastlyValueCache.put(key, Bytes::from_static(b"gone"), Duration::from_secs(60))); + assert!(run(FastlyValueCache.get(key)).is_some()); + + run(FastlyValueCache.remove(key)); + + assert_eq!(run(FastlyValueCache.get(key)), None); + } + + #[test] + fn the_surrogate_key_is_short_and_safe() { + let key = surrogate_key("ts.example.com/page/ts template; with ^ odd | chars"); + assert!(key.starts_with("vc-")); + assert_eq!(key.len(), 3 + 16); + assert!(key[3..].bytes().all(|byte| byte.is_ascii_hexdigit())); + assert_ne!(surrogate_key("a"), surrogate_key("b")); + } +} diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 8a12be44bb..a24414c3e1 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -91,6 +91,9 @@ pub struct AppState { /// Services a caller supplied for every request, rather than services built /// from the request context. `None` in a deployment. services: Option, + /// The loading cache the builders this state was built with offer, or one + /// that holds nothing when none does, handed to every request's services. + loading_cache: Arc, } /// Build the application state, loading settings and constructing all per-application components. @@ -237,6 +240,7 @@ fn build_state_with_registrations_and_services( &settings, &shipped_signal_modules(), )?; + let loading_cache = trusted_server_core::integrations::loading_cache_from(integrations)?; Ok(Arc::new(AppState { settings: Arc::new(settings), @@ -245,6 +249,7 @@ fn build_state_with_registrations_and_services( ec_module, permission_signal_modules, services, + loading_cache, })) } @@ -289,6 +294,7 @@ impl AppState { .clone() .unwrap_or_else(|| { build_runtime_services(ctx, &self.settings, &self.permission_signal_modules) + .with_loading_cache(Arc::clone(&self.loading_cache)) }) .with_resolved_ec_module(self.ec_module.clone()); if let Some(module) = self.registry.geo_module() { From 3c5a37c6eb3cf26875483723198a03ce93bc6842 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 10 Oct 2026 23:18:48 +0100 Subject: [PATCH 04/31] The 51Degrees loading cache is a module a stock build ships `crates/loading-cache/51degrees` implements `PlatformLoadingCache` with the published `fiftyone-caching` package, version 4.5.8-49, with its default features off so it builds for every host. Each cache is a least recently used copy in process memory with a short lifetime over the host's shared store, being its value cache where it has one and its key-value store otherwise, written in the package's entry format so a store that deletes late or keeps no lifetimes never returns an entry past its drop time. The memory layer is shared by every request in a process and loads a missing key once, covering the store read and the source together. The store layer is made for each call from that request's stores. A load a cache allows to run apart from its caller runs on a pool of threads on a native host and with the request's `wait_until` on Cloudflare, and every load runs in its caller on Fastly and Spin. The crate is the module `loading-cache.51degrees`, named by its folder, and `builder()` offers the cache whatever the settings say, because a loading cache is how a deployment caches rather than a module a document selects. The stock list carries the builder, so every adapter takes the cache from it with `loading_cache_from` and names no crate, and a test holds the list to offering one cache that keeps what it loads. The crate carries the maintainers table, the workspace lists it, and the Axum aliases lint and test it, where its own tests run natively. The crate's tests ask for one key from 16 callers at once on 16 threads with the pool, on 16 threads with loads in the caller, and on one thread with each load a task of its own, and the source runs once each time. Other tests cover a failure reaching every waiter and not being kept, the store answering a second process, the brief memory copy, idle expiry, a value's own lifetime, a reserving value cache being filled and released, two publishers kept apart, removal, the module's name and the builder's offer. --- .cargo/config.toml | 4 +- Cargo.toml | 6 + crates/loading-cache/51degrees/Cargo.toml | 38 ++ crates/loading-cache/51degrees/src/layer.rs | 167 +++++ crates/loading-cache/51degrees/src/lib.rs | 285 +++++++++ crates/loading-cache/51degrees/src/stores.rs | 262 ++++++++ crates/loading-cache/51degrees/src/tests.rs | 620 +++++++++++++++++++ crates/trusted-server-modules/Cargo.toml | 1 + crates/trusted-server-modules/src/lib.rs | 17 + 9 files changed, 1398 insertions(+), 2 deletions(-) create mode 100644 crates/loading-cache/51degrees/Cargo.toml create mode 100644 crates/loading-cache/51degrees/src/layer.rs create mode 100644 crates/loading-cache/51degrees/src/lib.rs create mode 100644 crates/loading-cache/51degrees/src/stores.rs create mode 100644 crates/loading-cache/51degrees/src/tests.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 211c7a899b..6da9679911 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -40,8 +40,8 @@ test-fastly-reuse = "test -p trusted-server-adapter-fastly --target wasm32-wasip # run with the Axum adapter in CI. build-axum = "build -p trusted-server-adapter-axum" check-axum = "check -p trusted-server-adapter-axum" -clippy-axum = "clippy -p trusted-server-adapter-axum -p trusted-server-permission-signal-gpc -p trusted-server-permission-signal-gpp -p trusted-server-permission-signal-mtm -p trusted-server-permission-signal-tcf -p trusted-server-permission-signal-us-privacy -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-ads-txt-sellers -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb --all-targets --all-features -- -D warnings" -test-axum = "test -p trusted-server-adapter-axum -p trusted-server-permission-signal-gpc -p trusted-server-permission-signal-gpp -p trusted-server-permission-signal-mtm -p trusted-server-permission-signal-tcf -p trusted-server-permission-signal-us-privacy -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-ads-txt-sellers -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb" +clippy-axum = "clippy -p trusted-server-adapter-axum -p trusted-server-permission-signal-gpc -p trusted-server-permission-signal-gpp -p trusted-server-permission-signal-mtm -p trusted-server-permission-signal-tcf -p trusted-server-permission-signal-us-privacy -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-ads-txt-sellers -p trusted-server-loading-cache-51degrees -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb --all-targets --all-features -- -D warnings" +test-axum = "test -p trusted-server-adapter-axum -p trusted-server-permission-signal-gpc -p trusted-server-permission-signal-gpp -p trusted-server-permission-signal-mtm -p trusted-server-permission-signal-tcf -p trusted-server-permission-signal-us-privacy -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-ads-txt-sellers -p trusted-server-loading-cache-51degrees -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb" # --- Cloudflare adapter (native host + wasm32-unknown-unknown) --- # Build/check target the WASM runtime (requires the `cloudflare` feature); diff --git a/Cargo.toml b/Cargo.toml index 1ce94a0328..7a4b6f451d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ members = [ "crates/cmp/didomi", "crates/cmp/inmobi", "crates/ads-txt/sellers", + "crates/loading-cache/51degrees", "crates/tag/google-tag-manager", "crates/testing/testlight", "crates/framework/nextjs", @@ -97,6 +98,10 @@ error-stack = "0.6" esi = "0.7.2" fastly = "0.12" fern = "0.7.1" +# The loading cache crate, used as a published package with the pipeline off +# so it builds for every host. Only crates/loading-cache/51degrees depends on +# it. +fiftyone-caching = { version = "4.5.8-49", default-features = false } flate2 = "1.1" futures = "0.3" # `ts dev lint domains` / `install-hooks` drive all git access through @@ -165,6 +170,7 @@ trusted-server-cmp-sourcepoint = { path = "crates/cmp/sourcepoint" } trusted-server-cmp-didomi = { path = "crates/cmp/didomi" } trusted-server-cmp-inmobi = { path = "crates/cmp/inmobi" } trusted-server-ads-txt-sellers = { path = "crates/ads-txt/sellers" } +trusted-server-loading-cache-51degrees = { path = "crates/loading-cache/51degrees" } trusted-server-tag-google-tag-manager = { path = "crates/tag/google-tag-manager" } trusted-server-testing-testlight = { path = "crates/testing/testlight" } trusted-server-framework-nextjs = { path = "crates/framework/nextjs" } diff --git a/crates/loading-cache/51degrees/Cargo.toml b/crates/loading-cache/51degrees/Cargo.toml new file mode 100644 index 0000000000..49e6cf455b --- /dev/null +++ b/crates/loading-cache/51degrees/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "trusted-server-loading-cache-51degrees" +description = "The loading cache core declares, written with 51Degrees' fiftyone-caching over each host's own stores." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } +async-trait = { workspace = true } +bytes = { workspace = true } +edgezero-core = { workspace = true } +log = { workspace = true } +web-time = { workspace = true } +# The tokio feature adds the pool of threads that runs a load apart from its +# caller on a native host, and the cloudflare feature adds the spawner that +# keeps a load running with a Worker request's wait_until. Each pulls in its +# platform's crate only on the target that platform runs. +fiftyone-caching = { workspace = true, features = ["tokio", "cloudflare"] } + +[dev-dependencies] +futures = { workspace = true } +tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros", "time"] } +trusted-server-core = { workspace = true, features = ["test-utils"] } + +# The visible owner of this vendor crate, the way Prebid.js requires a named +# maintainer of every adapter. +[package.metadata.maintainers] +owner = "51Degrees" +status = "vendor owned" diff --git a/crates/loading-cache/51degrees/src/layer.rs b/crates/loading-cache/51degrees/src/layer.rs new file mode 100644 index 0000000000..c85998df91 --- /dev/null +++ b/crates/loading-cache/51degrees/src/layer.rs @@ -0,0 +1,167 @@ +//! The memory layer, the brief copy every request in a process shares. +//! +//! Its key is a [`Call`], which carries the caller's own way of loading the +//! value through the shared store, because what loads a value belongs to the +//! request asking for it while the layer belongs to the process. The key is +//! compared by its text alone, so every caller for one key joins one load, and +//! the load of whichever caller leads is the one that runs. + +use std::future::Future; +use std::hash::{Hash, Hasher}; +use std::pin::Pin; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; + +use bytes::Bytes; +use fiftyone_caching::{ + Clock, Inline, Loaded, LoadingCache, LruLoadingCache, LruStore, SpawnedLocal, ValueLoader, +}; +use trusted_server_core::platform::{LoadError, StoreKey}; + +use crate::{LocalSpawner, Runner}; + +/// Loads a value through the shared store, for the caller that leads. +pub(crate) type Below = Box BelowFuture + Send>; + +/// The work a [`Below`] does. +pub(crate) type BelowFuture = Pin, LoadError>>>>; + +/// A key in the memory layer, carrying the caller's [`Below`]. +#[derive(Clone)] +pub(crate) struct Call { + text: Arc, + below: Arc>>, +} + +impl Call { + /// The call for `key`, loading with `below` if it leads. + pub(crate) fn new(key: &StoreKey, below: Below) -> Self { + Self { + text: key.to_string().into(), + below: Arc::new(Mutex::new(Some(below))), + } + } + + /// The key alone, for a removal, which loads nothing. + pub(crate) fn spent(key: &StoreKey) -> Self { + Self { + text: key.to_string().into(), + below: Arc::new(Mutex::new(None)), + } + } +} + +impl PartialEq for Call { + fn eq(&self, other: &Self) -> bool { + self.text == other.text + } +} + +impl Eq for Call {} + +impl Hash for Call { + fn hash(&self, state: &mut H) { + self.text.hash(state); + } +} + +impl std::fmt::Display for Call { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.text) + } +} + +/// The memory layer's loader, which runs the leading caller's [`Below`]. +pub(crate) struct FromCall; + +impl ValueLoader for FromCall { + type Error = LoadError; + + async fn load(&self, call: &Call) -> Result, LoadError> { + let below = call + .below + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take(); + match below { + Some(below) => below().await, + // Each caller brings a load of its own and a retry after an + // abandoned load leads with the retrying caller's, so the one that + // leads always has one. + None => Err(LoadError::new("this call's load was already run")), + } + } +} + +/// The memory layer's cache, which drops its least recently used entry when +/// full, and whose loader is the shared store layer below it. +type Memory = LruLoadingCache; + +/// One cache's memory layer, with the runner its loads use. +pub(crate) enum Layer { + InCaller(Memory), + #[cfg(not(target_family = "wasm"))] + Pool(Memory>), + Local(Memory>), +} + +impl Layer { + pub(crate) fn new( + lifetime: Duration, + entries: usize, + runner: Runner, + clock: Arc, + ) -> Self { + let store = LruStore::builder() + .size(entries) + .clock(Arc::clone(&clock)) + .build(); + let builder = LoadingCache::builder(store, FromCall) + .time_to_live(lifetime) + .clock(clock); + match runner { + Runner::InCaller => Self::InCaller(builder.build()), + #[cfg(not(target_family = "wasm"))] + Runner::Pool(pool, threads) => { + Self::Pool(builder.spawner(PoolSpawner { pool, threads }).build()) + } + Runner::Local(spawner) => Self::Local(builder.local_spawner(spawner).build()), + } + } + + pub(crate) async fn get(&self, call: &Call) -> Result { + match self { + Self::InCaller(cache) => cache.get(call).await, + #[cfg(not(target_family = "wasm"))] + Self::Pool(cache) => cache.get(call).await, + Self::Local(cache) => cache.get(call).await, + } + } + + pub(crate) async fn remove(&self, call: &Call) { + match self { + Self::InCaller(cache) => cache.remove(call).await, + #[cfg(not(target_family = "wasm"))] + Self::Pool(cache) => cache.remove(call).await, + Self::Local(cache) => cache.remove(call).await, + } + } +} + +/// Runs each load on a pool of threads, made the first time a load needs one, +/// so a process whose loads all run in their callers starts no threads. +#[cfg(not(target_family = "wasm"))] +pub(crate) struct PoolSpawner { + pool: Arc>, + threads: usize, +} + +#[cfg(not(target_family = "wasm"))] +impl fiftyone_caching::Spawn for PoolSpawner { + fn spawn(&self, start: fiftyone_caching::StartLoad) { + let pool = self + .pool + .get_or_init(|| fiftyone_caching::tokio::LocalPool::new(self.threads)); + fiftyone_caching::Spawn::spawn(pool, start); + } +} diff --git a/crates/loading-cache/51degrees/src/lib.rs b/crates/loading-cache/51degrees/src/lib.rs new file mode 100644 index 0000000000..f6f0173a43 --- /dev/null +++ b/crates/loading-cache/51degrees/src/lib.rs @@ -0,0 +1,285 @@ +//! The loading cache core declares, written with 51Degrees' `fiftyone-caching`. +//! +//! [`LoadingCaches`] implements core's +//! [`PlatformLoadingCache`](trusted_server_core::platform::PlatformLoadingCache). +//! Each cache is two layers of the one loading cache `fiftyone-caching` +//! provides, each the loader of the one above it. +//! +//! - **A brief copy in process memory.** A least recently used cache with a +//! short lifetime, shared by every request the process serves. It loads a +//! missing key once however many requests ask for it at the same moment, and +//! that one load covers the read of the shared store and the source together. +//! - **The host's shared store.** The host's value cache where it has one, +//! which on Fastly makes every instance at a location wait for one load, and +//! its key-value store otherwise. It is read before the source, written +//! through, and an entry left unused for the cache's idle time leaves it. +//! This layer is made for each call from that request's stores, because a +//! host may hand each request a store of its own. +//! +//! The source is the caller's own loader. A failed load is not stored, and +//! every caller waiting on it receives the error. +//! +//! A load may run apart from its caller where the host can run work on its +//! own and the cache allows it, on a pool of threads on a native host and as a +//! task kept running with the request's `wait_until` on Cloudflare. Fastly and +//! Spin run each load in the caller. +//! +//! An adapter takes the cache through +//! [`trusted_server_core::integrations::loading_cache_from`] from the builder +//! [`builder`] returns, which the stock list carries, so no adapter names this +//! crate. + +mod layer; +mod stores; + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; + +use bytes::Bytes; +use fiftyone_caching::{Clock, SpawnLocal}; +use trusted_server_core::integrations::IntegrationBuilder; +use trusted_server_core::platform::{ + CacheSpec, LoadError, Loader, PlatformLoadingCache, RuntimeServices, StoreKey, +}; + +use crate::layer::{Call, Layer}; +use crate::stores::WebClock; + +/// A load handed to a [`LocalSpawner`]. +pub use fiftyone_caching::LoadTask; + +/// The name of this module, from this crate's folder. No section selects it, +/// because a loading cache is how a deployment caches rather than a module a +/// document selects, and the name says which crate the cache came from. +pub const MODULE: &str = "loading-cache.51degrees"; + +/// The builder a deployment hands to an adapter, which offers this loading +/// cache whatever the settings say. A stock build lists it in +/// `trusted-server-modules`. +#[must_use] +pub fn builder() -> IntegrationBuilder { + IntegrationBuilder::implementations(MODULE, env!("CARGO_PKG_NAME")).with_loading_cache(factory) +} + +/// Builds the loading cache a deployment uses, for +/// [`IntegrationBuilder::with_loading_cache`]. +/// +/// A new one for each application state everywhere but Cloudflare, where the +/// state is built again for every request, so there one cache lives as long as +/// the isolate and every request it serves shares it. +#[must_use] +pub fn factory() -> Arc { + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + static ISOLATE: std::sync::OnceLock> = std::sync::OnceLock::new(); + let caches: Arc = + Arc::clone(ISOLATE.get_or_init(|| Arc::new(LoadingCaches::new()))); + caches + } + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + Arc::new(LoadingCaches::new()) + } +} + +/// Wraps the handling of one Cloudflare Worker request, so it can wait on a +/// load another request started and keep a load it started running after its +/// response. A deployment composing this crate on Cloudflare wraps its fetch +/// handler in this. +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +pub use fiftyone_caching::cloudflare::with_context as scope_request; + +/// Every cache a process uses, each a memory layer over the host's shared +/// store. See the crate documentation. +pub struct LoadingCaches { + layers: Mutex>>, + runner: Runner, + clock: Arc, +} + +impl std::fmt::Debug for LoadingCaches { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LoadingCaches").finish_non_exhaustive() + } +} + +/// What tells two memory layers apart. A cache's calls all carry one spec, so +/// in practice each cache has one layer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct LayerId { + name: &'static str, + memory: Duration, + entries: usize, + detached: bool, +} + +/// Where a load a cache allows to run apart from its caller runs. +#[derive(Clone)] +enum Runner { + /// In the caller, which is the only choice where the host runs nothing on + /// its own. + InCaller, + /// On a pool of threads, each with its own runtime, made the first time a + /// load needs one. + #[cfg(not(target_family = "wasm"))] + Pool( + Arc>, + usize, + ), + /// As a task of its own on the current thread. + Local(LocalSpawner), +} + +/// Starts a load as a task on the current thread. +#[derive(Clone)] +pub struct LocalSpawner(Arc); + +impl LocalSpawner { + /// A spawner that hands each task to `spawn`, which runs it to its end. + pub fn new(spawn: impl Fn(LoadTask) + Send + Sync + 'static) -> Self { + Self(Arc::new(spawn)) + } +} + +impl SpawnLocal for LocalSpawner { + fn spawn_local(&self, task: LoadTask) { + (self.0)(task); + } +} + +impl Default for LoadingCaches { + fn default() -> Self { + Self::new() + } +} + +impl LoadingCaches { + /// The caches for this host: a load a cache allows to run apart from its + /// caller does so on a pool of threads on a native host and with the + /// request's `wait_until` on Cloudflare, and every load runs in its caller + /// on Fastly and Spin. + #[must_use] + pub fn new() -> Self { + Self::with_runner(default_runner()) + } + + /// Caches whose every load runs in its caller. + #[must_use] + pub fn in_caller() -> Self { + Self::with_runner(Runner::InCaller) + } + + /// Caches that run a load a cache allows to run apart from its caller as a + /// task started by `spawner` on the current thread. + #[must_use] + pub fn with_local_spawner(spawner: LocalSpawner) -> Self { + Self::with_runner(Runner::Local(spawner)) + } + + /// Caches that run a load a cache allows to run apart from its caller on a + /// pool of `threads` threads. + #[cfg(not(target_family = "wasm"))] + #[must_use] + pub fn with_threads(threads: usize) -> Self { + Self::with_runner(Runner::Pool( + Arc::new(std::sync::OnceLock::new()), + threads.max(1), + )) + } + + /// Reads the time from `clock` rather than the system clock. + #[must_use] + pub fn with_clock(mut self, clock: Arc) -> Self { + self.clock = clock; + self + } + + fn with_runner(runner: Runner) -> Self { + Self { + layers: Mutex::new(HashMap::new()), + runner, + clock: Arc::new(WebClock), + } + } + + /// The memory layer for `spec`, made the first time it is asked for. + fn layer(&self, spec: &CacheSpec) -> Arc { + let detached = spec.detachable && !matches!(self.runner, Runner::InCaller); + let id = LayerId { + name: spec.name, + memory: spec.lifetimes.memory, + entries: spec.memory_entries.max(1), + detached, + }; + let mut layers = self.layers.lock().unwrap_or_else(PoisonError::into_inner); + Arc::clone(layers.entry(id).or_insert_with(|| { + Arc::new(Layer::new( + id.memory, + id.entries, + if detached { + self.runner.clone() + } else { + Runner::InCaller + }, + Arc::clone(&self.clock), + )) + })) + } +} + +/// The runner a host gets by default. +fn default_runner() -> Runner { + #[cfg(not(target_family = "wasm"))] + { + let threads = std::thread::available_parallelism() + .map(std::num::NonZeroUsize::get) + .unwrap_or(2) + .clamp(1, 4); + Runner::Pool(Arc::new(std::sync::OnceLock::new()), threads) + } + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + Runner::Local(LocalSpawner::new(|task| { + fiftyone_caching::cloudflare::WaitUntil.spawn_local(task); + })) + } + #[cfg(all( + target_family = "wasm", + not(all(target_arch = "wasm32", target_os = "unknown")) + ))] + { + Runner::InCaller + } +} + +#[async_trait::async_trait(?Send)] +impl PlatformLoadingCache for LoadingCaches { + async fn get_or_load( + &self, + services: &RuntimeServices, + spec: &CacheSpec, + key: &StoreKey, + load: Loader, + ) -> Result { + let call = Call::new( + key, + stores::through_store( + services.clone(), + spec.clone(), + key.clone(), + load, + Arc::clone(&self.clock), + ), + ); + self.layer(spec).get(&call).await + } + + async fn remove(&self, services: &RuntimeServices, spec: &CacheSpec, key: &StoreKey) { + self.layer(spec).remove(&Call::spent(key)).await; + stores::remove(services, key, Arc::clone(&self.clock)).await; + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/loading-cache/51degrees/src/stores.rs b/crates/loading-cache/51degrees/src/stores.rs new file mode 100644 index 0000000000..c88ad39c33 --- /dev/null +++ b/crates/loading-cache/51degrees/src/stores.rs @@ -0,0 +1,262 @@ +//! The shared store layer, over the stores the host already gives each +//! request. +//! +//! A host's value cache is used where it has one, because on Fastly it is the +//! cache of the location, and a transaction lookup there makes every instance +//! at the location wait for one load. Every other host keeps the copies in its +//! key-value store. Either way the entries are written in `fiftyone-caching`'s +//! entry format, which carries each entry's own drop time, so a store that +//! deletes late or keeps no lifetimes still never returns an entry past it. + +use std::convert::Infallible; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use bytes::Bytes; +use edgezero_core::key_value_store::KvStore as PlatformKvStore; +use fiftyone_caching::{ + ByteLookup, ByteStore, Clock, Codec, EncodedStore, Loaded, LoadingCache, Store, ValueLoader, +}; +use trusted_server_core::platform::{ + CacheSpec, LoadError, Loader, PlatformValueCache, RuntimeServices, StoreKey, ValueLookup, + ValueReservation, +}; + +use crate::layer::Below; + +/// The lifetime given to a write that has none of its own, where the store +/// needs one. +const LONGEST: Duration = Duration::from_secs(365 * 24 * 60 * 60); + +/// The shortest lifetime written to a key-value store. Workers KV refuses less +/// than a minute, and the entry's own drop time holds the exact lifetime. +const KV_SHORTEST: Duration = Duration::from_secs(60); + +/// How a call loads through the shared store, for the memory layer to run +/// when the call leads. +pub(crate) fn through_store( + services: RuntimeServices, + spec: CacheSpec, + key: StoreKey, + load: Loader, + clock: Arc, +) -> Below { + Box::new(move || { + Box::pin(async move { load_through(&services, &spec, &key, load, clock).await }) + }) +} + +async fn load_through( + services: &RuntimeServices, + spec: &CacheSpec, + key: &StoreKey, + load: Loader, + clock: Arc, +) -> Result, LoadError> { + let source = Source { + load: Mutex::new(Some(load)), + clock: Arc::clone(&clock), + }; + let key_text = key.key().to_owned(); + match services.value_cache() { + Some(cache) => { + let store = encoded(ValueCacheBytes(cache), key, &clock); + layer(store, source, spec, clock) + .get_loaded(&key_text) + .await + } + None => { + let store = encoded(KvBytes(services.kv_store()), key, &clock); + layer(store, source, spec, clock) + .get_loaded(&key_text) + .await + } + } +} + +/// Removes `key` from the shared store `services` supplies. +pub(crate) async fn remove(services: &RuntimeServices, key: &StoreKey, clock: Arc) { + let key_text = key.key().to_owned(); + match services.value_cache() { + Some(cache) => { + Store::::remove( + &encoded(ValueCacheBytes(cache), key, &clock), + &key_text, + ) + .await; + } + None => { + Store::::remove( + &encoded(KvBytes(services.kv_store()), key, &clock), + &key_text, + ) + .await; + } + } +} + +/// The store of `bytes` entries under `key`'s namespace, being the publisher's +/// prefix and the cache's name. +fn encoded(bytes: B, key: &StoreKey, clock: &Arc) -> EncodedStore { + EncodedStore::builder(bytes, BytesCodec) + .namespace(key.namespace()) + .clock(Arc::clone(clock)) + .build() +} + +/// The shared store layer for one call, loading from the caller's source. +/// +/// Made for each call, so it waits for nothing in the process. The memory +/// layer above has already made every other caller for the key wait, and a +/// host whose store can make other instances wait does so inside the store. +fn layer( + store: S, + source: Source, + spec: &CacheSpec, + clock: Arc, +) -> LoadingCache +where + S: Store, +{ + let mut builder = LoadingCache::builder(store, source) + .time_to_live(spec.lifetimes.store) + .clock(clock) + .concurrency(1); + if !spec.lifetimes.store_idle.is_zero() { + builder = builder.time_to_idle(spec.lifetimes.store_idle); + } + builder.build() +} + +/// The caller's own loader, as the source below the shared store. +pub(crate) struct Source { + load: Mutex>, + clock: Arc, +} + +impl ValueLoader for Source { + type Error = LoadError; + + async fn load(&self, _key: &String) -> Result, LoadError> { + let load = self + .load + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take() + // A layer is made for one call, whose one leader asks once. + .ok_or_else(|| LoadError::new("this call's source was already asked"))?; + let value = load().await?; + let loaded = Loaded::new(value.bytes); + Ok( + match value + .lifetime + .and_then(|lifetime| self.clock.now().checked_add(lifetime)) + { + Some(end) => loaded.expires_at(end), + None => loaded, + }, + ) + } +} + +/// A host's value cache as a store of bytes. +pub(crate) struct ValueCacheBytes<'a>(pub(crate) &'a dyn PlatformValueCache); + +impl ByteStore for ValueCacheBytes<'_> { + type Reservation = Box; + + async fn get(&self, key: &str) -> ByteLookup { + match self.0.lookup_or_reserve(key).await { + ValueLookup::Hit(bytes) => ByteLookup::Hit(bytes.to_vec()), + ValueLookup::Reserved(reservation) => ByteLookup::Reserved(reservation), + ValueLookup::Miss => ByteLookup::Miss, + } + } + + async fn put(&self, key: &str, bytes: Vec, keep_for: Option) { + self.0 + .put(key, Bytes::from(bytes), keep_for.unwrap_or(LONGEST)) + .await; + } + + async fn fill( + &self, + _key: &str, + reservation: Self::Reservation, + bytes: Vec, + keep_for: Option, + ) { + reservation.fill(Bytes::from(bytes), keep_for.unwrap_or(LONGEST)); + } + + async fn remove(&self, key: &str) { + self.0.remove(key).await; + } +} + +/// A host's key-value store as a store of bytes. +pub(crate) struct KvBytes<'a>(pub(crate) &'a dyn PlatformKvStore); + +impl ByteStore for KvBytes<'_> { + type Reservation = Infallible; + + // Not every host's key-value store deletes an entry when its lifetime + // ends, so an entry read past its drop time is removed. + const DROPS_EXPIRED: bool = false; + + async fn get(&self, key: &str) -> ByteLookup { + match self.0.get_bytes(key).await { + Ok(Some(bytes)) => ByteLookup::Hit(bytes.to_vec()), + Ok(None) => ByteLookup::Miss, + Err(error) => { + log::debug!("A loading cache entry could not be read, so it is loaded: {error}"); + ByteLookup::Miss + } + } + } + + async fn put(&self, key: &str, bytes: Vec, keep_for: Option) { + let ttl = keep_for.unwrap_or(LONGEST).max(KV_SHORTEST); + if let Err(error) = self + .0 + .put_bytes_with_ttl(key, Bytes::from(bytes), ttl) + .await + { + log::debug!( + "A loading cache entry was not written, so the next request loads it: {error}" + ); + } + } + + async fn remove(&self, key: &str) { + if let Err(error) = self.0.delete(key).await { + log::debug!("A loading cache entry was not removed: {error}"); + } + } +} + +/// Keeps a value's bytes as they are. +pub(crate) struct BytesCodec; + +impl Codec for BytesCodec { + fn encode(&self, value: &Bytes) -> Option> { + Some(value.to_vec()) + } + + fn decode(&self, bytes: &[u8]) -> Option { + Some(Bytes::copy_from_slice(bytes)) + } +} + +/// The time, read the same way on every host, including +/// `wasm32-unknown-unknown`, which has no system clock of its own. +pub(crate) struct WebClock; + +impl Clock for WebClock { + fn now(&self) -> SystemTime { + let since = web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) + .unwrap_or_default(); + UNIX_EPOCH + since + } +} diff --git a/crates/loading-cache/51degrees/src/tests.rs b/crates/loading-cache/51degrees/src/tests.rs new file mode 100644 index 0000000000..a59dd88b62 --- /dev/null +++ b/crates/loading-cache/51degrees/src/tests.rs @@ -0,0 +1,620 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Barrier, Mutex}; +use std::time::{Duration, SystemTime}; + +use bytes::Bytes; +use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; +use trusted_server_core::platform::test_support::{ + NoopBackend, NoopConfigStore, NoopGeo, NoopHttpClient, NoopSecretStore, +}; +use trusted_server_core::platform::{ + CacheLifetimes, CacheSpec, ClientInfo, LoadError, LoadedValue, Loader, PlatformLoadingCache, + PlatformValueCache, RuntimeServices, ServerKeyPrefix, StoreKey, ValueLookup, ValueReservation, +}; + +use super::*; + +/// How many callers ask for one key at the same moment. +const CALLERS: usize = 16; + +/// A key-value store in memory that counts its reads. It keeps no lifetimes, +/// as on a host whose store keeps none. +#[derive(Default)] +struct MemoryKv { + entries: Mutex>, + reads: AtomicUsize, +} + +impl MemoryKv { + fn keys(&self) -> Vec { + let mut keys: Vec = self + .entries + .lock() + .expect("should lock the test store") + .keys() + .cloned() + .collect(); + keys.sort(); + keys + } + + fn reads(&self) -> usize { + self.reads.load(Ordering::SeqCst) + } +} + +#[async_trait::async_trait(?Send)] +impl KvStore for MemoryKv { + async fn delete(&self, key: &str) -> Result<(), KvError> { + self.entries + .lock() + .expect("should lock the test store") + .remove(key); + Ok(()) + } + + async fn get_bytes(&self, key: &str) -> Result, KvError> { + self.reads.fetch_add(1, Ordering::SeqCst); + Ok(self + .entries + .lock() + .expect("should lock the test store") + .get(key) + .cloned()) + } + + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(KvPage::default()) + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + self.entries + .lock() + .expect("should lock the test store") + .insert(key.to_owned(), value); + Ok(()) + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + _ttl: Duration, + ) -> Result<(), KvError> { + self.put_bytes(key, value).await + } +} + +type Entries = Arc>>; + +/// A value cache that reserves a missing key for the caller that finds it +/// missing, as Fastly's does, counting how each reservation ended. +#[derive(Default)] +struct ReservingCache { + entries: Entries, + filled: Arc, + released: Arc, +} + +struct Reservation { + key: String, + entries: Entries, + filled: Arc, + released: Arc, + done: bool, +} + +impl ValueReservation for Reservation { + fn fill(mut self: Box, value: Bytes, _lifetime: Duration) { + self.entries + .lock() + .expect("should lock the test cache") + .insert(self.key.clone(), value); + self.filled.fetch_add(1, Ordering::SeqCst); + self.done = true; + } +} + +impl Drop for Reservation { + fn drop(&mut self) { + if !self.done { + self.released.fetch_add(1, Ordering::SeqCst); + } + } +} + +#[async_trait::async_trait(?Send)] +impl PlatformValueCache for ReservingCache { + async fn get(&self, key: &str) -> Option { + self.entries + .lock() + .expect("should lock the test cache") + .get(key) + .cloned() + } + + async fn put(&self, key: &str, value: Bytes, _lifetime: Duration) { + self.entries + .lock() + .expect("should lock the test cache") + .insert(key.to_owned(), value); + } + + async fn lookup_or_reserve(&self, key: &str) -> ValueLookup { + if let Some(value) = self.get(key).await { + return ValueLookup::Hit(value); + } + ValueLookup::Reserved(Box::new(Reservation { + key: key.to_owned(), + entries: Arc::clone(&self.entries), + filled: Arc::clone(&self.filled), + released: Arc::clone(&self.released), + done: false, + })) + } +} + +fn services_builder(kv: Arc) -> trusted_server_core::platform::RuntimeServicesBuilder { + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(kv) + .backend(Arc::new(NoopBackend)) + .http_client(Arc::new(NoopHttpClient)) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo::default()) +} + +fn services(kv: Arc) -> RuntimeServices { + services_builder(kv).build() +} + +fn spec(detachable: bool) -> CacheSpec { + CacheSpec { + name: "test", + lifetimes: CacheLifetimes { + memory: Duration::from_secs(5), + store: Duration::from_secs(60), + store_idle: Duration::from_secs(60), + }, + memory_entries: 16, + detachable, + } +} + +fn key(name: &str) -> StoreKey { + StoreKey::new( + &ServerKeyPrefix::for_domain("www.example.com"), + "test", + name, + ) +} + +/// A loader that counts its runs, waits long enough for every other caller to +/// arrive, and answers `value`. +fn counted(runs: &Arc, value: &'static str) -> Loader { + let runs = Arc::clone(runs); + Box::new(move || { + Box::pin(async move { + runs.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(200)).await; + Ok(LoadedValue::new(Bytes::from_static(value.as_bytes()))) + }) + }) +} + +/// A loader that fails the test if it runs. +fn never() -> Loader { + Box::new(|| Box::pin(async { panic!("the source should not be asked") })) +} + +/// Asks `caches` for one key from `CALLERS` threads at once, each with a +/// runtime of its own, as a native server's requests do. +fn ask_from_many_threads( + caches: &Arc, + services: &RuntimeServices, + spec: &CacheSpec, + load: impl Fn() -> Loader + Sync, +) -> Vec> { + let start = Barrier::new(CALLERS); + std::thread::scope(|scope| { + let handles: Vec<_> = (0..CALLERS) + .map(|_| { + scope.spawn(|| { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("should build a runtime"); + let loader = load(); + start.wait(); + runtime.block_on(caches.get_or_load(services, spec, &key("k"), loader)) + }) + }) + .collect(); + handles + .into_iter() + .map(|handle| handle.join().expect("a caller should not panic")) + .collect() + }) +} + +#[test] +fn module_constant_is_the_crate_folder() { + assert_eq!( + MODULE, + trusted_server_core::module_name!(), + "should be named by the folder this crate lives in" + ); +} + +#[test] +fn the_builder_offers_a_cache_that_keeps_values() { + let cache = trusted_server_core::integrations::loading_cache_from(&[crate::builder()]) + .expect("one builder offers one cache"); + + assert!(cache.keeps_values()); + assert!( + !crate::builder().supplies_integration(), + "the cache is offered whatever the settings say, and no section selects it" + ); +} + +#[test] +fn many_callers_on_many_threads_load_a_missing_key_once_on_the_pool() { + let runs = Arc::new(AtomicUsize::new(0)); + let caches = Arc::new(LoadingCaches::with_threads(2)); + let services = services(Arc::new(MemoryKv::default())); + + let answers = + ask_from_many_threads(&caches, &services, &spec(true), || counted(&runs, "value")); + + assert_eq!( + runs.load(Ordering::SeqCst), + 1, + "the source should be asked once" + ); + for answer in answers { + assert_eq!(answer.expect("every caller should get the value"), "value"); + } +} + +#[test] +fn many_callers_on_many_threads_load_a_missing_key_once_in_the_caller() { + let runs = Arc::new(AtomicUsize::new(0)); + let caches = Arc::new(LoadingCaches::in_caller()); + let services = services(Arc::new(MemoryKv::default())); + + let answers = + ask_from_many_threads(&caches, &services, &spec(false), || counted(&runs, "value")); + + assert_eq!( + runs.load(Ordering::SeqCst), + 1, + "the source should be asked once" + ); + for answer in answers { + assert_eq!(answer.expect("every caller should get the value"), "value"); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn many_callers_on_one_thread_load_a_missing_key_once_as_a_task() { + // One thread serving many requests in turn, as a Cloudflare isolate does, + // with each load a task of its own on that thread. + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let runs = Arc::new(AtomicUsize::new(0)); + let spawned = Arc::new(AtomicUsize::new(0)); + let counted_spawns = Arc::clone(&spawned); + let caches = LoadingCaches::with_local_spawner(LocalSpawner::new(move |task| { + counted_spawns.fetch_add(1, Ordering::SeqCst); + tokio::task::spawn_local(task); + })); + let services = services(Arc::new(MemoryKv::default())); + let spec = spec(true); + let key = key("k"); + + let answers = futures::future::join_all( + (0..CALLERS) + .map(|_| caches.get_or_load(&services, &spec, &key, counted(&runs, "value"))), + ) + .await; + + assert_eq!( + runs.load(Ordering::SeqCst), + 1, + "the source should be asked once" + ); + assert_eq!( + spawned.load(Ordering::SeqCst), + 1, + "the load should be a task of its own" + ); + for answer in answers { + assert_eq!(answer.expect("every caller should get the value"), "value"); + } + }) + .await; +} + +#[test] +fn a_failed_load_reaches_every_waiter_and_is_not_kept() { + let runs = Arc::new(AtomicUsize::new(0)); + let caches = Arc::new(LoadingCaches::with_threads(2)); + let kv = Arc::new(MemoryKv::default()); + let services = services(Arc::clone(&kv)); + + let answers = ask_from_many_threads(&caches, &services, &spec(true), || { + let runs = Arc::clone(&runs); + Box::new(move || { + Box::pin(async move { + runs.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(200)).await; + Err(LoadError::new("the source is down")) + }) + }) + }); + + assert_eq!( + runs.load(Ordering::SeqCst), + 1, + "the source should be asked once" + ); + for answer in answers { + assert_eq!( + answer + .expect_err("every caller should get the error") + .message(), + "the source is down" + ); + } + assert!(kv.keys().is_empty(), "a failure should not be written"); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("should build a runtime"); + let next = runtime + .block_on(caches.get_or_load(&services, &spec(true), &key("k"), counted(&runs, "value"))) + .expect("the next caller should load again"); + assert_eq!(next, "value"); + assert_eq!( + runs.load(Ordering::SeqCst), + 2, + "the next caller should ask the source again" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn the_store_answers_another_process_without_a_load() { + let kv = Arc::new(MemoryKv::default()); + let services = services(Arc::clone(&kv)); + let runs = Arc::new(AtomicUsize::new(0)); + + let first = LoadingCaches::in_caller() + .get_or_load(&services, &spec(false), &key("k"), counted(&runs, "value")) + .await + .expect("the first process should load"); + let second = LoadingCaches::in_caller() + .get_or_load(&services, &spec(false), &key("k"), never()) + .await + .expect("the second process should read the store"); + + assert_eq!(first, "value"); + assert_eq!(second, "value"); + assert_eq!(runs.load(Ordering::SeqCst), 1); + assert_eq!( + kv.keys(), + vec!["ts.example.com/test/k".to_owned()], + "the entry should be under the publisher's prefix and the cache's name" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn two_publishers_never_share_an_entry() { + let kv = Arc::new(MemoryKv::default()); + let services = services(Arc::clone(&kv)); + let caches = LoadingCaches::in_caller(); + let runs = Arc::new(AtomicUsize::new(0)); + let of = |domain: &str| StoreKey::new(&ServerKeyPrefix::for_domain(domain), "test", "k"); + + let one = caches + .get_or_load( + &services, + &spec(false), + &of("one.example"), + counted(&runs, "one"), + ) + .await + .expect("should load the first publisher's value"); + let other = caches + .get_or_load( + &services, + &spec(false), + &of("other.example"), + counted(&runs, "other"), + ) + .await + .expect("should load the second publisher's value"); + + assert_eq!(one, "one"); + assert_eq!(other, "other"); + assert_eq!(runs.load(Ordering::SeqCst), 2); +} + +/// A clock a test moves on by hand. +fn manual_clock() -> (Arc>, Arc) { + let now = Arc::new(Mutex::new( + SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000), + )); + let read = Arc::clone(&now); + let clock: Arc = Arc::new(move || *read.lock().expect("should lock the clock")); + (now, clock) +} + +fn advance(now: &Arc>, by: Duration) { + let mut now = now.lock().expect("should lock the clock"); + *now += by; +} + +#[tokio::test(flavor = "current_thread")] +async fn the_memory_copy_is_brief_and_the_store_is_read_after_it() { + let kv = Arc::new(MemoryKv::default()); + let services = services(Arc::clone(&kv)); + let (now, clock) = manual_clock(); + let caches = LoadingCaches::in_caller().with_clock(clock); + let runs = Arc::new(AtomicUsize::new(0)); + + caches + .get_or_load(&services, &spec(false), &key("k"), counted(&runs, "value")) + .await + .expect("should load"); + let reads = kv.reads(); + caches + .get_or_load(&services, &spec(false), &key("k"), never()) + .await + .expect("the memory copy should answer"); + assert_eq!(kv.reads(), reads, "the memory copy should answer first"); + + advance(&now, Duration::from_secs(6)); + caches + .get_or_load(&services, &spec(false), &key("k"), never()) + .await + .expect("the store should answer once the memory copy has gone"); + assert_eq!(kv.reads(), reads + 1, "the store should then be read"); + assert_eq!(runs.load(Ordering::SeqCst), 1); +} + +#[tokio::test(flavor = "current_thread")] +async fn an_entry_left_unused_for_its_idle_time_is_loaded_again() { + let kv = Arc::new(MemoryKv::default()); + let services = services(Arc::clone(&kv)); + let (now, clock) = manual_clock(); + let caches = LoadingCaches::in_caller().with_clock(clock); + let runs = Arc::new(AtomicUsize::new(0)); + let mut idle = spec(false); + idle.lifetimes.store = Duration::from_secs(3600); + idle.lifetimes.store_idle = Duration::from_secs(10); + + caches + .get_or_load(&services, &idle, &key("k"), counted(&runs, "value")) + .await + .expect("should load"); + advance(&now, Duration::from_secs(11)); + caches + .get_or_load(&services, &idle, &key("k"), counted(&runs, "again")) + .await + .expect("should load again"); + + assert_eq!( + runs.load(Ordering::SeqCst), + 2, + "the unused entry should have expired" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn a_value_with_a_lifetime_of_its_own_ends_by_then() { + let kv = Arc::new(MemoryKv::default()); + let services = services(Arc::clone(&kv)); + let (now, clock) = manual_clock(); + let caches = LoadingCaches::in_caller().with_clock(clock); + let runs = Arc::new(AtomicUsize::new(0)); + let short = + |runs: &Arc| -> Loader { + let runs = Arc::clone(runs); + Box::new(move || { + Box::pin(async move { + runs.fetch_add(1, Ordering::SeqCst); + Ok(LoadedValue::new(Bytes::from_static(b"v")) + .with_lifetime(Duration::from_secs(2))) + }) + }) + }; + + caches + .get_or_load(&services, &spec(false), &key("k"), short(&runs)) + .await + .expect("should load"); + advance(&now, Duration::from_secs(3)); + caches + .get_or_load(&services, &spec(false), &key("k"), short(&runs)) + .await + .expect("should load again"); + + assert_eq!( + runs.load(Ordering::SeqCst), + 2, + "the value's own lifetime should end it" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn a_reserving_value_cache_is_filled_by_the_load_and_released_by_a_failure() { + let cache = Arc::new(ReservingCache::default()); + let services = services_builder(Arc::new(MemoryKv::default())) + .value_cache(Arc::clone(&cache) as Arc) + .build(); + let runs = Arc::new(AtomicUsize::new(0)); + + let failed = LoadingCaches::in_caller() + .get_or_load( + &services, + &spec(false), + &key("k"), + Box::new(|| Box::pin(async { Err(LoadError::new("down")) })), + ) + .await; + assert!(failed.is_err(), "the failure should reach the caller"); + assert_eq!( + cache.released.load(Ordering::SeqCst), + 1, + "a failure should release the waiting callers" + ); + assert_eq!(cache.filled.load(Ordering::SeqCst), 0); + + let value = LoadingCaches::in_caller() + .get_or_load(&services, &spec(false), &key("k"), counted(&runs, "value")) + .await + .expect("should load"); + assert_eq!(value, "value"); + assert_eq!( + cache.filled.load(Ordering::SeqCst), + 1, + "the load should fill the reservation" + ); + + let again = LoadingCaches::in_caller() + .get_or_load(&services, &spec(false), &key("k"), never()) + .await + .expect("another process should read the filled entry"); + assert_eq!(again, "value"); +} + +#[tokio::test(flavor = "current_thread")] +async fn remove_drops_the_memory_copy_and_the_stored_one() { + let kv = Arc::new(MemoryKv::default()); + let services = services(Arc::clone(&kv)); + let caches = LoadingCaches::in_caller(); + let runs = Arc::new(AtomicUsize::new(0)); + + caches + .get_or_load(&services, &spec(false), &key("k"), counted(&runs, "old")) + .await + .expect("should load"); + caches.remove(&services, &spec(false), &key("k")).await; + let fresh = caches + .get_or_load(&services, &spec(false), &key("k"), counted(&runs, "new")) + .await + .expect("should load again"); + + assert_eq!(fresh, "new"); + assert_eq!(runs.load(Ordering::SeqCst), 2); +} diff --git a/crates/trusted-server-modules/Cargo.toml b/crates/trusted-server-modules/Cargo.toml index 9ba7a7b20e..0b3671d567 100644 --- a/crates/trusted-server-modules/Cargo.toml +++ b/crates/trusted-server-modules/Cargo.toml @@ -30,6 +30,7 @@ trusted-server-cmp-sourcepoint = { workspace = true } trusted-server-core = { workspace = true } trusted-server-framework-nextjs = { workspace = true } trusted-server-identity-lockr = { workspace = true } +trusted-server-loading-cache-51degrees = { workspace = true } trusted-server-tag-google-tag-manager = { workspace = true } trusted-server-testing-testlight = { workspace = true } diff --git a/crates/trusted-server-modules/src/lib.rs b/crates/trusted-server-modules/src/lib.rs index 90b4536440..6b716830a9 100644 --- a/crates/trusted-server-modules/src/lib.rs +++ b/crates/trusted-server-modules/src/lib.rs @@ -47,6 +47,9 @@ pub fn builders() -> Vec { trusted_server_auction_prebid_server::builder(), trusted_server_auction_aps::builder(), trusted_server_ad_server_mock::builder(), + // The loading cache a stock build caches with, offered whatever the + // settings say, which no section selects either. + trusted_server_loading_cache_51degrees::builder(), ] } @@ -433,11 +436,25 @@ mod tests { "auction.prebid-server", "auction.aps", "ad-server.mock", + "loading-cache.51degrees", ], "should offer the stock modules in the order their hooks run" ); } + /// The adapters take the loading cache from this list, so a stock build + /// keeps what it loads rather than asking each source every time. + #[test] + fn the_stock_list_offers_one_loading_cache() { + let cache = trusted_server_core::integrations::loading_cache_from(&builders()) + .expect("the stock list should offer one loading cache"); + + assert!( + cache.keeps_values(), + "the stock loading cache should keep what it loads" + ); + } + #[test] fn a_deployment_s_builders_follow_the_stock_ones() { let stock = builders().len(); From 80b3a938b871240827b64e5e74e25ab3e32a6f27 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 10 Oct 2026 23:25:02 +0100 Subject: [PATCH 05/31] A store that cannot give an entry a lifetime still holds it Spin's key-value store refuses a write with a lifetime, so a robots.txt answer kept for a later failure, and a loading cache entry in the module's key-value layer, are written without one there, as the held answer was before. The entry format carries each entry's own drop time, so a copy in a store that keeps no lifetimes still ends when it should, and a kept answer stays until it is replaced, which is what the configuration guide says of Spin. The robots.txt load carries the name the contributor was declared under as the static text the module context takes, and the handler hands it over from the selection. Tests cover the kept answer's lifetime where the store takes one, the answer kept where it refuses one, and a store that refuses lifetimes still answering a second process through the module. --- crates/loading-cache/51degrees/src/stores.rs | 15 ++-- crates/loading-cache/51degrees/src/tests.rs | 32 +++++++ .../src/robots_txt/holding.rs | 88 +++++++++++++++++-- 3 files changed, 122 insertions(+), 13 deletions(-) diff --git a/crates/loading-cache/51degrees/src/stores.rs b/crates/loading-cache/51degrees/src/stores.rs index c88ad39c33..5ca3d0d163 100644 --- a/crates/loading-cache/51degrees/src/stores.rs +++ b/crates/loading-cache/51degrees/src/stores.rs @@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex, PoisonError}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bytes::Bytes; -use edgezero_core::key_value_store::KvStore as PlatformKvStore; +use edgezero_core::key_value_store::{KvError, KvStore as PlatformKvStore}; use fiftyone_caching::{ ByteLookup, ByteStore, Clock, Codec, EncodedStore, Loaded, LoadingCache, Store, ValueLoader, }; @@ -217,11 +217,14 @@ impl ByteStore for KvBytes<'_> { async fn put(&self, key: &str, bytes: Vec, keep_for: Option) { let ttl = keep_for.unwrap_or(LONGEST).max(KV_SHORTEST); - if let Err(error) = self - .0 - .put_bytes_with_ttl(key, Bytes::from(bytes), ttl) - .await - { + let bytes = Bytes::from(bytes); + let written = match self.0.put_bytes_with_ttl(key, bytes.clone(), ttl).await { + // A store that cannot give an entry a lifetime keeps it until it + // is replaced, and the entry's own drop time ends its use. + Err(KvError::Unsupported { .. }) => self.0.put_bytes(key, bytes).await, + written => written, + }; + if let Err(error) = written { log::debug!( "A loading cache entry was not written, so the next request loads it: {error}" ); diff --git a/crates/loading-cache/51degrees/src/tests.rs b/crates/loading-cache/51degrees/src/tests.rs index a59dd88b62..ea03360e60 100644 --- a/crates/loading-cache/51degrees/src/tests.rs +++ b/crates/loading-cache/51degrees/src/tests.rs @@ -24,6 +24,9 @@ const CALLERS: usize = 16; struct MemoryKv { entries: Mutex>, reads: AtomicUsize, + /// Whether a write with a lifetime is refused, as it is by a store that + /// has none to give. + refuses_lifetimes: bool, } impl MemoryKv { @@ -87,6 +90,11 @@ impl KvStore for MemoryKv { value: Bytes, _ttl: Duration, ) -> Result<(), KvError> { + if self.refuses_lifetimes { + return Err(KvError::Unsupported { + operation: "put_bytes_with_ttl".to_owned(), + }); + } self.put_bytes(key, value).await } } @@ -417,6 +425,30 @@ async fn the_store_answers_another_process_without_a_load() { ); } +/// A store with no lifetime to give still holds the entry, whose own drop +/// time ends its use. +#[tokio::test(flavor = "current_thread")] +async fn a_store_that_refuses_lifetimes_still_holds_the_entry() { + let kv = Arc::new(MemoryKv { + refuses_lifetimes: true, + ..MemoryKv::default() + }); + let services = services(Arc::clone(&kv)); + let runs = Arc::new(AtomicUsize::new(0)); + + LoadingCaches::in_caller() + .get_or_load(&services, &spec(false), &key("k"), counted(&runs, "value")) + .await + .expect("the first process should load"); + let second = LoadingCaches::in_caller() + .get_or_load(&services, &spec(false), &key("k"), never()) + .await + .expect("the second process should read the store"); + + assert_eq!(second, "value"); + assert_eq!(kv.keys(), vec!["ts.example.com/test/k".to_owned()]); +} + #[tokio::test(flavor = "current_thread")] async fn two_publishers_never_share_an_entry() { let kv = Arc::new(MemoryKv::default()); diff --git a/crates/trusted-server-core/src/robots_txt/holding.rs b/crates/trusted-server-core/src/robots_txt/holding.rs index 5967f3b41d..c4609c5223 100644 --- a/crates/trusted-server-core/src/robots_txt/holding.rs +++ b/crates/trusted-server-core/src/robots_txt/holding.rs @@ -14,7 +14,8 @@ //! and in the key-value store otherwise, because a host that starts an instance //! for each request may give its requests a value cache and no key-value store. //! A value cache may drop it sooner, so on such a host a copy past its age is -//! likely to be there rather than certain. +//! likely to be there rather than certain, and a key-value store that cannot +//! give an entry a lifetime keeps it until it is replaced. use core::time::Duration; use std::sync::Arc; @@ -26,8 +27,8 @@ use super::{Contribution, RobotsTxtContributor}; use crate::error::TrustedServerError; use crate::module_context::{ModuleContext, ResolvedRequest}; use crate::platform::{ - CacheLifetimes, CacheSpec, LoadError, LoadedValue, Loader, RuntimeServices, ServerKeyPrefix, - StoreKey, + CacheLifetimes, CacheSpec, KvError, LoadError, LoadedValue, Loader, RuntimeServices, + ServerKeyPrefix, StoreKey, }; use crate::settings::Settings; @@ -113,6 +114,7 @@ pub(crate) async fn contribution_for( /// What a load owns to ask one contributor, because it may outlive the /// request that started it. struct Ask { + /// The name the contributor was declared under. id: &'static str, contributor: Arc, request: ResolvedRequest, @@ -188,11 +190,17 @@ async fn keep_answer(services: &RuntimeServices, kept: &StoreKey, bytes: &[u8]) cache.put(&key, bytes, KEPT_LIFETIME).await; return; } - if let Err(error) = services - .kv_store() - .put_bytes_with_ttl(&key, bytes, KEPT_LIFETIME) + let store = services.kv_store(); + let written = match store + .put_bytes_with_ttl(&key, bytes.clone(), KEPT_LIFETIME) .await { + // A store that cannot give an entry a lifetime keeps it until it is + // replaced, so the answer is still there when the contributor fails. + Err(KvError::Unsupported { .. }) => store.put_bytes(&key, bytes).await, + written => written, + }; + if let Err(error) = written { log::debug!("The robots.txt answer was not kept for a later failure: {error}"); } } @@ -351,6 +359,11 @@ pub(crate) mod tests { #[derive(Debug, Default)] pub(crate) struct MemoryKvStore { pub(crate) entries: Mutex>, + /// The lifetime each entry was last written with. + pub(crate) lifetimes: Mutex>, + /// Whether a write with a lifetime is refused, as it is by a store + /// that has none to give. + pub(crate) refuses_lifetimes: bool, } #[async_trait::async_trait(?Send)] @@ -383,8 +396,17 @@ pub(crate) mod tests { &self, key: &str, value: bytes::Bytes, - _ttl: Duration, + ttl: Duration, ) -> Result<(), crate::platform::KvError> { + if self.refuses_lifetimes { + return Err(KvError::Unsupported { + operation: "put_bytes_with_ttl".to_owned(), + }); + } + self.lifetimes + .lock() + .expect("should lock the test store") + .insert(key.to_owned(), ttl); self.put_bytes(key, value).await } @@ -607,6 +629,58 @@ pub(crate) mod tests { keys[0].starts_with("ts.publisher.example/robots-txt-kept/"), "{keys:?}" ); + let lifetimes = store.lifetimes.lock().expect("should lock the test store"); + assert_eq!( + lifetimes.values().copied().collect::>(), + vec![KEPT_LIFETIME], + "the answer should be kept far longer than any refresh" + ); + } + + /// A store with no lifetime to give still keeps the answer, so it is + /// there to serve when the contributor fails. + #[tokio::test] + async fn a_store_without_lifetimes_still_keeps_the_answer() { + let mut scripted = Scripted::new(vec![ + Ok(rules("User-Agent: OneBot\nDisallow: /")), + Err("cannot answer".to_owned()), + ]); + scripted.refresh = Duration::ZERO; + let scripted = Arc::new(scripted); + let store = Arc::new(MemoryKvStore { + refuses_lifetimes: true, + ..MemoryKvStore::default() + }); + let services = services_with(Arc::default(), Arc::clone(&store)); + + contribution_for( + "a", + &contributor(&scripted), + &request(), + &services, + &settings(), + ) + .await + .expect("should ask the contributor"); + let later = contribution_for( + "a", + &contributor(&scripted), + &request(), + &services, + &settings(), + ) + .await + .expect("should serve the kept answer"); + + assert_eq!(later, rules("User-Agent: OneBot\nDisallow: /")); + assert!( + store + .lifetimes + .lock() + .expect("should lock the test store") + .is_empty(), + "the entry was written without a lifetime" + ); } #[tokio::test] From 2d7936e009c99cc45037de549b98b00e3558e8b4 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 10 Oct 2026 23:25:03 +0100 Subject: [PATCH 06/31] The guides describe the loading cache The configuration guide gains "Loading caches" under the cache section, saying what is held, where the two copies live, how the keys start, which crate a stock build caches with and the `[cache.robots-txt]` lifetimes, and its robots.txt section says each answer is held through it. The integration guide gains "Supplying a loading cache", being how a crate offers one with `with_loading_cache` and how code loads through one with `load_cached`, a `CacheSpec`, a `StoreKey` and a `Loader`, and the builder table lists the offer. The example configuration shows `[cache.robots-txt]`, and the changelog records the cache, the lifetimes, the key prefix and the value cache under Unreleased. --- CHANGELOG.md | 5 +++ docs/guide/configuration.md | 47 +++++++++++++++++++-- docs/guide/integration-guide.md | 73 +++++++++++++++++++++++++++++++++ trusted-server.example.toml | 10 +++++ 4 files changed, 131 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c120f5e10..0f39252009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- A robots.txt module's answer is held by the deployment's loading cache, briefly in process memory and in the host's shared store outside it, so a module is asked once per refresh however many instances and requests want its rules, where each instance held its own copy in the key-value store. The last answer is still kept apart for 30 days and served when the module cannot answer, in the value cache on Fastly and in the key-value store elsewhere. - A consent management platform's middleware runs in the phase the publisher chooses. `cmp.inmobi`, `cmp.didomi` and `cmp.sourcepoint` may be named in a `[[fetch]]` or a `[[serve]]` entry, where each ran in the fetch phase alone. A generated configuration still places them in `[[fetch]]`. - **Breaking:** auction telemetry is selected the way every other module is. `[tinybird]` is now `[analytics.tinybird]`, and `[analytics] module = "tinybird"` selects it, which replaces the `enabled` line. A configuration that still carries `[tinybird]` is refused with directions. A stored configuration written by an earlier version carries the table even where telemetry was off, so push the configuration again with this version. The secret reference is `analytics.tinybird.auction_token_secret`, the table refuses a key it does not know, and a section that selects nothing, or a module this build does not have, is refused. - The DataDome module asks its Protection API about a publisher's pages beneath `/admin/` like any other page. It skipped every path beneath `/admin/` and `/_ts/admin/`, which left a publisher's own `/admin/` pages out of bot protection. @@ -80,6 +81,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - A middleware may prepare, and may refuse. `Middleware::prepare` runs in the request's handler before the document is processed, as the middleware's module with the request's services, and what it answers is handed to `create` under the middleware's name, so a decision that needs a store or the network is made there while `create` stays synchronous. A refusal answers the request with its status and `Retry-After`, with no document, and nothing is stored. +- A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A deployment takes the one a linked crate offers through `IntegrationBuilder::with_loading_cache` and `loading_cache_from`, and one that links none gets `NoLoadingCache`, which loads every time. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in process memory over the host's value cache or key-value store, and the stock list ships it, so every adapter takes it. A load that may finish apart from its request runs on a pool of threads on Axum and keeps running after the response on Cloudflare, and every other load runs in the caller. +- `[cache.robots-txt]` sets the lifetimes of the loading cache's copies of each robots.txt module's answer, in seconds: `memory_seconds` (default 60), `store_seconds` (default 604800) and `store_idle_seconds` (default 2592000). A memory lifetime may be 0, the others must be at least 1, and none may be more than a year. +- `ServerKeyPrefix`, the prefix every loading cache key starts with, being `ts.` and the publisher's domain with a leading `www.` dropped, so publishers sharing a store never share an entry. +- `PlatformValueCache`, the host's shared cache of small values with lifetimes, which `RuntimeServices` carries where the host supplies one. `lookup_or_reserve` obliges the caller that finds a key missing to fill it where the host can make other callers wait, and `remove` drops an entry. The Fastly adapter supplies one over Fastly's cache, answering the reservation with a transaction lookup and the removal with a purge of the entry's own surrogate key. - A file the origin does not have is made by the middleware a `[[fetch]]` entry with a `path` names for it. When the origin answers `404 Not Found` for that path, the middleware runs on an empty document of the entry's media type and its output is the file, so `ads.txt` is made from the sellers lines alone where the publisher has none. With no such entry the origin's answer stands. - `[ads-txt] modules = ["sellers"]` adds the lines `[ads-txt.sellers] lines` gives to the publisher's `ads.txt`, from `crates/ads-txt/sellers`. The module is a middleware for `text/plain`, named in a `[[fetch]]` entry on the file's path, which passes the origin's file through and writes the lines after its last one, so the publisher's file stays authoritative, and a publisher whose origin has no file gets one made of the lines. Each line is checked as the settings load as a data record or a variable of the ads.txt grammar, and a selected module no entry places is refused. - A `[[fetch]]` or `[[serve]]` entry may cover a media type other than `text/html`: another `text/` type except `text/x-component`, `application/javascript`, `application/json` or `image/svg+xml`, written as a bare `type/subtype` in lower case. A middleware says which media types it works on with `handles_media_type`, HTML alone unless it says otherwise, and an entry naming one under a media type it does not handle is refused when the registry is built. On a media type other than HTML a middleware's stream processor alone runs on the body, after core has moved the origin's addresses, in the fetch phase and then, for a response that is not stored, the serve phase, and an action that decides about HTML there is refused by name. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 2654f7e785..4eb8ad925d 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1057,9 +1057,9 @@ A module with settings of its own reads them from its own block, `[robots-txt.]` and -`[ad-server.]`. +`[ad-server.]`. `loading-cache.51degrees` supplies the loading cache a +stock build caches with, described under [Loading caches](#loading-caches), +and no section selects it either. ### Placing page changes diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index cbe18865c6..5c77a26d01 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -359,6 +359,7 @@ points at the functions that do the work. | `.with_plan_registration(...)` | Optional. Registers the module from the compiled auction plan, for a module whose page support follows what the plan selects. It runs whether or not a section selects the module, and its hooks go first | | `.with_plan_validator(...)` | Optional. Checks the module's settings against the compiled auction plan, when a deployment is validated and as the settings load | | `.with_demand(...)` and `.with_adserver(...)` | Optional. Registers an auction implementation that `[demand]` or `[ad-server]` can name. A crate that supplies nothing else starts from `IntegrationBuilder::implementations(...)` | +| `.with_loading_cache(...)` | Optional. Offers the loading cache the crate supplies, whatever the settings say. An adapter takes it with `loading_cache_from`, and a deployment links one | ```rust pub fn module_name() -> &'static str { @@ -787,6 +788,78 @@ the list does not order page changes, which run in the order an entry names them. Offering a module does not run it, because the registry builds a module only when a section of the settings selects it. +### Supplying a loading cache + +Some answers cost a call to make and are the same for many requests, such as +each robots.txt module's answer. Core loads such an answer through +`PlatformLoadingCache`, in `trusted_server_core::platform`, which loads a +missing value once however many requests ask for it at the same moment, gives +every request waiting on that load the value or the error, and holds nothing +that failed. Core declares the interface and `NoLoadingCache`, which holds +nothing and runs every load, and the implementation comes from a crate outside +core. + +A crate supplies one by implementing `PlatformLoadingCache` and offering a +factory on its builder with `.with_loading_cache(...)`. The offer stands +whatever the settings say, because a loading cache is how a deployment caches +rather than a module a document selects, so a crate that supplies nothing else +starts from `IntegrationBuilder::implementations(...)`. An adapter takes the +cache from the builders it is given with `loading_cache_from` when it builds +its state and hands it to every request's services. A deployment links one +such crate, because two offered are refused naming both. The stock list +carries `crates/loading-cache/51degrees`. + +```rust +pub fn builder() -> IntegrationBuilder { + IntegrationBuilder::implementations(MODULE, env!("CARGO_PKG_NAME")) + .with_loading_cache(factory) +} + +pub fn factory() -> Arc { + Arc::new(ExampleCaches::new()) +} +``` + +An implementation keeps its copies in the stores `RuntimeServices` hands each +call, being the host's value cache from `services.value_cache()` where the +host supplies one and its key-value store otherwise, under the `StoreKey` the +caller made, so a copy is shared by every instance that reads the same store. + +Code in core, or in a module, loads through the cache with +`RuntimeServices::load_cached`. It names its cache with a `CacheSpec`, being +the cache's name, the lifetimes of the memory and store copies, how many +values the memory copy holds and whether a load may run apart from the request +that started it, which only a load that never blocks its thread may. Its key +is a `StoreKey`, which starts with the publisher's `ServerKeyPrefix` from the +loaded settings, so two publishers sharing a store never share a copy. The +`Loader` it hands over owns everything it uses, and answers a `LoadedValue`, +with a lifetime of its own where the source gives one. + +```rust +let key = StoreKey::new(&ServerKeyPrefix::for_settings(settings), CACHE, digest); +let bytes = services + .load_cached( + &cache_spec(settings), + &key, + Box::new(move || { + Box::pin(async move { + let answer = ask(&owned) + .await + .map_err(|error| LoadError::new(error.to_string()))?; + Ok(LoadedValue::new(answer).with_lifetime(Duration::from_secs(3600))) + }) + }), + ) + .await?; +``` + +A caller whose load pays off only when its value is kept asks +`services.loading_cache().keeps_values()` first, which the cache of a build +that links none answers with false, and `remove` drops a copy that turned out +to be unusable. The lifetimes come from the settings, as `[cache.robots-txt]` +does through `LoadingCacheConfig::lifetimes`, so an operator tunes them +without a code change. + ### Two traps a vendor will hit **The carried script's hash literal must match the file's bytes.** A diff --git a/trusted-server.example.toml b/trusted-server.example.toml index bd44b9d45f..53dafee25d 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -423,6 +423,16 @@ pull_sync_concurrency = 3 # thumbnail = "width=150&crop=1:1,smart" # medium = "format=auto&width=828" +# How long the copies of each robots.txt module's answer last in the loading +# cache, in seconds: the brief copy in process memory, the longest the copy in +# the shared store is used, and how long that copy lasts unused. A module's own +# refresh ends its answer sooner. Memory may be 0, the other two must be at +# least 1, and none may be more than a year. +# [cache.robots-txt] +# memory_seconds = 60 +# store_seconds = 604800 +# store_idle_seconds = 2592000 + # Operator-controlled cache header policies for static/rehosted assets. Disabled # rules never match, and matcher/policy validation is deferred until a rule is # enabled; IDs must be non-empty and unique. Keep rules disabled unless the From 464f9a94ebb02c8fe522a9d96611ee15086d9b98 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 10 Oct 2026 23:28:45 +0100 Subject: [PATCH 07/31] A Cloudflare load runs on after the response only inside scope_request The crate keeps a load running with the request's `wait_until` only where the Worker's handling of the request is wrapped in `scope_request`, and nothing on an adapter wraps it, so the crate's documentation and the changelog say so rather than that the load keeps running after the response on Cloudflare. --- CHANGELOG.md | 2 +- crates/loading-cache/51degrees/src/lib.rs | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f39252009..201d9f2c00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,7 +81,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - A middleware may prepare, and may refuse. `Middleware::prepare` runs in the request's handler before the document is processed, as the middleware's module with the request's services, and what it answers is handed to `create` under the middleware's name, so a decision that needs a store or the network is made there while `create` stays synchronous. A refusal answers the request with its status and `Retry-After`, with no document, and nothing is stored. -- A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A deployment takes the one a linked crate offers through `IntegrationBuilder::with_loading_cache` and `loading_cache_from`, and one that links none gets `NoLoadingCache`, which loads every time. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in process memory over the host's value cache or key-value store, and the stock list ships it, so every adapter takes it. A load that may finish apart from its request runs on a pool of threads on Axum and keeps running after the response on Cloudflare, and every other load runs in the caller. +- A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A deployment takes the one a linked crate offers through `IntegrationBuilder::with_loading_cache` and `loading_cache_from`, and one that links none gets `NoLoadingCache`, which loads every time. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in process memory over the host's value cache or key-value store, and the stock list ships it, so every adapter takes it. A load that may finish apart from its request runs on a pool of threads on Axum and as a task of its own on Cloudflare, where it keeps running after the response only when the Worker's handling of the request is wrapped in the crate's `scope_request`, and every other load runs in the caller. - `[cache.robots-txt]` sets the lifetimes of the loading cache's copies of each robots.txt module's answer, in seconds: `memory_seconds` (default 60), `store_seconds` (default 604800) and `store_idle_seconds` (default 2592000). A memory lifetime may be 0, the others must be at least 1, and none may be more than a year. - `ServerKeyPrefix`, the prefix every loading cache key starts with, being `ts.` and the publisher's domain with a leading `www.` dropped, so publishers sharing a store never share an entry. - `PlatformValueCache`, the host's shared cache of small values with lifetimes, which `RuntimeServices` carries where the host supplies one. `lookup_or_reserve` obliges the caller that finds a key missing to fill it where the host can make other callers wait, and `remove` drops an entry. The Fastly adapter supplies one over Fastly's cache, answering the reservation with a transaction lookup and the removal with a purge of the entry's own surrogate key. diff --git a/crates/loading-cache/51degrees/src/lib.rs b/crates/loading-cache/51degrees/src/lib.rs index f6f0173a43..d66fc5af20 100644 --- a/crates/loading-cache/51degrees/src/lib.rs +++ b/crates/loading-cache/51degrees/src/lib.rs @@ -21,8 +21,9 @@ //! //! A load may run apart from its caller where the host can run work on its //! own and the cache allows it, on a pool of threads on a native host and as a -//! task kept running with the request's `wait_until` on Cloudflare. Fastly and -//! Spin run each load in the caller. +//! task of its own on Cloudflare, kept running with the request's +//! `wait_until` where the request's handling is wrapped in [`scope_request`]. +//! Fastly and Spin run each load in the caller. //! //! An adapter takes the cache through //! [`trusted_server_core::integrations::loading_cache_from`] from the builder @@ -156,9 +157,10 @@ impl Default for LoadingCaches { impl LoadingCaches { /// The caches for this host: a load a cache allows to run apart from its - /// caller does so on a pool of threads on a native host and with the - /// request's `wait_until` on Cloudflare, and every load runs in its caller - /// on Fastly and Spin. + /// caller does so on a pool of threads on a native host and as a task of + /// its own on Cloudflare, kept running with the request's `wait_until` + /// inside [`scope_request`], and every load runs in its caller on Fastly + /// and Spin. #[must_use] pub fn new() -> Self { Self::with_runner(default_runner()) From 6948c5d26c32ed4e66ee2f85d7ee020b8fcbb970 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 10 Oct 2026 23:29:15 +0100 Subject: [PATCH 08/31] The crate's documentation names scope_request without linking it The item exists only on the Cloudflare target, so a link to it from the crate's documentation is reported broken on every other target's documentation build. The name is written as plain code instead. --- crates/loading-cache/51degrees/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/loading-cache/51degrees/src/lib.rs b/crates/loading-cache/51degrees/src/lib.rs index d66fc5af20..4d34f82517 100644 --- a/crates/loading-cache/51degrees/src/lib.rs +++ b/crates/loading-cache/51degrees/src/lib.rs @@ -22,7 +22,7 @@ //! A load may run apart from its caller where the host can run work on its //! own and the cache allows it, on a pool of threads on a native host and as a //! task of its own on Cloudflare, kept running with the request's -//! `wait_until` where the request's handling is wrapped in [`scope_request`]. +//! `wait_until` where the request's handling is wrapped in `scope_request`. //! Fastly and Spin run each load in the caller. //! //! An adapter takes the cache through @@ -159,7 +159,7 @@ impl LoadingCaches { /// The caches for this host: a load a cache allows to run apart from its /// caller does so on a pool of threads on a native host and as a task of /// its own on Cloudflare, kept running with the request's `wait_until` - /// inside [`scope_request`], and every load runs in its caller on Fastly + /// inside `scope_request`, and every load runs in its caller on Fastly /// and Spin. #[must_use] pub fn new() -> Self { From b2069dd0add681a693265d2e146b8f47b054be5d Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 10 Oct 2026 23:54:03 +0100 Subject: [PATCH 09/31] Record the loading cache crate and its dependencies in Cargo.lock The first build adds the published fiftyone-caching crate, hashlink and tokio-util to the lock, which a build with --locked refuses until they are committed. --- Cargo.lock | 67 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ff8aa0428e..be3ebcbdb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,6 +25,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "const-random", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -1952,6 +1954,18 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiftyone-caching" +version = "4.5.8-49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312b19eef500787f70fa37b9c498bb2012ae68234559b181c002d3abda3db8d6" +dependencies = [ + "ahash", + "hashlink 0.10.0", + "tokio-util", + "worker", +] + [[package]] name = "filetime" version = "0.2.29" @@ -1999,6 +2013,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -3142,6 +3162,15 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -3150,7 +3179,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -3161,7 +3190,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -3173,6 +3202,15 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "hashlink" version = "0.11.1" @@ -3935,7 +3973,7 @@ dependencies = [ "cfg-if", "cssparser 0.36.0", "encoding_rs", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "memchr", "mime", @@ -4774,7 +4812,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ "heck", - "itertools 0.10.5", + "itertools 0.13.0", "log", "multimap", "once_cell", @@ -4794,7 +4832,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.118", @@ -4807,7 +4845,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.118", @@ -6295,6 +6333,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] @@ -7056,6 +7095,21 @@ dependencies = [ "which", ] +[[package]] +name = "trusted-server-loading-cache-51degrees" +version = "0.1.0" +dependencies = [ + "async-trait", + "bytes", + "edgezero-core", + "fiftyone-caching", + "futures", + "log", + "tokio", + "trusted-server-core", + "web-time", +] + [[package]] name = "trusted-server-modules" version = "0.1.0" @@ -7080,6 +7134,7 @@ dependencies = [ "trusted-server-core", "trusted-server-framework-nextjs", "trusted-server-identity-lockr", + "trusted-server-loading-cache-51degrees", "trusted-server-tag-google-tag-manager", "trusted-server-testing-testlight", ] From bff016ecc057befa7a9e43dcad2bfc058a59ec1e Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 11 Oct 2026 10:11:39 +0100 Subject: [PATCH 10/31] Each feature selects the loading cache that holds its copies A feature that holds answers in a loading cache names, in its own `[cache.]` table, the module whose cache holds them, as the module's short name or its name in full. `loading_caches_from` resolves every feature's selection against the caches the linked builders offer, makes each module's cache once however many features select it, and refuses a name no builder offers a cache from, naming the feature and the modules that do. The services carry the selections as `LoadingCaches`, and a feature that selected none loads through `NoLoadingCache`. Before this the stock build's one offered cache held every feature's copies, and a deployment linking two caches was refused. Now each feature decides, so the asset cache and any later feature can choose a cache of its own. `[cache.robots-txt] module = "51degrees"` selects the stock cache for the robots.txt modules' answers. The guides, the example configuration and the changelog say so, and the adapters pass the selections where they passed the one cache. --- CHANGELOG.md | 4 +- crates/loading-cache/51degrees/src/lib.rs | 9 +- crates/loading-cache/51degrees/src/tests.rs | 13 +- crates/trusted-server-adapter-axum/src/app.rs | 11 +- .../src/app.rs | 9 +- .../trusted-server-adapter-fastly/src/app.rs | 19 +- .../src/app/seam_probe_tests.rs | 6 +- .../trusted-server-adapter-fastly/src/lib.rs | 26 +-- .../src/middleware.rs | 2 +- crates/trusted-server-adapter-spin/src/app.rs | 9 +- .../src/integrations/mod.rs | 172 +++++++++++++----- .../src/platform/loading_cache.rs | 54 ++++++ .../trusted-server-core/src/platform/mod.rs | 4 +- .../trusted-server-core/src/platform/types.rs | 48 +++-- .../src/robots_txt/holding.rs | 6 +- crates/trusted-server-core/src/settings.rs | 48 ++++- crates/trusted-server-modules/src/lib.rs | 18 +- docs/guide/configuration.md | 21 ++- docs/guide/integration-guide.md | 20 +- trusted-server.example.toml | 12 +- 20 files changed, 358 insertions(+), 153 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 201d9f2c00..b928cf777f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,8 +81,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - A middleware may prepare, and may refuse. `Middleware::prepare` runs in the request's handler before the document is processed, as the middleware's module with the request's services, and what it answers is handed to `create` under the middleware's name, so a decision that needs a store or the network is made there while `create` stays synchronous. A refusal answers the request with its status and `Retry-After`, with no document, and nothing is stored. -- A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A deployment takes the one a linked crate offers through `IntegrationBuilder::with_loading_cache` and `loading_cache_from`, and one that links none gets `NoLoadingCache`, which loads every time. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in process memory over the host's value cache or key-value store, and the stock list ships it, so every adapter takes it. A load that may finish apart from its request runs on a pool of threads on Axum and as a task of its own on Cloudflare, where it keeps running after the response only when the Worker's handling of the request is wrapped in the crate's `scope_request`, and every other load runs in the caller. -- `[cache.robots-txt]` sets the lifetimes of the loading cache's copies of each robots.txt module's answer, in seconds: `memory_seconds` (default 60), `store_seconds` (default 604800) and `store_idle_seconds` (default 2592000). A memory lifetime may be 0, the others must be at least 1, and none may be more than a year. +- A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A crate offers one through `IntegrationBuilder::with_loading_cache`, each feature that holds answers selects the module whose cache holds them in its own `[cache.]` table, and an adapter takes the selections with `loading_caches_from`. A feature that selects none gets `NoLoadingCache`, which loads every time. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in process memory over the host's value cache or key-value store, and the stock list ships it, so every adapter takes it. A load that may finish apart from its request runs on a pool of threads on Axum and as a task of its own on Cloudflare, where it keeps running after the response only when the Worker's handling of the request is wrapped in the crate's `scope_request`, and every other load runs in the caller. +- `[cache.robots-txt]` selects, with `module`, the loading cache that holds each robots.txt module's answer, and sets the lifetimes of its copies, in seconds: `memory_seconds` (default 60), `store_seconds` (default 604800) and `store_idle_seconds` (default 2592000). A memory lifetime may be 0, the others must be at least 1, and none may be more than a year. - `ServerKeyPrefix`, the prefix every loading cache key starts with, being `ts.` and the publisher's domain with a leading `www.` dropped, so publishers sharing a store never share an entry. - `PlatformValueCache`, the host's shared cache of small values with lifetimes, which `RuntimeServices` carries where the host supplies one. `lookup_or_reserve` obliges the caller that finds a key missing to fill it where the host can make other callers wait, and `remove` drops an entry. The Fastly adapter supplies one over Fastly's cache, answering the reservation with a transaction lookup and the removal with a purge of the entry's own surrogate key. - A file the origin does not have is made by the middleware a `[[fetch]]` entry with a `path` names for it. When the origin answers `404 Not Found` for that path, the middleware runs on an empty document of the entry's media type and its output is the file, so `ads.txt` is made from the sellers lines alone where the publisher has none. With no such entry the origin's answer stands. diff --git a/crates/loading-cache/51degrees/src/lib.rs b/crates/loading-cache/51degrees/src/lib.rs index 4d34f82517..a515ce0a4b 100644 --- a/crates/loading-cache/51degrees/src/lib.rs +++ b/crates/loading-cache/51degrees/src/lib.rs @@ -25,10 +25,11 @@ //! `wait_until` where the request's handling is wrapped in `scope_request`. //! Fastly and Spin run each load in the caller. //! -//! An adapter takes the cache through -//! [`trusted_server_core::integrations::loading_cache_from`] from the builder -//! [`builder`] returns, which the stock list carries, so no adapter names this -//! crate. +//! A feature selects this cache by naming the module, `51degrees` or in full, +//! in its own `[cache.]` table, and an adapter takes the selections +//! through [`trusted_server_core::integrations::loading_caches_from`] from the +//! builder [`builder`] returns, which the stock list carries, so no adapter +//! names this crate. mod layer; mod stores; diff --git a/crates/loading-cache/51degrees/src/tests.rs b/crates/loading-cache/51degrees/src/tests.rs index ea03360e60..5296b8b68a 100644 --- a/crates/loading-cache/51degrees/src/tests.rs +++ b/crates/loading-cache/51degrees/src/tests.rs @@ -263,10 +263,17 @@ fn module_constant_is_the_crate_folder() { #[test] fn the_builder_offers_a_cache_that_keeps_values() { - let cache = trusted_server_core::integrations::loading_cache_from(&[crate::builder()]) - .expect("one builder offers one cache"); + let mut settings = trusted_server_core::test_support::tests::create_test_settings(); + settings.cache.robots_txt.module = Some("51degrees".to_owned()); + let caches = + trusted_server_core::integrations::loading_caches_from(&settings, &[crate::builder()]) + .expect("a feature selecting this module should find its cache"); - assert!(cache.keeps_values()); + assert!( + caches + .for_feature(trusted_server_core::settings::ROBOTS_TXT_CACHE) + .keeps_values() + ); assert!( !crate::builder().supplies_integration(), "the cache is offered whatever the settings say, and no section selects it" diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 3dada50884..e58be73e78 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -74,7 +74,7 @@ pub struct AppState { services: Option, /// The loading cache the linked builders offer, taken once here so every /// request in the process shares its memory copy and waits for one load. - loading_cache: Arc, + loading_caches: Arc, } /// The permission signal modules this adapter links, in the order they run @@ -210,7 +210,8 @@ fn build_state_with_registrations_and_services( &settings, &shipped_signal_modules(), )?; - let loading_cache = trusted_server_core::integrations::loading_cache_from(integrations)?; + let loading_caches = + trusted_server_core::integrations::loading_caches_from(&settings, integrations)?; Ok(Arc::new(AppState { settings: Arc::new(settings), @@ -218,7 +219,7 @@ fn build_state_with_registrations_and_services( registry: Arc::new(registry), permission_signal_modules, services, - loading_cache, + loading_caches, })) } @@ -239,7 +240,7 @@ impl AppState { fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { let mut services = self.services.clone().unwrap_or_else(|| { build_runtime_services(ctx, &self.settings, &self.permission_signal_modules) - .with_loading_cache(Arc::clone(&self.loading_cache)) + .with_loading_caches(Arc::clone(&self.loading_caches)) }); if let Some(module) = self.registry.geo_module() { services = services.with_geo(module); @@ -919,7 +920,7 @@ mod tests { // This test drives the per-request path, which builds its services // from the request context. services: None, - loading_cache: Arc::new(trusted_server_core::platform::NoLoadingCache), + loading_caches: Arc::default(), } } diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 5c43059642..21924ddc68 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -104,7 +104,7 @@ pub struct AppState { services: Option, /// The loading cache the builders this state was built with offer, or one /// that holds nothing when none does, handed to every request's services. - loading_cache: Arc, + loading_caches: Arc, } /// Build the application state, loading settings and constructing all per-application components. @@ -311,7 +311,8 @@ fn build_state_with_registrations_and_services( &settings, &shipped_signal_modules(), )?; - let loading_cache = trusted_server_core::integrations::loading_cache_from(integrations)?; + let loading_caches = + trusted_server_core::integrations::loading_caches_from(&settings, integrations)?; Ok(Arc::new(AppState { settings: Arc::new(settings), @@ -320,7 +321,7 @@ fn build_state_with_registrations_and_services( ec_module, permission_signal_modules, services, - loading_cache, + loading_caches, })) } @@ -365,7 +366,7 @@ impl AppState { .clone() .unwrap_or_else(|| { build_runtime_services(ctx, &self.settings, &self.permission_signal_modules) - .with_loading_cache(Arc::clone(&self.loading_cache)) + .with_loading_caches(Arc::clone(&self.loading_caches)) }) .with_resolved_ec_module(self.ec_module.clone()); if let Some(module) = self.registry.geo_module() { diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 5870f344d7..26ecf76fe5 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -127,7 +127,7 @@ use trusted_server_core::integrations::{ }; use trusted_server_core::permissions::PermissionState; use trusted_server_core::platform::{ - ClientInfo, GeoInfo, PlatformKvStore, PlatformLoadingCache, RuntimeServices, StoreName, + ClientInfo, GeoInfo, LoadingCaches, PlatformKvStore, RuntimeServices, StoreName, build_geo_module, }; use trusted_server_core::proxy::{ @@ -213,7 +213,7 @@ pub(crate) struct AppState { /// The loading cache the linked builders offer, taken once here and handed /// to every request's services, so lookups in one request share its /// memory copy. - pub(crate) loading_cache: Arc, + pub(crate) loading_caches: Arc, } /// Build the application state, loading settings and constructing all per-application components. @@ -343,7 +343,8 @@ pub(crate) fn build_state_with_registrations( let auction_telemetry_sink = crate::tinybird::auction_sink_from_settings(&settings); let default_kv_store = Arc::new(UnavailableKvStore) as Arc; - let loading_cache = trusted_server_core::integrations::loading_cache_from(integrations)?; + let loading_caches = + trusted_server_core::integrations::loading_caches_from(&settings, integrations)?; Ok(Arc::new(AppState { settings: Arc::new(settings), @@ -353,7 +354,7 @@ pub(crate) fn build_state_with_registrations( auction_telemetry_sink, ec_module, permission_signal_modules, - loading_cache, + loading_caches, })) } @@ -439,7 +440,7 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime .secret_store(Arc::new(FastlyPlatformSecretStore)) .kv_store(Arc::clone(&state.default_kv_store)) .value_cache(Arc::new(crate::value_cache::FastlyValueCache)) - .loading_cache(Arc::clone(&state.loading_cache)) + .loading_caches(Arc::clone(&state.loading_caches)) // Constructed unconditionally, but only read when the assembly mode is a // shared-template one — which defaults to Inline, so this is inert until an // operator opts in. @@ -497,14 +498,14 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime pub(crate) fn build_finalize_services( settings: &Settings, kv_store: Arc, - loading_cache: Arc, + loading_caches: Arc, ) -> RuntimeServices { RuntimeServices::builder() .config_store(Arc::new(FastlyPlatformConfigStore)) .secret_store(Arc::new(FastlyPlatformSecretStore)) .kv_store(kv_store) .value_cache(Arc::new(crate::value_cache::FastlyValueCache)) - .loading_cache(loading_cache) + .loading_caches(loading_caches) .backend(Arc::new(FastlyPlatformBackend)) .http_client(Arc::new(FastlyPlatformHttpClient)) .geo(build_geo_module(settings, Arc::new(FastlyPlatformGeo))) @@ -1548,7 +1549,7 @@ impl TrustedServerApp { build_finalize_services( &state.settings, Arc::clone(&state.default_kv_store), - Arc::clone(&state.loading_cache), + Arc::clone(&state.loading_caches), ), )); @@ -1884,7 +1885,7 @@ mod tests { default_kv_store, ec_module, permission_signal_modules: Arc::default(), - loading_cache: Arc::new(trusted_server_core::platform::NoLoadingCache), + loading_caches: Arc::default(), }); TrustedServerApp::routes_for_state(&state) } diff --git a/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs b/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs index 107815fbd9..0a2c58136f 100644 --- a/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs @@ -642,7 +642,7 @@ fn ec_and_device_selectors_naming_a_module_resolve_the_modules_it_declares() { let services = build_finalize_services( &state.settings, Arc::clone(&state.default_kv_store), - Arc::new(trusted_server_core::platform::NoLoadingCache), + Arc::default(), ); let request_info = OwnedRequestInfo::new("192.0.2.1".to_owned(), HeaderMap::new()); let method = Method::GET; @@ -690,7 +690,7 @@ fn device_selector_naming_a_module_classifies_a_request_with_it() { let services = build_finalize_services( &selected.settings, Arc::clone(&selected.default_kv_store), - Arc::new(trusted_server_core::platform::NoLoadingCache), + Arc::default(), ); let signals = block_on(crate::derive_device_signals( &selected.settings, @@ -711,7 +711,7 @@ fn device_selector_naming_a_module_classifies_a_request_with_it() { let services = build_finalize_services( &unset.settings, Arc::clone(&unset.default_kv_store), - Arc::new(trusted_server_core::platform::NoLoadingCache), + Arc::default(), ); let signals = block_on(crate::derive_device_signals( &unset.settings, diff --git a/crates/trusted-server-adapter-fastly/src/lib.rs b/crates/trusted-server-adapter-fastly/src/lib.rs index 94f446c7cf..fbc76e494c 100644 --- a/crates/trusted-server-adapter-fastly/src/lib.rs +++ b/crates/trusted-server-adapter-fastly/src/lib.rs @@ -35,9 +35,7 @@ use trusted_server_core::evidence::{BorrowedRequestInfo, HostSignals}; use trusted_server_core::integrations::RequestFilterEffects; use trusted_server_core::module_context::{ModuleContext, ModuleRequest}; use trusted_server_core::platform::build_geo_module; -use trusted_server_core::platform::{ - ClientInfo, NoLoadingCache, RuntimeServices, UnavailableKvStore, -}; +use trusted_server_core::platform::{ClientInfo, RuntimeServices, UnavailableKvStore}; use trusted_server_core::proxy::{AssetProxyCachePolicy, stream_asset_body}; use trusted_server_core::response_privacy::TerminalPrivateResponse; use trusted_server_core::settings::Settings; @@ -759,13 +757,9 @@ fn entry_point_finalize_services( Some(state) => build_finalize_services( settings, Arc::clone(&state.default_kv_store), - Arc::clone(&state.loading_cache), - ), - None => build_finalize_services( - settings, - Arc::new(UnavailableKvStore), - Arc::new(NoLoadingCache), + Arc::clone(&state.loading_caches), ), + None => build_finalize_services(settings, Arc::new(UnavailableKvStore), Arc::default()), } } @@ -1199,11 +1193,8 @@ mod tests { #[test] fn the_device_module_the_registry_resolved_classifies_the_request() { let settings = test_settings(); - let services = build_finalize_services( - &settings, - Arc::new(UnavailableKvStore), - Arc::new(NoLoadingCache), - ); + let services = + build_finalize_services(&settings, Arc::new(UnavailableKvStore), Arc::default()); let registered: Arc = Arc::new(MarkedDeviceModule); let signals = futures::executor::block_on(derive_device_signals( @@ -1224,11 +1215,8 @@ mod tests { #[test] fn with_no_registered_module_the_built_in_module_classifies_the_request() { let settings = test_settings(); - let services = build_finalize_services( - &settings, - Arc::new(UnavailableKvStore), - Arc::new(NoLoadingCache), - ); + let services = + build_finalize_services(&settings, Arc::new(UnavailableKvStore), Arc::default()); let req = page_request(); let signals = diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index e486cf1a36..e0510f861f 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -536,7 +536,7 @@ mod tests { crate::app::build_finalize_services( &test_settings(), Arc::new(trusted_server_core::platform::UnavailableKvStore), - Arc::new(trusted_server_core::platform::NoLoadingCache), + Arc::default(), ) } diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index a24414c3e1..7d5cbea8ed 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -93,7 +93,7 @@ pub struct AppState { services: Option, /// The loading cache the builders this state was built with offer, or one /// that holds nothing when none does, handed to every request's services. - loading_cache: Arc, + loading_caches: Arc, } /// Build the application state, loading settings and constructing all per-application components. @@ -240,7 +240,8 @@ fn build_state_with_registrations_and_services( &settings, &shipped_signal_modules(), )?; - let loading_cache = trusted_server_core::integrations::loading_cache_from(integrations)?; + let loading_caches = + trusted_server_core::integrations::loading_caches_from(&settings, integrations)?; Ok(Arc::new(AppState { settings: Arc::new(settings), @@ -249,7 +250,7 @@ fn build_state_with_registrations_and_services( ec_module, permission_signal_modules, services, - loading_cache, + loading_caches, })) } @@ -294,7 +295,7 @@ impl AppState { .clone() .unwrap_or_else(|| { build_runtime_services(ctx, &self.settings, &self.permission_signal_modules) - .with_loading_cache(Arc::clone(&self.loading_cache)) + .with_loading_caches(Arc::clone(&self.loading_caches)) }) .with_resolved_ec_module(self.ec_module.clone()); if let Some(module) = self.registry.geo_module() { diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index b3b293ccc9..c1780a4512 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -629,10 +629,10 @@ impl IntegrationBuilder { /// Offers the loading cache this crate supplies, see /// [`crate::platform::PlatformLoadingCache`]. /// - /// Offered whatever the settings say, because the loading cache is how a - /// deployment caches rather than a module a document selects. An adapter - /// takes it with [`loading_cache_from`] when it builds its state, and a - /// deployment offering none asks each source itself. + /// The offer is not a selection. A feature that holds answers names the + /// module in its own `[cache.]` table, and an adapter resolves every + /// feature's selection with [`loading_caches_from`] when it builds its + /// state. A feature that selects none asks each source itself. #[must_use] pub const fn with_loading_cache(mut self, factory: LoadingCacheFactory) -> Self { self.loading_cache = Some(factory); @@ -784,47 +784,80 @@ pub(crate) fn all_builders( builders().iter().copied().chain(extra.iter().copied()) } -/// The loading cache the builders a deployment links offer, or -/// [`NoLoadingCache`](crate::platform::NoLoadingCache) when none offers one. +/// The loading cache each feature selects, from the caches the builders a +/// deployment links offer. A feature whose `[cache.]` names no module +/// keeps no copy, and a module's cache is made once however many features +/// select it. /// /// # Errors /// -/// When more than one builder offers a loading cache, naming where each came -/// from, because a deployment caches one way. -pub fn loading_cache_from( +/// When a feature names a module no linked builder offers a cache from, +/// naming the feature and the modules that do. +pub fn loading_caches_from( + settings: &Settings, builders: &[IntegrationBuilder], -) -> Result, Report> { - let offered: Vec<(&str, LoadingCacheFactory)> = builders +) -> Result, Report> { + let offered: Vec<(&'static str, LoadingCacheFactory)> = builders .iter() .filter_map(|builder| { builder .loading_cache() - .map(|factory| (builder.source(), factory)) + .map(|factory| (builder.id(), factory)) }) .collect(); - match offered.as_slice() { - [] => Ok(Arc::new(crate::platform::NoLoadingCache)), - [(_, factory)] => Ok(factory()), - several => Err(Report::new(TrustedServerError::Configuration { - message: format!( - "more than one linked crate offers a loading cache: {}. A deployment \ - links one", - several - .iter() - .map(|(source, _)| *source) - .collect::>() - .join(", ") - ), - })), + let mut made: Vec<(&'static str, Arc)> = Vec::new(); + let mut caches = crate::platform::LoadingCaches::default(); + for (feature, config) in settings.cache.loading_caches() { + let Some(written) = config.module.as_deref() else { + continue; + }; + let Some((name, factory)) = offered.iter().find(|(name, _)| { + *name == written || crate::module_name::short_form(LOADING_CACHE_TYPE, name) == written + }) else { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "[cache.{feature}] module names `{written}`, which no linked crate offers \ + a loading cache from. The loading cache modules this build has are: {}", + offered_cache_names(&offered) + ), + })); + }; + let cache = match made.iter().find(|(made_name, _)| made_name == name) { + Some((_, cache)) => Arc::clone(cache), + None => { + let cache = factory(); + made.push((*name, Arc::clone(&cache))); + cache + } + }; + caches.insert(feature, cache); } + Ok(Arc::new(caches)) +} + +/// The type folder loading cache modules live under, which their short +/// names leave out. +const LOADING_CACHE_TYPE: &str = "loading-cache"; + +/// The short names of the modules offering a cache, for a refusal. +fn offered_cache_names(offered: &[(&'static str, LoadingCacheFactory)]) -> String { + if offered.is_empty() { + return "none".to_owned(); + } + offered + .iter() + .map(|(name, _)| crate::module_name::short_form(LOADING_CACHE_TYPE, name)) + .collect::>() + .join(", ") } #[cfg(test)] -mod loading_cache_from_tests { +mod loading_caches_from_tests { use bytes::Bytes; use super::*; use crate::platform::{CacheSpec, LoadError, Loader, PlatformLoadingCache, StoreKey}; + use crate::settings::ROBOTS_TXT_CACHE; fn no_build( _settings: &Settings, @@ -865,44 +898,89 @@ mod loading_cache_from_tests { Arc::new(Keeping) } + fn nothing() -> Arc { + Arc::new(crate::platform::NoLoadingCache) + } + fn builder(source: &'static str) -> IntegrationBuilder { IntegrationBuilder::new("an_id", source, no_build, no_validate) } - #[test] - fn with_none_offered_the_deployment_keeps_nothing() { - let cache = loading_cache_from(&[builder("first"), builder("second")]) - .expect("none offered is not an error"); + /// A module `id` offering a cache that keeps values. + fn offering(id: &'static str) -> IntegrationBuilder { + IntegrationBuilder::implementations(id, "a-crate").with_loading_cache(keeping) + } + + /// A module `id` offering a cache that keeps nothing. + fn offering_nothing(id: &'static str) -> IntegrationBuilder { + IntegrationBuilder::implementations(id, "a-crate").with_loading_cache(nothing) + } - assert!(!cache.keeps_values()); + 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); + settings } #[test] - fn the_one_offered_is_the_one_taken() { - let cache = loading_cache_from(&[ - builder("first"), - builder("second").with_loading_cache(keeping), - ]) - .expect("one offered should be taken"); + fn a_feature_selecting_nothing_keeps_nothing_whatever_is_offered() { + let caches = loading_caches_from( + &settings_selecting(None), + &[builder("first"), offering("loading-cache.one")], + ) + .expect("no selection is not an error"); - assert!(cache.keeps_values()); + assert!(!caches.for_feature(ROBOTS_TXT_CACHE).keeps_values()); + assert!(caches.is_empty()); } #[test] - fn two_offered_are_refused_naming_both() { - let error = loading_cache_from(&[ - builder("first").with_loading_cache(keeping), - builder("second").with_loading_cache(keeping), - ]) - .expect_err("a deployment caches one way"); + fn a_feature_selects_a_module_by_its_short_name_or_in_full() { + for written in ["one", "loading-cache.one"] { + let caches = loading_caches_from( + &settings_selecting(Some(written)), + &[ + offering_nothing("loading-cache.other"), + offering("loading-cache.one"), + ], + ) + .expect("a selected module should be found"); + + assert!( + caches.for_feature(ROBOTS_TXT_CACHE).keeps_values(), + "{written} should select the cache that keeps values, not the first offered" + ); + assert!(!caches.is_empty()); + } + } + + #[test] + fn a_feature_naming_a_module_nothing_offers_is_refused_naming_what_is_offered() { + let error = loading_caches_from( + &settings_selecting(Some("none")), + &[offering("loading-cache.one"), offering("loading-cache.two")], + ) + .expect_err("a module nothing offers should be refused"); let message = format!("{error:?}"); assert!( message.contains( - "more than one linked crate offers a loading cache: first, second. A deployment \ - links one" + "[cache.robots-txt] module names `none`, which no linked crate offers a \ + loading cache from. The loading cache modules this build has are: one, two" ), "{message}" ); } + + #[test] + fn with_nothing_offered_the_refusal_says_so() { + let error = loading_caches_from(&settings_selecting(Some("one")), &[builder("first")]) + .expect_err("a selection with nothing offered should be refused"); + + let message = format!("{error:?}"); + assert!( + message.contains("The loading cache modules this build has are: none"), + "{message}" + ); + } } diff --git a/crates/trusted-server-core/src/platform/loading_cache.rs b/crates/trusted-server-core/src/platform/loading_cache.rs index be87b7be41..372a14b2b7 100644 --- a/crates/trusted-server-core/src/platform/loading_cache.rs +++ b/crates/trusted-server-core/src/platform/loading_cache.rs @@ -17,8 +17,10 @@ //! entry. use core::fmt; +use std::collections::HashMap; use std::future::Future; use std::pin::Pin; +use std::sync::Arc; use std::time::Duration; use bytes::Bytes; @@ -235,6 +237,58 @@ impl PlatformLoadingCache for NoLoadingCache { } } +/// The loading cache each feature selected, by the name its [`CacheSpec`] +/// carries. A feature that selected none loads through [`NoLoadingCache`]. +#[derive(Default)] +pub struct LoadingCaches { + by_feature: HashMap<&'static str, Arc>, + /// What a feature that selected nothing loads through, when a test gives + /// every feature one cache. + unselected: Option>, +} + +impl LoadingCaches { + /// Every feature loading through `cache`, for a test of one cache. + #[must_use] + pub fn every(cache: Arc) -> Self { + Self { + by_feature: HashMap::new(), + unselected: Some(cache), + } + } + + /// Keeps `cache` as the one `feature` selected. + pub fn insert(&mut self, feature: &'static str, cache: Arc) { + self.by_feature.insert(feature, cache); + } + + /// The cache `feature` loads through. + #[must_use] + pub fn for_feature(&self, feature: &str) -> &dyn PlatformLoadingCache { + match self.by_feature.get(feature).or(self.unselected.as_ref()) { + Some(cache) => &**cache, + None => &NoLoadingCache, + } + } + + /// Whether no feature selected a cache. + #[must_use] + pub fn is_empty(&self) -> bool { + self.by_feature.is_empty() && self.unselected.is_none() + } +} + +impl fmt::Debug for LoadingCaches { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut selected: Vec<&&str> = self.by_feature.keys().collect(); + selected.sort(); + f.debug_struct("LoadingCaches") + .field("selected", &selected) + .field("every", &self.unselected.is_some()) + .finish() + } +} + #[cfg(test)] mod tests { use std::sync::Arc; diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 66922a1860..36231dfc6c 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -76,8 +76,8 @@ pub use image_optimizer::{ }; pub use kv::UnavailableKvStore; pub use loading_cache::{ - CacheLifetimes, CacheSpec, LoadError, LoadFuture, LoadedValue, Loader, NoLoadingCache, - PlatformLoadingCache, StoreKey, + CacheLifetimes, CacheSpec, LoadError, LoadFuture, LoadedValue, Loader, LoadingCaches, + NoLoadingCache, PlatformLoadingCache, StoreKey, }; pub use server_key::ServerKeyPrefix; pub use template_assembly::{ diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index d1028483bd..e9a6500953 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -177,10 +177,11 @@ pub struct RuntimeServices { /// none, in which case a caller holds what it pays for in the key-value /// store instead. pub(crate) value_cache: Option>, - /// The cache that loads a missing value once per key, keeping its copies - /// in [`value_cache`](Self::value_cache) or the key-value store. Defaults - /// to [`super::NoLoadingCache`], which holds nothing. - pub(crate) loading_cache: Arc, + /// The loading cache each feature selected, which loads a missing value + /// once per key and keeps its copies in [`value_cache`](Self::value_cache) + /// or the key-value store. A feature that selected none loads through + /// [`super::NoLoadingCache`], which holds nothing. + pub(crate) loading_caches: Arc, /// Shared transformed-template cache. Defaults to /// [`UnavailableTemplateCache`], so adapters without one degrade to transforming /// per request rather than failing. See @@ -271,10 +272,11 @@ impl RuntimeServices { self.value_cache.as_deref() } - /// The cache that loads a missing value once per key. + /// The loading cache `feature` selected, by the name its + /// [`super::CacheSpec`] carries. #[must_use] - pub fn loading_cache(&self) -> &dyn super::PlatformLoadingCache { - &*self.loading_cache + pub fn loading_cache_for(&self, feature: &str) -> &dyn super::PlatformLoadingCache { + self.loading_caches.for_feature(feature) } /// The value for `key` in the cache `spec` describes, from a fresh copy @@ -290,7 +292,9 @@ impl RuntimeServices { key: &super::StoreKey, load: super::Loader, ) -> Result { - self.loading_cache.get_or_load(self, spec, key, load).await + self.loading_cache_for(spec.name) + .get_or_load(self, spec, key, load) + .await } /// The shared transformed-template cache. @@ -469,15 +473,23 @@ impl RuntimeServices { } } - /// Returns a clone of this instance with the loading cache replaced. + /// Returns a clone of this instance with the loading caches replaced. #[must_use] - pub fn with_loading_cache(self, cache: Arc) -> Self { + pub fn with_loading_caches(self, caches: Arc) -> Self { Self { - loading_cache: cache, + loading_caches: caches, ..self } } + /// Returns a clone of this instance with every feature loading through + /// `cache`, for a test of one cache. + #[cfg(any(test, feature = "test-utils"))] + #[must_use] + pub fn with_loading_cache(self, cache: Arc) -> Self { + self.with_loading_caches(Arc::new(super::LoadingCaches::every(cache))) + } + /// Returns a clone of this instance with the template cache replaced. #[must_use] pub fn with_template_cache(self, cache: Arc) -> Self { @@ -517,7 +529,7 @@ pub struct RuntimeServicesBuilder { secret_store: Option>, kv_store: Option>, value_cache: Option>, - loading_cache: Option>, + loading_caches: Option>, template_cache: Option>, template_assembler: Option>, backend: Option>, @@ -538,7 +550,7 @@ impl RuntimeServicesBuilder { secret_store: None, kv_store: None, value_cache: None, - loading_cache: None, + loading_caches: None, template_cache: None, template_assembler: None, backend: None, @@ -574,10 +586,10 @@ impl RuntimeServicesBuilder { self } - /// Set the cache that loads a missing value once per key. + /// Set the loading cache each feature selected. #[must_use] - pub fn loading_cache(mut self, cache: Arc) -> Self { - self.loading_cache = Some(cache); + pub fn loading_caches(mut self, caches: Arc) -> Self { + self.loading_caches = Some(caches); self } @@ -705,9 +717,7 @@ impl RuntimeServicesBuilder { value_cache: self.value_cache, // Defaulted rather than required: a deployment that links no // module supplying one asks each source itself. - loading_cache: self - .loading_cache - .unwrap_or_else(|| Arc::new(super::NoLoadingCache)), + loading_caches: self.loading_caches.unwrap_or_default(), // Defaulted rather than required: an adapter with no template cache // should degrade to transforming per request, not fail to build. template_cache: self diff --git a/crates/trusted-server-core/src/robots_txt/holding.rs b/crates/trusted-server-core/src/robots_txt/holding.rs index c4609c5223..dbf0d824a1 100644 --- a/crates/trusted-server-core/src/robots_txt/holding.rs +++ b/crates/trusted-server-core/src/robots_txt/holding.rs @@ -33,7 +33,7 @@ use crate::platform::{ use crate::settings::Settings; /// The cache contributors' answers are held in. -pub(crate) const CACHE: &str = "robots-txt"; +pub(crate) const CACHE: &str = crate::settings::ROBOTS_TXT_CACHE; /// Where each contributor's last answer is kept apart from the cache. const KEPT: &str = "robots-txt-kept"; @@ -760,7 +760,9 @@ pub(crate) mod tests { .http_client(Arc::new(StubHttpClient::new())) .geo(Arc::new(crate::platform::test_support::NoopGeo)) .value_cache(Arc::clone(&value_cache) as Arc) - .loading_cache(Arc::new(MemoryLoadingCache::default())) + .loading_caches(Arc::new(crate::platform::LoadingCaches::every(Arc::new( + MemoryLoadingCache::default(), + )))) .client_info(crate::platform::ClientInfo::default()) .build(); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 9ecb56465a..9d98899e2f 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -2887,15 +2887,25 @@ pub struct CacheSettings { /// The most seconds a loading cache lifetime setting accepts, a year. pub const MAX_LOADING_CACHE_SECONDS: u32 = 365 * 24 * 60 * 60; +/// The name of the cache that holds each robots.txt module's answer, whose +/// settings are `[cache.robots-txt]`. +pub const ROBOTS_TXT_CACHE: &str = "robots-txt"; + /// How long one loading cache's copies last, each field defaulting to the /// cache's own default when absent. /// /// See [`crate::platform::PlatformLoadingCache`]. The memory copy is the brief /// one a process keeps in front of the shared store, and the shared store's /// copy is the one every instance reads. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct LoadingCacheConfig { + /// The module whose loading cache holds this feature's copies, as its + /// short name or in full, for example `51degrees` or + /// `loading-cache.51degrees`. Absent, the feature keeps no copy and loads + /// on every call. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub module: Option, /// How long the brief copy in process memory is used, in seconds. Zero /// keeps no memory copy, and a missing value is still loaded once. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -2966,6 +2976,13 @@ fn check_loading_cache_seconds( } impl CacheSettings { + /// Each feature whose copies a loading cache holds, by the name of its + /// cache, with the settings its `[cache.]` table carries. + #[must_use] + pub fn loading_caches(&self) -> [(&'static str, &LoadingCacheConfig); 1] { + [(ROBOTS_TXT_CACHE, &self.robots_txt)] + } + fn normalize(&mut self) { for rule in &mut self.asset_rules { rule.normalize(); @@ -6587,6 +6604,35 @@ module = \"none\"", assert_eq!(written, serde_json::json!({ "asset_rules": [] })); } + #[test] + fn a_loading_cache_module_is_read_and_written_back() { + let toml_str = format!( + r#"{} + + [cache.robots-txt] + module = "51degrees" + "#, + crate_test_settings_str() + ); + let settings = Settings::from_toml(&toml_str).expect("should parse the selection"); + + assert_eq!( + settings.cache.robots_txt.module.as_deref(), + Some("51degrees") + ); + assert!(!settings.cache.robots_txt.is_default()); + assert_eq!( + settings.cache.loading_caches(), + [(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!( + written, + serde_json::json!({ "asset_rules": [], "robots-txt": { "module": "51degrees" } }) + ); + } + #[test] fn cache_asset_rule_nextjs_preset_is_operator_controlled() { let toml_str = format!( diff --git a/crates/trusted-server-modules/src/lib.rs b/crates/trusted-server-modules/src/lib.rs index 6b716830a9..ff9703e551 100644 --- a/crates/trusted-server-modules/src/lib.rs +++ b/crates/trusted-server-modules/src/lib.rs @@ -442,15 +442,21 @@ mod tests { ); } - /// The adapters take the loading cache from this list, so a stock build - /// keeps what it loads rather than asking each source every time. + /// A feature selects the stock loading cache by the module's short name, + /// so a stock build keeps what it loads rather than asking each source + /// every time. #[test] - fn the_stock_list_offers_one_loading_cache() { - let cache = trusted_server_core::integrations::loading_cache_from(&builders()) - .expect("the stock list should offer one loading cache"); + fn a_feature_selects_the_stock_loading_cache_by_name() { + let mut settings = trusted_server_core::test_support::tests::create_test_settings(); + settings.cache.robots_txt.module = Some("51degrees".to_owned()); + + let caches = trusted_server_core::integrations::loading_caches_from(&settings, &builders()) + .expect("the stock list should offer the selected loading cache"); assert!( - cache.keeps_values(), + caches + .for_feature(trusted_server_core::settings::ROBOTS_TXT_CACHE) + .keeps_values(), "the stock loading cache should keep what it loads" ); } diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 4eb8ad925d..e3fec94be1 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1684,20 +1684,25 @@ has used it for the idle time, and an answer with a shorter lifetime of its own ends sooner still. Every key starts with `ts.` and the publisher's domain, with a leading `www.` dropped, so publishers sharing a store never share a copy. -A deployment caches this way when a crate it links offers a loading cache. A -stock build links `loading-cache.51degrees`, from -`crates/loading-cache/51degrees`, which no section selects, because a loading -cache is how the deployment caches rather than a module a document selects. A -build that links none makes each answer every time it is asked for. +Each feature that holds answers this way selects the cache that holds them, +by naming in its own `[cache.]` table a module that offers a loading +cache, with the module's short name or its name in full. A stock build links +`loading-cache.51degrees`, from `crates/loading-cache/51degrees`, so +`module = "51degrees"` selects it. A feature that names no module keeps no +copy and makes each answer every time it is asked for, and a name that no +linked crate offers a cache from is refused when the service starts, with +the names that could be written. | 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. | ```toml [cache.robots-txt] +module = "51degrees" memory_seconds = 60 store_seconds = 604800 store_idle_seconds = 2592000 @@ -1758,9 +1763,9 @@ behind after a module is switched off is caught rather than sitting unread. `ad-server.mock` supply demand and ad server implementations only. They are not page integrations, so a section that names one refuses startup like any name no module supplies. Their settings live in `[demand.]` and -`[ad-server.]`. `loading-cache.51degrees` supplies the loading cache a -stock build caches with, described under [Loading caches](#loading-caches), -and no section selects it either. +`[ad-server.]`. `loading-cache.51degrees` supplies a loading cache, +which a feature selects in its own `[cache.]` table, described under +[Loading caches](#loading-caches), and no section selects it either. ### Placing page changes diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 5c77a26d01..e2cfde2632 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -359,7 +359,7 @@ points at the functions that do the work. | `.with_plan_registration(...)` | Optional. Registers the module from the compiled auction plan, for a module whose page support follows what the plan selects. It runs whether or not a section selects the module, and its hooks go first | | `.with_plan_validator(...)` | Optional. Checks the module's settings against the compiled auction plan, when a deployment is validated and as the settings load | | `.with_demand(...)` and `.with_adserver(...)` | Optional. Registers an auction implementation that `[demand]` or `[ad-server]` can name. A crate that supplies nothing else starts from `IntegrationBuilder::implementations(...)` | -| `.with_loading_cache(...)` | Optional. Offers the loading cache the crate supplies, whatever the settings say. An adapter takes it with `loading_cache_from`, and a deployment links one | +| `.with_loading_cache(...)` | Optional. Offers the loading cache the crate supplies, which a feature selects by the module's name in its `[cache.]` table. An adapter takes the selections with `loading_caches_from` | ```rust pub fn module_name() -> &'static str { @@ -800,14 +800,16 @@ nothing and runs every load, and the implementation comes from a crate outside core. A crate supplies one by implementing `PlatformLoadingCache` and offering a -factory on its builder with `.with_loading_cache(...)`. The offer stands -whatever the settings say, because a loading cache is how a deployment caches -rather than a module a document selects, so a crate that supplies nothing else -starts from `IntegrationBuilder::implementations(...)`. An adapter takes the -cache from the builders it is given with `loading_cache_from` when it builds -its state and hands it to every request's services. A deployment links one -such crate, because two offered are refused naming both. The stock list -carries `crates/loading-cache/51degrees`. +factory on its builder with `.with_loading_cache(...)`. The offer is not a +selection. Each feature that holds answers selects the cache that holds them +by naming the module in its own `[cache.]` table, so a crate that +supplies nothing else starts from `IntegrationBuilder::implementations(...)` +and is named by its id. An adapter takes the selections from the settings and +the builders it is given with `loading_caches_from` when it builds its state, +which refuses a name no builder offers a cache from, and hands them to every +request's services as `LoadingCaches`. A feature that selects nothing loads +through `NoLoadingCache`. The stock list carries +`crates/loading-cache/51degrees`. ```rust pub fn builder() -> IntegrationBuilder { diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 53dafee25d..565bedb857 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -423,12 +423,14 @@ pull_sync_concurrency = 3 # thumbnail = "width=150&crop=1:1,smart" # medium = "format=auto&width=828" -# How long the copies of each robots.txt module's answer last in the loading -# cache, in seconds: the brief copy in process memory, the longest the copy in -# the shared store is used, and how long that copy lasts unused. A module's own -# refresh ends its answer sooner. Memory may be 0, the other two must be at -# least 1, and none may be more than a year. +# The loading cache that holds each robots.txt module's answer, named by the +# module that offers it, and how long the copies last, in seconds: the brief +# copy in process memory, the longest the copy in the shared store is used, and +# how long that copy lasts unused. A module's own refresh ends its answer +# sooner. Memory may be 0, the other two must be at least 1, and none may be +# more than a year. Without `module` no copy is kept. # [cache.robots-txt] +# module = "51degrees" # memory_seconds = 60 # store_seconds = 604800 # store_idle_seconds = 2592000 From 6d022e6007d2c3e8c4902d1412d55c7d8889ab2d Mon Sep 17 00:00:00 2001 From: James Rogers Date: Mon, 12 Oct 2026 15:18:10 +0100 Subject: [PATCH 11/31] An unreadable held robots.txt copy is removed and the module asked again A held copy that no longer reads as a contribution is removed from the loading cache and the module is asked again, where it used to answer 503 until the copy ended. The 51Degrees crate drops its unused thread pool, local spawner and Cloudflare request scope, so every load runs in its caller. The guides and changelog say holding is opt-in, the four adapters describe the loading cache field in one sentence, and the lock no longer moves prost onto itertools 0.13. --- CHANGELOG.md | 4 +- Cargo.lock | 9 +- Cargo.toml | 4 +- crates/loading-cache/51degrees/Cargo.toml | 6 +- crates/loading-cache/51degrees/src/layer.rs | 75 ++------- crates/loading-cache/51degrees/src/lib.rs | 154 ++---------------- crates/loading-cache/51degrees/src/tests.rs | 135 +++++---------- crates/trusted-server-adapter-axum/src/app.rs | 5 +- .../src/app.rs | 5 +- .../trusted-server-adapter-fastly/src/app.rs | 6 +- crates/trusted-server-adapter-spin/src/app.rs | 5 +- .../src/integrations/mod.rs | 25 ++- .../src/platform/loading_cache.rs | 25 +-- .../trusted-server-core/src/platform/types.rs | 19 ++- .../src/robots_txt/holding.rs | 141 +++++++++++++--- crates/trusted-server-core/src/settings.rs | 11 +- docs/guide/configuration.md | 4 +- docs/guide/integration-guide.md | 2 +- 18 files changed, 243 insertions(+), 392 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 554caf2f08..cc156dafff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- A robots.txt module's answer is held by the deployment's loading cache, briefly in process memory and in the host's shared store outside it, so a module is asked once per refresh however many instances and requests want its rules, where each instance held its own copy in the key-value store. The last answer is still kept apart for 30 days and served when the module cannot answer, in the value cache on Fastly and in the key-value store elsewhere. +- Holding a robots.txt module's answer is opt-in. With `[cache.robots-txt] module` set, the answer is held by the loading cache that module offers, briefly in process memory and in the host's shared store outside it, so a module is asked once per refresh however many instances and requests want its rules. With it unset nothing is held and every request for the file asks each module, where each instance held its own copy in the key-value store. The last answer is still kept apart for 30 days and served when the module cannot answer, in the value cache on Fastly and in the key-value store elsewhere. - A consent management platform's middleware runs in the phase the publisher chooses. `cmp.inmobi`, `cmp.didomi` and `cmp.sourcepoint` may be named in a `[[fetch]]` or a `[[serve]]` entry, where each ran in the fetch phase alone. A generated configuration still places them in `[[fetch]]`. - **Breaking:** auction telemetry is selected the way every other module is. `[tinybird]` is now `[analytics.tinybird]`, and `[analytics] module = "tinybird"` selects it, which replaces the `enabled` line. A configuration that still carries `[tinybird]` is refused with directions. A stored configuration written by an earlier version carries the table even where telemetry was off, so push the configuration again with this version. The secret reference is `analytics.tinybird.auction_token_secret`, the table refuses a key it does not know, and a section that selects nothing, or a module this build does not have, is refused. - The DataDome module asks its Protection API about a publisher's pages beneath `/admin/` like any other page. It skipped every path beneath `/admin/` and `/_ts/admin/`, which left a publisher's own `/admin/` pages out of bot protection. @@ -81,7 +81,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - A middleware may prepare, and may refuse. `Middleware::prepare` runs in the request's handler before the document is processed, as the middleware's module with the request's services, and what it answers is handed to `create` under the middleware's name, so a decision that needs a store or the network is made there while `create` stays synchronous. A refusal answers the request with its status and `Retry-After`, with no document, and nothing is stored. -- A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A crate offers one through `IntegrationBuilder::with_loading_cache`, each feature that holds answers selects the module whose cache holds them in its own `[cache.]` table, and an adapter takes the selections with `loading_caches_from`. A feature that selects none gets `NoLoadingCache`, which loads every time. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in process memory over the host's value cache or key-value store, and the stock list ships it, so every adapter takes it. A load that may finish apart from its request runs on a pool of threads on Axum and as a task of its own on Cloudflare, where it keeps running after the response only when the Worker's handling of the request is wrapped in the crate's `scope_request`, and every other load runs in the caller. +- A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A crate offers one through `IntegrationBuilder::with_loading_cache`, each feature that holds answers selects the module whose cache holds them in its own `[cache.]` table, and an adapter takes the selections with `loading_caches_from`. A feature that selects none gets `NoLoadingCache`, which loads every time. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in process memory over the host's value cache or key-value store, and the stock list ships it, so every adapter takes it. Every load runs in its caller. - `[cache.robots-txt]` selects, with `module`, the loading cache that holds each robots.txt module's answer, and sets the lifetimes of its copies, in seconds: `memory_seconds` (default 60), `store_seconds` (default 604800) and `store_idle_seconds` (default 2592000). A memory lifetime may be 0, the others must be at least 1, and none may be more than a year. - `ServerKeyPrefix`, the prefix every loading cache key starts with, being `ts.` and the publisher's domain with a leading `www.` dropped, so publishers sharing a store never share an entry. - `PlatformValueCache`, the host's shared cache of small values with lifetimes, which `RuntimeServices` carries where the host supplies one. `lookup_or_reserve` obliges the caller that finds a key missing to fill it where the host can make other callers wait, and `remove` drops an entry. The Fastly adapter supplies one over Fastly's cache, answering the reservation with a transaction lookup and the removal with a purge of the entry's own surrogate key. diff --git a/Cargo.lock b/Cargo.lock index be3ebcbdb1..4242980879 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1962,8 +1962,6 @@ checksum = "312b19eef500787f70fa37b9c498bb2012ae68234559b181c002d3abda3db8d6" dependencies = [ "ahash", "hashlink 0.10.0", - "tokio-util", - "worker", ] [[package]] @@ -4812,7 +4810,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ "heck", - "itertools 0.13.0", + "itertools 0.10.5", "log", "multimap", "once_cell", @@ -4832,7 +4830,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.118", @@ -4845,7 +4843,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.118", @@ -6333,7 +6331,6 @@ dependencies = [ "bytes", "futures-core", "futures-sink", - "futures-util", "pin-project-lite", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 7a4b6f451d..66ce690e29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,9 +98,7 @@ error-stack = "0.6" esi = "0.7.2" fastly = "0.12" fern = "0.7.1" -# The loading cache crate, used as a published package with the pipeline off -# so it builds for every host. Only crates/loading-cache/51degrees depends on -# it. +# The published loading cache package, which builds for every host. fiftyone-caching = { version = "4.5.8-49", default-features = false } flate2 = "1.1" futures = "0.3" diff --git a/crates/loading-cache/51degrees/Cargo.toml b/crates/loading-cache/51degrees/Cargo.toml index 49e6cf455b..5804f1b2a2 100644 --- a/crates/loading-cache/51degrees/Cargo.toml +++ b/crates/loading-cache/51degrees/Cargo.toml @@ -20,11 +20,7 @@ bytes = { workspace = true } edgezero-core = { workspace = true } log = { workspace = true } web-time = { workspace = true } -# The tokio feature adds the pool of threads that runs a load apart from its -# caller on a native host, and the cloudflare feature adds the spawner that -# keeps a load running with a Worker request's wait_until. Each pulls in its -# platform's crate only on the target that platform runs. -fiftyone-caching = { workspace = true, features = ["tokio", "cloudflare"] } +fiftyone-caching = { workspace = true } [dev-dependencies] futures = { workspace = true } diff --git a/crates/loading-cache/51degrees/src/layer.rs b/crates/loading-cache/51degrees/src/layer.rs index c85998df91..f4d532ecab 100644 --- a/crates/loading-cache/51degrees/src/layer.rs +++ b/crates/loading-cache/51degrees/src/layer.rs @@ -14,12 +14,10 @@ use std::time::Duration; use bytes::Bytes; use fiftyone_caching::{ - Clock, Inline, Loaded, LoadingCache, LruLoadingCache, LruStore, SpawnedLocal, ValueLoader, + Clock, Inline, Loaded, LoadingCache, LruLoadingCache, LruStore, ValueLoader, }; use trusted_server_core::platform::{LoadError, StoreKey}; -use crate::{LocalSpawner, Runner}; - /// Loads a value through the shared store, for the caller that leads. pub(crate) type Below = Box BelowFuture + Send>; @@ -93,75 +91,30 @@ impl ValueLoader for FromCall { } } -/// The memory layer's cache, which drops its least recently used entry when -/// full, and whose loader is the shared store layer below it. -type Memory = LruLoadingCache; - -/// One cache's memory layer, with the runner its loads use. -pub(crate) enum Layer { - InCaller(Memory), - #[cfg(not(target_family = "wasm"))] - Pool(Memory>), - Local(Memory>), -} +/// One cache's memory layer, which drops its least recently used entry when +/// full, and whose loader is the shared store layer below it. Every load runs +/// in its caller. +pub(crate) struct Layer(LruLoadingCache); impl Layer { - pub(crate) fn new( - lifetime: Duration, - entries: usize, - runner: Runner, - clock: Arc, - ) -> Self { + pub(crate) fn new(lifetime: Duration, entries: usize, clock: Arc) -> Self { let store = LruStore::builder() .size(entries) .clock(Arc::clone(&clock)) .build(); - let builder = LoadingCache::builder(store, FromCall) - .time_to_live(lifetime) - .clock(clock); - match runner { - Runner::InCaller => Self::InCaller(builder.build()), - #[cfg(not(target_family = "wasm"))] - Runner::Pool(pool, threads) => { - Self::Pool(builder.spawner(PoolSpawner { pool, threads }).build()) - } - Runner::Local(spawner) => Self::Local(builder.local_spawner(spawner).build()), - } + Self( + LoadingCache::builder(store, FromCall) + .time_to_live(lifetime) + .clock(clock) + .build(), + ) } pub(crate) async fn get(&self, call: &Call) -> Result { - match self { - Self::InCaller(cache) => cache.get(call).await, - #[cfg(not(target_family = "wasm"))] - Self::Pool(cache) => cache.get(call).await, - Self::Local(cache) => cache.get(call).await, - } + self.0.get(call).await } pub(crate) async fn remove(&self, call: &Call) { - match self { - Self::InCaller(cache) => cache.remove(call).await, - #[cfg(not(target_family = "wasm"))] - Self::Pool(cache) => cache.remove(call).await, - Self::Local(cache) => cache.remove(call).await, - } - } -} - -/// Runs each load on a pool of threads, made the first time a load needs one, -/// so a process whose loads all run in their callers starts no threads. -#[cfg(not(target_family = "wasm"))] -pub(crate) struct PoolSpawner { - pool: Arc>, - threads: usize, -} - -#[cfg(not(target_family = "wasm"))] -impl fiftyone_caching::Spawn for PoolSpawner { - fn spawn(&self, start: fiftyone_caching::StartLoad) { - let pool = self - .pool - .get_or_init(|| fiftyone_caching::tokio::LocalPool::new(self.threads)); - fiftyone_caching::Spawn::spawn(pool, start); + self.0.remove(call).await; } } diff --git a/crates/loading-cache/51degrees/src/lib.rs b/crates/loading-cache/51degrees/src/lib.rs index a515ce0a4b..88dffca5e7 100644 --- a/crates/loading-cache/51degrees/src/lib.rs +++ b/crates/loading-cache/51degrees/src/lib.rs @@ -1,10 +1,5 @@ //! The loading cache core declares, written with 51Degrees' `fiftyone-caching`. //! -//! [`LoadingCaches`] implements core's -//! [`PlatformLoadingCache`](trusted_server_core::platform::PlatformLoadingCache). -//! Each cache is two layers of the one loading cache `fiftyone-caching` -//! provides, each the loader of the one above it. -//! //! - **A brief copy in process memory.** A least recently used cache with a //! short lifetime, shared by every request the process serves. It loads a //! missing key once however many requests ask for it at the same moment, and @@ -16,20 +11,8 @@ //! This layer is made for each call from that request's stores, because a //! host may hand each request a store of its own. //! -//! The source is the caller's own loader. A failed load is not stored, and -//! every caller waiting on it receives the error. -//! -//! A load may run apart from its caller where the host can run work on its -//! own and the cache allows it, on a pool of threads on a native host and as a -//! task of its own on Cloudflare, kept running with the request's -//! `wait_until` where the request's handling is wrapped in `scope_request`. -//! Fastly and Spin run each load in the caller. -//! -//! A feature selects this cache by naming the module, `51degrees` or in full, -//! in its own `[cache.]` table, and an adapter takes the selections -//! through [`trusted_server_core::integrations::loading_caches_from`] from the -//! builder [`builder`] returns, which the stock list carries, so no adapter -//! names this crate. +//! A cache is selected by naming this module, `51degrees` or in full, in its +//! own `[cache.]` table. mod layer; mod stores; @@ -39,7 +22,7 @@ use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; use bytes::Bytes; -use fiftyone_caching::{Clock, SpawnLocal}; +use fiftyone_caching::Clock; use trusted_server_core::integrations::IntegrationBuilder; use trusted_server_core::platform::{ CacheSpec, LoadError, Loader, PlatformLoadingCache, RuntimeServices, StoreKey, @@ -48,12 +31,7 @@ use trusted_server_core::platform::{ use crate::layer::{Call, Layer}; use crate::stores::WebClock; -/// A load handed to a [`LocalSpawner`]. -pub use fiftyone_caching::LoadTask; - -/// The name of this module, from this crate's folder. No section selects it, -/// because a loading cache is how a deployment caches rather than a module a -/// document selects, and the name says which crate the cache came from. +/// The name of this module, from this crate's folder. pub const MODULE: &str = "loading-cache.51degrees"; /// The builder a deployment hands to an adapter, which offers this loading @@ -85,18 +63,10 @@ pub fn factory() -> Arc { } } -/// Wraps the handling of one Cloudflare Worker request, so it can wait on a -/// load another request started and keep a load it started running after its -/// response. A deployment composing this crate on Cloudflare wraps its fetch -/// handler in this. -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -pub use fiftyone_caching::cloudflare::with_context as scope_request; - /// Every cache a process uses, each a memory layer over the host's shared /// store. See the crate documentation. pub struct LoadingCaches { layers: Mutex>>, - runner: Runner, clock: Arc, } @@ -113,41 +83,6 @@ struct LayerId { name: &'static str, memory: Duration, entries: usize, - detached: bool, -} - -/// Where a load a cache allows to run apart from its caller runs. -#[derive(Clone)] -enum Runner { - /// In the caller, which is the only choice where the host runs nothing on - /// its own. - InCaller, - /// On a pool of threads, each with its own runtime, made the first time a - /// load needs one. - #[cfg(not(target_family = "wasm"))] - Pool( - Arc>, - usize, - ), - /// As a task of its own on the current thread. - Local(LocalSpawner), -} - -/// Starts a load as a task on the current thread. -#[derive(Clone)] -pub struct LocalSpawner(Arc); - -impl LocalSpawner { - /// A spawner that hands each task to `spawn`, which runs it to its end. - pub fn new(spawn: impl Fn(LoadTask) + Send + Sync + 'static) -> Self { - Self(Arc::new(spawn)) - } -} - -impl SpawnLocal for LocalSpawner { - fn spawn_local(&self, task: LoadTask) { - (self.0)(task); - } } impl Default for LoadingCaches { @@ -157,38 +92,13 @@ impl Default for LoadingCaches { } impl LoadingCaches { - /// The caches for this host: a load a cache allows to run apart from its - /// caller does so on a pool of threads on a native host and as a task of - /// its own on Cloudflare, kept running with the request's `wait_until` - /// inside `scope_request`, and every load runs in its caller on Fastly - /// and Spin. + /// The caches for this host. Every load runs in its caller. #[must_use] pub fn new() -> Self { - Self::with_runner(default_runner()) - } - - /// Caches whose every load runs in its caller. - #[must_use] - pub fn in_caller() -> Self { - Self::with_runner(Runner::InCaller) - } - - /// Caches that run a load a cache allows to run apart from its caller as a - /// task started by `spawner` on the current thread. - #[must_use] - pub fn with_local_spawner(spawner: LocalSpawner) -> Self { - Self::with_runner(Runner::Local(spawner)) - } - - /// Caches that run a load a cache allows to run apart from its caller on a - /// pool of `threads` threads. - #[cfg(not(target_family = "wasm"))] - #[must_use] - pub fn with_threads(threads: usize) -> Self { - Self::with_runner(Runner::Pool( - Arc::new(std::sync::OnceLock::new()), - threads.max(1), - )) + Self { + layers: Mutex::new(HashMap::new()), + clock: Arc::new(WebClock), + } } /// Reads the time from `clock` rather than the system clock. @@ -198,64 +108,20 @@ impl LoadingCaches { self } - fn with_runner(runner: Runner) -> Self { - Self { - layers: Mutex::new(HashMap::new()), - runner, - clock: Arc::new(WebClock), - } - } - /// The memory layer for `spec`, made the first time it is asked for. fn layer(&self, spec: &CacheSpec) -> Arc { - let detached = spec.detachable && !matches!(self.runner, Runner::InCaller); let id = LayerId { name: spec.name, memory: spec.lifetimes.memory, entries: spec.memory_entries.max(1), - detached, }; let mut layers = self.layers.lock().unwrap_or_else(PoisonError::into_inner); Arc::clone(layers.entry(id).or_insert_with(|| { - Arc::new(Layer::new( - id.memory, - id.entries, - if detached { - self.runner.clone() - } else { - Runner::InCaller - }, - Arc::clone(&self.clock), - )) + Arc::new(Layer::new(id.memory, id.entries, Arc::clone(&self.clock))) })) } } -/// The runner a host gets by default. -fn default_runner() -> Runner { - #[cfg(not(target_family = "wasm"))] - { - let threads = std::thread::available_parallelism() - .map(std::num::NonZeroUsize::get) - .unwrap_or(2) - .clamp(1, 4); - Runner::Pool(Arc::new(std::sync::OnceLock::new()), threads) - } - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] - { - Runner::Local(LocalSpawner::new(|task| { - fiftyone_caching::cloudflare::WaitUntil.spawn_local(task); - })) - } - #[cfg(all( - target_family = "wasm", - not(all(target_arch = "wasm32", target_os = "unknown")) - ))] - { - Runner::InCaller - } -} - #[async_trait::async_trait(?Send)] impl PlatformLoadingCache for LoadingCaches { async fn get_or_load( diff --git a/crates/loading-cache/51degrees/src/tests.rs b/crates/loading-cache/51degrees/src/tests.rs index 5296b8b68a..918311e8a0 100644 --- a/crates/loading-cache/51degrees/src/tests.rs +++ b/crates/loading-cache/51degrees/src/tests.rs @@ -183,7 +183,7 @@ fn services(kv: Arc) -> RuntimeServices { services_builder(kv).build() } -fn spec(detachable: bool) -> CacheSpec { +fn spec() -> CacheSpec { CacheSpec { name: "test", lifetimes: CacheLifetimes { @@ -192,7 +192,7 @@ fn spec(detachable: bool) -> CacheSpec { store_idle: Duration::from_secs(60), }, memory_entries: 16, - detachable, + detachable: false, } } @@ -280,33 +280,13 @@ fn the_builder_offers_a_cache_that_keeps_values() { ); } -#[test] -fn many_callers_on_many_threads_load_a_missing_key_once_on_the_pool() { - let runs = Arc::new(AtomicUsize::new(0)); - let caches = Arc::new(LoadingCaches::with_threads(2)); - let services = services(Arc::new(MemoryKv::default())); - - let answers = - ask_from_many_threads(&caches, &services, &spec(true), || counted(&runs, "value")); - - assert_eq!( - runs.load(Ordering::SeqCst), - 1, - "the source should be asked once" - ); - for answer in answers { - assert_eq!(answer.expect("every caller should get the value"), "value"); - } -} - #[test] fn many_callers_on_many_threads_load_a_missing_key_once_in_the_caller() { let runs = Arc::new(AtomicUsize::new(0)); - let caches = Arc::new(LoadingCaches::in_caller()); + let caches = Arc::new(LoadingCaches::new()); let services = services(Arc::new(MemoryKv::default())); - let answers = - ask_from_many_threads(&caches, &services, &spec(false), || counted(&runs, "value")); + let answers = ask_from_many_threads(&caches, &services, &spec(), || counted(&runs, "value")); assert_eq!( runs.load(Ordering::SeqCst), @@ -318,55 +298,14 @@ fn many_callers_on_many_threads_load_a_missing_key_once_in_the_caller() { } } -#[tokio::test(flavor = "current_thread")] -async fn many_callers_on_one_thread_load_a_missing_key_once_as_a_task() { - // One thread serving many requests in turn, as a Cloudflare isolate does, - // with each load a task of its own on that thread. - let local = tokio::task::LocalSet::new(); - local - .run_until(async { - let runs = Arc::new(AtomicUsize::new(0)); - let spawned = Arc::new(AtomicUsize::new(0)); - let counted_spawns = Arc::clone(&spawned); - let caches = LoadingCaches::with_local_spawner(LocalSpawner::new(move |task| { - counted_spawns.fetch_add(1, Ordering::SeqCst); - tokio::task::spawn_local(task); - })); - let services = services(Arc::new(MemoryKv::default())); - let spec = spec(true); - let key = key("k"); - - let answers = futures::future::join_all( - (0..CALLERS) - .map(|_| caches.get_or_load(&services, &spec, &key, counted(&runs, "value"))), - ) - .await; - - assert_eq!( - runs.load(Ordering::SeqCst), - 1, - "the source should be asked once" - ); - assert_eq!( - spawned.load(Ordering::SeqCst), - 1, - "the load should be a task of its own" - ); - for answer in answers { - assert_eq!(answer.expect("every caller should get the value"), "value"); - } - }) - .await; -} - #[test] fn a_failed_load_reaches_every_waiter_and_is_not_kept() { let runs = Arc::new(AtomicUsize::new(0)); - let caches = Arc::new(LoadingCaches::with_threads(2)); + let caches = Arc::new(LoadingCaches::new()); let kv = Arc::new(MemoryKv::default()); let services = services(Arc::clone(&kv)); - let answers = ask_from_many_threads(&caches, &services, &spec(true), || { + let answers = ask_from_many_threads(&caches, &services, &spec(), || { let runs = Arc::clone(&runs); Box::new(move || { Box::pin(async move { @@ -397,7 +336,7 @@ fn a_failed_load_reaches_every_waiter_and_is_not_kept() { .build() .expect("should build a runtime"); let next = runtime - .block_on(caches.get_or_load(&services, &spec(true), &key("k"), counted(&runs, "value"))) + .block_on(caches.get_or_load(&services, &spec(), &key("k"), counted(&runs, "value"))) .expect("the next caller should load again"); assert_eq!(next, "value"); assert_eq!( @@ -413,12 +352,12 @@ async fn the_store_answers_another_process_without_a_load() { let services = services(Arc::clone(&kv)); let runs = Arc::new(AtomicUsize::new(0)); - let first = LoadingCaches::in_caller() - .get_or_load(&services, &spec(false), &key("k"), counted(&runs, "value")) + let first = LoadingCaches::new() + .get_or_load(&services, &spec(), &key("k"), counted(&runs, "value")) .await .expect("the first process should load"); - let second = LoadingCaches::in_caller() - .get_or_load(&services, &spec(false), &key("k"), never()) + let second = LoadingCaches::new() + .get_or_load(&services, &spec(), &key("k"), never()) .await .expect("the second process should read the store"); @@ -443,12 +382,12 @@ async fn a_store_that_refuses_lifetimes_still_holds_the_entry() { let services = services(Arc::clone(&kv)); let runs = Arc::new(AtomicUsize::new(0)); - LoadingCaches::in_caller() - .get_or_load(&services, &spec(false), &key("k"), counted(&runs, "value")) + LoadingCaches::new() + .get_or_load(&services, &spec(), &key("k"), counted(&runs, "value")) .await .expect("the first process should load"); - let second = LoadingCaches::in_caller() - .get_or_load(&services, &spec(false), &key("k"), never()) + let second = LoadingCaches::new() + .get_or_load(&services, &spec(), &key("k"), never()) .await .expect("the second process should read the store"); @@ -460,14 +399,14 @@ async fn a_store_that_refuses_lifetimes_still_holds_the_entry() { async fn two_publishers_never_share_an_entry() { let kv = Arc::new(MemoryKv::default()); let services = services(Arc::clone(&kv)); - let caches = LoadingCaches::in_caller(); + let caches = LoadingCaches::new(); let runs = Arc::new(AtomicUsize::new(0)); let of = |domain: &str| StoreKey::new(&ServerKeyPrefix::for_domain(domain), "test", "k"); let one = caches .get_or_load( &services, - &spec(false), + &spec(), &of("one.example"), counted(&runs, "one"), ) @@ -476,7 +415,7 @@ async fn two_publishers_never_share_an_entry() { let other = caches .get_or_load( &services, - &spec(false), + &spec(), &of("other.example"), counted(&runs, "other"), ) @@ -508,23 +447,23 @@ async fn the_memory_copy_is_brief_and_the_store_is_read_after_it() { let kv = Arc::new(MemoryKv::default()); let services = services(Arc::clone(&kv)); let (now, clock) = manual_clock(); - let caches = LoadingCaches::in_caller().with_clock(clock); + let caches = LoadingCaches::new().with_clock(clock); let runs = Arc::new(AtomicUsize::new(0)); caches - .get_or_load(&services, &spec(false), &key("k"), counted(&runs, "value")) + .get_or_load(&services, &spec(), &key("k"), counted(&runs, "value")) .await .expect("should load"); let reads = kv.reads(); caches - .get_or_load(&services, &spec(false), &key("k"), never()) + .get_or_load(&services, &spec(), &key("k"), never()) .await .expect("the memory copy should answer"); assert_eq!(kv.reads(), reads, "the memory copy should answer first"); advance(&now, Duration::from_secs(6)); caches - .get_or_load(&services, &spec(false), &key("k"), never()) + .get_or_load(&services, &spec(), &key("k"), never()) .await .expect("the store should answer once the memory copy has gone"); assert_eq!(kv.reads(), reads + 1, "the store should then be read"); @@ -536,9 +475,9 @@ async fn an_entry_left_unused_for_its_idle_time_is_loaded_again() { let kv = Arc::new(MemoryKv::default()); let services = services(Arc::clone(&kv)); let (now, clock) = manual_clock(); - let caches = LoadingCaches::in_caller().with_clock(clock); + let caches = LoadingCaches::new().with_clock(clock); let runs = Arc::new(AtomicUsize::new(0)); - let mut idle = spec(false); + let mut idle = spec(); idle.lifetimes.store = Duration::from_secs(3600); idle.lifetimes.store_idle = Duration::from_secs(10); @@ -564,7 +503,7 @@ async fn a_value_with_a_lifetime_of_its_own_ends_by_then() { let kv = Arc::new(MemoryKv::default()); let services = services(Arc::clone(&kv)); let (now, clock) = manual_clock(); - let caches = LoadingCaches::in_caller().with_clock(clock); + let caches = LoadingCaches::new().with_clock(clock); let runs = Arc::new(AtomicUsize::new(0)); let short = |runs: &Arc| -> Loader { @@ -579,12 +518,12 @@ async fn a_value_with_a_lifetime_of_its_own_ends_by_then() { }; caches - .get_or_load(&services, &spec(false), &key("k"), short(&runs)) + .get_or_load(&services, &spec(), &key("k"), short(&runs)) .await .expect("should load"); advance(&now, Duration::from_secs(3)); caches - .get_or_load(&services, &spec(false), &key("k"), short(&runs)) + .get_or_load(&services, &spec(), &key("k"), short(&runs)) .await .expect("should load again"); @@ -603,10 +542,10 @@ async fn a_reserving_value_cache_is_filled_by_the_load_and_released_by_a_failure .build(); let runs = Arc::new(AtomicUsize::new(0)); - let failed = LoadingCaches::in_caller() + let failed = LoadingCaches::new() .get_or_load( &services, - &spec(false), + &spec(), &key("k"), Box::new(|| Box::pin(async { Err(LoadError::new("down")) })), ) @@ -619,8 +558,8 @@ async fn a_reserving_value_cache_is_filled_by_the_load_and_released_by_a_failure ); assert_eq!(cache.filled.load(Ordering::SeqCst), 0); - let value = LoadingCaches::in_caller() - .get_or_load(&services, &spec(false), &key("k"), counted(&runs, "value")) + let value = LoadingCaches::new() + .get_or_load(&services, &spec(), &key("k"), counted(&runs, "value")) .await .expect("should load"); assert_eq!(value, "value"); @@ -630,8 +569,8 @@ async fn a_reserving_value_cache_is_filled_by_the_load_and_released_by_a_failure "the load should fill the reservation" ); - let again = LoadingCaches::in_caller() - .get_or_load(&services, &spec(false), &key("k"), never()) + let again = LoadingCaches::new() + .get_or_load(&services, &spec(), &key("k"), never()) .await .expect("another process should read the filled entry"); assert_eq!(again, "value"); @@ -641,16 +580,16 @@ async fn a_reserving_value_cache_is_filled_by_the_load_and_released_by_a_failure async fn remove_drops_the_memory_copy_and_the_stored_one() { let kv = Arc::new(MemoryKv::default()); let services = services(Arc::clone(&kv)); - let caches = LoadingCaches::in_caller(); + let caches = LoadingCaches::new(); let runs = Arc::new(AtomicUsize::new(0)); caches - .get_or_load(&services, &spec(false), &key("k"), counted(&runs, "old")) + .get_or_load(&services, &spec(), &key("k"), counted(&runs, "old")) .await .expect("should load"); - caches.remove(&services, &spec(false), &key("k")).await; + caches.remove(&services, &spec(), &key("k")).await; let fresh = caches - .get_or_load(&services, &spec(false), &key("k"), counted(&runs, "new")) + .get_or_load(&services, &spec(), &key("k"), counted(&runs, "new")) .await .expect("should load again"); diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index aae5c28e79..ee3e28f60f 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -72,8 +72,9 @@ pub struct AppState { /// Services a caller supplied for every request, rather than services built /// from the request context. `None` in a deployment. services: Option, - /// The loading cache the linked builders offer, taken once here so every - /// request in the process shares its memory copy and waits for one load. + /// The loading caches the `[cache.]` tables select from the linked + /// builders, taken when this state is built and handed to every request's + /// services. loading_caches: Arc, } diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index ceb6026f28..fc2b911236 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -102,8 +102,9 @@ pub struct AppState { /// Services a caller supplied for every request, rather than services built /// from the request context. `None` in a deployment. services: Option, - /// The loading cache the builders this state was built with offer, or one - /// that holds nothing when none does, handed to every request's services. + /// The loading caches the `[cache.]` tables select from the linked + /// builders, taken when this state is built and handed to every request's + /// services. loading_caches: Arc, } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 9264fb8374..be357c1a02 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -210,9 +210,9 @@ pub(crate) struct AppState { /// than the first request, and handed to every request's services. pub(crate) permission_signal_modules: Arc<[Arc]>, - /// The loading cache the linked builders offer, taken once here and handed - /// to every request's services, so lookups in one request share its - /// memory copy. + /// The loading caches the `[cache.]` tables select from the linked + /// builders, taken when this state is built and handed to every request's + /// services. pub(crate) loading_caches: Arc, } diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 445d580748..cb73b6b2b9 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -91,8 +91,9 @@ pub struct AppState { /// Services a caller supplied for every request, rather than services built /// from the request context. `None` in a deployment. services: Option, - /// The loading cache the builders this state was built with offer, or one - /// that holds nothing when none does, handed to every request's services. + /// The loading caches the `[cache.]` tables select from the linked + /// builders, taken when this state is built and handed to every request's + /// services. loading_caches: Arc, } diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 94a333417e..b9228ace05 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -629,10 +629,10 @@ impl IntegrationBuilder { /// Offers the loading cache this crate supplies, see /// [`crate::platform::PlatformLoadingCache`]. /// - /// The offer is not a selection. A feature that holds answers names the - /// module in its own `[cache.]` table, and an adapter resolves every - /// feature's selection with [`loading_caches_from`] when it builds its - /// state. A feature that selects none asks each source itself. + /// The offer is not a selection. A cache is selected by naming the module + /// in its own `[cache.]` table, and an adapter resolves every table's + /// selection with [`loading_caches_from`] when it builds its state. A cache + /// whose table selects none asks each source itself. #[must_use] pub const fn with_loading_cache(mut self, factory: LoadingCacheFactory) -> Self { self.loading_cache = Some(factory); @@ -785,15 +785,14 @@ pub(crate) fn all_builders( builders().iter().copied().chain(extra.iter().copied()) } -/// The loading cache each feature selects, from the caches the builders a -/// deployment links offer. A feature whose `[cache.]` names no module -/// keeps no copy, and a module's cache is made once however many features -/// select it. +/// 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. /// /// # Errors /// -/// When a feature names a module no linked builder offers a cache from, -/// naming the feature and the modules that do. +/// When a table names a module no linked builder offers a cache from, naming +/// the table and the modules that do. pub fn loading_caches_from( settings: &Settings, builders: &[IntegrationBuilder], @@ -808,7 +807,7 @@ pub fn loading_caches_from( .collect(); let mut made: Vec<(&'static str, Arc)> = Vec::new(); let mut caches = crate::platform::LoadingCaches::default(); - for (feature, config) in settings.cache.loading_caches() { + for (table, config) in settings.cache.loading_caches() { let Some(written) = config.module.as_deref() else { continue; }; @@ -817,7 +816,7 @@ pub fn loading_caches_from( }) else { return Err(Report::new(TrustedServerError::Configuration { message: format!( - "[cache.{feature}] module names `{written}`, which no linked crate offers \ + "[cache.{table}] module names `{written}`, which no linked crate offers \ a loading cache from. The loading cache modules this build has are: {}", offered_cache_names(&offered) ), @@ -831,7 +830,7 @@ pub fn loading_caches_from( cache } }; - caches.insert(feature, cache); + caches.insert(table, cache); } Ok(Arc::new(caches)) } diff --git a/crates/trusted-server-core/src/platform/loading_cache.rs b/crates/trusted-server-core/src/platform/loading_cache.rs index 372a14b2b7..5ed73a3477 100644 --- a/crates/trusted-server-core/src/platform/loading_cache.rs +++ b/crates/trusted-server-core/src/platform/loading_cache.rs @@ -237,18 +237,19 @@ impl PlatformLoadingCache for NoLoadingCache { } } -/// The loading cache each feature selected, by the name its [`CacheSpec`] -/// carries. A feature that selected none loads through [`NoLoadingCache`]. +/// The loading cache each `[cache.]` table selected, by the name a +/// [`CacheSpec`] carries. A cache whose table selected none loads through +/// [`NoLoadingCache`]. #[derive(Default)] pub struct LoadingCaches { by_feature: HashMap<&'static str, Arc>, - /// What a feature that selected nothing loads through, when a test gives - /// every feature one cache. + /// What a cache whose table selected nothing loads through, when a test + /// gives every caller one cache. unselected: Option>, } impl LoadingCaches { - /// Every feature loading through `cache`, for a test of one cache. + /// Every caller loading through `cache`, for a test of one cache. #[must_use] pub fn every(cache: Arc) -> Self { Self { @@ -257,21 +258,21 @@ impl LoadingCaches { } } - /// Keeps `cache` as the one `feature` selected. - pub fn insert(&mut self, feature: &'static str, cache: Arc) { - self.by_feature.insert(feature, cache); + /// Keeps `cache` as the one the table `name` selected. + pub fn insert(&mut self, name: &'static str, cache: Arc) { + self.by_feature.insert(name, cache); } - /// The cache `feature` loads through. + /// The cache named `name` loads through. #[must_use] - pub fn for_feature(&self, feature: &str) -> &dyn PlatformLoadingCache { - match self.by_feature.get(feature).or(self.unselected.as_ref()) { + pub fn for_feature(&self, name: &str) -> &dyn PlatformLoadingCache { + match self.by_feature.get(name).or(self.unselected.as_ref()) { Some(cache) => &**cache, None => &NoLoadingCache, } } - /// Whether no feature selected a cache. + /// Whether no table selected a cache. #[must_use] pub fn is_empty(&self) -> bool { self.by_feature.is_empty() && self.unselected.is_none() diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index e9a6500953..6eb9386f80 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -177,10 +177,11 @@ pub struct RuntimeServices { /// none, in which case a caller holds what it pays for in the key-value /// store instead. pub(crate) value_cache: Option>, - /// The loading cache each feature selected, which loads a missing value - /// once per key and keeps its copies in [`value_cache`](Self::value_cache) - /// or the key-value store. A feature that selected none loads through - /// [`super::NoLoadingCache`], which holds nothing. + /// The loading cache each `[cache.]` table selected, which loads a + /// missing value once per key and keeps its copies in + /// [`value_cache`](Self::value_cache) or the key-value store. A cache whose + /// table selected none loads through [`super::NoLoadingCache`], which holds + /// nothing. pub(crate) loading_caches: Arc, /// Shared transformed-template cache. Defaults to /// [`UnavailableTemplateCache`], so adapters without one degrade to transforming @@ -272,11 +273,11 @@ impl RuntimeServices { self.value_cache.as_deref() } - /// The loading cache `feature` selected, by the name its + /// The loading cache the table `[cache.]` selected, by the name a /// [`super::CacheSpec`] carries. #[must_use] - pub fn loading_cache_for(&self, feature: &str) -> &dyn super::PlatformLoadingCache { - self.loading_caches.for_feature(feature) + pub fn loading_cache_for(&self, name: &str) -> &dyn super::PlatformLoadingCache { + self.loading_caches.for_feature(name) } /// The value for `key` in the cache `spec` describes, from a fresh copy @@ -482,7 +483,7 @@ impl RuntimeServices { } } - /// Returns a clone of this instance with every feature loading through + /// Returns a clone of this instance with every caller loading through /// `cache`, for a test of one cache. #[cfg(any(test, feature = "test-utils"))] #[must_use] @@ -586,7 +587,7 @@ impl RuntimeServicesBuilder { self } - /// Set the loading cache each feature selected. + /// Set the loading cache each `[cache.]` table selected. #[must_use] pub fn loading_caches(mut self, caches: Arc) -> Self { self.loading_caches = Some(caches); diff --git a/crates/trusted-server-core/src/robots_txt/holding.rs b/crates/trusted-server-core/src/robots_txt/holding.rs index 7d1088e4fd..136b162adf 100644 --- a/crates/trusted-server-core/src/robots_txt/holding.rs +++ b/crates/trusted-server-core/src/robots_txt/holding.rs @@ -81,34 +81,79 @@ pub(crate) async fn contribution_for( let held = held_key(id, &contributor.fingerprint()); let key = StoreKey::new(&prefix, CACHE, held.clone()); let kept = StoreKey::new(&prefix, KEPT, held); - let ask = Ask { - id, - contributor: Arc::clone(contributor), - request: request.clone(), - services: services.clone(), + let spec = cache_spec(settings); + let ask = || { + load( + Ask { + id, + contributor: Arc::clone(contributor), + request: request.clone(), + services: services.clone(), + }, + kept.clone(), + ) + }; + let loaded = match held_answer(services, &spec, &key, ask()).await { + Err(Held::Unreadable(error)) => { + // A copy that no longer reads would answer every request until + // it ends, so it is removed and the contributor asked again. + log::warn!( + "Asking `{id}` again for robots.txt rules, the held copy is unreadable: {error}" + ); + services + .loading_cache_for(CACHE) + .remove(services, &spec, &key) + .await; + held_answer(services, &spec, &key, ask()).await + } + loaded => loaded, }; - let loaded = services - .load_cached(&cache_spec(settings), &key, load(ask, kept.clone())) - .await - .and_then(|bytes| { - serde_json::from_slice::(&bytes) - .map_err(|error| LoadError::new(format!("the held answer is unreadable: {error}"))) - }); match loaded { Ok(contribution) => Ok(contribution), - Err(error) => match kept_answer(services, &kept).await { - Some(contribution) => { - log::warn!("Serving the held robots.txt rules of `{id}` past their age: {error}"); - Ok(contribution) + Err(Held::Failed(error) | Held::Unreadable(error)) => { + match kept_answer(services, &kept).await { + Some(contribution) => { + log::warn!( + "Serving the kept robots.txt rules of `{id}` past their age: {error}" + ); + Ok(contribution) + } + None => Err(Report::new(TrustedServerError::Integration { + integration: id.to_owned(), + message: format!("the robots.txt contributor did not answer: {error}"), + })), } - None => Err(Report::new(TrustedServerError::Integration { - integration: id.to_owned(), - message: format!("the robots.txt contributor did not answer: {error}"), - })), - }, + } } } +/// Why no held or fresh answer was read. +enum Held { + /// The load failed. + Failed(LoadError), + /// The cache answered with a copy that does not read as a contribution. + Unreadable(LoadError), +} + +/// The answer the cache holds for `key`, loaded with `load` when it holds +/// none. +async fn held_answer( + services: &RuntimeServices, + spec: &CacheSpec, + key: &StoreKey, + load: Loader, +) -> Result { + let bytes = services + .load_cached(spec, key, load) + .await + .map_err(Held::Failed)?; + serde_json::from_slice::(&bytes).map_err(|error| { + Held::Unreadable(LoadError::new(format!( + "the held answer is unreadable: {error}" + ))) + }) +} + /// What a load owns to ask one contributor, because it may outlive the /// request that started it. struct Ask { @@ -714,6 +759,60 @@ pub(crate) mod tests { assert_eq!(scripted.times_asked(), 2); } + /// A held copy that no longer reads is removed and the contributor asked + /// again, rather than every request failing until the copy ends. + #[tokio::test] + async fn an_unreadable_held_copy_is_removed_and_the_contributor_asked_again() { + let scripted = Arc::new(Scripted::new(vec![Ok(rules( + "User-Agent: FreshBot\nDisallow: /", + ))])); + let cache = Arc::new(MemoryLoadingCache::default()); + let services = services_with(Arc::clone(&cache), Arc::default()); + let settings = settings(); + let key = StoreKey::new( + &ServerKeyPrefix::for_settings(&settings), + CACHE, + held_key("a", &scripted.fingerprint()), + ); + services + .load_cached( + &cache_spec(&settings), + &key, + Box::new(|| { + Box::pin(async { + Ok(LoadedValue::new(bytes::Bytes::from_static( + b"not a contribution", + ))) + }) + }), + ) + .await + .expect("should hold the unreadable copy"); + + let answer = contribution_for( + "a", + &contributor(&scripted), + &request(), + &services, + &settings, + ) + .await + .expect("should ask the contributor again"); + let again = contribution_for( + "a", + &contributor(&scripted), + &request(), + &services, + &settings, + ) + .await + .expect("should serve the fresh copy"); + + assert_eq!(answer, rules("User-Agent: FreshBot\nDisallow: /")); + assert_eq!(again, answer); + assert_eq!(scripted.times_asked(), 1, "the fresh answer should be held"); + } + /// A value cache held in memory for the life of one test. #[derive(Debug, Default)] struct MemoryValueCache { diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index a674ad19c6..c09e439a6d 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -2892,10 +2892,9 @@ pub const ROBOTS_TXT_CACHE: &str = "robots-txt"; #[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct LoadingCacheConfig { - /// The module whose loading cache holds this feature's copies, as its - /// short name or in full, for example `51degrees` or - /// `loading-cache.51degrees`. Absent, the feature keeps no copy and loads - /// on every call. + /// 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. #[serde(default, skip_serializing_if = "Option::is_none")] pub module: Option, /// How long the brief copy in process memory is used, in seconds. Zero @@ -2968,8 +2967,8 @@ fn check_loading_cache_seconds( } impl CacheSettings { - /// Each feature whose copies a loading cache holds, by the name of its - /// cache, with the settings its `[cache.]` table carries. + /// Each cache a loading cache may hold, by its name, with the settings + /// its `[cache.]` table carries. #[must_use] pub fn loading_caches(&self) -> [(&'static str, &LoadingCacheConfig); 1] { [(ROBOTS_TXT_CACHE, &self.robots_txt)] diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 810980fbc3..8ab9832d9d 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1057,9 +1057,9 @@ A module with settings of its own reads them from its own block, `[robots-txt. Date: Tue, 13 Oct 2026 01:27:55 +0100 Subject: [PATCH 12/31] A cache table naming no module takes the one loading cache module the build links --- CHANGELOG.md | 4 +-- .../src/integrations/mod.rs | 32 +++++++++++++++++-- docs/guide/configuration.md | 10 +++--- trusted-server.example.toml | 3 +- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc156dafff..a2af1ad828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Holding a robots.txt module's answer is opt-in. With `[cache.robots-txt] module` set, the answer is held by the loading cache that module offers, briefly in process memory and in the host's shared store outside it, so a module is asked once per refresh however many instances and requests want its rules. With it unset nothing is held and every request for the file asks each module, where each instance held its own copy in the key-value store. The last answer is still kept apart for 30 days and served when the module cannot answer, in the value cache on Fastly and in the key-value store elsewhere. +- A robots.txt module's answer is held by the loading cache of the one loading cache module the build links, or of the module `[cache.robots-txt] module` names, briefly in process memory and in the host's shared store outside it, so a module is asked once per refresh however many instances and requests want its rules. With no loading cache module linked, or several and none named, nothing is held and every request for the file asks each module, where each instance held its own copy in the key-value store. The last answer is still kept apart for 30 days and served when the module cannot answer, in the value cache on Fastly and in the key-value store elsewhere. - A consent management platform's middleware runs in the phase the publisher chooses. `cmp.inmobi`, `cmp.didomi` and `cmp.sourcepoint` may be named in a `[[fetch]]` or a `[[serve]]` entry, where each ran in the fetch phase alone. A generated configuration still places them in `[[fetch]]`. - **Breaking:** auction telemetry is selected the way every other module is. `[tinybird]` is now `[analytics.tinybird]`, and `[analytics] module = "tinybird"` selects it, which replaces the `enabled` line. A configuration that still carries `[tinybird]` is refused with directions. A stored configuration written by an earlier version carries the table even where telemetry was off, so push the configuration again with this version. The secret reference is `analytics.tinybird.auction_token_secret`, the table refuses a key it does not know, and a section that selects nothing, or a module this build does not have, is refused. - The DataDome module asks its Protection API about a publisher's pages beneath `/admin/` like any other page. It skipped every path beneath `/admin/` and `/_ts/admin/`, which left a publisher's own `/admin/` pages out of bot protection. @@ -81,7 +81,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - A middleware may prepare, and may refuse. `Middleware::prepare` runs in the request's handler before the document is processed, as the middleware's module with the request's services, and what it answers is handed to `create` under the middleware's name, so a decision that needs a store or the network is made there while `create` stays synchronous. A refusal answers the request with its status and `Retry-After`, with no document, and nothing is stored. -- A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A crate offers one through `IntegrationBuilder::with_loading_cache`, each feature that holds answers selects the module whose cache holds them in its own `[cache.]` table, and an adapter takes the selections with `loading_caches_from`. A feature that selects none gets `NoLoadingCache`, which loads every time. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in process memory over the host's value cache or key-value store, and the stock list ships it, so every adapter takes it. Every load runs in its caller. +- A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A crate offers one through `IntegrationBuilder::with_loading_cache`, each feature that holds answers selects the module whose cache holds them in its own `[cache.]` table, and an adapter takes the selections with `loading_caches_from`. A feature that selects none takes the one loading cache module the build links, and gets `NoLoadingCache`, which loads every time, when the build links none or several. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in process memory over the host's value cache or key-value store, and the stock list ships it, so every adapter takes it. Every load runs in its caller. - `[cache.robots-txt]` selects, with `module`, the loading cache that holds each robots.txt module's answer, and sets the lifetimes of its copies, in seconds: `memory_seconds` (default 60), `store_seconds` (default 604800) and `store_idle_seconds` (default 2592000). A memory lifetime may be 0, the others must be at least 1, and none may be more than a year. - `ServerKeyPrefix`, the prefix every loading cache key starts with, being `ts.` and the publisher's domain with a leading `www.` dropped, so publishers sharing a store never share an entry. - `PlatformValueCache`, the host's shared cache of small values with lifetimes, which `RuntimeServices` carries where the host supplies one. `lookup_or_reserve` obliges the caller that finds a key missing to fill it where the host can make other callers wait, and `remove` drops an entry. The Fastly adapter supplies one over Fastly's cache, answering the reservation with a transaction lookup and the removal with a purge of the entry's own surrogate key. diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index b9228ace05..f35e4f479f 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -808,8 +808,13 @@ pub fn loading_caches_from( let mut made: Vec<(&'static str, Arc)> = Vec::new(); let mut caches = crate::platform::LoadingCaches::default(); for (table, config) in settings.cache.loading_caches() { - let Some(written) = config.module.as_deref() else { - 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. + let written = match config.module.as_deref() { + Some(written) => written, + None if offered.len() == 1 => offered[0].0, + None => continue, }; let Some((name, factory)) = offered.iter().find(|(name, _)| { *name == written || crate::module_name::short_form(LOADING_CACHE_TYPE, name) == written @@ -923,13 +928,34 @@ mod loading_caches_from_tests { } #[test] - fn a_feature_selecting_nothing_keeps_nothing_whatever_is_offered() { + fn a_feature_selecting_nothing_takes_the_one_module_offered() { let caches = loading_caches_from( &settings_selecting(None), &[builder("first"), offering("loading-cache.one")], ) .expect("no selection is not an error"); + assert!(caches.for_feature(ROBOTS_TXT_CACHE).keeps_values()); + assert!(!caches.is_empty()); + } + + #[test] + fn a_feature_selecting_nothing_keeps_nothing_when_two_are_offered() { + let caches = loading_caches_from( + &settings_selecting(None), + &[offering("loading-cache.one"), offering("loading-cache.two")], + ) + .expect("no selection is not an error"); + + assert!(!caches.for_feature(ROBOTS_TXT_CACHE).keeps_values()); + assert!(caches.is_empty()); + } + + #[test] + fn a_feature_selecting_nothing_keeps_nothing_when_none_is_offered() { + let caches = loading_caches_from(&settings_selecting(None), &[builder("first")]) + .expect("no selection is not an error"); + assert!(!caches.for_feature(ROBOTS_TXT_CACHE).keeps_values()); assert!(caches.is_empty()); } diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 229092f712..3ad5c8bcce 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1057,9 +1057,9 @@ A module with settings of its own reads them from its own block, `[robots-txt.]` table a module that offers a loading cache, with the module's short name or its name in full. A stock build links `loading-cache.51degrees`, from `crates/loading-cache/51degrees`, so -`module = "51degrees"` selects it. A feature that names no module keeps no -copy and makes each answer every time it is asked for, and a name that no +`module = "51degrees"` selects it. A feature that names no module takes the +one loading cache module the build links, so a stock build holds answers +without a line in the document, and keeps no copy when the build links none +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. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 4e5347f4e7..ed7c3eac41 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -426,7 +426,8 @@ pull_sync_concurrency = 3 # copy in process memory, the longest the copy in the shared store is used, and # how long that copy lasts unused. A module's own refresh ends its answer # sooner. Memory may be 0, the other two must be at least 1, and none may be -# more than a year. Without `module` no copy is kept. +# more than a year. Without `module` the one loading cache module the build +# links is used, and with none or several linked no copy is kept. # [cache.robots-txt] # module = "51degrees" # memory_seconds = 60 From a1931fd7e7e69bef0848db72b42e05b09d173821 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 10:09:31 +0100 Subject: [PATCH 13/31] A shared load is given nothing of the reader whose request started it RuntimeServices::load_cached handed the loading cache the request's own services, and the robots.txt holding handed each contributor the same, so the client address, connection details and host signals of whichever reader asked first reached a load whose answer every reader is served. RuntimeServices::without_reader clears those and keeps the stores, and both paths now take it. --- CHANGELOG.md | 2 +- .../src/platform/loading_cache.rs | 75 +++++++++++++-- .../trusted-server-core/src/platform/types.rs | 22 ++++- .../src/robots_txt/contributor.rs | 9 +- .../src/robots_txt/holding.rs | 95 ++++++++++++++++++- docs/guide/integration-guide.md | 4 +- 6 files changed, 192 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0a82f0647..8f9f928cb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,7 +85,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - A middleware may prepare, and may refuse. `Middleware::prepare` runs in the request's handler before the document is processed, as the middleware's module with the request's services, and what it answers is handed to `create` under the middleware's name, so a decision that needs a store or the network is made there while `create` stays synchronous. A refusal answers the request with its status and `Retry-After`, with no document, and nothing is stored. -- A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A crate offers one through `IntegrationBuilder::with_loading_cache`, each feature that holds answers selects the module whose cache holds them in its own `[cache.]` table, and an adapter takes the selections with `loading_caches_from`. A feature that selects none takes the one loading cache module the build links, and gets `NoLoadingCache`, which loads every time, when the build links none or several. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in process memory over the host's value cache or key-value store, and the stock list ships it, so every adapter takes it. Every load runs in its caller. +- A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A load is handed services with nothing of the reader in them, from `RuntimeServices::without_reader`, because its value serves every reader. A crate offers one through `IntegrationBuilder::with_loading_cache`, each feature that holds answers selects the module whose cache holds them in its own `[cache.]` table, and an adapter takes the selections with `loading_caches_from`. A feature that selects none takes the one loading cache module the build links, and gets `NoLoadingCache`, which loads every time, when the build links none or several. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in process memory over the host's value cache or key-value store, and the stock list ships it, so every adapter takes it. Every load runs in its caller. - `[cache.robots-txt]` selects, with `module`, the loading cache that holds each robots.txt module's answer, and sets the lifetimes of its copies, in seconds: `memory_seconds` (default 60), `store_seconds` (default 604800) and `store_idle_seconds` (default 2592000). A memory lifetime may be 0, the others must be at least 1, and none may be more than a year. - `ServerKeyPrefix`, the prefix every loading cache key starts with, being `ts.` and the publisher's domain with a leading `www.` dropped, so publishers sharing a store never share an entry. - `PlatformValueCache`, the host's shared cache of small values with lifetimes, which `RuntimeServices` carries where the host supplies one. `lookup_or_reserve` obliges the caller that finds a key missing to fill it where the host can make other callers wait, and `remove` drops an entry. The Fastly adapter supplies one over Fastly's cache, answering the reservation with a transaction lookup and the removal with a purge of the entry's own surrogate key. diff --git a/crates/trusted-server-core/src/platform/loading_cache.rs b/crates/trusted-server-core/src/platform/loading_cache.rs index 5ed73a3477..72b5f16f53 100644 --- a/crates/trusted-server-core/src/platform/loading_cache.rs +++ b/crates/trusted-server-core/src/platform/loading_cache.rs @@ -96,6 +96,11 @@ pub type LoadFuture = Pin /// What a cache runs when no layer holds a key. /// +/// Its value serves every caller waiting on the load and every later reader +/// of the copies kept, so it uses nothing of the reader whose request started +/// it. Services it needs come from [`RuntimeServices::without_reader`], never +/// the request's own. +/// /// It owns everything it uses, and is `Send`, so a host that can run work on /// its own can start it on another thread and keep it running after the /// caller that started it has gone. @@ -181,7 +186,8 @@ pub trait PlatformLoadingCache: Send + Sync { /// /// `load` runs at most once per key at a time, and a caller arriving while /// it runs waits for it. The copies are kept in the stores `services` - /// supplies. + /// supplies. [`RuntimeServices::load_cached`] hands over services that + /// carry nothing of the reader, because the load serves every waiter. /// /// # Errors /// @@ -321,10 +327,8 @@ mod tests { assert_eq!(key.to_string(), "ts.example.com/robots-txt/k1"); } - #[test] - fn the_default_cache_runs_every_load() { - let services = super::super::test_support::noop_services(); - let spec = CacheSpec { + fn spec() -> CacheSpec { + CacheSpec { name: "test", lifetimes: CacheLifetimes { memory: Duration::from_secs(1), @@ -333,14 +337,19 @@ mod tests { }, memory_entries: 1, detachable: false, - }; + } + } + + #[test] + fn the_default_cache_runs_every_load() { + let services = super::super::test_support::noop_services(); let key = StoreKey::new(&ServerKeyPrefix::for_domain("example.com"), "test", "k"); let runs = Arc::new(AtomicUsize::new(0)); for _ in 0..2 { let counted = Arc::clone(&runs); let value = futures::executor::block_on(NoLoadingCache.get_or_load( &services, - &spec, + &spec(), &key, Box::new(move || { Box::pin(async move { @@ -355,4 +364,56 @@ mod tests { assert_eq!(runs.load(Ordering::SeqCst), 2); } + + /// A cache that records the client address in the services it is + /// handed. + #[derive(Default)] + struct Recording { + seen: std::sync::Mutex>>, + } + + #[async_trait::async_trait(?Send)] + impl PlatformLoadingCache for Recording { + async fn get_or_load( + &self, + services: &RuntimeServices, + _spec: &CacheSpec, + _key: &StoreKey, + load: Loader, + ) -> Result { + self.seen + .lock() + .expect("should lock the record") + .push(services.client_info().client_ip); + load().await.map(|loaded| loaded.bytes) + } + + async fn remove(&self, _services: &RuntimeServices, _spec: &CacheSpec, _key: &StoreKey) {} + } + + /// One load serves every caller waiting on it, so the cache is handed + /// nothing of the reader whose request asked, while that request keeps + /// its own. + #[test] + fn a_cache_is_handed_nothing_of_the_reader_whose_request_loads() { + let reader = std::net::IpAddr::from([203, 0, 113, 7]); + let recording = Arc::new(Recording::default()); + let services = super::super::test_support::noop_services_with_client_ip(reader) + .with_loading_cache(Arc::clone(&recording) as Arc); + let key = StoreKey::new(&ServerKeyPrefix::for_domain("example.com"), "test", "k"); + + futures::executor::block_on(services.load_cached( + &spec(), + &key, + Box::new(|| Box::pin(async { Ok(LoadedValue::new(Bytes::from_static(b"v"))) })), + )) + .expect("the load should succeed"); + + assert_eq!( + *recording.seen.lock().expect("should lock the record"), + vec![None], + "the cache should see no client address" + ); + assert_eq!(services.client_info().client_ip, Some(reader)); + } } diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 6eb9386f80..f588601a42 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -284,6 +284,10 @@ impl RuntimeServices { /// or from `load`, kept in this request's stores. See /// [`super::PlatformLoadingCache::get_or_load`]. /// + /// The cache is handed these services [without the + /// reader](Self::without_reader), because one load serves every caller + /// waiting on it. + /// /// # Errors /// /// The error of the load this caller waited on. @@ -294,7 +298,7 @@ impl RuntimeServices { load: super::Loader, ) -> Result { self.loading_cache_for(spec.name) - .get_or_load(self, spec, key, load) + .get_or_load(&self.without_reader(), spec, key, load) .await } @@ -446,6 +450,22 @@ impl RuntimeServices { } } + /// A clone of these services carrying nothing of the reader whose + /// request they were built for, being the client's connection details + /// and the host's signals about it. The stores, caches, backends, HTTP + /// client and modules stay. + /// + /// For work whose result serves other readers, such as a load a cache + /// shares, so one reader's details never shape what another is served. + #[must_use] + pub fn without_reader(&self) -> Self { + Self { + client_info: ClientInfo::default(), + host_signals: None, + ..self.clone() + } + } + /// Returns a clone of this instance with the Edge Cookie module replaced. /// /// Adapters use this to apply the module-supplied module selected by diff --git a/crates/trusted-server-core/src/robots_txt/contributor.rs b/crates/trusted-server-core/src/robots_txt/contributor.rs index c4ee61a4ea..d691052ac0 100644 --- a/crates/trusted-server-core/src/robots_txt/contributor.rs +++ b/crates/trusted-server-core/src/robots_txt/contributor.rs @@ -63,10 +63,11 @@ pub trait RobotsTxtContributor: Send + Sync + core::fmt::Debug { /// Core holds the answer for the publisher and serves it to every /// crawler. So the call carries the request the answer was asked for, /// being its method, path, host and scheme without its query, and the - /// request's services, and nothing about who asked. An implementation - /// hands its own function to [`ModuleCall::inject`], naming what it - /// needs. Its answer must be the same for every request the publisher - /// receives, because it is served for all of them. + /// services [without the reader](crate::platform::RuntimeServices::without_reader), + /// and nothing about who asked. An implementation hands its own function + /// to [`ModuleCall::inject`], naming what it needs. Its answer must be the + /// same for every request the publisher receives, because it is served + /// for all of them. /// /// A contribution with no groups is an answer, and is served as one. An /// error is a failure, and core serves this contributor's held copy diff --git a/crates/trusted-server-core/src/robots_txt/holding.rs b/crates/trusted-server-core/src/robots_txt/holding.rs index 136b162adf..6433f3e992 100644 --- a/crates/trusted-server-core/src/robots_txt/holding.rs +++ b/crates/trusted-server-core/src/robots_txt/holding.rs @@ -82,13 +82,16 @@ pub(crate) async fn contribution_for( let key = StoreKey::new(&prefix, CACHE, held.clone()); let kept = StoreKey::new(&prefix, KEPT, held); let spec = cache_spec(settings); + // The answer serves every reader, so the contributor is handed nothing + // of the reader whose request happens to ask first. + let shared = services.without_reader(); let ask = || { load( Ask { id, contributor: Arc::clone(contributor), request: request.clone(), - services: services.clone(), + services: shared.clone(), }, kept.clone(), ) @@ -397,6 +400,96 @@ pub(crate) mod tests { assert_eq!(answer, rules(ECHOED)); } + /// Host signals about a reader, as a host that computes them gives each + /// request. + #[derive(Debug)] + struct ReaderSignals; + + impl crate::evidence::HostSignals for ReaderSignals { + fn ja4(&self) -> Option<&str> { + Some("t13d1516h2_reader") + } + + fn h2(&self) -> Option<&str> { + Some("1:65536|reader") + } + } + + /// A contributor whose rules name what its services say about the + /// reader, so a test can see whether any of it arrived. + #[derive(Debug)] + struct ReaderEcho; + + impl ReaderEcho { + fn rules(&self, services: Option<&RuntimeServices>) -> Contribution { + let client = services.map(RuntimeServices::client_info); + let ip = client + .and_then(|client| client.client_ip) + .map_or_else(|| "no-ip".to_owned(), |ip| ip.to_string()); + let ja4 = client + .and_then(|client| client.tls_ja4.as_deref()) + .unwrap_or("no-ja4"); + let signals = if services.is_some_and(|services| services.host_signals().is_some()) { + "signals" + } else { + "no-signals" + }; + rules(&format!( + "User-Agent: ReaderBot\nDisallow: /{ip}/{ja4}/{signals}" + )) + } + } + + #[async_trait::async_trait(?Send)] + impl RobotsTxtContributor for ReaderEcho { + fn refresh(&self) -> Duration { + Duration::from_secs(60) + } + + fn fingerprint(&self) -> String { + "reader".to_owned() + } + + async fn contribute( + &self, + call: ModuleCall<'_>, + ) -> Result> { + Ok(call.inject(self, Self::rules)?) + } + } + + /// One load answers every crawler, so the contributor is handed nothing + /// of the reader whose request asks first, being the client's address, + /// its connection details and the host's signals about it. + #[tokio::test] + async fn the_contributor_is_handed_nothing_of_the_reader_who_asks_first() { + let reading: Arc = Arc::new(ReaderEcho); + let services = crate::platform::RuntimeServices::builder() + .config_store(Arc::new(crate::platform::test_support::NoopConfigStore)) + .secret_store(Arc::new(crate::platform::test_support::NoopSecretStore)) + .kv_store(Arc::new(MemoryKvStore::default())) + .backend(Arc::new(crate::platform::test_support::NoopBackend)) + .http_client(Arc::new(StubHttpClient::new())) + .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .client_info(crate::platform::ClientInfo { + client_ip: Some(std::net::IpAddr::from([203, 0, 113, 7])), + tls_ja4: Some("t13d1516h2_reader".to_owned()), + ..crate::platform::ClientInfo::default() + }) + .host_signals(Arc::new(ReaderSignals)) + .build() + .with_loading_cache(Arc::new(MemoryLoadingCache::default())); + + let answer = contribution_for("reader", &reading, &request(), &services, &settings()) + .await + .expect("should ask the contributor"); + + assert_eq!( + answer, + rules("User-Agent: ReaderBot\nDisallow: /no-ip/no-ja4/no-signals") + ); + } + /// A store held in memory for the life of one test, so that what one /// instance wrote is what the next one reads. #[derive(Debug, Default)] diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 61e75665af..141df7a89d 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -827,7 +827,9 @@ that started it, which only a load that never blocks its thread may. Its key is a `StoreKey`, which starts with the publisher's `ServerKeyPrefix` from the loaded settings, so two publishers sharing a store never share a copy. The `Loader` it hands over owns everything it uses, and answers a `LoadedValue`, -with a lifetime of its own where the source gives one. +with a lifetime of its own where the source gives one. Its value serves every +reader, so any services it needs come from `services.without_reader()`, never +the request's own, and `load_cached` hands the cache the same. ```rust let key = StoreKey::new(&ServerKeyPrefix::for_settings(settings), CACHE, digest); From 146f9554091c73aee77cc1b5af99331afeec944b Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 10:12:58 +0100 Subject: [PATCH 14/31] Each application state gets a loading cache of its own, Cloudflare included On wasm32-unknown-unknown the factory kept one cache for the whole Worker isolate, so a request could wait on a load another request was running. The published caching crate says such a request fails unless every request runs in its request scope with its wait_until spawner, and nothing here does. The factory now makes a cache for each application state on every host. Cloudflare builds its state for each request, so its memory copy lasts one request and Workers KV holds the copy between requests. --- CHANGELOG.md | 4 +- crates/loading-cache/51degrees/src/layer.rs | 5 ++- crates/loading-cache/51degrees/src/lib.rs | 31 ++++++--------- crates/loading-cache/51degrees/src/tests.rs | 39 +++++++++++++++++-- .../src/robots_txt/holding.rs | 7 ++-- docs/guide/configuration.md | 18 +++++---- 6 files changed, 67 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f9f928cb4..53c8c0c45f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- A robots.txt module's answer is held by the loading cache of the one loading cache module the build links, or of the module `[cache.robots-txt] module` names, briefly in process memory and in the host's shared store outside it, so a module is asked once per refresh however many instances and requests want its rules. With no loading cache module linked, or several and none named, nothing is held and every request for the file asks each module, where each instance held its own copy in the key-value store. The last answer is still kept apart for 30 days and served when the module cannot answer, in the value cache on Fastly and in the key-value store elsewhere. +- A robots.txt module's answer is held by the loading cache of the one loading cache module the build links, or of the module `[cache.robots-txt] module` names, briefly in memory and in the host's shared store outside it, so a module is asked again only when no copy young enough is held, rather than by every request. With no loading cache module linked, or several and none named, nothing is held and every request for the file asks each module, where each instance held its own copy in the key-value store. The last answer is still kept apart for 30 days and served when the module cannot answer, in the value cache on Fastly and in the key-value store elsewhere. - A consent management platform's middleware runs in the phase the publisher chooses. `cmp.inmobi`, `cmp.didomi` and `cmp.sourcepoint` may be named in a `[[fetch]]` or a `[[serve]]` entry, where each ran in the fetch phase alone. A generated configuration still places them in `[[fetch]]`. - **Breaking:** auction telemetry is selected the way every other module is. `[tinybird]` is now `[analytics.tinybird]`, and `[analytics] module = "tinybird"` selects it, which replaces the `enabled` line. A configuration that still carries `[tinybird]` is refused with directions. A stored configuration written by an earlier version carries the table even where telemetry was off, so push the configuration again with this version. The secret reference is `analytics.tinybird.auction_token_secret`, the table refuses a key it does not know, and a section that selects nothing, or a module this build does not have, is refused. - The DataDome module asks its Protection API about a publisher's pages beneath `/admin/` like any other page. It skipped every path beneath `/admin/` and `/_ts/admin/`, which left a publisher's own `/admin/` pages out of bot protection. @@ -85,7 +85,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - A middleware may prepare, and may refuse. `Middleware::prepare` runs in the request's handler before the document is processed, as the middleware's module with the request's services, and what it answers is handed to `create` under the middleware's name, so a decision that needs a store or the network is made there while `create` stays synchronous. A refusal answers the request with its status and `Retry-After`, with no document, and nothing is stored. -- A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A load is handed services with nothing of the reader in them, from `RuntimeServices::without_reader`, because its value serves every reader. A crate offers one through `IntegrationBuilder::with_loading_cache`, each feature that holds answers selects the module whose cache holds them in its own `[cache.]` table, and an adapter takes the selections with `loading_caches_from`. A feature that selects none takes the one loading cache module the build links, and gets `NoLoadingCache`, which loads every time, when the build links none or several. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in process memory over the host's value cache or key-value store, and the stock list ships it, so every adapter takes it. Every load runs in its caller. +- A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A load is handed services with nothing of the reader in them, from `RuntimeServices::without_reader`, because its value serves every reader. A crate offers one through `IntegrationBuilder::with_loading_cache`, each feature that holds answers selects the module whose cache holds them in its own `[cache.]` table, and an adapter takes the selections with `loading_caches_from`. A feature that selects none takes the one loading cache module the build links, and gets `NoLoadingCache`, which loads every time, when the build links none or several. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in memory over the host's value cache or key-value store, and the stock list ships it, so every adapter takes it. Each application state keeps a memory copy of its own, which lasts one request where an adapter builds its state for each request, as Cloudflare and Spin do. Every load runs in its caller. - `[cache.robots-txt]` selects, with `module`, the loading cache that holds each robots.txt module's answer, and sets the lifetimes of its copies, in seconds: `memory_seconds` (default 60), `store_seconds` (default 604800) and `store_idle_seconds` (default 2592000). A memory lifetime may be 0, the others must be at least 1, and none may be more than a year. - `ServerKeyPrefix`, the prefix every loading cache key starts with, being `ts.` and the publisher's domain with a leading `www.` dropped, so publishers sharing a store never share an entry. - `PlatformValueCache`, the host's shared cache of small values with lifetimes, which `RuntimeServices` carries where the host supplies one. `lookup_or_reserve` obliges the caller that finds a key missing to fill it where the host can make other callers wait, and `remove` drops an entry. The Fastly adapter supplies one over Fastly's cache, answering the reservation with a transaction lookup and the removal with a purge of the entry's own surrogate key. diff --git a/crates/loading-cache/51degrees/src/layer.rs b/crates/loading-cache/51degrees/src/layer.rs index f4d532ecab..66bd73a970 100644 --- a/crates/loading-cache/51degrees/src/layer.rs +++ b/crates/loading-cache/51degrees/src/layer.rs @@ -1,8 +1,9 @@ -//! The memory layer, the brief copy every request in a process shares. +//! The memory layer, the brief copy every request served with one application +//! state shares. //! //! Its key is a [`Call`], which carries the caller's own way of loading the //! value through the shared store, because what loads a value belongs to the -//! request asking for it while the layer belongs to the process. The key is +//! request asking for it while the layer belongs to the state. The key is //! compared by its text alone, so every caller for one key joins one load, and //! the load of whichever caller leads is the one that runs. diff --git a/crates/loading-cache/51degrees/src/lib.rs b/crates/loading-cache/51degrees/src/lib.rs index 88dffca5e7..c951365e94 100644 --- a/crates/loading-cache/51degrees/src/lib.rs +++ b/crates/loading-cache/51degrees/src/lib.rs @@ -1,9 +1,10 @@ //! The loading cache core declares, written with 51Degrees' `fiftyone-caching`. //! //! - **A brief copy in process memory.** A least recently used cache with a -//! short lifetime, shared by every request the process serves. It loads a -//! missing key once however many requests ask for it at the same moment, and -//! that one load covers the read of the shared store and the source together. +//! short lifetime, shared by every request an adapter serves with one +//! application state. It loads a missing key once however many of them ask +//! for it at the same moment, and that one load covers the read of the +//! shared store and the source together. //! - **The host's shared store.** The host's value cache where it has one, //! which on Fastly makes every instance at a location wait for one load, and //! its key-value store otherwise. It is read before the source, written @@ -45,26 +46,18 @@ pub fn builder() -> IntegrationBuilder { /// Builds the loading cache a deployment uses, for /// [`IntegrationBuilder::with_loading_cache`]. /// -/// A new one for each application state everywhere but Cloudflare, where the -/// state is built again for every request, so there one cache lives as long as -/// the isolate and every request it serves shares it. +/// A new one for each application state, so requests share a memory copy +/// only where their adapter keeps one state for them. Cloudflare builds its +/// state for every request, so no Worker request waits on another request's +/// load, which `fiftyone-caching` says fails unless each request runs in the +/// request scope it provides. #[must_use] pub fn factory() -> Arc { - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] - { - static ISOLATE: std::sync::OnceLock> = std::sync::OnceLock::new(); - let caches: Arc = - Arc::clone(ISOLATE.get_or_init(|| Arc::new(LoadingCaches::new()))); - caches - } - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] - { - Arc::new(LoadingCaches::new()) - } + Arc::new(LoadingCaches::new()) } -/// Every cache a process uses, each a memory layer over the host's shared -/// store. See the crate documentation. +/// Every cache one application state uses, each a memory layer over the +/// host's shared store. See the crate documentation. pub struct LoadingCaches { layers: Mutex>>, clock: Arc, diff --git a/crates/loading-cache/51degrees/src/tests.rs b/crates/loading-cache/51degrees/src/tests.rs index 918311e8a0..1c789654c7 100644 --- a/crates/loading-cache/51degrees/src/tests.rs +++ b/crates/loading-cache/51degrees/src/tests.rs @@ -10,7 +10,8 @@ use trusted_server_core::platform::test_support::{ }; use trusted_server_core::platform::{ CacheLifetimes, CacheSpec, ClientInfo, LoadError, LoadedValue, Loader, PlatformLoadingCache, - PlatformValueCache, RuntimeServices, ServerKeyPrefix, StoreKey, ValueLookup, ValueReservation, + PlatformValueCache, RuntimeServices, ServerKeyPrefix, StoreKey, UnavailableKvStore, + ValueLookup, ValueReservation, }; use super::*; @@ -168,7 +169,9 @@ impl PlatformValueCache for ReservingCache { } } -fn services_builder(kv: Arc) -> trusted_server_core::platform::RuntimeServicesBuilder { +fn services_builder( + kv: Arc, +) -> trusted_server_core::platform::RuntimeServicesBuilder { RuntimeServices::builder() .config_store(Arc::new(NoopConfigStore)) .secret_store(Arc::new(NoopSecretStore)) @@ -179,7 +182,7 @@ fn services_builder(kv: Arc) -> trusted_server_core::platform::Runtime .client_info(ClientInfo::default()) } -fn services(kv: Arc) -> RuntimeServices { +fn services(kv: Arc) -> RuntimeServices { services_builder(kv).build() } @@ -280,6 +283,36 @@ fn the_builder_offers_a_cache_that_keeps_values() { ); } +/// Each application state is given a cache of its own, so two states never +/// share a memory copy. Cloudflare builds a state for every request, and a +/// Worker request that waits on another request's load fails. +#[tokio::test(flavor = "current_thread")] +async fn each_application_state_is_given_a_cache_of_its_own() { + // A store that keeps nothing, so only a memory copy could answer. + let services = services(Arc::new(UnavailableKvStore)); + let runs = Arc::new(AtomicUsize::new(0)); + let state = factory(); + + state + .get_or_load(&services, &spec(), &key("k"), counted(&runs, "value")) + .await + .expect("should load"); + state + .get_or_load(&services, &spec(), &key("k"), never()) + .await + .expect("the state's own memory copy should answer"); + factory() + .get_or_load(&services, &spec(), &key("k"), counted(&runs, "value")) + .await + .expect("another state should load for itself"); + + assert_eq!( + runs.load(Ordering::SeqCst), + 2, + "each state should load for itself" + ); +} + #[test] fn many_callers_on_many_threads_load_a_missing_key_once_in_the_caller() { let runs = Arc::new(AtomicUsize::new(0)); diff --git a/crates/trusted-server-core/src/robots_txt/holding.rs b/crates/trusted-server-core/src/robots_txt/holding.rs index 6433f3e992..3ffb3e98db 100644 --- a/crates/trusted-server-core/src/robots_txt/holding.rs +++ b/crates/trusted-server-core/src/robots_txt/holding.rs @@ -1,9 +1,10 @@ //! Each contributor's answer, held between requests by the loading cache. //! //! A contributor's answer is loaded through -//! [`RuntimeServices::load_cached`], so the contributor is asked once however -//! many requests want its rules at the same moment, and the copy in the host's -//! shared store answers every instance until the contributor's refresh is due. +//! [`RuntimeServices::load_cached`], so requests that share the cache's memory +//! copy and want its rules at the same moment ask the contributor once, and +//! the copy in the host's shared store answers every instance until the +//! contributor's refresh is due. //! //! The last answer a contributor gave is also kept apart from the cache, for //! far longer than any refresh, and read only when the contributor fails. It is diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index c8c37a9555..cf6ac15389 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1073,7 +1073,7 @@ A module with settings of its own reads them from its own block, `[robots-txt. Date: Fri, 16 Oct 2026 10:15:10 +0100 Subject: [PATCH 15/31] CacheSpec loses detachable, which nothing read Every load runs in the caller that leads it, since the loading cache module stopped running loads apart from their callers, yet CacheSpec still carried a flag saying whether a load could, and the Loader documentation, a comment in the robots.txt holding and the integration guide still described loads that outlive their caller. The field and that text go, and a test pins that a load runs on its leading caller's thread. --- crates/loading-cache/51degrees/src/tests.rs | 35 ++++++++++++++++++- .../src/platform/loading_cache.rs | 15 +++----- .../src/robots_txt/holding.rs | 8 ++--- docs/guide/integration-guide.md | 6 ++-- 4 files changed, 43 insertions(+), 21 deletions(-) diff --git a/crates/loading-cache/51degrees/src/tests.rs b/crates/loading-cache/51degrees/src/tests.rs index 1c789654c7..b0635d321a 100644 --- a/crates/loading-cache/51degrees/src/tests.rs +++ b/crates/loading-cache/51degrees/src/tests.rs @@ -195,7 +195,6 @@ fn spec() -> CacheSpec { store_idle: Duration::from_secs(60), }, memory_entries: 16, - detachable: false, } } @@ -331,6 +330,40 @@ fn many_callers_on_many_threads_load_a_missing_key_once_in_the_caller() { } } +/// A load runs on the thread of the caller that leads it, as nothing runs a +/// load apart from its caller. +#[test] +fn a_load_runs_on_the_thread_of_the_caller_that_leads_it() { + let caches = LoadingCaches::new(); + let services = services(Arc::new(MemoryKv::default())); + let ran_on = Arc::new(Mutex::new(None)); + let seen = Arc::clone(&ran_on); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("should build a runtime"); + + runtime + .block_on(caches.get_or_load( + &services, + &spec(), + &key("k"), + Box::new(move || { + Box::pin(async move { + *seen.lock().expect("should lock the record") = + Some(std::thread::current().id()); + Ok(LoadedValue::new(Bytes::from_static(b"value"))) + }) + }), + )) + .expect("should load"); + + assert_eq!( + *ran_on.lock().expect("should lock the record"), + Some(std::thread::current().id()) + ); +} + #[test] fn a_failed_load_reaches_every_waiter_and_is_not_kept() { let runs = Arc::new(AtomicUsize::new(0)); diff --git a/crates/trusted-server-core/src/platform/loading_cache.rs b/crates/trusted-server-core/src/platform/loading_cache.rs index 72b5f16f53..719e2b0ee1 100644 --- a/crates/trusted-server-core/src/platform/loading_cache.rs +++ b/crates/trusted-server-core/src/platform/loading_cache.rs @@ -101,9 +101,9 @@ pub type LoadFuture = Pin /// it. Services it needs come from [`RuntimeServices::without_reader`], never /// the request's own. /// -/// It owns everything it uses, and is `Send`, so a host that can run work on -/// its own can start it on another thread and keep it running after the -/// caller that started it has gone. +/// It runs in the caller that leads the load. It owns everything it uses, +/// and is `Send`, because a cache may keep it with the key it loads, in +/// memory every thread of the process can reach. pub type Loader = Box LoadFuture + Send>; /// How long one cache's copies last. @@ -118,8 +118,8 @@ pub struct CacheLifetimes { pub store_idle: Duration, } -/// One cache: its name, the lifetimes its copies get, and how its loads may -/// run. +/// One cache: its name, the lifetimes its copies get, and how many values +/// its memory copy holds. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CacheSpec { /// The cache's name, which is part of every key it writes. @@ -128,10 +128,6 @@ pub struct CacheSpec { pub lifetimes: CacheLifetimes, /// The most values the brief copy in memory holds. pub memory_entries: usize, - /// Whether a load may run apart from the caller that started it, on a - /// host that can run work on its own. False for a load that blocks the - /// thread it runs on, which only the caller's own thread can afford. - pub detachable: bool, } /// A key in a shared store, being the publisher's prefix and the cache's name @@ -336,7 +332,6 @@ mod tests { store_idle: Duration::from_secs(1), }, memory_entries: 1, - detachable: false, } } diff --git a/crates/trusted-server-core/src/robots_txt/holding.rs b/crates/trusted-server-core/src/robots_txt/holding.rs index 3ffb3e98db..3a87b78e40 100644 --- a/crates/trusted-server-core/src/robots_txt/holding.rs +++ b/crates/trusted-server-core/src/robots_txt/holding.rs @@ -51,15 +51,11 @@ const DEFAULT_LIFETIMES: CacheLifetimes = CacheLifetimes { /// The cache contributors' answers are held in, with the lifetimes /// `[cache.robots-txt]` sets. -/// -/// Its loads run in the caller, because a contributor may block the thread it -/// runs on while it asks its service. pub(crate) fn cache_spec(settings: &Settings) -> CacheSpec { CacheSpec { name: CACHE, lifetimes: settings.cache.robots_txt.lifetimes(DEFAULT_LIFETIMES), memory_entries: 64, - detachable: false, } } @@ -158,8 +154,8 @@ async fn held_answer( }) } -/// What a load owns to ask one contributor, because it may outlive the -/// request that started it. +/// What a load owns to ask one contributor, because the cache keeps the load +/// with its key rather than borrowing the request. struct Ask { /// The name the contributor was declared under. id: &'static str, diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 141df7a89d..25355f41e5 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -821,10 +821,8 @@ caller made, so a copy is shared by every instance that reads the same store. Code in core, or in a module, loads through the cache with `RuntimeServices::load_cached`. It names its cache with a `CacheSpec`, being -the cache's name, the lifetimes of the memory and store copies, how many -values the memory copy holds and whether a load may run apart from the request -that started it, which only a load that never blocks its thread may. Its key -is a `StoreKey`, which starts with the publisher's `ServerKeyPrefix` from the +the cache's name, the lifetimes of the memory and store copies and how many +values the memory copy holds. Its key is a `StoreKey`, which starts with the publisher's `ServerKeyPrefix` from the loaded settings, so two publishers sharing a store never share a copy. The `Loader` it hands over owns everything it uses, and answers a `LoadedValue`, with a lifetime of its own where the source gives one. Its value serves every From 624230359bfb100d6602d7f44bcf98abcadefb0e Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 10:17:51 +0100 Subject: [PATCH 16/31] The comments and guides say what a table naming no module takes A table naming no module takes the one loading cache module the build links, and keeps no copy only when the build links none or several, yet the documentation of LoadingCacheConfig::module, IntegrationBuilder's offer, loading_caches_from, the services' loading caches and LoadingCaches, the integration guide and the configuration table said it keeps no copy at all. --- crates/trusted-server-core/src/integrations/mod.rs | 10 ++++++---- .../src/platform/loading_cache.rs | 5 +++-- crates/trusted-server-core/src/platform/types.rs | 7 ++++--- crates/trusted-server-core/src/settings.rs | 3 ++- docs/guide/configuration.md | 12 ++++++------ docs/guide/integration-guide.md | 5 +++-- 6 files changed, 24 insertions(+), 18 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index f35e4f479f..f64d305beb 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -631,8 +631,8 @@ impl IntegrationBuilder { /// /// The offer is not a selection. A cache is selected by naming the module /// in its own `[cache.]` table, and an adapter resolves every table's - /// selection with [`loading_caches_from`] when it builds its state. A cache - /// whose table selects none asks each source itself. + /// selection with [`loading_caches_from`] when it builds its state. A table + /// naming no module takes the one loading cache module the build links. #[must_use] pub const fn with_loading_cache(mut self, factory: LoadingCacheFactory) -> Self { self.loading_cache = Some(factory); @@ -786,8 +786,10 @@ pub(crate) fn all_builders( } /// 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 the build +/// links none or several. A module's cache is made once however many tables +/// select it. /// /// # Errors /// diff --git a/crates/trusted-server-core/src/platform/loading_cache.rs b/crates/trusted-server-core/src/platform/loading_cache.rs index 719e2b0ee1..94455c3f04 100644 --- a/crates/trusted-server-core/src/platform/loading_cache.rs +++ b/crates/trusted-server-core/src/platform/loading_cache.rs @@ -240,8 +240,9 @@ impl PlatformLoadingCache for NoLoadingCache { } /// The loading cache each `[cache.]` table selected, by the name a -/// [`CacheSpec`] carries. A cache whose table selected none loads through -/// [`NoLoadingCache`]. +/// [`CacheSpec`] carries. A cache with none, being one whose table names no +/// module where the build links no loading cache module or several, loads +/// through [`NoLoadingCache`]. #[derive(Default)] pub struct LoadingCaches { by_feature: HashMap<&'static str, Arc>, diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index f588601a42..9c30403b42 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -179,9 +179,10 @@ pub struct RuntimeServices { pub(crate) value_cache: Option>, /// The loading cache each `[cache.]` table selected, which loads a /// missing value once per key and keeps its copies in - /// [`value_cache`](Self::value_cache) or the key-value store. A cache whose - /// table selected none loads through [`super::NoLoadingCache`], which holds - /// nothing. + /// [`value_cache`](Self::value_cache) or the key-value store. A table + /// naming no module takes the one loading cache module the build links, + /// and with none linked, or several, its cache loads through + /// [`super::NoLoadingCache`], which holds nothing. pub(crate) loading_caches: Arc, /// Shared transformed-template cache. Defaults to /// [`UnavailableTemplateCache`], so adapters without one degrade to transforming diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index b000534197..6d1bf8085e 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -2946,7 +2946,8 @@ pub const ROBOTS_TXT_CACHE: &str = "robots-txt"; 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, and with none + /// linked, or several, no copy is kept and every call loads. #[serde(default, skip_serializing_if = "Option::is_none")] pub module: Option, /// How long the brief copy in process memory is used, in seconds. Zero diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index cf6ac15389..c6ebb1b011 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1712,12 +1712,12 @@ 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. -| 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. | +| 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. | ```toml [cache.robots-txt] diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 25355f41e5..8a2e27bd5a 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -799,8 +799,9 @@ supplies nothing else starts from `IntegrationBuilder::implementations(...)` and is named by its id. An adapter takes the selections from the settings and the builders it is given with `loading_caches_from` when it builds its state, which refuses a name no builder offers a cache from, and hands them to every -request's services as `LoadingCaches`. A feature that selects nothing loads -through `NoLoadingCache`. The stock list carries +request's services as `LoadingCaches`. A feature whose table names no module +takes the one loading cache module the build links, and loads through +`NoLoadingCache` when the build links none or several. The stock list carries `crates/loading-cache/51degrees`. ```rust From 1533d5175a8fb1606f3ad7732b417c11a0e79e8c Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 10:20:55 +0100 Subject: [PATCH 17/31] The deploy check refuses a loading cache module nothing offers validate_settings_for_deploy_with checked only the lifetimes in a [cache.] table, so a misspelt module passed ts config validate and was refused only when an adapter built its state, which on Fastly, Cloudflare and Spin is every request. check_loading_cache_modules refuses it with the message loading_caches_from gives, and both share the lookup. --- CHANGELOG.md | 2 +- crates/trusted-server-core/src/config.rs | 40 +++++++++ .../src/integrations/mod.rs | 88 ++++++++++++++----- crates/trusted-server-modules/src/lib.rs | 26 ++++++ docs/guide/configuration.md | 4 +- 5 files changed, 136 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c8c0c45f..369ce00548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,7 +86,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A middleware may prepare, and may refuse. `Middleware::prepare` runs in the request's handler before the document is processed, as the middleware's module with the request's services, and what it answers is handed to `create` under the middleware's name, so a decision that needs a store or the network is made there while `create` stays synchronous. A refusal answers the request with its status and `Retry-After`, with no document, and nothing is stored. - A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A load is handed services with nothing of the reader in them, from `RuntimeServices::without_reader`, because its value serves every reader. A crate offers one through `IntegrationBuilder::with_loading_cache`, each feature that holds answers selects the module whose cache holds them in its own `[cache.]` table, and an adapter takes the selections with `loading_caches_from`. A feature that selects none takes the one loading cache module the build links, and gets `NoLoadingCache`, which loads every time, when the build links none or several. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in memory over the host's value cache or key-value store, and the stock list ships it, so every adapter takes it. Each application state keeps a memory copy of its own, which lasts one request where an adapter builds its state for each request, as Cloudflare and Spin do. Every load runs in its caller. -- `[cache.robots-txt]` selects, with `module`, the loading cache that holds each robots.txt module's answer, and sets the lifetimes of its copies, in seconds: `memory_seconds` (default 60), `store_seconds` (default 604800) and `store_idle_seconds` (default 2592000). A memory lifetime may be 0, the others must be at least 1, and none may be more than a year. +- `[cache.robots-txt]` selects, with `module`, the loading cache that holds each robots.txt module's answer, and sets the lifetimes of its copies, in seconds: `memory_seconds` (default 60), `store_seconds` (default 604800) and `store_idle_seconds` (default 2592000). A memory lifetime may be 0, the others must be at least 1, and none may be more than a year. A `module` no linked crate offers a cache from is refused by `ts config validate` and when the service starts. - `ServerKeyPrefix`, the prefix every loading cache key starts with, being `ts.` and the publisher's domain with a leading `www.` dropped, so publishers sharing a store never share an entry. - `PlatformValueCache`, the host's shared cache of small values with lifetimes, which `RuntimeServices` carries where the host supplies one. `lookup_or_reserve` obliges the caller that finds a key missing to fill it where the host can make other callers wait, and `remove` drops an entry. The Fastly adapter supplies one over Fastly's cache, answering the reservation with a transaction lookup and the removal with a purge of the entry's own surrogate key. - A file the origin does not have is made by the middleware a `[[fetch]]` entry with a `path`, for a media type other than HTML, names for it. When the origin answers `404 Not Found` for a path under it, the middleware runs on an empty document of the entry's media type and its output is the file, so `ads.txt` is made from the sellers lines alone where the publisher has none. With no such entry the origin's answer stands. diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 6e939ac7db..eb7fe80448 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -359,6 +359,13 @@ pub fn validate_settings_for_deploy_with( let mut structural_settings = settings.clone(); structural_settings.prepare_runtime()?; + // A `[cache.]` module no linked crate offers a cache from is refused + // here as making the caches refuses it, so a misspelt module fails + // `ts config validate` rather than the service. + let builders: Vec = + crate::integrations::all_builders(extra_integrations).collect(); + crate::integrations::check_loading_cache_modules(settings, &builders)?; + // The plan is compiled with the builders the blocks are validated // against, so a `[demand]` or `[ad-server]` name one of them supplies // compiles here. @@ -1493,6 +1500,39 @@ passphrase = "production-secret-key-32-bytes-min" .expect("the external builder's ad server should pass runtime validation"); } + fn example_loading_cache() -> std::sync::Arc { + std::sync::Arc::new(crate::platform::NoLoadingCache) + } + + /// A `[cache.]` module no linked crate offers a cache from is + /// refused by deploy validation, as it is when the caches are made, so a + /// misspelt module fails `ts config validate` rather than the service. + /// Both halves are asserted, so the test fails if the check stops + /// refusing the misspelling or starts refusing the module offered. + #[test] + fn deploy_validation_refuses_a_loading_cache_module_nothing_offers() { + let builders = [ + IntegrationBuilder::implementations("loading-cache.example", "example-crate") + .with_loading_cache(example_loading_cache), + ]; + let mut settings = valid_settings(); + + settings.cache.robots_txt.module = Some("example".to_owned()); + validate_settings_for_deploy_with(&settings, &builders) + .expect("a module that offers a cache should pass deploy validation"); + + settings.cache.robots_txt.module = Some("exmaple".to_owned()); + let error = validate_settings_for_deploy_with(&settings, &builders) + .expect_err("a module nothing offers should be refused"); + assert!( + format!("{error:?}").contains( + "[cache.robots-txt] module names `exmaple`, which no linked crate offers a \ + loading cache from. The loading cache modules this build has are: example" + ), + "should name the table and the modules offered: {error:?}" + ); + } + /// Every builder handed to deploy validation has its `validate` run, and /// reporting disabled does not excuse a builder from validating. #[test] diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index f64d305beb..8795c29505 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -799,14 +799,7 @@ pub fn loading_caches_from( settings: &Settings, builders: &[IntegrationBuilder], ) -> Result, Report> { - let offered: Vec<(&'static str, LoadingCacheFactory)> = builders - .iter() - .filter_map(|builder| { - builder - .loading_cache() - .map(|factory| (builder.id(), factory)) - }) - .collect(); + let offered = offered_caches(builders); let mut made: Vec<(&'static str, Arc)> = Vec::new(); let mut caches = crate::platform::LoadingCaches::default(); for (table, config) in settings.cache.loading_caches() { @@ -818,22 +811,12 @@ pub fn loading_caches_from( None if offered.len() == 1 => offered[0].0, None => continue, }; - let Some((name, factory)) = offered.iter().find(|(name, _)| { - *name == written || crate::module_name::short_form(LOADING_CACHE_TYPE, name) == written - }) else { - return Err(Report::new(TrustedServerError::Configuration { - message: format!( - "[cache.{table}] module names `{written}`, which no linked crate offers \ - a loading cache from. The loading cache modules this build has are: {}", - offered_cache_names(&offered) - ), - })); - }; - let cache = match made.iter().find(|(made_name, _)| made_name == name) { + let (name, factory) = named_cache(table, written, &offered)?; + let cache = match made.iter().find(|(made_name, _)| *made_name == name) { Some((_, cache)) => Arc::clone(cache), None => { let cache = factory(); - made.push((*name, Arc::clone(&cache))); + made.push((name, Arc::clone(&cache))); cache } }; @@ -842,6 +825,69 @@ pub fn loading_caches_from( Ok(Arc::new(caches)) } +/// Refuses a `[cache.]` table whose `module` no linked builder offers +/// a loading cache from, as [`loading_caches_from`] does. The deploy check +/// runs it, so a misspelt module fails `ts config validate` rather than the +/// service. +/// +/// # Errors +/// +/// When a table names a module no linked builder offers a cache from, naming +/// the table and the modules that do. +pub fn check_loading_cache_modules( + settings: &Settings, + builders: &[IntegrationBuilder], +) -> Result<(), Report> { + let offered = offered_caches(builders); + for (table, config) in settings.cache.loading_caches() { + if let Some(written) = config.module.as_deref() { + named_cache(table, written, &offered)?; + } + } + Ok(()) +} + +/// The loading cache each builder offers, by its module name. +fn offered_caches(builders: &[IntegrationBuilder]) -> Vec<(&'static str, LoadingCacheFactory)> { + builders + .iter() + .filter_map(|builder| { + builder + .loading_cache() + .map(|factory| (builder.id(), factory)) + }) + .collect() +} + +/// The offered cache that `[cache.] module` names as `written`, by the +/// module's short name or in full. +/// +/// # Errors +/// +/// When nothing offered has that name, naming the table and the modules that +/// offer a cache. +fn named_cache( + table: &str, + written: &str, + offered: &[(&'static str, LoadingCacheFactory)], +) -> Result<(&'static str, LoadingCacheFactory), Report> { + offered + .iter() + .find(|(name, _)| { + *name == written || crate::module_name::short_form(LOADING_CACHE_TYPE, name) == written + }) + .copied() + .ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: format!( + "[cache.{table}] module names `{written}`, which no linked crate offers \ + a loading cache from. The loading cache modules this build has are: {}", + offered_cache_names(offered) + ), + }) + }) +} + /// The type folder loading cache modules live under, which their short /// names leave out. const LOADING_CACHE_TYPE: &str = "loading-cache"; diff --git a/crates/trusted-server-modules/src/lib.rs b/crates/trusted-server-modules/src/lib.rs index 291ddf7383..dc9a11b6ff 100644 --- a/crates/trusted-server-modules/src/lib.rs +++ b/crates/trusted-server-modules/src/lib.rs @@ -467,6 +467,32 @@ mod tests { ); } + /// `ts config validate` validates against this list, so it accepts the + /// stock loading cache by name and refuses a misspelling, which the + /// service would otherwise refuse only as it starts. + #[test] + fn deploy_validation_knows_the_stock_loading_cache() { + use trusted_server_core::config::validate_settings_for_deploy_with; + + let stock = builders(); + let mut settings = trusted_server_core::test_support::tests::create_test_settings(); + + settings.cache.robots_txt.module = Some("51degrees".to_owned()); + validate_settings_for_deploy_with(&settings, &stock) + .expect("the stock loading cache should pass deploy validation"); + + settings.cache.robots_txt.module = Some("51degreez".to_owned()); + let error = validate_settings_for_deploy_with(&settings, &stock) + .expect_err("a module nothing offers should be refused"); + assert!( + format!("{error:?}").contains( + "[cache.robots-txt] module names `51degreez`, which no linked crate offers a \ + loading cache from. The loading cache modules this build has are: 51degrees" + ), + "should name the table and the stock loading cache: {error:?}" + ); + } + #[test] fn a_deployment_s_builders_follow_the_stock_ones() { let stock = builders().len(); diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index c6ebb1b011..cddaf2dcea 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1709,8 +1709,8 @@ cache, with the module's short name or its name in full. A stock build links one loading cache module the build links, so a stock build holds answers without a line in the document, and keeps no copy when the build links none 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. +linked crate offers a cache from is refused by `ts config validate` and when +the service starts, with the names that could be written. | Table | Field | Default | Range | Description | | -------------------- | -------------------- | ----------------------- | ------------- | ------------------------------------------------------------------------------- | From 0d49c4601891d5a74ae3c6b19258a874bec595e2 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 10:23:42 +0100 Subject: [PATCH 18/31] Tests for the stock default, a zero memory lifetime, a dropped leader and a failing store The stock list with no module line holds with the one loading cache module it links, so a second one added there fails a test rather than silently stopping the holding. A memory lifetime of zero keeps no memory copy and still loads once for callers asking together, a caller waiting on a dropped leader loads for itself, and a store that fails every call, as the Axum adapter's does, leaves the memory copy answering. --- crates/loading-cache/51degrees/src/tests.rs | 98 +++++++++++++++++++++ crates/trusted-server-modules/src/lib.rs | 23 +++++ 2 files changed, 121 insertions(+) diff --git a/crates/loading-cache/51degrees/src/tests.rs b/crates/loading-cache/51degrees/src/tests.rs index b0635d321a..10e4df1dfa 100644 --- a/crates/loading-cache/51degrees/src/tests.rs +++ b/crates/loading-cache/51degrees/src/tests.rs @@ -662,3 +662,101 @@ async fn remove_drops_the_memory_copy_and_the_stored_one() { assert_eq!(fresh, "new"); assert_eq!(runs.load(Ordering::SeqCst), 2); } + +/// A memory lifetime of zero keeps no memory copy, so every call reads the +/// store, and callers asking at the same moment still share one load. +#[test] +fn a_memory_lifetime_of_zero_keeps_no_memory_copy_and_still_loads_once() { + let runs = Arc::new(AtomicUsize::new(0)); + let caches = Arc::new(LoadingCaches::new()); + let kv = Arc::new(MemoryKv::default()); + let services = services(Arc::clone(&kv)); + let mut no_memory = spec(); + no_memory.lifetimes.memory = Duration::ZERO; + + let answers = ask_from_many_threads(&caches, &services, &no_memory, || { + counted(&runs, "value") + }); + + assert_eq!( + runs.load(Ordering::SeqCst), + 1, + "the source should be asked once" + ); + for answer in answers { + assert_eq!(answer.expect("every caller should get the value"), "value"); + } + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("should build a runtime"); + let reads = kv.reads(); + let again = runtime + .block_on(caches.get_or_load(&services, &no_memory, &key("k"), never())) + .expect("the store should answer"); + assert_eq!(again, "value"); + assert_eq!(kv.reads(), reads + 1, "with no memory copy the store is read"); +} + +/// A caller whose load is dropped before it ends hands the load on, so a +/// caller waiting on it loads for itself rather than waiting for ever. +#[tokio::test(flavor = "current_thread")] +async fn a_waiting_caller_loads_when_the_caller_leading_the_load_is_dropped() { + let caches = LoadingCaches::new(); + let services = services(Arc::new(MemoryKv::default())); + let (spec, key) = (spec(), key("k")); + let runs = Arc::new(AtomicUsize::new(0)); + let unending: Loader = + Box::new(|| Box::pin(std::future::pending::>())); + + let mut leading = caches.get_or_load(&services, &spec, &key, unending); + assert!( + futures::poll!(leading.as_mut()).is_pending(), + "the first caller's load should be running" + ); + let mut waiting = caches.get_or_load(&services, &spec, &key, counted(&runs, "value")); + assert!( + futures::poll!(waiting.as_mut()).is_pending(), + "the second caller should wait on the first caller's load" + ); + drop(leading); + + let value = waiting + .await + .expect("the waiting caller should load for itself"); + assert_eq!(value, "value"); + assert_eq!( + runs.load(Ordering::SeqCst), + 1, + "the waiting caller's own load should run" + ); +} + +/// A store that fails every call, as the Axum adapter's does, holds nothing. +/// A failed read is a miss and a failed write still serves the value, so the +/// memory copy alone answers until it is removed. +#[tokio::test(flavor = "current_thread")] +async fn a_store_that_fails_every_call_leaves_the_memory_copy_answering() { + let services = services(Arc::new(UnavailableKvStore)); + let caches = LoadingCaches::new(); + let runs = Arc::new(AtomicUsize::new(0)); + + let value = caches + .get_or_load(&services, &spec(), &key("k"), counted(&runs, "value")) + .await + .expect("a store that fails should not fail the load"); + let again = caches + .get_or_load(&services, &spec(), &key("k"), never()) + .await + .expect("the memory copy should answer"); + caches.remove(&services, &spec(), &key("k")).await; + let fresh = caches + .get_or_load(&services, &spec(), &key("k"), counted(&runs, "fresh")) + .await + .expect("should load again once the memory copy is removed"); + + assert_eq!(value, "value"); + assert_eq!(again, "value"); + assert_eq!(fresh, "fresh"); + assert_eq!(runs.load(Ordering::SeqCst), 2); +} diff --git a/crates/trusted-server-modules/src/lib.rs b/crates/trusted-server-modules/src/lib.rs index dc9a11b6ff..28b49e40c2 100644 --- a/crates/trusted-server-modules/src/lib.rs +++ b/crates/trusted-server-modules/src/lib.rs @@ -467,6 +467,29 @@ mod tests { ); } + /// A table naming no module takes the one loading cache module the build + /// links, so a stock build holds each answer only while this list links + /// exactly one. A second would leave the table holding nothing, with no + /// message. + #[test] + fn a_table_naming_no_module_holds_with_the_stock_loading_cache() { + let settings = trusted_server_core::test_support::tests::create_test_settings(); + assert!( + settings.cache.robots_txt.module.is_none(), + "the test settings should name no module" + ); + + let caches = trusted_server_core::integrations::loading_caches_from(&settings, &builders()) + .expect("a table naming no module is not an error"); + + assert!( + caches + .for_feature(trusted_server_core::settings::ROBOTS_TXT_CACHE) + .keeps_values(), + "a stock build should hold each robots.txt module's answer" + ); + } + /// `ts config validate` validates against this list, so it accepts the /// stock loading cache by name and refuses a misspelling, which the /// service would otherwise refuse only as it starts. From dd6f86ec252a228bf8ca3396045e2b60f574cec6 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 22:19:34 +0100 Subject: [PATCH 19/31] Three test statements take the layout rustfmt gives them `cargo fmt --check` refused two statements in the loading cache's tests and the list of builders in the deploy check's test of a loading cache module nothing offers. Each now takes rustfmt's layout, and nothing else changes. --- crates/loading-cache/51degrees/src/tests.rs | 10 ++++++---- crates/trusted-server-core/src/config.rs | 9 +++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/crates/loading-cache/51degrees/src/tests.rs b/crates/loading-cache/51degrees/src/tests.rs index 10e4df1dfa..401330608b 100644 --- a/crates/loading-cache/51degrees/src/tests.rs +++ b/crates/loading-cache/51degrees/src/tests.rs @@ -674,9 +674,7 @@ fn a_memory_lifetime_of_zero_keeps_no_memory_copy_and_still_loads_once() { let mut no_memory = spec(); no_memory.lifetimes.memory = Duration::ZERO; - let answers = ask_from_many_threads(&caches, &services, &no_memory, || { - counted(&runs, "value") - }); + let answers = ask_from_many_threads(&caches, &services, &no_memory, || counted(&runs, "value")); assert_eq!( runs.load(Ordering::SeqCst), @@ -695,7 +693,11 @@ fn a_memory_lifetime_of_zero_keeps_no_memory_copy_and_still_loads_once() { .block_on(caches.get_or_load(&services, &no_memory, &key("k"), never())) .expect("the store should answer"); assert_eq!(again, "value"); - assert_eq!(kv.reads(), reads + 1, "with no memory copy the store is read"); + assert_eq!( + kv.reads(), + reads + 1, + "with no memory copy the store is read" + ); } /// A caller whose load is dropped before it ends hands the load on, so a diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index eb7fe80448..ff65ed7c2a 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -1511,10 +1511,11 @@ passphrase = "production-secret-key-32-bytes-min" /// refusing the misspelling or starts refusing the module offered. #[test] fn deploy_validation_refuses_a_loading_cache_module_nothing_offers() { - let builders = [ - IntegrationBuilder::implementations("loading-cache.example", "example-crate") - .with_loading_cache(example_loading_cache), - ]; + let builders = + [ + IntegrationBuilder::implementations("loading-cache.example", "example-crate") + .with_loading_cache(example_loading_cache), + ]; let mut settings = valid_settings(); settings.cache.robots_txt.module = Some("example".to_owned()); From 5313f0e9e4396dbb6f20791c9bc5dd30ed8a738e Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 17 Oct 2026 00:24:02 +0100 Subject: [PATCH 20/31] The Fastly robots.txt tests expect the kept answer the loading cache serves The test written with robots.txt said this adapter holds no answer, so a module that answered once and then failed made the second request a 503. With the loading cache, the last answer a contributor gave is kept and served past its age when the contributor fails, so the second request is answered 200 with that answer, and the test failed in CI. One test now asks for the file twice and expects the kept answer the second time. The 503 is tested with a module that never answers, so nothing is kept. All 275 tests of the Fastly adapter pass under Viceroy. --- .../src/app/seam_probe_tests.rs | 85 +++++++++++++++++-- 1 file changed, 77 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs b/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs index 651bc81045..4a19355daa 100644 --- a/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs @@ -540,11 +540,52 @@ fn accept_answers_once(_settings: &Settings) -> Result std::time::Duration { + std::time::Duration::ZERO + } + + fn fingerprint(&self) -> String { + "never-answers".to_owned() + } + + async fn contribute( + &self, + _call: ModuleCall<'_>, + ) -> Result> { + Err(Report::new(TrustedServerError::Proxy { + message: "the rules service is down".to_owned(), + })) + } +} + +fn register_never_answers( + _settings: &Settings, +) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder(NEVER_ANSWERS_ID) + .with_robots_txt_contributor("robots-txt.never-answers", Arc::new(NeverAnswers)) + .build(), + )) +} + +fn accept_never_answers(_settings: &Settings) -> Result> { + Ok(true) +} + +/// A module whose answer is due for a refresh at once is asked again on the +/// next request, and when it fails the last answer it gave is served past its +/// age, because yesterday's rules are a better answer than none. #[test] -fn with_nothing_held_a_module_that_fails_makes_the_file_unavailable() { +fn a_module_that_fails_after_answering_has_its_last_answer_served() { let settings = settings_with(&format!( r#" [robots-txt] @@ -571,11 +612,39 @@ fn with_nothing_held_a_module_that_fails_makes_the_file_unavailable() { let second = get(&router, "/robots.txt", None); assert_eq!( second.status().as_u16(), - 503, - "nothing is held here, so the module is asked again and its failure fails the file" + 200, + "the module fails, so the answer it gave last is served" ); - assert_eq!(header_text(&second, "retry-after"), Some("600")); - assert_eq!(header_text(&second, "cache-control"), Some("no-store")); + assert_eq!(body_text(second), "User-Agent: OnceBot\nDisallow: /\n"); +} + +/// A module that fails with no answer kept makes the file a `503` that tells +/// a crawler to come back in ten minutes and is not stored. +#[test] +fn with_nothing_kept_a_module_that_fails_makes_the_file_unavailable() { + let settings = settings_with(&format!( + r#" + [robots-txt] + modules = ["never-answers"] + {HMAC_BLOCK} + + [testing] + modules = ["robots-txt-never-answers"] + "# + )); + let builder = IntegrationBuilder::new( + NEVER_ANSWERS_ID, + NEVER_ANSWERS_ID, + register_never_answers, + accept_never_answers, + ) + .with_module_name("testing.robots-txt-never-answers"); + let router = router_with(settings, &[builder]); + + let response = get(&router, "/robots.txt", None); + assert_eq!(response.status().as_u16(), 503); + assert_eq!(header_text(&response, "retry-after"), Some("600")); + assert_eq!(header_text(&response, "cache-control"), Some("no-store")); } /// A request prepares exactly once whether a named route or the fallback From 4ffa62ab867fbb3375c344d4d5db4ea5f216ff08 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 17 Oct 2026 11:12:11 +0100 Subject: [PATCH 21/31] The Cloudflare and Spin adapters declare their key-value stores Neither adapter implemented Hooks::stores, so EdgeZero's empty default applied, run_app built no key-value registry, and every request was given UnavailableKvStore. The loading cache, the kept robots.txt answers and any cache a module declares therefore held nothing between requests on either host, and both build their application state for every request, so the memory copy held nothing past one request either. The Cloudflare adapter now declares TRUSTED_SERVER_KV, which EdgeZero opens as the Workers KV binding of that name because a store's binding is its id unless EDGEZERO__STORES__KV__TRUSTED_SERVER_KV__NAME names another. The Spin adapter declares default, the label spin.toml grants and the store startup already reads the settings from. EdgeZero treats a declared store as required, so a Worker without the binding, or a component that cannot open default, fails every request before any route runs. Tests: every_wrangler_manifest_binds_the_declared_key_value_store checks that both Wrangler manifests bind the namespace EdgeZero opens for the declared id, the_declared_key_value_store_is_a_label_spin_grants checks spin.toml grants the declared label, and on each adapter the_declared_store_is_the_key_value_store_a_request_is_given shows the store reaches a request's services, with the robots.txt tests now putting the store on a request under the declared id as run_app does. The Spin test that asserted edgezero.toml's store declarations is replaced, because the adapter never read that manifest at run time. --- .../src/app.rs | 27 +++++- .../tests/config_defaults.rs | 43 +++++++++ .../tests/routes.rs | 52 ++++++++++- .../wrangler.toml | 2 + crates/trusted-server-adapter-spin/src/app.rs | 27 +++++- .../tests/routes.rs | 92 ++++++++++++++----- 6 files changed, 211 insertions(+), 32 deletions(-) diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 72d94d8023..3d4c5215f1 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -3,7 +3,7 @@ use core::pin::Pin; use std::sync::Arc; use std::sync::atomic::AtomicBool; -use edgezero_core::app::Hooks; +use edgezero_core::app::{Hooks, StoreMetadata, StoresMetadata}; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Method, Request, Response, header}; @@ -587,6 +587,15 @@ fn startup_error_router(e: &Report) -> RouterService { /// `EdgeZero` [`Hooks`] implementation for the Trusted Server application. pub struct TrustedServerApp; +/// The key-value store this adapter declares, which is also the Workers KV +/// binding `run_app` opens for it on every request, because `EdgeZero` binds +/// a store by its id unless `EDGEZERO__STORES__KV__TRUSTED_SERVER_KV__NAME` +/// names another binding. +/// +/// `EdgeZero` treats a declared store as required, so a Worker deployed +/// without the binding fails every request before any route runs. +const KV_STORE_ID: &str = "TRUSTED_SERVER_KV"; + impl Hooks for TrustedServerApp { fn name() -> &'static str { "TrustedServer" @@ -603,6 +612,22 @@ impl Hooks for TrustedServerApp { build_router(&state) } + + /// Declares the key-value store alone, so `run_app` opens it for every + /// request and the request's services keep copies in Workers KV between + /// requests. The settings come from the `TRUSTED_SERVER_CONFIG` variable + /// at startup and secrets from the Worker environment, so no config or + /// secret store is declared. + fn stores() -> StoresMetadata { + StoresMetadata { + config: None, + kv: Some(StoreMetadata { + default: KV_STORE_ID, + ids: &[KV_STORE_ID], + }), + secrets: None, + } + } } impl TrustedServerApp { diff --git a/crates/trusted-server-adapter-cloudflare/tests/config_defaults.rs b/crates/trusted-server-adapter-cloudflare/tests/config_defaults.rs index e573c97470..20f6e290cc 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/config_defaults.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/config_defaults.rs @@ -1,7 +1,50 @@ //! Keep the checked-in Cloudflare configuration aligned with the runtime defaults. +use edgezero_core::app::Hooks as _; +use edgezero_core::env_config::EnvConfig; +use trusted_server_adapter_cloudflare::app::TrustedServerApp; use trusted_server_core::config_payload::{CONFIG_BLOB_KEY, DEFAULT_CONFIG_STORE_ID}; +/// `EdgeZero` treats a declared store as required, so a manifest that binds +/// no namespace under the name it opens for the declared key-value store +/// would fail every request. +#[test] +fn every_wrangler_manifest_binds_the_declared_key_value_store() { + let declared = TrustedServerApp::stores() + .kv + .expect("the adapter should declare its key-value store"); + + for (manifest_name, text) in [ + ("wrangler.toml", include_str!("../wrangler.toml")), + ("wrangler.ci.toml", include_str!("../wrangler.ci.toml")), + ] { + let manifest: toml::Value = + toml::from_str(text).expect("should parse the Wrangler manifest"); + // `EdgeZero` reads a store's binding name from the Worker's variables + // and falls back to the store's id. + let vars = manifest + .get("vars") + .and_then(toml::Value::as_table) + .into_iter() + .flatten() + .filter_map(|(key, value)| Some((key.clone(), value.as_str()?.to_owned()))); + let binding = EnvConfig::from_vars(vars).store_name("kv", declared.default); + let bound: Vec<&str> = manifest + .get("kv_namespaces") + .and_then(toml::Value::as_array) + .into_iter() + .flatten() + .filter_map(|namespace| namespace.get("binding")?.as_str()) + .collect(); + + assert!( + bound.contains(&binding.as_str()), + "{manifest_name} should bind `{binding}`, which the declared store opens, \ + and binds {bound:?}" + ); + } +} + #[test] fn cloudflare_manifest_uses_the_runtime_config_store_id() { let manifest: toml::Value = toml::from_str(include_str!("../cloudflare.toml")) diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index e7ba54f030..4dc345a087 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -832,12 +832,16 @@ mod robots_txt_from_a_module { use std::time::Duration; use bytes::Bytes; + use edgezero_core::app::Hooks as _; + use edgezero_core::context::RequestContext; use edgezero_core::http::Request; use edgezero_core::key_value_store::{KvError, KvHandle, KvPage, KvStore}; + use edgezero_core::params::PathParams; use edgezero_core::router::RouterService; use edgezero_core::store_registry::KvRegistry; use error_stack::Report; use trusted_server_adapter_cloudflare::app::TrustedServerApp; + use trusted_server_adapter_cloudflare::platform::build_runtime_services; use trusted_server_core::error::TrustedServerError; use trusted_server_core::integrations::{IntegrationBuilder, IntegrationRegistration}; use trusted_server_core::module_context::ModuleCall; @@ -990,20 +994,58 @@ mod robots_txt_from_a_module { } } + /// The registry `run_app` puts on a request for the key-value store the + /// adapter declares, built the way `EdgeZero` builds it, with `store` + /// standing in for the Workers KV namespace. + fn declared_store_registry(store: &Arc) -> KvRegistry { + let declared = TrustedServerApp::stores() + .kv + .expect("the adapter should declare its key-value store"); + let handle = KvHandle::new(Arc::clone(store) as Arc); + let by_id = declared + .ids + .iter() + .map(|id| ((*id).to_owned(), handle.clone())) + .collect(); + KvRegistry::from_parts(by_id, declared.default.to_owned()) + .expect("the declared default should be a declared id") + } + /// A crawler's `GET` for the file, carrying `store` as the key-value store /// the runtime hands the application, when there is one. fn robots_get(store: Option<&Arc>) -> Request { let mut request = robots_request("GET", "/robots.txt"); if let Some(store) = store { - let handle = KvHandle::new(Arc::clone(store) as Arc); - request.extensions_mut().insert(KvRegistry::single_id( - "trusted_server_kv".to_owned(), - handle, - )); + request + .extensions_mut() + .insert(declared_store_registry(store)); } request } + /// The store `run_app` opens for the declaration is the key-value store + /// a request's services are given, so what one request writes there the + /// next one reads. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn the_declared_store_is_the_key_value_store_a_request_is_given() { + let store = Arc::new(MemoryStore::default()); + let ctx = RequestContext::new(robots_get(Some(&store)), PathParams::default()); + + let services = build_runtime_services(&ctx, &Settings::default(), &Arc::default()); + services + .kv_store() + .put_bytes("a-key", Bytes::from_static(b"a value")) + .await + .expect("should write through the request's key-value store"); + + let entries = store.entries.lock().expect("should lock the store"); + assert_eq!( + entries.get("a-key").map(|value| &value[..]), + Some(&b"a value"[..]), + "the write should reach the declared store" + ); + } + /// With the store the runtime hands the application, a module's answer /// is held, so when the module next fails the file is made from the /// answer it gave before. diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.toml b/crates/trusted-server-adapter-cloudflare/wrangler.toml index f3b5ef6aa3..760cacacb2 100644 --- a/crates/trusted-server-adapter-cloudflare/wrangler.toml +++ b/crates/trusted-server-adapter-cloudflare/wrangler.toml @@ -15,6 +15,8 @@ compatibility_flags = ["nodejs_compat", "cache_option_enabled"] command = "bash build.sh" [[kv_namespaces]] +# The adapter declares this store and opens it for every request, so a Worker +# without the binding answers every request with a 500. binding = "TRUSTED_SERVER_KV" # Run `wrangler kv:namespace create TRUSTED_SERVER_KV` and paste the returned # id here before `wrangler deploy` or `wrangler dev --remote`. diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 046fcb4de6..df2544c411 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -5,7 +5,7 @@ use std::sync::atomic::AtomicBool; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use edgezero_adapter_spin::config_store::SpinConfigStore; use edgezero_adapter_spin::context::SpinRequestContext; -use edgezero_core::app::Hooks; +use edgezero_core::app::{Hooks, StoreMetadata, StoresMetadata}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::context::RequestContext; @@ -670,6 +670,15 @@ fn startup_error_router(e: &Report) -> RouterService { /// `EdgeZero` [`Hooks`] implementation for the Trusted Server application. pub struct TrustedServerApp; +/// The key-value store this adapter declares, by the label `spin.toml` +/// grants the component, which is also the store startup reads the settings +/// from. `EdgeZero` opens the label named by the id unless +/// `EDGEZERO__STORES__KV__DEFAULT__NAME` names another. +/// +/// `EdgeZero` treats a declared store as required, so a component that +/// cannot open the label fails every request before any route runs. +const KV_STORE_ID: &str = "default"; + impl Hooks for TrustedServerApp { fn name() -> &'static str { "TrustedServer" @@ -686,6 +695,22 @@ impl Hooks for TrustedServerApp { build_router(&state) } + + /// Declares the key-value store alone, so `run_app` opens it for every + /// request and the request's services keep copies in it between + /// requests. Startup opens the same store itself for the settings, and + /// secrets come from Spin variables, so no config or secret store is + /// declared. + fn stores() -> StoresMetadata { + StoresMetadata { + config: None, + kv: Some(StoreMetadata { + default: KV_STORE_ID, + ids: &[KV_STORE_ID], + }), + secrets: None, + } + } } impl TrustedServerApp { diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index a2f052bcad..3c63ee3771 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -53,30 +53,30 @@ fn routes_build_without_panic() { let _router = TrustedServerApp::routes(); } +/// `EdgeZero` opens the key-value store the adapter declares for every +/// request and treats it as required, so the label it opens has to be one +/// `spin.toml` grants the component, or every request fails. #[test] -fn edgezero_manifest_loads_and_resolves_spin_stores() { - let loader = edgezero_core::manifest::ManifestLoader::load_from_str(include_str!( - "../../../edgezero.toml" - )); - let manifest = loader.manifest(); - +fn the_declared_key_value_store_is_a_label_spin_grants() { + let declared = TrustedServerApp::stores() + .kv + .expect("the adapter should declare its key-value store"); + let manifest = include_str!("../spin.toml"); + + // `EdgeZero` takes the label from an `EDGEZERO__STORES__KV____NAME` + // variable in the component's environment and falls back to the id. assert!( - manifest.stores.config.is_some(), - "Spin EdgeZero manifest must enable config store injection" - ); - assert_eq!( - manifest - .stores - .kv - .as_ref() - .expect("should declare a KV store") - .default_id(), - "trusted_server_kv", - "Spin KV declaration must expose its default logical store id" + !manifest.contains("EDGEZERO__STORES__KV__"), + "spin.toml should not rename the declared store" ); + let granted = manifest + .lines() + .find_map(|line| line.trim().strip_prefix("key_value_stores = ")) + .expect("spin.toml should grant the component its key-value stores"); assert!( - manifest.secret_store_enabled(edgezero_core::app::SPIN_ADAPTER), - "Spin EdgeZero manifest must enable secret handle injection" + granted.contains(&format!("\"{}\"", declared.default)), + "spin.toml should grant `{}`, which the declared store opens, and grants {granted}", + declared.default ); } @@ -1077,12 +1077,16 @@ mod robots_txt_from_a_module { use std::time::Duration; use bytes::Bytes; + use edgezero_core::app::Hooks as _; + use edgezero_core::context::RequestContext; use edgezero_core::http::Request; use edgezero_core::key_value_store::{KvError, KvHandle, KvPage, KvStore}; + use edgezero_core::params::PathParams; use edgezero_core::router::RouterService; use edgezero_core::store_registry::KvRegistry; use error_stack::Report; use trusted_server_adapter_spin::app::TrustedServerApp; + use trusted_server_adapter_spin::platform::build_runtime_services; use trusted_server_core::error::TrustedServerError; use trusted_server_core::integrations::{IntegrationBuilder, IntegrationRegistration}; use trusted_server_core::module_context::ModuleCall; @@ -1235,20 +1239,58 @@ mod robots_txt_from_a_module { } } + /// The registry `run_app` puts on a request for the key-value store the + /// adapter declares, built the way `EdgeZero` builds it, with `store` + /// standing in for the Spin store. + fn declared_store_registry(store: &Arc) -> KvRegistry { + let declared = TrustedServerApp::stores() + .kv + .expect("the adapter should declare its key-value store"); + let handle = KvHandle::new(Arc::clone(store) as Arc); + let by_id = declared + .ids + .iter() + .map(|id| ((*id).to_owned(), handle.clone())) + .collect(); + KvRegistry::from_parts(by_id, declared.default.to_owned()) + .expect("the declared default should be a declared id") + } + /// A crawler's `GET` for the file, carrying `store` as the key-value store /// the runtime hands the application, when there is one. fn robots_get(store: Option<&Arc>) -> Request { let mut request = robots_request("GET", "/robots.txt"); if let Some(store) = store { - let handle = KvHandle::new(Arc::clone(store) as Arc); - request.extensions_mut().insert(KvRegistry::single_id( - "trusted_server_kv".to_owned(), - handle, - )); + request + .extensions_mut() + .insert(declared_store_registry(store)); } request } + /// The store `run_app` opens for the declaration is the key-value store + /// a request's services are given, so what one request writes there the + /// next one reads. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn the_declared_store_is_the_key_value_store_a_request_is_given() { + let store = Arc::new(MemoryStore::default()); + let ctx = RequestContext::new(robots_get(Some(&store)), PathParams::default()); + + let services = build_runtime_services(&ctx, &Settings::default(), &Arc::default()); + services + .kv_store() + .put_bytes("a-key", Bytes::from_static(b"a value")) + .await + .expect("should write through the request's key-value store"); + + let entries = store.entries.lock().expect("should lock the store"); + assert_eq!( + entries.get("a-key").map(|value| &value[..]), + Some(&b"a value"[..]), + "the write should reach the declared store" + ); + } + /// With the store the runtime hands the application, a module's answer /// is held, so when the module next fails the file is made from the /// answer it gave before. From 3ebba58f4287096bb4e7aa8db9a617bd6f418919 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 17 Oct 2026 11:12:20 +0100 Subject: [PATCH 22/31] The adapter comments say which stores run_app opens The Cloudflare comments said run_app injects a config handle from the TRUSTED_SERVER_CONFIG variable and the KV handle from TRUSTED_SERVER_KV, and the Spin comments said run_app injects config and KV handles. run_app opens only the stores an app declares. Both adapters now declare their key-value store and no config store, so a request gets the declared key-value store and the no-op config store, while startup reads the settings from TRUSTED_SERVER_CONFIG on Cloudflare and from the default store on Spin. The comments say so. --- .../src/platform.rs | 36 +++++++++++-------- .../src/platform.rs | 12 ++++--- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index e83ce1c584..fb5fbe3b36 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -80,15 +80,16 @@ impl PlatformBackend for NoopBackend { // construction is handled by edgezero's run_app before we receive the ctx. // --------------------------------------------------------------------------- -/// Bridges edgezero's [`ConfigStoreHandle`] (injected by `run_app` from the -/// `TRUSTED_SERVER_CONFIG` env-var binding) to [`PlatformConfigStore`]. +/// Bridges edgezero's [`ConfigStoreHandle`] to [`PlatformConfigStore`]. /// -/// Reads delegate through the handle. Writes are unsupported on all current -/// adapter targets and return errors. +/// `run_app` hands a request a config handle only for a config store the app +/// declares, and [`TrustedServerApp`](crate::app::TrustedServerApp) declares +/// none, because startup reads the settings from the `TRUSTED_SERVER_CONFIG` +/// variable. A request is therefore given [`NoopConfigStore`] instead. /// -/// Note: Cloudflare config is a single flat JSON env-var binding — all keys -/// live in one namespace. The `store_name` argument is intentionally ignored; -/// callers cannot route to a different store by passing a different name. +/// Reads delegate through the handle. Writes are unsupported on all current +/// adapter targets and return errors. The `store_name` argument is ignored, +/// because one handle answers for every name. struct ConfigStoreHandleAdapter(ConfigStoreHandle); impl PlatformConfigStore for ConfigStoreHandleAdapter { @@ -113,7 +114,9 @@ impl PlatformConfigStore for ConfigStoreHandleAdapter { } /// Bridges edgezero's [`KvHandle`] (injected by `run_app` from the -/// `TRUSTED_SERVER_KV` KV namespace binding) to [`PlatformKvStore`]. +/// `TRUSTED_SERVER_KV` KV namespace binding, the store +/// [`TrustedServerApp`](crate::app::TrustedServerApp) declares) to +/// [`PlatformKvStore`]. /// /// Delegates all operations through `KvHandle`'s raw-bytes API, which includes /// key/value validation before forwarding to the underlying store. @@ -582,10 +585,12 @@ impl PlatformSecretStore for CloudflareSecretStoreAdapter { /// Construct [`RuntimeServices`] for an incoming Cloudflare Workers request. /// -/// Config and KV are sourced from the edgezero handles that `run_app` injects -/// before routing — via the `TRUSTED_SERVER_CONFIG` env-var binding and the -/// `TRUSTED_SERVER_KV` KV namespace declared in `cloudflare.toml`. No -/// platform-specific `#[cfg]` is required for these two stores. +/// The key-value store is the handle `run_app` opens before routing from the +/// `TRUSTED_SERVER_KV` namespace binding, because +/// [`TrustedServerApp`](crate::app::TrustedServerApp) declares that store. It +/// declares no config store, so the config store is the no-op one, and +/// startup reads the settings from the `TRUSTED_SERVER_CONFIG` variable +/// instead. No platform-specific `#[cfg]` is required for these two stores. /// /// Secrets still require direct `worker::Env` access because /// `SecretHandle::get_bytes` is async while `PlatformSecretStore::get_bytes` @@ -608,13 +613,16 @@ pub fn build_runtime_services( #[cfg(not(target_arch = "wasm32"))] let http_client: Arc = Arc::new(UnavailableHttpClient); - // Config: use the ConfigStoreHandle injected by run_app — no #[cfg] needed. + // Config: no config store is declared, so run_app injects no handle and + // the no-op store answers. let config_store: Arc = ctx .config_store_default() .map(|h| Arc::new(ConfigStoreHandleAdapter(h)) as Arc) .unwrap_or_else(|| Arc::new(NoopConfigStore)); - // KV: use the KvHandle injected by run_app — no #[cfg] needed. + // KV: the handle run_app opens for the declared store. A Worker without + // the binding fails in run_app before routing, so the unavailable store + // only answers where no run_app ran, as in host tests. let kv_store: Arc = ctx .kv_store_default() .map(|h| Arc::new(KvHandleAdapter(h)) as Arc) diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index b4914202af..693e808d78 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -98,7 +98,8 @@ impl PlatformBackend for NoopBackend { } // --------------------------------------------------------------------------- -// edgezero handle adapters — injected by edgezero_adapter_spin::run_app +// edgezero handle adapters, for the handles edgezero_adapter_spin::run_app +// injects for the stores the app declares and the config handle startup opens // --------------------------------------------------------------------------- /// Bridges edgezero's [`ConfigStoreHandle`] to [`PlatformConfigStore`]. @@ -700,9 +701,12 @@ impl PlatformSecretStore for SpinSecretStoreAdapter { /// Construct [`RuntimeServices`] for an incoming Spin request. /// -/// Config and KV are sourced from the `EdgeZero` handles that `run_app` injects -/// before routing. Secrets are read synchronously from Spin component -/// variables because Trusted Server's platform secret trait is sync. +/// The key-value store is the handle `run_app` opens before routing for the +/// `default` store [`TrustedServerApp`](crate::app::TrustedServerApp) +/// declares. It declares no config store, so the config store is the no-op +/// one, and startup reads the settings from the `default` store itself. +/// Secrets are read synchronously from Spin component variables because +/// Trusted Server's platform secret trait is sync. #[must_use] pub fn build_runtime_services( ctx: &edgezero_core::context::RequestContext, From 72fb57966f4b74a59e377a36978c490a0bc3886b Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 17 Oct 2026 11:12:30 +0100 Subject: [PATCH 23/31] A Worker without its key-value binding fails every request in CI test_cloudflare_without_its_kv_binding_fails_every_request runs the Worker under wrangler dev with the [[kv_namespaces]] table taken out of the CI manifest and expects 500 with the dispatch error on /, the discovery document and /robots.txt, which shows the Worker declares its store and that EdgeZero refuses to route without it. The harness waits for any answer in that case, because the health check never succeeds, and without_kv_namespaces has tests of its own. A test that a robots.txt module's answer is kept across two Worker requests is not possible with this bundle, because the Worker CI builds links the stock modules alone and none of them contributes robots.txt rules. --- .../tests/environments/cloudflare.rs | 149 +++++++++++++++++- .../tests/integration.rs | 34 ++++ 2 files changed, 175 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs b/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs index 72328f42f1..6013d2da51 100644 --- a/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs +++ b/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs @@ -27,7 +27,32 @@ const CI_CONFIG_TEMPLATE: &str = "wrangler.ci.toml"; const GENERATED_CI_CONFIG: &str = "wrangler.integration.generated.toml"; const TRUSTED_SERVER_CONFIG_PLACEHOLDER: &str = "TRUSTED_SERVER_CONFIG = \"{}\""; -fn write_generated_ci_config(wrangler_dir: &Path, config_json: &str) -> TestResult { +/// The table of the CI wrangler template that binds the key-value namespace +/// the adapter declares. +const KV_NAMESPACES_TABLE: &str = "[[kv_namespaces]]"; + +/// Whether a generated wrangler config binds the key-value namespace. +#[derive(Clone, Copy)] +enum KvBinding { + Bound, + Unbound, +} + +/// What [`CloudflareWorkers::spawn_wrangler`] waits for before it hands the +/// Worker to a test. +#[derive(Clone, Copy)] +enum Readiness { + /// The health check answers with success. + Healthy, + /// The Worker answers at all, for a Worker whose every answer is an error. + AnyAnswer, +} + +fn write_generated_ci_config( + wrangler_dir: &Path, + config_json: &str, + kv: KvBinding, +) -> TestResult { let template_path = wrangler_dir.join(CI_CONFIG_TEMPLATE); let template = std::fs::read_to_string(&template_path) .change_context(TestError::RuntimeSpawn) @@ -35,7 +60,10 @@ fn write_generated_ci_config(wrangler_dir: &Path, config_json: &str) -> TestResu "failed to read Cloudflare CI wrangler config at {}", template_path.display() ))?; - let generated = inject_cloudflare_config(&template, config_json)?; + let mut generated = inject_cloudflare_config(&template, config_json)?; + if matches!(kv, KvBinding::Unbound) { + generated = without_kv_namespaces(&generated)?; + } let output_path = wrangler_dir.join(GENERATED_CI_CONFIG); std::fs::write(&output_path, generated) .change_context(TestError::RuntimeSpawn) @@ -60,6 +88,51 @@ fn inject_cloudflare_config(template: &str, config_json: &str) -> TestResult TestResult { + let tables = template + .lines() + .filter(|line| line.trim() == KV_NAMESPACES_TABLE) + .count(); + if tables != 1 { + return Err(Report::new(TestError::RuntimeSpawn).attach(format!( + "Cloudflare CI wrangler config must contain exactly one `{KV_NAMESPACES_TABLE}` table, found {tables}" + ))); + } + + let mut kept = Vec::new(); + let mut in_table = false; + for line in template.lines() { + let trimmed = line.trim(); + if trimmed == KV_NAMESPACES_TABLE { + in_table = true; + continue; + } + if in_table && (trimmed.is_empty() || trimmed.starts_with('[')) { + in_table = false; + } + if !in_table { + kept.push(line); + } + } + let mut generated = kept.join("\n"); + generated.push('\n'); + Ok(generated) +} + +/// Polls `base_url` until the Worker answers with any status. +fn wait_for_any_answer(base_url: &str) -> TestResult<()> { + for _ in 0..30 { + if reqwest::blocking::get(base_url).is_ok() { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + Err(Report::new(TestError::RuntimeNotReady) + .attach(format!("Runtime at {base_url} did not answer after 15s"))) +} + impl RuntimeEnvironment for CloudflareWorkers { fn id(&self) -> &'static str { "cloudflare" @@ -68,11 +141,15 @@ impl RuntimeEnvironment for CloudflareWorkers { fn spawn(&self, _wasm_path: &Path) -> TestResult { let wrangler_dir = self.wrangler_dir(); let config = if std::env::var("CI").is_ok() { - write_generated_ci_config(&wrangler_dir, &cloudflare_config_json(origin_port())?)? + write_generated_ci_config( + &wrangler_dir, + &cloudflare_config_json(origin_port())?, + KvBinding::Bound, + )? } else { "wrangler.toml".to_string() }; - self.spawn_wrangler(&wrangler_dir, &config) + self.spawn_wrangler(&wrangler_dir, &config, Readiness::Healthy) } fn health_check_path(&self) -> &str { @@ -94,11 +171,31 @@ impl CloudflareWorkers { /// [`TestError::RuntimeNotReady`] if the health check times out. pub fn spawn_with_config_json(&self, config_json: &str) -> TestResult { let wrangler_dir = self.wrangler_dir(); - let config = write_generated_ci_config(&wrangler_dir, config_json)?; - self.spawn_wrangler(&wrangler_dir, &config) + let config = write_generated_ci_config(&wrangler_dir, config_json, KvBinding::Bound)?; + self.spawn_wrangler(&wrangler_dir, &config, Readiness::Healthy) } - fn spawn_wrangler(&self, wrangler_dir: &Path, config: &str) -> TestResult { + /// Start `wrangler dev` with a test-specific `TRUSTED_SERVER_CONFIG` + /// binding and no key-value namespace bound, returning once the Worker + /// answers at all, because every answer it gives is then an error. + /// + /// # Errors + /// + /// Returns [`TestError::RuntimeSpawn`] if the wrangler config cannot be + /// written or `wrangler dev` cannot be started, and + /// [`TestError::RuntimeNotReady`] if the Worker never answers. + pub fn spawn_without_kv_binding(&self, config_json: &str) -> TestResult { + let wrangler_dir = self.wrangler_dir(); + let config = write_generated_ci_config(&wrangler_dir, config_json, KvBinding::Unbound)?; + self.spawn_wrangler(&wrangler_dir, &config, Readiness::AnyAnswer) + } + + fn spawn_wrangler( + &self, + wrangler_dir: &Path, + config: &str, + readiness: Readiness, + ) -> TestResult { let port = super::find_available_port().unwrap_or(CLOUDFLARE_DEFAULT_PORT); #[cfg(unix)] @@ -166,7 +263,12 @@ impl CloudflareWorkers { let handle = CloudflareHandle { child }; let base_url = format!("http://127.0.0.1:{port}"); - super::wait_for_ready(&base_url, self.health_check_path(), true)?; + match readiness { + Readiness::Healthy => { + super::wait_for_ready(&base_url, self.health_check_path(), true)?; + } + Readiness::AnyAnswer => wait_for_any_answer(&base_url)?, + } Ok(RuntimeProcess { inner: Box::new(handle), @@ -252,4 +354,35 @@ mod tests { assert!(result.is_err(), "should reject duplicate placeholders"); } + + #[test] + fn without_kv_namespaces_drops_only_the_namespace_table() { + let template = include_str!("../../../trusted-server-adapter-cloudflare/wrangler.ci.toml"); + + let generated = without_kv_namespaces(template).expect("should drop the namespace table"); + + let manifest: toml::Value = toml::from_str(&generated).expect("should stay valid TOML"); + let original: toml::Value = toml::from_str(template).expect("should parse the template"); + assert!( + manifest.get("kv_namespaces").is_none(), + "should bind no namespace" + ); + assert_eq!( + manifest.get("vars"), + original.get("vars"), + "should keep the variables" + ); + assert_eq!( + manifest.get("main"), + original.get("main"), + "should keep the bundle" + ); + } + + #[test] + fn without_kv_namespaces_rejects_a_template_without_the_table() { + let result = without_kv_namespaces("[vars]\n"); + + assert!(result.is_err(), "should reject a template with no table"); + } } diff --git a/crates/trusted-server-integration-tests/tests/integration.rs b/crates/trusted-server-integration-tests/tests/integration.rs index ad8249307e..b73d18a900 100644 --- a/crates/trusted-server-integration-tests/tests/integration.rs +++ b/crates/trusted-server-integration-tests/tests/integration.rs @@ -214,6 +214,40 @@ fn test_cloudflare_dynamic_publisher_response_does_not_cross_cookie_boundaries() ); } +/// The Worker declares its key-value store and `EdgeZero` treats a declared +/// store as required, so without the `TRUSTED_SERVER_KV` binding every +/// request fails before any route runs, health check and robots.txt included. +#[test] +#[ignore = "requires the `wrangler` CLI in $PATH and a prebuilt Cloudflare Workers bundle (run build.sh first); the test starts wrangler dev automatically"] +fn test_cloudflare_without_its_kv_binding_fails_every_request() { + init_logger(); + let config_json = common::config::cloudflare_config_json(origin_port()) + .expect("should build the Cloudflare config"); + let process = environments::cloudflare::CloudflareWorkers + .spawn_without_kv_binding(&config_json) + .expect("should start Cloudflare Worker"); + let client = reqwest::blocking::Client::new(); + + for path in ["/", "/.well-known/trusted-server.json", "/robots.txt"] { + let response = client + .get(format!("{}{path}", process.base_url)) + .send() + .expect("should reach the Worker"); + let status = response.status().as_u16(); + let body = response.text().expect("should read the response"); + + assert_eq!( + status, 500, + "{path} should fail without the binding: {body}" + ); + assert_eq!( + body.trim(), + "internal server error", + "{path} should fail where the Worker dispatches" + ); + } +} + /// Exercises an enabled `/auction` request through auction telemetry in workerd. /// /// `std::time::Instant::now()` traps on Cloudflare's `wasm32-unknown-unknown` From 4780e10f85d18a372fea848fd3f62af89bbe2051 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 17 Oct 2026 11:12:40 +0100 Subject: [PATCH 24/31] The changelog and guides say what Cloudflare and Spin keep The changelog said answers were held in the key-value store on Cloudflare and Spin while Fastly and Axum held nothing, that before this change each instance held its own copy, and that the last robots.txt answer is kept in the key-value store on every adapter but Fastly. The guide said the same of the last answer and that the shared store is the host's key-value store wherever there is no value cache. Before this branch no adapter gave a request a working key-value store, and Axum still has none. Now the changelog has an entry for the declared stores and the binding they require, the loading cache entries name the store on each adapter, and the robots.txt entry says the loading cache holds the answer. The configuration guide names Workers KV and Spin's default store as the shared store and says Axum keeps the memory copy alone, and the Cloudflare and Spin guides say each adapter opens its store for every request and fails every request without it. --- CHANGELOG.md | 7 ++++--- docs/guide/cloudflare.md | 15 +++++++++++---- docs/guide/configuration.md | 8 +++++--- docs/guide/spin.md | 12 +++++++++--- 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27e5307865..bd12187f35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- A robots.txt module's answer is held by the loading cache of the one loading cache module the build links, or of the module `[cache.robots-txt] module` names, briefly in memory and in the host's shared store outside it, so a module is asked again only when no copy young enough is held, rather than by every request. With no loading cache module linked, or several and none named, nothing is held and every request for the file asks each module, where each instance held its own copy in the key-value store. The last answer is still kept apart for 30 days and served when the module cannot answer, in the value cache on Fastly and in the key-value store elsewhere. +- A robots.txt module's answer is held by the loading cache of the one loading cache module the build links, or of the module `[cache.robots-txt] module` names, briefly in memory and in the host's shared store outside it, so a module is asked again only when no copy young enough is held, rather than by every request. With no loading cache module linked, or several and none named, nothing is held and every request for the file asks each module, as every request did when no adapter gave the file a key-value store. The last answer is kept apart for 30 days and served when the module cannot answer, in the value cache on Fastly and in the key-value store on Cloudflare and Spin, and Axum, which has no key-value store, keeps none. +- **Breaking:** the Cloudflare and Spin adapters declare their key-value store, the Workers KV binding `TRUSTED_SERVER_KV` and Spin's `default` store, so EdgeZero opens it for every request and the loading cache keeps its copies there between requests, where every request was given a store whose every call failed. A declared store is required, so a Worker without the binding, or a Spin component that cannot open `default`, fails every request before any route runs. - A consent management platform's middleware runs in the phase the publisher chooses. `cmp.inmobi`, `cmp.didomi` and `cmp.sourcepoint` may be named in a `[[fetch]]` or a `[[serve]]` entry, where each ran in the fetch phase alone. A generated configuration still places them in `[[fetch]]`. A `[[serve]]` entry naming `cmp.inmobi` for a page whose `[[fetch]]` entry names another middleware is refused, because the serve phase writes its head markup after what the fetch phase writes ahead of the script bundle, so a vendor's tag there would come ahead of the prompt. Naming `cmp.inmobi` for one page in both phases is refused, because the prompt would be written twice. - **Breaking:** auction telemetry is selected the way every other module is. `[tinybird]` is now `[analytics.tinybird]`, and `[analytics] module = "tinybird"` selects it, which replaces the `enabled` line. A configuration that still carries `[tinybird]` is refused with directions. A stored configuration written by an earlier version carries the table even where telemetry was off, so push the configuration again with this version. The secret reference is `analytics.tinybird.auction_token_secret`, the table refuses a key it does not know, and a section that selects nothing, or a module this build does not have, is refused. A row is sent only for a reader whose permissions include `analytics.ad_reporting.measure_ad_performance`, so none is sent for a reader the consent gate refuses unless they permit it. The Axum, Cloudflare and Spin adapters send no telemetry, and log a warning at startup when the section selects a module. - The DataDome module asks its Protection API about a publisher's pages beneath `/admin/` like any other page. It skipped every path beneath `/admin/` and `/_ts/admin/`, which left a publisher's own `/admin/` pages out of bot protection. @@ -89,7 +90,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - A middleware may prepare, and may refuse. `Middleware::prepare` runs in the request's handler before the document is processed, as the middleware's module with the request's services, and what it answers is kept under the phase and the middleware's name for `create` in that phase, so a decision that needs a store or the network is made there while `create` stays synchronous. The fetch phase of a document that may be stored is prepared with no client IP, TLS details or client signals in the services. A refusal answers the request with its status and `Retry-After`, with no document, and nothing is stored. Core logs a refusal as a warning naming the middleware, and answers a status that is not a client or server error with `500`. -- A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A load is handed services with nothing of the reader in them, from `RuntimeServices::without_connection`, because its value serves every reader. A crate offers one through `IntegrationBuilder::with_loading_cache`, each feature that holds answers selects the module whose cache holds them in its own `[cache.]` table, and an adapter takes the selections with `loading_caches_from`. A feature that selects none takes the one loading cache module the build links, and gets `NoLoadingCache`, which loads every time, when the build links none or several. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in memory over the host's value cache or key-value store, and the stock list ships it, so every adapter takes it. Each application state keeps a memory copy of its own, which lasts one request where an adapter builds its state for each request, as Cloudflare and Spin do. Every load runs in its caller. +- A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A load is handed services with nothing of the reader in them, from `RuntimeServices::without_connection`, because its value serves every reader. A crate offers one through `IntegrationBuilder::with_loading_cache`, each feature that holds answers selects the module whose cache holds them in its own `[cache.]` table, and an adapter takes the selections with `loading_caches_from`. A feature that selects none takes the one loading cache module the build links, and gets `NoLoadingCache`, which loads every time, when the build links none or several. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in memory over the host's value cache on Fastly or its key-value store on Cloudflare and Spin, with the memory copy alone on Axum, and the stock list ships it, so every adapter takes it. Each application state keeps a memory copy of its own, which lasts one request where an adapter builds its state for each request, as Cloudflare and Spin do. Every load runs in its caller. - `[cache.robots-txt]` selects, with `module`, the loading cache that holds each robots.txt module's answer, and sets the lifetimes of its copies, in seconds: `memory_seconds` (default 60), `store_seconds` (default 604800) and `store_idle_seconds` (default 2592000). A memory lifetime may be 0, the others must be at least 1, and none may be more than a year. A `module` no linked crate offers a cache from is refused by `ts config validate` and when the service starts. - `ServerKeyPrefix`, the prefix every loading cache key starts with, being `ts.` and the publisher's domain with a leading `www.` dropped, so publishers sharing a store never share an entry. - `PlatformValueCache`, the host's shared cache of small values with lifetimes, which `RuntimeServices` carries where the host supplies one. `lookup_or_reserve` obliges the caller that finds a key missing to fill it where the host can make other callers wait, and `remove` drops an entry. The Fastly adapter supplies one over Fastly's cache, answering the reservation with a transaction lookup and the removal with a purge of the entry's own surrogate key. @@ -98,7 +99,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A `[[fetch]]` or `[[serve]]` entry may cover a media type other than `text/html`: another `text/` type except `text/x-component`, `application/javascript`, `application/json` or `image/svg+xml`, written as a bare `type/subtype` in lower case. A response that declares a charset other than UTF-8, or whose status is not `200`, gets no middleware. A middleware says which media types it works on with `handles_media_type`, HTML alone unless it says otherwise, and an entry naming one under a media type it does not handle is refused when the registry is built. On a media type other than HTML a middleware's stream processor alone runs on the body, after core has moved the origin's addresses, in the fetch phase and then, for a response that is not stored, the serve phase, and an action that decides about HTML there is refused by name. A response an entry covers loses the origin's `ETag` and `Last-Modified`, and one a `[[serve]]` entry covers is sent `private, no-store` when a module left state on the request. - The InMobi Choice consent prompt as a module, `[cmp] module = "inmobi"` with `[cmp.inmobi] script_url`, from `crates/cmp/inmobi`. Where a `[[fetch]]` entry names `cmp.inmobi`, its middleware writes the IAB TCF stub, InMobi's GPP stub and the deferred Choice loader into the head, in that order, and the two stubs' behavior is tested with Node's test runner, which CI now runs for every module's `tests/*.test.mjs`. The script URL is refused unless it is `https` on an InMobi Choice host with no user name or password, `cmp_id` unless it is a whole number from 1 to 4095, and entries that leave any page without the prompt are refused. The new `Middleware::template_key` keys a stored page by what a middleware writes that no setting decides, so a change to a stub stores pages again. The Trusted Server maintainers hold the crate until InMobi adopt it. - The stock modules' fetch middleware give as their `template_key` the text compiled into their crates that they write into a page, so a change to it stores pages again. The Google ad tag gives its head scripts, the `tsjs.adInit` bootstrap among them, and the GPT script's first-party path, Prebid its configuration script and bundle tag, Didomi its settings script, and Sourcepoint its head scripts and its CDN's first-party path. Permutive, Lockr, DataDome and Google Tag Manager give the first-party path they point a vendor's address at. -- `[robots-txt]` serves a publisher's `/robots.txt` from the settings. `modules` names what makes the file, in order. `refuse_all` and `allow_all` are Trusted Server's own, and any other name is a module that contributes rules through the new `RobotsTxtContributor` extension point, declared with `with_robots_txt_contributor`. Trusted Server writes the file, so a contributor cannot produce a malformed one, a refusal cannot be loosened, and a path in `always_allow` stays open to every crawler refused the whole site. With `refuse_all` selected every response an adapter finalizes carries `X-Robots-Tag: noindex, nofollow`. A module is asked for the host the request was addressed to, never one a forwarded header names, and is given nothing of the crawler's connection. Its answer is held for that host in the key-value store until its refresh is due on Cloudflare and Spin, while Fastly and Axum hold nothing and ask each module on every request. A module that cannot answer has its last held answer served, and with none held, or with no rule in the whole file, the answer is `503` and never an empty file. On Fastly the file is answered before the Edge Cookie steps and the request filters. Without the section `/robots.txt` reaches the publisher's origin as before. +- `[robots-txt]` serves a publisher's `/robots.txt` from the settings. `modules` names what makes the file, in order. `refuse_all` and `allow_all` are Trusted Server's own, and any other name is a module that contributes rules through the new `RobotsTxtContributor` extension point, declared with `with_robots_txt_contributor`. Trusted Server writes the file, so a contributor cannot produce a malformed one, a refusal cannot be loosened, and a path in `always_allow` stays open to every crawler refused the whole site. With `refuse_all` selected every response an adapter finalizes carries `X-Robots-Tag: noindex, nofollow`. A module is asked for the host the request was addressed to, never one a forwarded header names, and is given nothing of the crawler's connection. Its answer is held for that host by the loading cache until its refresh is due. A module that cannot answer has its last held answer served, and with none held, or with no rule in the whole file, the answer is `503` and never an empty file. On Fastly the file is answered before the Edge Cookie steps and the request filters. Without the section `/robots.txt` reaches the publisher's origin as before. - `ts keys rotate` and `ts keys deactivate` rotate and retire the request signing keys from an operator's machine. They write the two stores the service links as `jwks_store` and `signing_keys` through the `fastly` CLI, with the token from `FASTLY_API_TOKEN`. A private key reaches `fastly` on its standard input and never in its arguments, and a key id no platform can store is refused before anything is written. A command that cannot read the config store writes nothing, and a delete that failed part way can be run again. - Attestation. With `[attestation]` in the settings a deployment serves evidence, in the sense of RFC 9334, of who operates it and which build it runs, as a page and as JSON at `[attestation] endpoint`, which defaults to `/_ts/attestation`. The evidence names the host the request arrived for, the publisher, the platform service and the build, with the time and the caller's nonce, and is signed with ECDSA P-256 under a configured context. The signing keys are a schedule compiled in from the build input `TRUSTED_SERVER_ATTESTATION_KEYS`, and a build given none answers `503` at the endpoint, as does a clock that cannot be read. A key in the schedule that is not a P-256 private key stops the build. A build also reports the commit, the build run, the tag it was made from and the build time it was given, from `TRUSTED_SERVER_COMMIT`, `TRUSTED_SERVER_BUILD_RUN`, `TRUSTED_SERVER_BUILD_TAG` and `SOURCE_DATE_EPOCH`. See `docs/guide/attestation.md`. - One Fastly service can serve several publishers. A `TS__CONFIG__KEY` entry in the `trusted_server_config` store that contains `{host}` names an app-config blob for each host, pushed with `ts config push --key `, and each request reads the blob under its own host. A host with no blob is answered `421 Misdirected Request`, never from the logical store ID or from another host's blob, a malformed selector is answered `500`, and a sandbox that serves several requests keeps an application for each blob, found only by that blob's key. Without the entry, or with a selector that has no placeholder, the service reads one blob. `scripts/config-by-host-local-test.sh` runs it under Viceroy. diff --git a/docs/guide/cloudflare.md b/docs/guide/cloudflare.md index d9ca66a543..68a9dd7e5a 100644 --- a/docs/guide/cloudflare.md +++ b/docs/guide/cloudflare.md @@ -51,11 +51,18 @@ the validated envelope from the target KV namespace, construct the same nested `TRUSTED_SERVER_CONFIG` JSON, and configure it in the deployment environment. Store the two credential values with `wrangler secret put publisher_proxy_secret` and `wrangler secret put ec_passphrase`; do not commit -them to a Wrangler manifest. Deploy only after all three bindings are present. +them to a Wrangler manifest. Deploy only after all three bindings and the +`TRUSTED_SERVER_KV` namespace are present. -A successful `ts config push` alone does not configure this runtime: the -Worker currently does not open `TRUSTED_SERVER_KV` during startup. Request-time -store registries are also unwired, so EC KV lookup is not available. +A successful `ts config push` alone does not configure this runtime, because +the Worker reads its settings from `TRUSTED_SERVER_CONFIG` at startup and does +not open `TRUSTED_SERVER_KV` for them. The adapter declares `TRUSTED_SERVER_KV` +as its key-value store, so EdgeZero opens it for every request and the loading +cache keeps its copies there between requests, while a copy in memory lasts one +request because the Worker builds its state for each request. EdgeZero treats a +declared store as required, so a Worker deployed without the binding answers +every request with `500` before any route runs. No request-time config store is +declared, and EC KV lookup is not available. Forwarded client-IP headers are sanitized, but `[trusted_client_ip]` is not resolved. See the [API reference](./api-reference) for route-level behavior. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 7baf538d40..cd51249452 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1073,9 +1073,9 @@ A module with settings of its own reads them from its own block, `[robots-txt. Date: Sat, 17 Oct 2026 11:14:24 +0100 Subject: [PATCH 25/31] The adapter READMEs say each opens its key-value store for every request The Cloudflare README said the adapter has no request-time KV registry and the Spin README that request-time KV services remain unavailable. Each adapter now declares its key-value store, TRUSTED_SERVER_KV and default, which every request is given, and a Worker or component without it fails every request. --- crates/trusted-server-adapter-cloudflare/README.md | 5 +++-- crates/trusted-server-adapter-spin/README.md | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-adapter-cloudflare/README.md b/crates/trusted-server-adapter-cloudflare/README.md index 208827e689..cf08e163c7 100644 --- a/crates/trusted-server-adapter-cloudflare/README.md +++ b/crates/trusted-server-adapter-cloudflare/README.md @@ -8,8 +8,9 @@ The crate translates Worker requests and responses, registers the shared router, and implements Cloudflare-specific HTTP and secret access. The current runtime does not open the KV config value written by `ts config push`; startup instead consumes the nested `TRUSTED_SERVER_CONFIG` variable bridge. It also -has no `/health` route or request-time KV registry and permits one enabled -auction provider. +has no `/health` route and permits one enabled auction provider. It declares +the `TRUSTED_SERVER_KV` namespace as its key-value store, which every request +is given, so a Worker without the binding fails every request. Build and test from the repository root: diff --git a/crates/trusted-server-adapter-spin/README.md b/crates/trusted-server-adapter-spin/README.md index fe33c90557..1c77a9fb25 100644 --- a/crates/trusted-server-adapter-spin/README.md +++ b/crates/trusted-server-adapter-spin/README.md @@ -6,8 +6,9 @@ platform tests. The crate maps Spin HTTP, variables, and the `default` key-value store into the shared runtime. Startup failures install a restricted 503 router that keeps -`/health` live. The adapter permits one enabled auction provider. Request-time -KV services beyond startup config loading remain unavailable. Its entry point +`/health` live. The adapter permits one enabled auction provider. It declares +the `default` store as its key-value store, which every request is given, so a +component that cannot open that store fails every request. Its entry point uses `anyhow::Result` only because the EdgeZero Spin FFI requires that type. Build and test from the repository root: From 7b0a714bd86a60f6d212c77558719123ba9b6ad1 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 17 Oct 2026 13:30:04 +0100 Subject: [PATCH 26/31] The store tests move to a file of their own in each adapter The next branch removes the robots.txt route and the route tests that held a module's answer, so a store test inside that module would not outlive this branch. the_declared_store_is_the_key_value_store_a_request_is_given now lives in each adapter's tests/key_value_store.rs with its own store kept in memory, and builds the registry from the declaration the way EdgeZero does. The Spin test that the declared label is one spin.toml grants moves there too. The robots.txt route tests are left as they were. --- .../tests/key_value_store.rs | 118 +++++++++++++++ .../tests/routes.rs | 52 +------ .../tests/key_value_store.rs | 143 ++++++++++++++++++ .../tests/routes.rs | 79 +--------- 4 files changed, 271 insertions(+), 121 deletions(-) create mode 100644 crates/trusted-server-adapter-cloudflare/tests/key_value_store.rs create mode 100644 crates/trusted-server-adapter-spin/tests/key_value_store.rs diff --git a/crates/trusted-server-adapter-cloudflare/tests/key_value_store.rs b/crates/trusted-server-adapter-cloudflare/tests/key_value_store.rs new file mode 100644 index 0000000000..410b0fa0a4 --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/tests/key_value_store.rs @@ -0,0 +1,118 @@ +//! The key-value store the Cloudflare adapter declares, as the services a +//! request is given. `config_defaults.rs` checks that every Wrangler manifest +//! binds it. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bytes::Bytes; +use edgezero_core::app::Hooks as _; +use edgezero_core::body::Body; +use edgezero_core::context::RequestContext; +use edgezero_core::http::request_builder; +use edgezero_core::key_value_store::{KvError, KvHandle, KvPage, KvStore}; +use edgezero_core::params::PathParams; +use edgezero_core::store_registry::KvRegistry; +use trusted_server_adapter_cloudflare::app::TrustedServerApp; +use trusted_server_adapter_cloudflare::platform::build_runtime_services; +use trusted_server_core::settings::Settings; + +/// A key-value store kept in memory, standing in for the Workers KV +/// namespace. +#[derive(Default)] +struct MemoryStore { + entries: Mutex>, +} + +#[async_trait::async_trait(?Send)] +impl KvStore for MemoryStore { + async fn delete(&self, key: &str) -> Result<(), KvError> { + self.entries + .lock() + .expect("should lock the store") + .remove(key); + Ok(()) + } + + async fn get_bytes(&self, key: &str) -> Result, KvError> { + Ok(self + .entries + .lock() + .expect("should lock the store") + .get(key) + .cloned()) + } + + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(KvPage::default()) + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + self.entries + .lock() + .expect("should lock the store") + .insert(key.to_owned(), value); + Ok(()) + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + _ttl: Duration, + ) -> Result<(), KvError> { + self.put_bytes(key, value).await + } +} + +/// The store `run_app` opens for the declaration is the key-value store a +/// request's services are given, so what one request writes there the next +/// one reads. The registry is built from the declaration the way `EdgeZero` +/// builds it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_declared_store_is_the_key_value_store_a_request_is_given() { + let declared = TrustedServerApp::stores() + .kv + .expect("the adapter should declare its key-value store"); + let store = Arc::new(MemoryStore::default()); + let handle = KvHandle::new(Arc::clone(&store) as Arc); + let by_id = declared + .ids + .iter() + .map(|id| ((*id).to_owned(), handle.clone())) + .collect(); + let registry = KvRegistry::from_parts(by_id, declared.default.to_owned()) + .expect("the declared default should be a declared id"); + let mut request = request_builder() + .method("GET") + .uri("https://test-publisher.example.com/") + .body(Body::empty()) + .expect("should build request"); + request.extensions_mut().insert(registry); + let ctx = RequestContext::new(request, PathParams::default()); + + let services = build_runtime_services(&ctx, &Settings::default(), &Arc::default()); + services + .kv_store() + .put_bytes("a-key", Bytes::from_static(b"a value")) + .await + .expect("should write through the request's key-value store"); + + let written = store + .entries + .lock() + .expect("should lock the store") + .get("a-key") + .cloned(); + assert_eq!( + written.as_deref(), + Some(&b"a value"[..]), + "the write should reach the declared store" + ); +} diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 4dc345a087..e7ba54f030 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -832,16 +832,12 @@ mod robots_txt_from_a_module { use std::time::Duration; use bytes::Bytes; - use edgezero_core::app::Hooks as _; - use edgezero_core::context::RequestContext; use edgezero_core::http::Request; use edgezero_core::key_value_store::{KvError, KvHandle, KvPage, KvStore}; - use edgezero_core::params::PathParams; use edgezero_core::router::RouterService; use edgezero_core::store_registry::KvRegistry; use error_stack::Report; use trusted_server_adapter_cloudflare::app::TrustedServerApp; - use trusted_server_adapter_cloudflare::platform::build_runtime_services; use trusted_server_core::error::TrustedServerError; use trusted_server_core::integrations::{IntegrationBuilder, IntegrationRegistration}; use trusted_server_core::module_context::ModuleCall; @@ -994,58 +990,20 @@ mod robots_txt_from_a_module { } } - /// The registry `run_app` puts on a request for the key-value store the - /// adapter declares, built the way `EdgeZero` builds it, with `store` - /// standing in for the Workers KV namespace. - fn declared_store_registry(store: &Arc) -> KvRegistry { - let declared = TrustedServerApp::stores() - .kv - .expect("the adapter should declare its key-value store"); - let handle = KvHandle::new(Arc::clone(store) as Arc); - let by_id = declared - .ids - .iter() - .map(|id| ((*id).to_owned(), handle.clone())) - .collect(); - KvRegistry::from_parts(by_id, declared.default.to_owned()) - .expect("the declared default should be a declared id") - } - /// A crawler's `GET` for the file, carrying `store` as the key-value store /// the runtime hands the application, when there is one. fn robots_get(store: Option<&Arc>) -> Request { let mut request = robots_request("GET", "/robots.txt"); if let Some(store) = store { - request - .extensions_mut() - .insert(declared_store_registry(store)); + let handle = KvHandle::new(Arc::clone(store) as Arc); + request.extensions_mut().insert(KvRegistry::single_id( + "trusted_server_kv".to_owned(), + handle, + )); } request } - /// The store `run_app` opens for the declaration is the key-value store - /// a request's services are given, so what one request writes there the - /// next one reads. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn the_declared_store_is_the_key_value_store_a_request_is_given() { - let store = Arc::new(MemoryStore::default()); - let ctx = RequestContext::new(robots_get(Some(&store)), PathParams::default()); - - let services = build_runtime_services(&ctx, &Settings::default(), &Arc::default()); - services - .kv_store() - .put_bytes("a-key", Bytes::from_static(b"a value")) - .await - .expect("should write through the request's key-value store"); - - let entries = store.entries.lock().expect("should lock the store"); - assert_eq!( - entries.get("a-key").map(|value| &value[..]), - Some(&b"a value"[..]), - "the write should reach the declared store" - ); - } - /// With the store the runtime hands the application, a module's answer /// is held, so when the module next fails the file is made from the /// answer it gave before. diff --git a/crates/trusted-server-adapter-spin/tests/key_value_store.rs b/crates/trusted-server-adapter-spin/tests/key_value_store.rs new file mode 100644 index 0000000000..b088afe641 --- /dev/null +++ b/crates/trusted-server-adapter-spin/tests/key_value_store.rs @@ -0,0 +1,143 @@ +//! The key-value store the Spin adapter declares, from the label `spin.toml` +//! grants to the services a request is given. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bytes::Bytes; +use edgezero_core::app::Hooks as _; +use edgezero_core::body::Body; +use edgezero_core::context::RequestContext; +use edgezero_core::http::request_builder; +use edgezero_core::key_value_store::{KvError, KvHandle, KvPage, KvStore}; +use edgezero_core::params::PathParams; +use edgezero_core::store_registry::KvRegistry; +use trusted_server_adapter_spin::app::TrustedServerApp; +use trusted_server_adapter_spin::platform::build_runtime_services; +use trusted_server_core::settings::Settings; + +/// A key-value store kept in memory, standing in for the Spin store. +#[derive(Default)] +struct MemoryStore { + entries: Mutex>, +} + +#[async_trait::async_trait(?Send)] +impl KvStore for MemoryStore { + async fn delete(&self, key: &str) -> Result<(), KvError> { + self.entries + .lock() + .expect("should lock the store") + .remove(key); + Ok(()) + } + + async fn get_bytes(&self, key: &str) -> Result, KvError> { + Ok(self + .entries + .lock() + .expect("should lock the store") + .get(key) + .cloned()) + } + + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(KvPage::default()) + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + self.entries + .lock() + .expect("should lock the store") + .insert(key.to_owned(), value); + Ok(()) + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + _ttl: Duration, + ) -> Result<(), KvError> { + self.put_bytes(key, value).await + } +} + +/// `EdgeZero` opens the key-value store the adapter declares for every +/// request and treats it as required, so the label it opens has to be one +/// `spin.toml` grants the component, or every request fails. +#[test] +fn the_declared_key_value_store_is_a_label_spin_grants() { + let declared = TrustedServerApp::stores() + .kv + .expect("the adapter should declare its key-value store"); + let manifest = include_str!("../spin.toml"); + + // `EdgeZero` takes the label from an `EDGEZERO__STORES__KV____NAME` + // variable in the component's environment and falls back to the id. + assert!( + !manifest.contains("EDGEZERO__STORES__KV__"), + "spin.toml should not rename the declared store" + ); + let granted = manifest + .lines() + .find_map(|line| line.trim().strip_prefix("key_value_stores = ")) + .expect("spin.toml should grant the component its key-value stores"); + assert!( + granted.contains(&format!("\"{}\"", declared.default)), + "spin.toml should grant `{}`, which the declared store opens, and grants {granted}", + declared.default + ); +} + +/// The store `run_app` opens for the declaration is the key-value store a +/// request's services are given, so what one request writes there the next +/// one reads. The registry is built from the declaration the way `EdgeZero` +/// builds it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_declared_store_is_the_key_value_store_a_request_is_given() { + let declared = TrustedServerApp::stores() + .kv + .expect("the adapter should declare its key-value store"); + let store = Arc::new(MemoryStore::default()); + let handle = KvHandle::new(Arc::clone(&store) as Arc); + let by_id = declared + .ids + .iter() + .map(|id| ((*id).to_owned(), handle.clone())) + .collect(); + let registry = KvRegistry::from_parts(by_id, declared.default.to_owned()) + .expect("the declared default should be a declared id"); + let mut request = request_builder() + .method("GET") + .uri("https://test-publisher.example.com/") + .body(Body::empty()) + .expect("should build request"); + request.extensions_mut().insert(registry); + let ctx = RequestContext::new(request, PathParams::default()); + + let services = build_runtime_services(&ctx, &Settings::default(), &Arc::default()); + services + .kv_store() + .put_bytes("a-key", Bytes::from_static(b"a value")) + .await + .expect("should write through the request's key-value store"); + + let written = store + .entries + .lock() + .expect("should lock the store") + .get("a-key") + .cloned(); + assert_eq!( + written.as_deref(), + Some(&b"a value"[..]), + "the write should reach the declared store" + ); +} diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 3c63ee3771..faafe59ed0 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -53,33 +53,6 @@ fn routes_build_without_panic() { let _router = TrustedServerApp::routes(); } -/// `EdgeZero` opens the key-value store the adapter declares for every -/// request and treats it as required, so the label it opens has to be one -/// `spin.toml` grants the component, or every request fails. -#[test] -fn the_declared_key_value_store_is_a_label_spin_grants() { - let declared = TrustedServerApp::stores() - .kv - .expect("the adapter should declare its key-value store"); - let manifest = include_str!("../spin.toml"); - - // `EdgeZero` takes the label from an `EDGEZERO__STORES__KV____NAME` - // variable in the component's environment and falls back to the id. - assert!( - !manifest.contains("EDGEZERO__STORES__KV__"), - "spin.toml should not rename the declared store" - ); - let granted = manifest - .lines() - .find_map(|line| line.trim().strip_prefix("key_value_stores = ")) - .expect("spin.toml should grant the component its key-value stores"); - assert!( - granted.contains(&format!("\"{}\"", declared.default)), - "spin.toml should grant `{}`, which the declared store opens, and grants {granted}", - declared.default - ); -} - // --------------------------------------------------------------------------- // Middleware regression tests, which verify FinalizeResponseMiddleware is // wired so it cannot be removed silently. @@ -1077,16 +1050,12 @@ mod robots_txt_from_a_module { use std::time::Duration; use bytes::Bytes; - use edgezero_core::app::Hooks as _; - use edgezero_core::context::RequestContext; use edgezero_core::http::Request; use edgezero_core::key_value_store::{KvError, KvHandle, KvPage, KvStore}; - use edgezero_core::params::PathParams; use edgezero_core::router::RouterService; use edgezero_core::store_registry::KvRegistry; use error_stack::Report; use trusted_server_adapter_spin::app::TrustedServerApp; - use trusted_server_adapter_spin::platform::build_runtime_services; use trusted_server_core::error::TrustedServerError; use trusted_server_core::integrations::{IntegrationBuilder, IntegrationRegistration}; use trusted_server_core::module_context::ModuleCall; @@ -1239,58 +1208,20 @@ mod robots_txt_from_a_module { } } - /// The registry `run_app` puts on a request for the key-value store the - /// adapter declares, built the way `EdgeZero` builds it, with `store` - /// standing in for the Spin store. - fn declared_store_registry(store: &Arc) -> KvRegistry { - let declared = TrustedServerApp::stores() - .kv - .expect("the adapter should declare its key-value store"); - let handle = KvHandle::new(Arc::clone(store) as Arc); - let by_id = declared - .ids - .iter() - .map(|id| ((*id).to_owned(), handle.clone())) - .collect(); - KvRegistry::from_parts(by_id, declared.default.to_owned()) - .expect("the declared default should be a declared id") - } - /// A crawler's `GET` for the file, carrying `store` as the key-value store /// the runtime hands the application, when there is one. fn robots_get(store: Option<&Arc>) -> Request { let mut request = robots_request("GET", "/robots.txt"); if let Some(store) = store { - request - .extensions_mut() - .insert(declared_store_registry(store)); + let handle = KvHandle::new(Arc::clone(store) as Arc); + request.extensions_mut().insert(KvRegistry::single_id( + "trusted_server_kv".to_owned(), + handle, + )); } request } - /// The store `run_app` opens for the declaration is the key-value store - /// a request's services are given, so what one request writes there the - /// next one reads. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn the_declared_store_is_the_key_value_store_a_request_is_given() { - let store = Arc::new(MemoryStore::default()); - let ctx = RequestContext::new(robots_get(Some(&store)), PathParams::default()); - - let services = build_runtime_services(&ctx, &Settings::default(), &Arc::default()); - services - .kv_store() - .put_bytes("a-key", Bytes::from_static(b"a value")) - .await - .expect("should write through the request's key-value store"); - - let entries = store.entries.lock().expect("should lock the store"); - assert_eq!( - entries.get("a-key").map(|value| &value[..]), - Some(&b"a value"[..]), - "the write should reach the declared store" - ); - } - /// With the store the runtime hands the application, a module's answer /// is held, so when the module next fails the file is made from the /// answer it gave before. From 8c31abcc19c6eaf6f16d2d38bcfc967f14f38990 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 17 Oct 2026 13:33:59 +0100 Subject: [PATCH 27/31] The robots.txt guide leaves its point on stores that cannot be opened The sentence said a deployment whose key-value store cannot be opened keeps no last answer, which stays true now that Cloudflare and Spin fail every request without their store, and the Cloudflare and Spin guides say that. The point goes back to its wording so the guide merges cleanly into the next branch, which adds a point after it. --- docs/guide/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index cd51249452..13aac549f1 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1075,7 +1075,7 @@ A module with settings of its own reads them from its own block, `[robots-txt. Date: Wed, 28 Oct 2026 11:27:26 +0000 Subject: [PATCH 28/31] A stock build links no loading cache module The stock list in crates/trusted-server-modules no longer offers trusted_server_loading_cache_51degrees::builder(), and the crate no longer depends on the module crate, so no adapter and not the ts tool compiles the module or fiftyone-caching. The module crate stays in the tree for a deployment that links it through run_with on Fastly or routes_with_registrations on Axum, Cloudflare and Spin. the_stock_build_links_no_loading_cache fails if a builder in the stock list offers a loading cache. It replaces the three tests that pinned the stock cache, and the order test stops naming loading-cache.51degrees. The module crate's builder documentation no longer says the stock list carries it. --- Cargo.lock | 1 - crates/loading-cache/51degrees/src/lib.rs | 3 +- crates/trusted-server-modules/Cargo.toml | 1 - crates/trusted-server-modules/src/lib.rs | 81 ++++------------------- 4 files changed, 15 insertions(+), 71 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ec84d51020..77ae9c3c2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7212,7 +7212,6 @@ dependencies = [ "trusted-server-core", "trusted-server-framework-nextjs", "trusted-server-identity-lockr", - "trusted-server-loading-cache-51degrees", "trusted-server-tag-google-tag-manager", "trusted-server-testing-testlight", ] diff --git a/crates/loading-cache/51degrees/src/lib.rs b/crates/loading-cache/51degrees/src/lib.rs index c951365e94..e010eb158b 100644 --- a/crates/loading-cache/51degrees/src/lib.rs +++ b/crates/loading-cache/51degrees/src/lib.rs @@ -36,8 +36,7 @@ use crate::stores::WebClock; pub const MODULE: &str = "loading-cache.51degrees"; /// The builder a deployment hands to an adapter, which offers this loading -/// cache whatever the settings say. A stock build lists it in -/// `trusted-server-modules`. +/// cache whatever the settings say. No stock build links it. #[must_use] pub fn builder() -> IntegrationBuilder { IntegrationBuilder::implementations(MODULE, env!("CARGO_PKG_NAME")).with_loading_cache(factory) diff --git a/crates/trusted-server-modules/Cargo.toml b/crates/trusted-server-modules/Cargo.toml index 0b3671d567..9ba7a7b20e 100644 --- a/crates/trusted-server-modules/Cargo.toml +++ b/crates/trusted-server-modules/Cargo.toml @@ -30,7 +30,6 @@ trusted-server-cmp-sourcepoint = { workspace = true } trusted-server-core = { workspace = true } trusted-server-framework-nextjs = { workspace = true } trusted-server-identity-lockr = { workspace = true } -trusted-server-loading-cache-51degrees = { workspace = true } trusted-server-tag-google-tag-manager = { workspace = true } trusted-server-testing-testlight = { workspace = true } diff --git a/crates/trusted-server-modules/src/lib.rs b/crates/trusted-server-modules/src/lib.rs index 28b49e40c2..fb790b49ec 100644 --- a/crates/trusted-server-modules/src/lib.rs +++ b/crates/trusted-server-modules/src/lib.rs @@ -21,7 +21,8 @@ use trusted_server_core::integrations::IntegrationBuilder; use trusted_server_core::middleware::{HTML_MEDIA_TYPE, MiddlewarePhase}; /// The builders of the modules a stock build ships from crates of their own, -/// in hook order. +/// in hook order. None offers a loading cache, so a deployment that wants +/// copies kept hands a loading cache module's builder to its adapter. #[must_use] pub fn builders() -> Vec { vec![ @@ -47,9 +48,6 @@ pub fn builders() -> Vec { trusted_server_auction_prebid_server::builder(), trusted_server_auction_aps::builder(), trusted_server_ad_server_mock::builder(), - // The loading cache a stock build caches with, offered whatever the - // settings say, which no section selects either. - trusted_server_loading_cache_51degrees::builder(), ] } @@ -442,77 +440,26 @@ mod tests { "auction.prebid-server", "auction.aps", "ad-server.mock", - "loading-cache.51degrees", ], "should offer the stock modules in the order their hooks run" ); } - /// A feature selects the stock loading cache by the module's short name, - /// so a stock build keeps what it loads rather than asking each source - /// every time. + /// A stock build links no loading cache module, so every feature loads + /// through `NoLoadingCache`, which keeps no copy, and a deployment that + /// wants copies kept hands a loading cache module's builder to its + /// adapter. #[test] - fn a_feature_selects_the_stock_loading_cache_by_name() { - let mut settings = trusted_server_core::test_support::tests::create_test_settings(); - settings.cache.robots_txt.module = Some("51degrees".to_owned()); - - let caches = trusted_server_core::integrations::loading_caches_from(&settings, &builders()) - .expect("the stock list should offer the selected loading cache"); - - assert!( - caches - .for_feature(trusted_server_core::settings::ROBOTS_TXT_CACHE) - .keeps_values(), - "the stock loading cache should keep what it loads" - ); - } - - /// A table naming no module takes the one loading cache module the build - /// links, so a stock build holds each answer only while this list links - /// exactly one. A second would leave the table holding nothing, with no - /// message. - #[test] - fn a_table_naming_no_module_holds_with_the_stock_loading_cache() { - let settings = trusted_server_core::test_support::tests::create_test_settings(); - assert!( - settings.cache.robots_txt.module.is_none(), - "the test settings should name no module" - ); - - let caches = trusted_server_core::integrations::loading_caches_from(&settings, &builders()) - .expect("a table naming no module is not an error"); - - assert!( - caches - .for_feature(trusted_server_core::settings::ROBOTS_TXT_CACHE) - .keeps_values(), - "a stock build should hold each robots.txt module's answer" - ); - } - - /// `ts config validate` validates against this list, so it accepts the - /// stock loading cache by name and refuses a misspelling, which the - /// service would otherwise refuse only as it starts. - #[test] - fn deploy_validation_knows_the_stock_loading_cache() { - use trusted_server_core::config::validate_settings_for_deploy_with; - - let stock = builders(); - let mut settings = trusted_server_core::test_support::tests::create_test_settings(); - - settings.cache.robots_txt.module = Some("51degrees".to_owned()); - validate_settings_for_deploy_with(&settings, &stock) - .expect("the stock loading cache should pass deploy validation"); + fn the_stock_build_links_no_loading_cache() { + let offering: Vec<&str> = builders() + .iter() + .filter(|builder| builder.loading_cache().is_some()) + .map(IntegrationBuilder::id) + .collect(); - settings.cache.robots_txt.module = Some("51degreez".to_owned()); - let error = validate_settings_for_deploy_with(&settings, &stock) - .expect_err("a module nothing offers should be refused"); assert!( - format!("{error:?}").contains( - "[cache.robots-txt] module names `51degreez`, which no linked crate offers a \ - loading cache from. The loading cache modules this build has are: 51degrees" - ), - "should name the table and the stock loading cache: {error:?}" + offering.is_empty(), + "a stock build should link no loading cache, and links {offering:?}" ); } From 2aac42432530dd18424934f12fcfe7ef101f03da Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:27:27 +0000 Subject: [PATCH 29/31] The guides and the changelog say a stock build links no loading cache The configuration guide, the integration guide, the Cloudflare and Spin guides, the example settings and the changelog said that a stock build links loading-cache.51degrees, holds answers without a line in the document, or keeps copies in its key-value store. They now say that a stock build links no loading cache module, so no feature keeps a copy and every module a [cache.] table names is refused, and that a deployment that wants copies kept links a loading cache module itself. The changelog adds an entry saying that a stock build refuses module = "51degrees", so a deployment whose document names the module links it first. --- CHANGELOG.md | 5 +++-- docs/guide/cloudflare.md | 13 +++++++------ docs/guide/configuration.md | 25 +++++++++++++++---------- docs/guide/integration-guide.md | 8 ++++++-- docs/guide/spin.md | 13 +++++++------ trusted-server.example.toml | 4 +++- 6 files changed, 41 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 055a193360..8dd48d3f1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - A robots.txt module's answer is held by the loading cache of the one loading cache module the build links, or of the module `[cache.robots-txt] module` names, briefly in memory and in the host's shared store outside it, so a module is asked again only when no copy young enough is held, rather than by every request. With no loading cache module linked, or several and none named, nothing is held and every request for the file asks each module, as every request did when no adapter gave the file a key-value store. The last answer is kept apart for 30 days and served when the module cannot answer, in the value cache on Fastly and in the key-value store on Cloudflare and Spin, and Axum, which has no key-value store, keeps none. -- **Breaking:** the Cloudflare and Spin adapters declare their key-value store, the Workers KV binding `TRUSTED_SERVER_KV` and Spin's `default` store, so EdgeZero opens it for every request and the loading cache keeps its copies there between requests, where every request was given a store whose every call failed. A declared store is required, so a Worker without the binding, or a Spin component that cannot open `default`, fails every request before any route runs. +- **Breaking:** the Cloudflare and Spin adapters declare their key-value store, the Workers KV binding `TRUSTED_SERVER_KV` and Spin's `default` store, so EdgeZero opens it for every request, where every request was given a store whose every call failed, and a loading cache module that a deployment links keeps its copies there between requests. A declared store is required, so a Worker without the binding, or a Spin component that cannot open `default`, fails every request before any route runs. - A consent management platform's middleware runs in the phase the publisher chooses. `cmp.inmobi`, `cmp.didomi` and `cmp.sourcepoint` may be named in a `[[fetch]]` or a `[[serve]]` entry, where each ran in the fetch phase alone. A generated configuration still places them in `[[fetch]]`. A `[[serve]]` entry naming `cmp.inmobi` for a page whose `[[fetch]]` entry names another middleware is refused, because the serve phase writes its head markup after what the fetch phase writes ahead of the script bundle, so a vendor's tag there would come ahead of the prompt. Naming `cmp.inmobi` for one page in both phases is refused, because the prompt would be written twice. - **Breaking:** auction telemetry is selected the way every other module is. `[tinybird]` is now `[analytics.tinybird]`, and `[analytics] module = "tinybird"` selects it, which replaces the `enabled` line. A configuration that still carries `[tinybird]` is refused with directions. A stored configuration written by an earlier version carries the table even where telemetry was off, so push the configuration again with this version. The secret reference is `analytics.tinybird.auction_token_secret`, the table refuses a key it does not know, and a section that selects nothing, or a module this build does not have, is refused. A row is sent only for a reader whose permissions include `analytics.ad_reporting.measure_ad_performance`, so none is sent for a reader the consent gate refuses unless they permit it. The Axum, Cloudflare and Spin adapters send no telemetry, and log a warning at startup when the section selects a module. - The DataDome module asks its Protection API about a publisher's pages beneath `/admin/` like any other page. It skipped every path beneath `/admin/` and `/_ts/admin/`, which left a publisher's own `/admin/` pages out of bot protection. @@ -91,8 +91,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - A middleware may prepare, and may refuse. `Middleware::prepare` runs in the request's handler before the document is processed, as the middleware's module with the request's services, and what it answers is kept under the phase and the middleware's name for `create` in that phase, so a decision that needs a store or the network is made there while `create` stays synchronous. The fetch phase of a document that may be stored is prepared with no client IP, TLS details or client signals in the services. A refusal answers the request with its status and `Retry-After`, with no document, and nothing is stored. Core logs a refusal as a warning naming the middleware, and answers a status that is not a client or server error with `500`. -- A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A load is handed services with nothing of the reader in them, from `RuntimeServices::without_connection`, because its value serves every reader. A crate offers one through `IntegrationBuilder::with_loading_cache`, each feature that holds answers selects the module whose cache holds them in its own `[cache.]` table, and an adapter takes the selections with `loading_caches_from`. A feature that selects none takes the one loading cache module the build links, and gets `NoLoadingCache`, which loads every time, when the build links none or several. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in memory over the host's value cache on Fastly or its key-value store on Cloudflare and Spin, with the memory copy alone on Axum, and the stock list ships it, so every adapter takes it. Each application state keeps a memory copy of its own, which lasts one request where an adapter builds its state for each request, as Cloudflare and Spin do. Every load runs in its caller. +- A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A load is handed services with nothing of the reader in them, from `RuntimeServices::without_connection`, because its value serves every reader. A crate offers one through `IntegrationBuilder::with_loading_cache`, each feature that holds answers selects the module whose cache holds them in its own `[cache.]` table, and an adapter takes the selections with `loading_caches_from`. A feature that selects none takes the one loading cache module the build links, and gets `NoLoadingCache`, which loads every time, when the build links none or several. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in memory over the host's value cache on Fastly or its key-value store on Cloudflare and Spin, with the memory copy alone on Axum, for a deployment that links it, because no stock build does. Each application state keeps a memory copy of its own, which lasts one request where an adapter builds its state for each request, as Cloudflare and Spin do. Every load runs in its caller. - `[cache.robots-txt]` selects, with `module`, the loading cache that holds each robots.txt module's answer, and sets the lifetimes of its copies, in seconds: `memory_seconds` (default 60), `store_seconds` (default 604800) and `store_idle_seconds` (default 2592000). A memory lifetime may be 0, the others must be at least 1, and none may be more than a year. A `module` no linked crate offers a cache from is refused by `ts config validate` and when the service starts. +- A stock build links no loading cache module, so every feature loads through `NoLoadingCache`, which keeps no copy, and `module = "51degrees"`, or any other `module`, is refused by `ts config validate` and when the service starts. A deployment whose document names the module links it first, by handing `trusted_server_loading_cache_51degrees::builder()` to its adapter through `run_with` on Fastly or `routes_with_registrations` on Axum, Cloudflare and Spin. - `ServerKeyPrefix`, the prefix every loading cache key starts with, being `ts.` and the publisher's domain with a leading `www.` dropped, so publishers sharing a store never share an entry. - `PlatformValueCache`, the host's shared cache of small values with lifetimes, which `RuntimeServices` carries where the host supplies one. `lookup_or_reserve` obliges the caller that finds a key missing to fill it where the host can make other callers wait, and `remove` drops an entry. The Fastly adapter supplies one over Fastly's cache, answering the reservation with a transaction lookup and the removal with a purge of the entry's own surrogate key. - A file the origin does not have is made by the middleware a `[[fetch]]` entry with a `path`, for a media type other than HTML, names for it. When the origin answers `404 Not Found` for that path itself, which names a file and not a folder, the middleware runs on an empty document of the entry's media type and its output is the file, so `ads.txt` is made from the sellers lines alone where the publisher has none. With no such entry, or for a request that names no host, the origin's answer stands. diff --git a/docs/guide/cloudflare.md b/docs/guide/cloudflare.md index 9a256f4230..f9ab269955 100644 --- a/docs/guide/cloudflare.md +++ b/docs/guide/cloudflare.md @@ -59,12 +59,13 @@ them to a Wrangler manifest. Deploy only after all three bindings and the A successful `ts config push` alone does not configure this runtime, because the Worker reads its settings from `TRUSTED_SERVER_CONFIG` at startup and does not open `TRUSTED_SERVER_KV` for them. The adapter declares `TRUSTED_SERVER_KV` -as its key-value store, so EdgeZero opens it for every request and the loading -cache keeps its copies there between requests, while a copy in memory lasts one -request because the Worker builds its state for each request. EdgeZero treats a -declared store as required, so a Worker deployed without the binding answers -every request with `500` before any route runs. No request-time config store is -declared, and EC KV lookup is not available. +as its key-value store, so EdgeZero opens it for every request, and a loading +cache module that a deployment links keeps its copies there between requests, +while a copy in memory lasts one request because the Worker builds its state +for each request. EdgeZero treats a declared store as required, so a Worker +deployed without the binding answers every request with `500` before any route +runs. No request-time config store is declared, and EC KV lookup is not +available. Forwarded client-IP headers are sanitized, but `[trusted_client_ip]` is not resolved. See the [API reference](./api-reference) for route-level behavior. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 88dbaf8315..8e05660f12 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1144,7 +1144,7 @@ A module with settings of its own reads them from its own block, `[robots-txt.]` table a module that offers a loading -cache, with the module's short name or its name in full. A stock build links -`loading-cache.51degrees`, from `crates/loading-cache/51degrees`, so -`module = "51degrees"` selects it. A feature that names no module takes the -one loading cache module the build links, so a stock build holds answers -without a line in the document, and keeps no copy when the build links none -or several, making each answer every time it is asked for. A name that no +cache, with the module's short name or its name in full. A feature that names +no module takes the one loading cache module the build links, and keeps no +copy when the build links none or several, making each answer every time it +is asked for. A stock build links none, so there no feature keeps a copy and +every `module` is refused. A deployment that wants copies kept links a +loading cache module itself, handing its builder to the adapter as the +[integration guide](/guide/integration-guide#supplying-a-loading-cache) +describes. `crates/loading-cache/51degrees` offers `loading-cache.51degrees`, +which `module = "51degrees"` selects in a build that links it. 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. @@ -1797,6 +1800,7 @@ the service starts, with the names that could be written. ```toml [cache.robots-txt] +# Refused unless the build links crates/loading-cache/51degrees. module = "51degrees" memory_seconds = 60 store_seconds = 604800 @@ -1858,9 +1862,10 @@ behind after a module is switched off is caught rather than sitting unread. `ad-server.mock` supply demand and ad server implementations only. They are not page integrations, so a section that names one refuses startup like any name no module supplies. Their settings live in `[demand.]` and -`[ad-server.]`. `loading-cache.51degrees` supplies a loading cache, -which a feature selects in its own `[cache.]` table, described under -[Loading caches](#loading-caches), and no section selects it either. +`[ad-server.]`. `loading-cache.51degrees` supplies a loading cache +that a feature selects in its own `[cache.]` table, described under +[Loading caches](#loading-caches), and no section selects it either. No +stock build links it, so it is offered only to a deployment that does. ### Placing page changes diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 9e5487a995..db795f6987 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -812,8 +812,12 @@ the builders it is given with `loading_caches_from` when it builds its state, which refuses a name no builder offers a cache from, and hands them to every request's services as `LoadingCaches`. A feature whose table names no module takes the one loading cache module the build links, and loads through -`NoLoadingCache` when the build links none or several. The stock list carries -`crates/loading-cache/51degrees`. +`NoLoadingCache` when the build links none or several. No stock list carries a +loading cache module, so a stock build loads every value through +`NoLoadingCache`. A deployment that wants copies kept hands a loading cache +module's builder to its adapter, through `run_with` on Fastly or +`routes_with_registrations` on Axum, Cloudflare and Spin, as it does for any +crate of its own. `crates/loading-cache/51degrees` is one such module. ```rust pub fn builder() -> IntegrationBuilder { diff --git a/docs/guide/spin.md b/docs/guide/spin.md index 1e333436e0..963f7e05a5 100644 --- a/docs/guide/spin.md +++ b/docs/guide/spin.md @@ -73,12 +73,13 @@ manifest, SQLite store, response captures, and logs. Use the same store mapping and encoded variables in the deployment provider; empty variable defaults fail closed. The adapter declares Spin's `default` key-value store, the store startup reads the settings from, so EdgeZero opens it -for every request and the loading cache keeps its copies there between requests, -while a copy in memory lasts one request because Spin builds the state for each -request. EdgeZero treats a declared store as required, so a component that -cannot open `default` fails every request before any route runs, `/health` -included. No request-time config store is declared, so request signing cannot -read its key metadata, and EC KV lookup is not available. Forwarded client-IP +for every request, and a loading cache module that a deployment links keeps its +copies there between requests, while a copy in memory lasts one request because +Spin builds the state for each request. EdgeZero treats a declared store as +required, so a component that cannot open `default` fails every request before +any route runs, `/health` included. No request-time config store is declared, +so request signing cannot read its key metadata, and EC KV lookup is not +available. Forwarded client-IP headers are sanitized, and Spin derives its runtime authority, scheme, and client-address headers in the innermost normalization layer. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 9cb5b0989b..d670db8f8a 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -430,7 +430,9 @@ pull_sync_concurrency = 3 # how long that copy lasts unused. A module's own refresh ends its answer # sooner. Memory may be 0, the other two must be at least 1, and none may be # more than a year. Without `module` the one loading cache module the build -# links is used, and with none or several linked no copy is kept. +# links is used, and with none or several linked no copy is kept. A stock +# build links none and refuses any `module`, so `51degrees` is written only +# where a deployment links crates/loading-cache/51degrees. # [cache.robots-txt] # module = "51degrees" # memory_seconds = 60 From e1e0034cbf4d0dc705954b53ae7eecf4395e8be7 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:28:59 +0000 Subject: [PATCH 30/31] The robots.txt and loading cache tests' services carry the sample policy --- crates/loading-cache/51degrees/src/tests.rs | 1 + crates/trusted-server-core/src/robots_txt/holding.rs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/loading-cache/51degrees/src/tests.rs b/crates/loading-cache/51degrees/src/tests.rs index 401330608b..f8af14ba68 100644 --- a/crates/loading-cache/51degrees/src/tests.rs +++ b/crates/loading-cache/51degrees/src/tests.rs @@ -173,6 +173,7 @@ fn services_builder( kv: Arc, ) -> trusted_server_core::platform::RuntimeServicesBuilder { RuntimeServices::builder() + .permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()) .config_store(Arc::new(NoopConfigStore)) .secret_store(Arc::new(NoopSecretStore)) .kv_store(kv) diff --git a/crates/trusted-server-core/src/robots_txt/holding.rs b/crates/trusted-server-core/src/robots_txt/holding.rs index b4057197cb..22d2bf01b9 100644 --- a/crates/trusted-server-core/src/robots_txt/holding.rs +++ b/crates/trusted-server-core/src/robots_txt/holding.rs @@ -469,7 +469,7 @@ pub(crate) mod tests { #[tokio::test] async fn the_contributor_is_handed_nothing_of_the_reader_who_asks_first() { let reading: Arc = Arc::new(ReaderEcho); - let services = crate::platform::RuntimeServices::builder() + let services = crate::platform::test_support::test_services_builder() .config_store(Arc::new(crate::platform::test_support::NoopConfigStore)) .secret_store(Arc::new(crate::platform::test_support::NoopSecretStore)) .kv_store(Arc::new(MemoryKvStore::default())) @@ -988,7 +988,7 @@ pub(crate) mod tests { scripted.refresh = Duration::ZERO; let scripted = Arc::new(scripted); let value_cache = Arc::new(MemoryValueCache::default()); - let services = crate::platform::RuntimeServices::builder() + let services = crate::platform::test_support::test_services_builder() .config_store(Arc::new(crate::platform::test_support::NoopConfigStore)) .secret_store(Arc::new(crate::platform::test_support::NoopSecretStore)) .kv_store(Arc::new(crate::platform::UnavailableKvStore)) From a1a2596f573bcfc784af326bf9abd88dcc3bc97d Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:29:00 +0000 Subject: [PATCH 31/31] The store tests' services carry the sample policy --- .../tests/key_value_store.rs | 8 +++++++- .../trusted-server-adapter-spin/tests/key_value_store.rs | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-adapter-cloudflare/tests/key_value_store.rs b/crates/trusted-server-adapter-cloudflare/tests/key_value_store.rs index 410b0fa0a4..79f2220ef0 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/key_value_store.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/key_value_store.rs @@ -16,6 +16,7 @@ use edgezero_core::params::PathParams; use edgezero_core::store_registry::KvRegistry; use trusted_server_adapter_cloudflare::app::TrustedServerApp; use trusted_server_adapter_cloudflare::platform::build_runtime_services; +use trusted_server_core::permissions::PermissionPolicy; use trusted_server_core::settings::Settings; /// A key-value store kept in memory, standing in for the Workers KV @@ -97,7 +98,12 @@ async fn the_declared_store_is_the_key_value_store_a_request_is_given() { request.extensions_mut().insert(registry); let ctx = RequestContext::new(request, PathParams::default()); - let services = build_runtime_services(&ctx, &Settings::default(), &Arc::default()); + let services = build_runtime_services( + &ctx, + &Settings::default(), + &Arc::default(), + &PermissionPolicy::sample(), + ); services .kv_store() .put_bytes("a-key", Bytes::from_static(b"a value")) diff --git a/crates/trusted-server-adapter-spin/tests/key_value_store.rs b/crates/trusted-server-adapter-spin/tests/key_value_store.rs index b088afe641..860ebf9e01 100644 --- a/crates/trusted-server-adapter-spin/tests/key_value_store.rs +++ b/crates/trusted-server-adapter-spin/tests/key_value_store.rs @@ -15,6 +15,7 @@ use edgezero_core::params::PathParams; use edgezero_core::store_registry::KvRegistry; use trusted_server_adapter_spin::app::TrustedServerApp; use trusted_server_adapter_spin::platform::build_runtime_services; +use trusted_server_core::permissions::PermissionPolicy; use trusted_server_core::settings::Settings; /// A key-value store kept in memory, standing in for the Spin store. @@ -122,7 +123,12 @@ async fn the_declared_store_is_the_key_value_store_a_request_is_given() { request.extensions_mut().insert(registry); let ctx = RequestContext::new(request, PathParams::default()); - let services = build_runtime_services(&ctx, &Settings::default(), &Arc::default()); + let services = build_runtime_services( + &ctx, + &Settings::default(), + &Arc::default(), + &PermissionPolicy::sample(), + ); services .kv_store() .put_bytes("a-key", Bytes::from_static(b"a value"))