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/CHANGELOG.md b/CHANGELOG.md index f04117759b..949a52b40a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +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, 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, 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. @@ -92,12 +94,17 @@ 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, 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. - `[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 that no entry for `text/plain` on `/ads.txt` 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 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 request's key-value store until its refresh is due, and no adapter gives a request a working one, so every adapter asks 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. - 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. Each host's blob names its permission policy file and pins its SHA-256, and the push keeps each file `--permissions` supplies once, in a shared entry under `permissions_`, written before the blob, which a live push writes a minute after it writes an entry. 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. - `ts audit generate` writes the `[[fetch]]` and `[[serve]]` entries that run the page changes of the modules it selects on every HTML page, and `trusted-server.example.toml` documents the entries with every middleware the stock modules supply. `trusted-server-modules` lists those middleware with `middleware()` and `middleware_for(...)`. diff --git a/Cargo.lock b/Cargo.lock index 8b62430bb3..9783c40ac1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,20 @@ dependencies = [ "inout", ] +[[package]] +name = "ahash" +version = "0.8.12" +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", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -1974,6 +1988,16 @@ 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", +] + [[package]] name = "filetime" version = "0.2.29" @@ -2021,6 +2045,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" @@ -3161,6 +3191,15 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[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" @@ -3169,7 +3208,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -3180,7 +3219,16 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", +] + +[[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]] @@ -3954,7 +4002,7 @@ dependencies = [ "cfg-if", "cssparser 0.36.0", "encoding_rs", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "memchr", "mime", @@ -7090,6 +7138,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" @@ -8132,7 +8195,7 @@ checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" dependencies = [ "arraydeque", "encoding_rs", - "hashlink", + "hashlink 0.11.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3b1794e46e..69460d46cc 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,8 @@ error-stack = "0.6" esi = "0.7.2" fastly = "0.13" fern = "0.7.1" +# 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" # `ts dev lint domains` / `install-hooks` drive all git access through @@ -165,6 +168,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..5804f1b2a2 --- /dev/null +++ b/crates/loading-cache/51degrees/Cargo.toml @@ -0,0 +1,34 @@ +[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 } +fiftyone-caching = { workspace = true } + +[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..66bd73a970 --- /dev/null +++ b/crates/loading-cache/51degrees/src/layer.rs @@ -0,0 +1,121 @@ +//! 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 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. + +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, ValueLoader, +}; +use trusted_server_core::platform::{LoadError, StoreKey}; + +/// 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")), + } + } +} + +/// 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, clock: Arc) -> Self { + let store = LruStore::builder() + .size(entries) + .clock(Arc::clone(&clock)) + .build(); + Self( + LoadingCache::builder(store, FromCall) + .time_to_live(lifetime) + .clock(clock) + .build(), + ) + } + + pub(crate) async fn get(&self, call: &Call) -> Result { + self.0.get(call).await + } + + pub(crate) async fn remove(&self, call: &Call) { + self.0.remove(call).await; + } +} diff --git a/crates/loading-cache/51degrees/src/lib.rs b/crates/loading-cache/51degrees/src/lib.rs new file mode 100644 index 0000000000..e010eb158b --- /dev/null +++ b/crates/loading-cache/51degrees/src/lib.rs @@ -0,0 +1,146 @@ +//! 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 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 +//! 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. +//! +//! A cache is selected by naming this module, `51degrees` or in full, in its +//! own `[cache.]` table. + +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; +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; + +/// 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 +/// 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) +} + +/// Builds the loading cache a deployment uses, for +/// [`IntegrationBuilder::with_loading_cache`]. +/// +/// 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 { + Arc::new(LoadingCaches::new()) +} + +/// 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, +} + +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, +} + +impl Default for LoadingCaches { + fn default() -> Self { + Self::new() + } +} + +impl LoadingCaches { + /// The caches for this host. Every load runs in its caller. + #[must_use] + pub fn new() -> Self { + Self { + layers: Mutex::new(HashMap::new()), + clock: Arc::new(WebClock), + } + } + + /// 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 + } + + /// The memory layer for `spec`, made the first time it is asked for. + fn layer(&self, spec: &CacheSpec) -> Arc { + let id = LayerId { + name: spec.name, + memory: spec.lifetimes.memory, + entries: spec.memory_entries.max(1), + }; + 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, Arc::clone(&self.clock))) + })) + } +} + +#[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..5ca3d0d163 --- /dev/null +++ b/crates/loading-cache/51degrees/src/stores.rs @@ -0,0 +1,265 @@ +//! 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::{KvError, 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); + 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}" + ); + } + } + + 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..f8af14ba68 --- /dev/null +++ b/crates/loading-cache/51degrees/src/tests.rs @@ -0,0 +1,765 @@ +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, UnavailableKvStore, + 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, + /// Whether a write with a lifetime is refused, as it is by a store that + /// has none to give. + refuses_lifetimes: bool, +} + +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> { + if self.refuses_lifetimes { + return Err(KvError::Unsupported { + operation: "put_bytes_with_ttl".to_owned(), + }); + } + 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() + .permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()) + .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() -> 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, + } +} + +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 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!( + 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" + ); +} + +/// 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)); + let caches = Arc::new(LoadingCaches::new()); + let services = services(Arc::new(MemoryKv::default())); + + let answers = ask_from_many_threads(&caches, &services, &spec(), || 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"); + } +} + +/// 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)); + 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(), || { + 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(), &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::new() + .get_or_load(&services, &spec(), &key("k"), counted(&runs, "value")) + .await + .expect("the first process should load"); + let second = LoadingCaches::new() + .get_or_load(&services, &spec(), &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" + ); +} + +/// 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::new() + .get_or_load(&services, &spec(), &key("k"), counted(&runs, "value")) + .await + .expect("the first process should load"); + let second = LoadingCaches::new() + .get_or_load(&services, &spec(), &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()); + let services = services(Arc::clone(&kv)); + 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(), + &of("one.example"), + counted(&runs, "one"), + ) + .await + .expect("should load the first publisher's value"); + let other = caches + .get_or_load( + &services, + &spec(), + &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::new().with_clock(clock); + let runs = Arc::new(AtomicUsize::new(0)); + + caches + .get_or_load(&services, &spec(), &key("k"), counted(&runs, "value")) + .await + .expect("should load"); + let reads = kv.reads(); + caches + .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(), &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::new().with_clock(clock); + let runs = Arc::new(AtomicUsize::new(0)); + let mut idle = spec(); + 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::new().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(), &key("k"), short(&runs)) + .await + .expect("should load"); + advance(&now, Duration::from_secs(3)); + caches + .get_or_load(&services, &spec(), &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::new() + .get_or_load( + &services, + &spec(), + &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::new() + .get_or_load(&services, &spec(), &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::new() + .get_or_load(&services, &spec(), &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::new(); + let runs = Arc::new(AtomicUsize::new(0)); + + caches + .get_or_load(&services, &spec(), &key("k"), counted(&runs, "old")) + .await + .expect("should load"); + caches.remove(&services, &spec(), &key("k")).await; + let fresh = caches + .get_or_load(&services, &spec(), &key("k"), counted(&runs, "new")) + .await + .expect("should load again"); + + 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-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 5a7be228f8..b339fa8057 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -78,6 +78,10 @@ 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 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, } /// Set once startup has logged that this adapter sends no auction telemetry. @@ -218,6 +222,8 @@ fn build_state_with_registrations_and_services( &settings, &shipped_signal_modules(), )?; + let loading_caches = + trusted_server_core::integrations::loading_caches_from(&settings, integrations)?; // This adapter builds no telemetry sink, so a module `[analytics]` selects // sends nothing unless the caller's services carry a sink. warn_once_when_analytics_is_unsent( @@ -235,6 +241,7 @@ fn build_state_with_registrations_and_services( permission_signal_modules, permission_policy, services, + loading_caches, })) } @@ -264,6 +271,7 @@ impl AppState { &self.permission_signal_modules, &self.permission_policy, ) + .with_loading_caches(Arc::clone(&self.loading_caches)) }) .with_permission_policy(Arc::clone(&self.permission_policy)); if let Some(module) = self.registry.geo_module() { @@ -906,6 +914,7 @@ mod tests { // This test drives the per-request path, which builds its services // from the request context. services: None, + loading_caches: Arc::default(), } } diff --git a/crates/trusted-server-adapter-cloudflare/README.md b/crates/trusted-server-adapter-cloudflare/README.md index 231bba3d73..d54eb4ad4e 100644 --- a/crates/trusted-server-adapter-cloudflare/README.md +++ b/crates/trusted-server-adapter-cloudflare/README.md @@ -10,7 +10,9 @@ request the Worker reads its settings from the config store, the Workers KV namespace bound as `trusted_server_config` that `ts config push --adapter cloudflare` writes, and serves the router built from them, or the startup error router when they cannot be read or do not load. It has no `/health` -route or request-time KV registry and permits one enabled auction provider. +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-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index b5681bffbc..5ec58c1278 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}; @@ -84,6 +84,10 @@ 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 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, } /// Builds the application a Worker serves a request with. @@ -259,6 +263,8 @@ fn build_state_with_registrations_and_services( &settings, &shipped_signal_modules(), )?; + let loading_caches = + trusted_server_core::integrations::loading_caches_from(&settings, integrations)?; // This adapter builds no telemetry sink, so a module `[analytics]` selects // sends nothing unless the caller's services carry a sink. warn_once_when_analytics_is_unsent( @@ -277,6 +283,7 @@ fn build_state_with_registrations_and_services( permission_signal_modules, permission_policy, services, + loading_caches, })) } @@ -332,6 +339,7 @@ impl AppState { &self.permission_signal_modules, &self.permission_policy, ) + .with_loading_caches(Arc::clone(&self.loading_caches)) }) .with_resolved_ec_module(self.ec_module.clone()) .with_permission_policy(Arc::clone(&self.permission_policy)); @@ -519,6 +527,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 `EdgeZero`'s dispatch 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" @@ -536,6 +553,22 @@ impl Hooks for TrustedServerApp { .to_string(), })) } + + /// Declares the key-value store alone, so `EdgeZero`'s dispatch opens it + /// for every request and the request's services keep copies in Workers KV + /// between requests. `build_app` reads the settings from the config + /// store's namespace itself, and secrets come 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/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index cb61c324db..3cd88d3d5c 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -84,12 +84,13 @@ impl PlatformBackend for NoopBackend { /// registers for a request from the stores the application declares, to /// [`PlatformConfigStore`]. /// -/// Reads delegate through the handle. Writes are unsupported on all current -/// adapter targets and return errors. +/// [`TrustedServerApp`](crate::app::TrustedServerApp) declares no config +/// store, because startup reads the settings from the config store's +/// namespace itself, so a request is given [`NoopConfigStore`] instead. /// -/// Note: the handle opens one Workers KV 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 handle opens one Workers KV +/// namespace, so the `store_name` argument is ignored. struct ConfigStoreHandleAdapter(ConfigStoreHandle); impl PlatformConfigStore for ConfigStoreHandleAdapter { @@ -114,7 +115,9 @@ impl PlatformConfigStore for ConfigStoreHandleAdapter { } /// Bridges edgezero's [`KvHandle`] (registered by `EdgeZero`'s dispatch from -/// the `TRUSTED_SERVER_KV` KV namespace binding) to [`PlatformKvStore`]. +/// the `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. @@ -583,10 +586,12 @@ impl PlatformSecretStore for CloudflareSecretStoreAdapter { /// Construct [`RuntimeServices`] for an incoming Cloudflare Workers request. /// -/// Config and KV are sourced from the edgezero handles that `EdgeZero`'s -/// dispatch registers before routing, from the stores the application -/// declares, and a request with no handle gets a store that answers -/// nothing. No platform-specific `#[cfg]` is required for these two stores. +/// The key-value store is the handle `EdgeZero`'s dispatch 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 config store's namespace itself. 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` @@ -610,14 +615,16 @@ pub fn build_runtime_services( #[cfg(not(target_arch = "wasm32"))] let http_client: Arc = Arc::new(UnavailableHttpClient); - // Config: use the ConfigStoreHandle the dispatch registered, so no - // #[cfg] is needed. + // Config: no config store is declared, so the dispatch registers 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 the dispatch registered, so no #[cfg] is needed. + // KV: the handle the dispatch opens for the declared store. A Worker + // without the binding fails in the dispatch before routing, so the + // unavailable store only answers where no dispatch 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-cloudflare/tests/config_defaults.rs b/crates/trusted-server-adapter-cloudflare/tests/config_defaults.rs index 4ff88815d6..3beed4179c 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/config_defaults.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/config_defaults.rs @@ -1,5 +1,8 @@ //! 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::DEFAULT_CONFIG_STORE_ID; /// The binding of the key-value store's own namespace. @@ -33,6 +36,46 @@ fn kv_namespaces(manifest: &str) -> Vec<(String, String)> { .collect() } +/// `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/key_value_store.rs b/crates/trusted-server-adapter-cloudflare/tests/key_value_store.rs new file mode 100644 index 0000000000..79f2220ef0 --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/tests/key_value_store.rs @@ -0,0 +1,124 @@ +//! 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::permissions::PermissionPolicy; +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(), + &PermissionPolicy::sample(), + ); + 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/wrangler.toml b/crates/trusted-server-adapter-cloudflare/wrangler.toml index 2af0255b7e..7f03cacb53 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-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 382d0f1a13..ff50ce6087 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -123,8 +123,8 @@ use trusted_server_core::integrations::{ }; use trusted_server_core::permissions::PermissionState; use trusted_server_core::platform::{ - ClientInfo, GeoInfo, PlatformIdentity, PlatformKvStore, PolicyOrigin, RuntimeServices, - StoreName, UNKNOWN, build_geo_module, + ClientInfo, GeoInfo, LoadingCaches, PlatformIdentity, PlatformKvStore, PolicyOrigin, + RuntimeServices, StoreName, UNKNOWN, build_geo_module, }; use trusted_server_core::proxy::{ AssetProxyCachePolicy, handle_asset_proxy_request, handle_first_party_click, @@ -213,6 +213,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 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, /// The permission policy the domain's settings loaded, which every /// request is given. pub(crate) permission_policy: Arc, @@ -362,6 +366,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_caches = + trusted_server_core::integrations::loading_caches_from(&settings, integrations)?; Ok(Arc::new(AppState { settings: Arc::new(settings), @@ -371,6 +377,7 @@ pub(crate) fn build_state_with_registrations( auction_telemetry_sink, ec_module, permission_signal_modules, + loading_caches, permission_policy, })) } @@ -474,6 +481,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_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. @@ -542,11 +551,14 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime pub(crate) fn build_finalize_services( settings: &Settings, kv_store: 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_caches(loading_caches) .backend(Arc::new(FastlyPlatformBackend)) .http_client(Arc::new(FastlyPlatformHttpClient)) .geo(build_geo_module(settings, Arc::new(FastlyPlatformGeo))) @@ -1551,7 +1563,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_caches), + ), )); let fallback_handler = fallback_route_handler(Arc::clone(state)); @@ -1909,6 +1925,7 @@ mod tests { default_kv_store, ec_module, permission_signal_modules: Arc::default(), + loading_caches: Arc::default(), permission_policy: trusted_server_core::permissions::PermissionPolicy::sample(), }); 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 a1211f9b32..8e50a52318 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 @@ -542,11 +542,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] @@ -573,11 +614,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 @@ -1150,7 +1219,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::default(), + ); let request_info = OwnedRequestInfo::new("192.0.2.1".to_owned(), HeaderMap::new()); let method = Method::GET; let context = ModuleContext::new(ModuleRequest::new( @@ -1194,8 +1267,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::default(), + ); let signals = block_on(crate::derive_device_signals( &selected.settings, selected.registry.device_module(), @@ -1212,7 +1288,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::default(), + ); 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 b978535dfe..075d3f3bad 100644 --- a/crates/trusted-server-adapter-fastly/src/lib.rs +++ b/crates/trusted-server-adapter-fastly/src/lib.rs @@ -33,9 +33,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, PlatformKvStore, 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; @@ -54,6 +52,7 @@ mod rate_limiter; mod sandbox; mod template_cache; mod tinybird; +mod value_cache; use crate::app::{ AppState, EcFinalizeState, RuntimeStoreConfig, TrustedServerApp, build_finalize_services, @@ -630,7 +629,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()); @@ -679,7 +678,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) { @@ -688,7 +687,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) => { @@ -783,31 +782,40 @@ 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_caches), + ), + None => build_finalize_services(settings, Arc::new(UnavailableKvStore), Arc::default()), + } } 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() }); @@ -1228,7 +1236,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)); + 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( @@ -1249,7 +1258,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)); + 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 ae57eda168..2bc6c3b58f 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -561,6 +561,7 @@ mod tests { crate::app::build_finalize_services( &test_settings(), Arc::new(trusted_server_core::platform::UnavailableKvStore), + Arc::default(), ) } 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/README.md b/crates/trusted-server-adapter-spin/README.md index 1c9adbb844..52e4f470ea 100644 --- a/crates/trusted-server-adapter-spin/README.md +++ b/crates/trusted-server-adapter-spin/README.md @@ -7,8 +7,9 @@ platform tests. The crate maps Spin HTTP, variables, and the `default` key-value store into the shared runtime. Startup failures install a restricted router that answers with the status of the error's kind and keeps `/health` live. The adapter permits -one enabled auction provider. Request-time KV services beyond startup config -loading remain unavailable. Its entry point uses `anyhow::Result` only because +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: diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 2129b8e6cc..dfa6c82c63 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; @@ -99,6 +99,10 @@ 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 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, } /// Build the application state, loading settings and constructing all per-application components. @@ -247,6 +251,8 @@ fn build_state_with_registrations_and_services( &settings, &shipped_signal_modules(), )?; + let loading_caches = + trusted_server_core::integrations::loading_caches_from(&settings, integrations)?; // This adapter builds no telemetry sink, so a module `[analytics]` selects // sends nothing unless the caller's services carry a sink. warn_once_when_analytics_is_unsent( @@ -265,6 +271,7 @@ fn build_state_with_registrations_and_services( permission_signal_modules, permission_policy, services, + loading_caches, })) } @@ -320,6 +327,7 @@ impl AppState { &self.permission_signal_modules, &self.permission_policy, ) + .with_loading_caches(Arc::clone(&self.loading_caches)) }) .with_resolved_ec_module(self.ec_module.clone()) .with_permission_policy(Arc::clone(&self.permission_policy)); @@ -682,6 +690,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" @@ -698,6 +715,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/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index b3c3ada2c5..146e871bcb 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, 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..860ebf9e01 --- /dev/null +++ b/crates/trusted-server-adapter-spin/tests/key_value_store.rs @@ -0,0 +1,149 @@ +//! 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::permissions::PermissionPolicy; +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(), + &PermissionPolicy::sample(), + ); + 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 3f686305b6..b7d9867339 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -56,33 +56,6 @@ fn routes_build_without_panic() { let _router = TrustedServerApp::routes(); } -#[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(); - - 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" - ); - assert!( - manifest.secret_store_enabled(edgezero_core::app::SPIN_ADAPTER), - "Spin EdgeZero manifest must enable secret handle injection" - ); -} - // --------------------------------------------------------------------------- // Middleware regression tests, which verify FinalizeResponseMiddleware is // wired so it cannot be removed silently. diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 6e939ac7db..ff65ed7c2a 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,40 @@ 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 c100a74336..8795c29505 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`]. + /// + /// 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 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); + 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. @@ -757,3 +784,277 @@ pub(crate) fn all_builders( ) -> impl Iterator + '_ { builders().iter().copied().chain(extra.iter().copied()) } + +/// The loading cache each `[cache.]` table selects, from the caches the +/// 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 +/// +/// 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], +) -> Result, Report> { + 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() { + // 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 (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))); + cache + } + }; + caches.insert(table, cache); + } + 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"; + +/// 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_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, + ) -> 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 nothing() -> Arc { + Arc::new(crate::platform::NoLoadingCache) + } + + fn builder(source: &'static str) -> IntegrationBuilder { + IntegrationBuilder::new("an_id", source, no_build, no_validate) + } + + /// 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) + } + + 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 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()); + } + + #[test] + 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( + "[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 new file mode 100644 index 0000000000..766237d207 --- /dev/null +++ b/crates/trusted-server-core/src/platform/loading_cache.rs @@ -0,0 +1,415 @@ +//! 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::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +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. +/// +/// 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_connection`], +/// never the request's own. +/// +/// 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. +#[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 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. + 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, +} + +/// 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. [`RuntimeServices::load_cached`] hands over services that + /// carry nothing of the reader, because the load serves every waiter. + /// + /// # 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 + } +} + +/// The loading cache each `[cache.]` table selected, by the name a +/// [`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>, + /// What a cache whose table selected nothing loads through, when a test + /// gives every caller one cache. + unselected: Option>, +} + +impl LoadingCaches { + /// Every caller 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 the table `name` selected. + pub fn insert(&mut self, name: &'static str, cache: Arc) { + self.by_feature.insert(name, cache); + } + + /// The cache named `name` loads through. + #[must_use] + 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 table 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; + 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"); + } + + fn spec() -> CacheSpec { + CacheSpec { + name: "test", + lifetimes: CacheLifetimes { + memory: Duration::from_secs(1), + store: Duration::from_secs(1), + store_idle: Duration::from_secs(1), + }, + memory_entries: 1, + } + } + + #[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(), + &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); + } + + /// 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/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 672af1419e..b462361a3c 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -15,6 +15,8 @@ //! - [`PlatformPolicySource`] (permission policy files kept outside the blob) //! - [`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 //! @@ -42,7 +44,9 @@ mod http; mod identity; mod image_optimizer; mod kv; +mod loading_cache; mod policy_source; +mod server_key; mod template_assembly; mod template_cache; #[cfg(any(test, feature = "test-utils"))] @@ -56,6 +60,7 @@ mod template_cache; pub mod test_support; mod traits; mod types; +mod value_cache; pub use backend_naming::{ AuctionTargetCapabilities, AuctionTargetDescriptor, AuctionTargetId, BackendNamingError, @@ -74,10 +79,15 @@ pub use image_optimizer::{ PlatformImageOptimizerParams, PlatformImageOptimizerRegion, }; pub use kv::UnavailableKvStore; +pub use loading_cache::{ + CacheLifetimes, CacheSpec, LoadError, LoadFuture, LoadedValue, Loader, LoadingCaches, + NoLoadingCache, PlatformLoadingCache, StoreKey, +}; pub use policy_source::{ POLICY_KEY_PREFIX, PlatformPolicySource, PolicyEntry, PolicyOrigin, policy_entry_text, policy_key, }; +pub use server_key::ServerKeyPrefix; pub use template_assembly::{ PlatformTemplateAssembler, TemplateAssemblyError, UnavailableTemplateAssembler, contains_publisher_esi_directive, @@ -94,6 +104,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 8ded8a577e..eb670dded3 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -1286,6 +1286,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 562b135a52..4f83f96f23 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -174,6 +174,17 @@ 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 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 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 /// per request rather than failing. See @@ -262,6 +273,41 @@ 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 loading cache the table `[cache.]` selected, by the name a + /// [`super::CacheSpec`] carries. + #[must_use] + 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 + /// or from `load`, kept in this request's stores. See + /// [`super::PlatformLoadingCache::get_or_load`]. + /// + /// The cache is handed these services [without the + /// connection](Self::without_connection), because one load serves every + /// caller waiting on it. + /// + /// # 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_for(spec.name) + .get_or_load(&self.clone().without_connection(), spec, key, load) + .await + } + /// The shared transformed-template cache. #[must_use] pub fn template_cache(&self) -> &dyn super::PlatformTemplateCache { @@ -417,12 +463,14 @@ impl RuntimeServices { } /// Returns a clone of this instance with nothing of the connection it was - /// built for, being default client metadata and no host signals. + /// built for, being default client metadata and no host signals. The + /// stores, caches, backends, HTTP client and modules stay. /// - /// For a module whose answer is held and served to everyone who asks, so - /// that nothing of one connection can reach what the others are given. + /// For work whose answer is held and served to everyone who asks, such as + /// a load a cache shares, so that nothing of one connection can reach what + /// the others are given. #[must_use] - pub(crate) fn without_connection(self) -> Self { + pub fn without_connection(self) -> Self { Self { client_info: ClientInfo::default(), host_signals: None, @@ -458,6 +506,23 @@ impl RuntimeServices { } } + /// Returns a clone of this instance with the loading caches replaced. + #[must_use] + pub fn with_loading_caches(self, caches: Arc) -> Self { + Self { + loading_caches: caches, + ..self + } + } + + /// 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] + 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 carrying the permission policy the /// request's domain loaded. /// @@ -509,6 +574,8 @@ pub struct RuntimeServicesBuilder { config_store: Option>, secret_store: Option>, kv_store: Option>, + value_cache: Option>, + loading_caches: Option>, template_cache: Option>, template_assembler: Option>, backend: Option>, @@ -529,6 +596,8 @@ impl RuntimeServicesBuilder { config_store: None, secret_store: None, kv_store: None, + value_cache: None, + loading_caches: None, template_cache: None, template_assembler: None, backend: None, @@ -558,6 +627,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 loading cache each `[cache.]` table selected. + #[must_use] + pub fn loading_caches(mut self, caches: Arc) -> Self { + self.loading_caches = Some(caches); + self + } + /// Set the shared transformed-template cache. #[must_use] pub fn template_cache(mut self, cache: Arc) -> Self { @@ -687,6 +770,10 @@ 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_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/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); +} diff --git a/crates/trusted-server-core/src/robots_txt/holding.rs b/crates/trusted-server-core/src/robots_txt/holding.rs index d85500d0ca..22d2bf01b9 100644 --- a/crates/trusted-server-core/src/robots_txt/holding.rs +++ b/crates/trusted-server-core/src/robots_txt/holding.rs @@ -1,209 +1,278 @@ -//! 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 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. //! -//! 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. +//! 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. 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, and a key-value store that cannot +//! give an entry a lifetime keeps it until it is deleted there. //! //! An answer is held for the host the contributor was asked for, as well as //! for the publisher and the contributor's settings, so one host's answer is //! never served for another. -//! -//! 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. 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, KvError, 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 = crate::settings::ROBOTS_TXT_CACHE; -/// How long an 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); +/// Where each contributor's last answer is kept apart from the cache. +const KEPT: &str = "robots-txt-kept"; -/// 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, -} +/// 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); -/// Who an answer is for and who gave it, which with the host the contributor -/// is asked for 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 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), +}; + +/// The cache contributors' answers are held in, with the lifetimes +/// `[cache.robots-txt]` sets. +pub(crate) fn cache_spec(settings: &Settings) -> CacheSpec { + CacheSpec { + name: CACHE, + lifetimes: settings.cache.robots_txt.lifetimes(DEFAULT_LIFETIMES), + memory_entries: 64, + } } -/// 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, request.view().host(), &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 prefix = ServerKeyPrefix::for_settings(settings); + let held = held_key(id, request.view().host(), &contributor.fingerprint()); + let key = StoreKey::new(&prefix, CACHE, held.clone()); + let kept = StoreKey::new(&prefix, KEPT, held); + let spec = cache_spec(settings); // The answer is served to every crawler, so the contributor's services // carry nothing of the connection that asked. let services_handed = services.clone().without_connection(); - let context = ModuleContext::new(request.view()).with_services(&services_handed); - 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) + let ask = || { + load( + Ask { + id, + contributor: Arc::clone(contributor), + request: request.clone(), + services: services_handed.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 } - Err(report) => match held { - Some(held) => { - log::warn!( - "Serving the held robots.txt rules of `{}` past their age: {report:?}", - holder.id - ); - Ok(held.contribution) + loaded => loaded, + }; + match loaded { + Ok(contribution) => 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.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(), - })), - }, + } } } -/// The key one contributor's answer for one publisher, one host 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<'_>, host: &str, fingerprint: &str) -> String { +/// 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 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, + 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 host and one fingerprint is held +/// under, a hash so the fingerprint's contents, whatever they are, never +/// appear in a store. The publisher is the key's prefix. +fn held_key(id: &str, host: &str, fingerprint: &str) -> String { let mut hasher = Sha256::new(); - for part in [holder.publisher, host, holder.id, fingerprint] { + for part in [host, id, fingerprint] { // Each part with its length, so that no two different sets of parts // 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); + 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; - } - }; +/// 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; + } let store = services.kv_store(); let written = match store - .put_bytes_with_ttl(key, bytes.clone(), KEPT_LIFETIME) + .put_bytes_with_ttl(&key, bytes.clone(), KEPT_LIFETIME) .await { - Err(KvError::Unsupported { .. }) => store.put_bytes(key, bytes).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 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, } @@ -256,14 +325,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 @@ -320,21 +390,129 @@ 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)); + } + + /// 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::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())) + .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 - /// 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>, + pub(crate) entries: Mutex>, /// The lifetime each entry was last written with. - pub(crate) lifetimes: Mutex>, + 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)] - 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() @@ -343,7 +521,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") @@ -354,9 +536,9 @@ pub(crate) mod tests { async fn put_bytes_with_ttl( &self, key: &str, - value: Bytes, + value: bytes::Bytes, ttl: Duration, - ) -> Result<(), KvError> { + ) -> Result<(), crate::platform::KvError> { if self.refuses_lifetimes { return Err(KvError::Unsupported { operation: "put_bytes_with_ttl".to_owned(), @@ -369,7 +551,7 @@ pub(crate) mod tests { 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") @@ -382,269 +564,295 @@ 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) + /// 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 contributor(scripted: &Arc) -> Arc { - Arc::clone(scripted) as Arc - } - - fn holder(id: &'static str) -> Holder<'static> { - Holder { - publisher: PUBLISHER, - id, - } - } + /// The publisher the tests hold answers for. + pub(crate) const PUBLISHER: &str = "www.publisher.example"; - async fn ask( - scripted: &Arc, - services: &RuntimeServices, - now: u64, - ) -> Result> { - contribution_for( - &holder("a"), - &contributor(scripted), - &request(), - services, - now, - ) - .await + pub(crate) fn settings() -> Settings { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.publisher.domain = PUBLISHER.to_owned(); + settings } - /// 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() { - let scripted = Arc::new(Scripted::new(vec![ - Ok(rules("User-Agent: OneBot\nDisallow: /")), - Ok(rules("User-Agent: TwoBot\nDisallow: /")), - ])); - let services = services_with(Arc::default()); - - ask(&scripted, &services, NOW) - .await - .expect("should ask the contributor"); - let later = ask(&scripted, &services, NOW + 3600) - .await - .expect("should ask the contributor again"); - - assert_eq!(later, rules("User-Agent: TwoBot\nDisallow: /")); - assert_eq!(scripted.times_asked(), 2); - } - - /// 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 the_held_answer_is_keyed_by_the_publisher() { let scripted = Arc::new(Scripted::new(vec![ Ok(rules("User-Agent: OneBot\nDisallow: /")), - Err("cannot answer".to_owned()), + Ok(rules("User-Agent: OtherBot\nDisallow: /")), ])); - let services = services_with(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"); + 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(); - assert_eq!(later, rules("User-Agent: OneBot\nDisallow: /")); - assert_eq!(scripted.times_asked(), 2, "should have asked first"); - } - - #[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()); - - let error = ask(&scripted, &services, NOW) + 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_err("there is nothing to serve"); + .expect("should answer the second publisher"); + assert_eq!(answer, rules("User-Agent: OtherBot\nDisallow: /")); + assert_eq!(scripted.times_asked(), 2, "each publisher is asked for"); + let keys = cache.keys(); assert!( - format!("{error:?}").contains("cannot answer"), - "should keep what the contributor said: {error:?}" + keys[0].starts_with("ts.other.example/robots-txt/"), + "{keys:?}" + ); + assert!( + keys[1].starts_with("ts.publisher.example/robots-txt/"), + "{keys:?}" ); } - /// An answer given for other settings is never served for these. + /// One deployment may serve several hosts, and each is answered for + /// itself. #[tokio::test] - async fn an_answer_is_held_for_the_fingerprint_it_was_given_for() { + async fn an_answer_is_held_for_the_host_it_was_asked_for() { let scripted = Arc::new(Scripted::new(vec![ Ok(rules("User-Agent: OneBot\nDisallow: /")), Ok(rules("User-Agent: TwoBot\nDisallow: /")), ])); - let services = services_with(Arc::default()); + let services = services_with(Arc::default(), Arc::default()); + let other_host = http::Request::builder() + .uri("/robots.txt") + .header(http::header::HOST, "other.publisher.example") + .body(EdgeBody::empty()) + .expect("should build the request"); - 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, + &settings(), + ) + .await + .expect("should ask the contributor"); + let for_other_host = contribution_for( + "a", + &contributor(&scripted), + &ResolvedRequest::of(&other_host, &crate::platform::ClientInfo::default()), + &services, + &settings(), + ) + .await + .expect("should ask for the other host"); - assert_eq!(after, rules("User-Agent: TwoBot\nDisallow: /")); + assert_eq!(for_other_host, rules("User-Agent: TwoBot\nDisallow: /")); + assert_eq!(scripted.times_asked(), 2, "each host is asked for"); } - /// 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 changed_settings_are_not_answered_from_the_old_copy() { let scripted = Arc::new(Scripted::new(vec![ - Ok(rules("User-Agent: OneBot\nDisallow: /")), - Ok(rules("User-Agent: TwoBot\nDisallow: /")), + Ok(rules("User-Agent: FirstBot\nDisallow: /")), + Ok(rules("User-Agent: SecondBot\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) - .await - .expect("should ask the contributor"); - let for_other = contribution_for( - &other, + contribution_for( + "a", &contributor(&scripted), &request(), &services, - NOW + 1, + &settings(), ) .await - .expect("should ask for the other publisher"); - - assert_eq!(for_other, rules("User-Agent: TwoBot\nDisallow: /")); - } - - #[test] - fn a_key_holds_nothing_of_what_it_was_made_from() { - let key = held_key( - &holder("a_contributor"), - "crawled.example", - "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, "crawled.example", "a_contributor", "secret"] { - assert!(!key.contains(part), "{key} should not hold {part}"); - } - } - - #[test] - fn parts_that_run_together_do_not_share_a_key() { - let key = |publisher, host, id| held_key(&Holder { publisher, id }, host, "d"); + .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_ne!(key("ab", "h", "c"), key("a", "bh", "c")); - assert_ne!(key("p", "ab", "c"), key("p", "a", "bc")); - assert_ne!(key("p", "h", "c"), key("p", "other.example", "c")); + assert_eq!(answer, rules("User-Agent: SecondBot\nDisallow: /")); } - /// One deployment may serve several hosts, and each is answered for - /// itself. #[tokio::test] - async fn an_answer_is_held_for_the_host_it_was_asked_for() { - let scripted = Arc::new(Scripted::new(vec![ - Ok(rules("User-Agent: OneBot\nDisallow: /")), - Ok(rules("User-Agent: TwoBot\nDisallow: /")), - ])); - let services = services_with(Arc::default()); - let other_host = http::Request::builder() - .uri("/robots.txt") - .header(http::header::HOST, "other.publisher.example") - .body(EdgeBody::empty()) - .expect("should build the request"); + 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()); - ask(&scripted, &services, NOW) - .await - .expect("should ask the contributor"); - let for_other_host = contribution_for( - &holder("a"), + contribution_for( + "a", &contributor(&scripted), - &ResolvedRequest::of(&other_host, &ClientInfo::default()), + &request(), &services, - NOW + 1, + &settings(), ) .await - .expect("should ask for the other host"); + .expect("should ask the contributor"); + let answer = contribution_for( + "a", + &contributor(&scripted), + &request(), + &services, + &settings(), + ) + .await + .expect("should serve yesterday's rules"); - assert_eq!(for_other_host, rules("User-Agent: TwoBot\nDisallow: /")); + assert_eq!(answer, rules("User-Agent: HeldBot\nDisallow: /")); + assert_eq!(scripted.times_asked(), 2, "should have asked again first"); } #[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: /"))])); + async fn the_kept_answer_outlives_the_instance_that_kept_it() { + let scripted = Arc::new(Scripted::new(vec![ + Ok(rules("User-Agent: KeptBot\nDisallow: /")), + Err("down".to_owned()), + ])); let store = Arc::new(MemoryKvStore::default()); - let services = services_with(Arc::clone(&store)); - ask(&scripted, &services, NOW) - .await - .expect("should ask the contributor"); + 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!(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:?}" + ); let lifetimes = store.lifetimes.lock().expect("should lock the test store"); assert_eq!( lifetimes.values().copied().collect::>(), - vec![KEPT_LIFETIME] + vec![KEPT_LIFETIME], + "the answer should be kept far longer than any refresh" ); } - /// A store with no lifetime to give still holds the answer, so it is + /// 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_holds_the_answer() { - let scripted = Arc::new(Scripted::new(vec![ + 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::clone(&store)); + let services = services_with(Arc::default(), 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"); + 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!( @@ -657,23 +865,192 @@ pub(crate) mod tests { ); } - /// 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() { + 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: *\nAllow: /")), - Ok(rules("User-Agent: *\nAllow: /")), + Err("down".to_owned()), + Ok(rules("User-Agent: LateBot\nDisallow: /")), ])); - let services = noop_services(); + let services = services_with(Arc::default(), Arc::default()); - ask(&scripted, &services, NOW) - .await - .expect("should ask the contributor"); - ask(&scripted, &services, NOW + 1) + assert!( + contribution_for( + "a", + &contributor(&scripted), + &request(), + &services, + &settings() + ) .await - .expect("should ask the contributor again"); + .is_err() + ); + let answer = contribution_for( + "a", + &contributor(&scripted), + &request(), + &services, + &settings(), + ) + .await + .expect("the next request should ask again"); + assert_eq!(answer, rules("User-Agent: LateBot\nDisallow: /")); 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", PUBLISHER, &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 { + entries: Mutex>, + } + + #[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() + } + + 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 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::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)) + .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_caches(Arc::new(crate::platform::LoadingCaches::every(Arc::new( + MemoryLoadingCache::default(), + )))) + .client_info(crate::platform::ClientInfo::default()) + .build(); + + 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"); + + assert_eq!(answer, rules("User-Agent: CachedBot\nDisallow: /")); + assert_eq!( + value_cache + .entries + .lock() + .expect("should lock the test cache") + .len(), + 1, + "the answer should be kept in the value cache" + ); + } + + #[test] + fn the_key_carries_nothing_of_the_fingerprint() { + let key = held_key("a", "crawled.example", "license=secret-value"); + + assert_eq!(key.len(), 64); + for part in ["crawled.example", "secret"] { + assert!(!key.contains(part), "{key} should not hold {part}"); + } + assert_ne!( + key, + held_key("b", "crawled.example", "license=secret-value") + ); + } + + #[test] + fn parts_that_run_together_do_not_share_a_key() { + assert_ne!(held_key("c", "ab", "d"), held_key("bc", "a", "d")); + assert_ne!(held_key("ab", "h", "c"), held_key("a", "h", "bc")); + assert_ne!(held_key("c", "h", "d"), held_key("c", "other.example", "d")); + } } diff --git a/crates/trusted-server-core/src/robots_txt/mod.rs b/crates/trusted-server-core/src/robots_txt/mod.rs index 43fd30cc2c..77153976e0 100644 --- a/crates/trusted-server-core/src/robots_txt/mod.rs +++ b/crates/trusted-server-core/src/robots_txt/mod.rs @@ -325,16 +325,16 @@ 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 { // Unreachable, the registry refuses this name when it is built. 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 of the host, so the // contributors are handed the request with nothing one crawler put in @@ -345,42 +345,33 @@ async fn answer( .without_query() .with_host(crate::http_util::addressed_host(req)); 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` and the request's host. +/// each asked for `request` and held for the publisher `settings` describe and +/// the request's host. /// -/// Fails, and the file is answered `503`, when a contributor fails with -/// nothing of its held, and when the file would hold no rule. +/// Fails, and the file is answered `503`, when a contributor fails with no +/// answer of its kept, and when the file would hold no rule. 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? } }); } @@ -434,8 +425,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, PUBLISHER, Scripted, request, robots_request, rules, + services_with, settings, }; use super::*; use crate::integrations::{IntegrationBuilder, IntegrationRegistration}; @@ -444,12 +435,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" })); @@ -620,11 +605,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"); @@ -645,11 +629,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, ); @@ -684,7 +667,7 @@ mod tests { let response = handle_robots_txt( &settings, - &services_with(Arc::clone(&store)), + &services_with(Arc::default(), Arc::clone(&store)), ®istry, robots_request(), ) @@ -728,7 +711,7 @@ mod tests { let response = handle_robots_txt( &settings, - &services_with(Arc::default()), + &services_with(Arc::default(), Arc::default()), ®istry, request, ) @@ -793,7 +776,7 @@ mod tests { let response = handle_robots_txt( &settings, - &services_with(Arc::default()), + &services_with(Arc::default(), Arc::default()), ®istry, robots_request(), ) @@ -863,7 +846,7 @@ mod tests { let settings = settings_selecting_echo(); let registry = IntegrationRegistry::with_registrations(&settings, &[echo_builder()]) .expect("should build the registry"); - let services = services_with(Arc::default()); + let services = services_with(Arc::default(), Arc::default()); for host in [PUBLISHER, "other.publisher.example"] { let response = handle_robots_txt( @@ -891,7 +874,7 @@ mod tests { let settings = settings_selecting_echo(); let registry = IntegrationRegistry::with_registrations(&settings, &[echo_builder()]) .expect("should build the registry"); - let services = services_with(Arc::default()); + let services = services_with(Arc::default(), Arc::default()); let claiming = robots_request_to( PUBLISHER, &[ @@ -999,7 +982,7 @@ mod tests { let response = handle_robots_txt( &settings, - &services_with(Arc::default()), + &services_with(Arc::default(), Arc::default()), ®istry, robots_request(), ) @@ -1037,7 +1020,7 @@ mod tests { let response = handle_robots_txt( &settings, - &services_with(Arc::default()), + &services_with(Arc::default(), Arc::default()), ®istry, robots_request(), ) @@ -1146,7 +1129,7 @@ mod tests { }; let services = RuntimeServices { host_signals: Some(Arc::new(CrawlerSignals)), - ..services_with(Arc::default()).with_client_info(connection) + ..services_with(Arc::default(), Arc::default()).with_client_info(connection) }; let response = handle_robots_txt(&settings, &services, ®istry, robots_request()) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index df4af8a75e..1946ecd9cc 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -2982,9 +2982,115 @@ 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; + +/// 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, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +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, 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 + /// 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 { + /// 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)] + } + fn normalize(&mut self) { for rule in &mut self.asset_rules { rule.normalize(); @@ -2995,9 +3101,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() { @@ -6681,6 +6789,99 @@ 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 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-integration-tests/tests/environments/cloudflare.rs b/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs index eb25471e93..138d1c3322 100644 --- a/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs +++ b/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs @@ -26,6 +26,27 @@ pub struct CloudflareWorkers; const CLOUDFLARE_DEFAULT_PORT: u16 = 8787; const CI_CONFIG: &str = "wrangler.ci.toml"; +/// The CI wrangler config without the key-value store's namespace, which +/// [`CloudflareWorkers::spawn_without_kv_binding`] writes. +const GENERATED_UNBOUND_CONFIG: &str = "wrangler.integration.generated.toml"; + +/// The header of a table of the CI wrangler config that binds a key-value +/// namespace. +const KV_NAMESPACES_TABLE: &str = "[[kv_namespaces]]"; + +/// The binding of the key-value namespace the adapter declares as its store. +const KV_STORE_BINDING: &str = "TRUSTED_SERVER_KV"; + +/// 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, +} + /// The arguments that store `envelope_path`'s contents in the Worker's local /// config store, the namespace bound as the store's default platform name in /// `config`. @@ -75,6 +96,85 @@ fn seed_config_store(wrangler_dir: &Path, config: &str, envelope: &str) -> TestR Ok(()) } +/// Writes the CI wrangler config without the key-value store's namespace, +/// keeping the config store's, and returns the file's name. +fn write_ci_config_without_kv_binding(wrangler_dir: &Path) -> TestResult { + let template_path = wrangler_dir.join(CI_CONFIG); + let template = std::fs::read_to_string(&template_path) + .change_context(TestError::RuntimeSpawn) + .attach(format!( + "failed to read Cloudflare CI wrangler config at {}", + template_path.display() + ))?; + let generated = without_kv_store_binding(&template)?; + let output_path = wrangler_dir.join(GENERATED_UNBOUND_CONFIG); + std::fs::write(&output_path, generated) + .change_context(TestError::RuntimeSpawn) + .attach(format!( + "failed to write generated Cloudflare CI wrangler config at {}", + output_path.display() + ))?; + Ok(GENERATED_UNBOUND_CONFIG.to_string()) +} + +/// `template` without the `[[kv_namespaces]]` table that binds +/// [`KV_STORE_BINDING`], being the table's header and every line after it up +/// to the next blank line or table. Every other table is kept. +fn without_kv_store_binding(template: &str) -> TestResult { + let binding = format!("binding = \"{KV_STORE_BINDING}\""); + let mut kept = Vec::new(); + let mut table = Vec::new(); + let mut dropped = 0; + for line in template.lines() { + let trimmed = line.trim(); + if !table.is_empty() && (trimmed.is_empty() || trimmed.starts_with('[')) { + dropped += keep_unless_bound(&mut table, &mut kept, &binding); + } + if trimmed == KV_NAMESPACES_TABLE || !table.is_empty() { + table.push(line); + } else { + kept.push(line); + } + } + dropped += keep_unless_bound(&mut table, &mut kept, &binding); + if dropped != 1 { + return Err(Report::new(TestError::RuntimeSpawn).attach(format!( + "Cloudflare CI wrangler config must bind `{KV_STORE_BINDING}` in exactly one `{KV_NAMESPACES_TABLE}` table, found {dropped}" + ))); + } + let mut generated = kept.join("\n"); + generated.push('\n'); + Ok(generated) +} + +/// Moves the lines of `table` onto `kept`, unless the table holds the line +/// `binding`, and returns the number of tables dropped. +fn keep_unless_bound<'a>( + table: &mut Vec<&'a str>, + kept: &mut Vec<&'a str>, + binding: &str, +) -> usize { + if table.iter().any(|line| line.trim() == binding) { + table.clear(); + 1 + } else { + kept.append(table); + 0 + } +} + +/// 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" @@ -92,7 +192,7 @@ impl RuntimeEnvironment for CloudflareWorkers { } else { "wrangler.toml" }; - self.spawn_wrangler(&wrangler_dir, config) + self.spawn_wrangler(&wrangler_dir, config, Readiness::Healthy) } fn health_check_path(&self) -> &str { @@ -116,10 +216,33 @@ impl CloudflareWorkers { pub fn spawn_with_config_envelope(&self, envelope: &str) -> TestResult { let wrangler_dir = self.wrangler_dir(); seed_config_store(&wrangler_dir, CI_CONFIG, envelope)?; - self.spawn_wrangler(&wrangler_dir, CI_CONFIG) + self.spawn_wrangler(&wrangler_dir, CI_CONFIG, Readiness::Healthy) } - fn spawn_wrangler(&self, wrangler_dir: &Path, config: &str) -> TestResult { + /// Start `wrangler dev` with a test-specific settings blob in its config + /// store and no namespace bound for the key-value store, 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, the config store cannot be seeded or `wrangler dev` cannot be + /// started, and [`TestError::RuntimeNotReady`] if the Worker never + /// answers. + pub fn spawn_without_kv_binding(&self, envelope: &str) -> TestResult { + let wrangler_dir = self.wrangler_dir(); + let config = write_ci_config_without_kv_binding(&wrangler_dir)?; + seed_config_store(&wrangler_dir, &config, envelope)?; + 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)] @@ -187,7 +310,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), @@ -280,4 +408,46 @@ mod tests { "the CI config should bind the config store's namespace" ); } + + #[test] + fn without_kv_store_binding_drops_only_the_key_value_stores_table() { + let template = include_str!("../../../trusted-server-adapter-cloudflare/wrangler.ci.toml"); + + let generated = + without_kv_store_binding(template).expect("should drop the key-value store's 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"); + let bound: Vec<&str> = manifest["kv_namespaces"] + .as_array() + .expect("should keep the config store's namespace") + .iter() + .filter_map(|namespace| namespace["binding"].as_str()) + .collect(); + assert_eq!( + bound, + [DEFAULT_CONFIG_STORE_ID], + "should bind the config store's namespace alone" + ); + 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_store_binding_rejects_a_template_that_does_not_bind_the_store() { + let result = without_kv_store_binding("[vars]\n"); + + assert!( + result.is_err(), + "should reject a template that does not bind the store" + ); + } } diff --git a/crates/trusted-server-integration-tests/tests/integration.rs b/crates/trusted-server-integration-tests/tests/integration.rs index b00ed82cbe..8124dc03f9 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 envelope = common::config::integration_app_config_envelope(origin_port()) + .expect("should build the settings blob"); + let process = environments::cloudflare::CloudflareWorkers + .spawn_without_kv_binding(&envelope) + .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` diff --git a/crates/trusted-server-modules/src/lib.rs b/crates/trusted-server-modules/src/lib.rs index d4bc77289c..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![ @@ -444,6 +445,24 @@ mod tests { ); } + /// 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 the_stock_build_links_no_loading_cache() { + let offering: Vec<&str> = builders() + .iter() + .filter(|builder| builder.loading_cache().is_some()) + .map(IntegrationBuilder::id) + .collect(); + + assert!( + offering.is_empty(), + "a stock build should link no loading cache, and links {offering:?}" + ); + } + #[test] fn a_deployment_s_builders_follow_the_stock_ones() { let stock = builders().len(); diff --git a/docs/guide/cloudflare.md b/docs/guide/cloudflare.md index 2113f256b4..3c29b7e49c 100644 --- a/docs/guide/cloudflare.md +++ b/docs/guide/cloudflare.md @@ -68,9 +68,16 @@ cloudflare --permissions `, which writes the settings and the policy file they name into the remote config store. 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 -the config store holds the blob and the two secrets are set. - -Request-time store registries are unwired, so EC KV lookup is not available. +the config store holds the blob, the two secrets are set and the +`TRUSTED_SERVER_KV` namespace is bound. + +The adapter declares `TRUSTED_SERVER_KV` 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 6dead8b236..fcc08cfc7b 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1168,9 +1168,10 @@ 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 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. + +| 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] +# Refused unless the build links crates/loading-cache/51degrees. +module = "51degrees" +memory_seconds = 60 +store_seconds = 604800 +store_idle_seconds = 2592000 +``` + ## Integration Configurations A page integration is a module, selected in the section of its type, and @@ -1833,7 +1886,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.]`. +`[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 439a949d8e..ac5e8a3924 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -413,6 +413,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, 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 { @@ -838,6 +839,86 @@ 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 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 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. 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 { + 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 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 +reader, so any services it needs come from +`services.clone().without_connection()`, never +the request's own, and `load_cached` hands the cache the same. + +```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_for(name).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/docs/guide/spin.md b/docs/guide/spin.md index 90c4eb769e..973b8479a4 100644 --- a/docs/guide/spin.md +++ b/docs/guide/spin.md @@ -74,9 +74,16 @@ manifest, SQLite store, response captures, and logs. ## Runtime boundaries Use the same store mapping and encoded variables in the deployment provider; -empty variable defaults fail closed. Spin's request-time store registry is -currently unwired, so EC KV lookup and request-signing key variables are -unavailable. Forwarded client-IP headers are sanitized, and Spin derives its +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 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 1e339434b2..e894214814 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -437,6 +437,21 @@ pull_sync_concurrency = 3 # thumbnail = "width=150&crop=1:1,smart" # medium = "format=auto&width=828" +# 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` the one loading cache module the build +# 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 +# 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