Repository navigation
Hold the publisher's assets in the loading cache - #1375
Open
jwrosewell wants to merge 59 commits into
Open
jwrosewell wants to merge 59 commits into
jwrosewell wants to merge 59 commits into
Conversation
Core gains source_cache.rs, which keeps each address's record of what the origin's responses vary on and a copy under each request's values for those headers, both through the loading cache, so requests for an address share one load and a copy is served only to a request whose values match, and asset_cache.rs, which is what a kept asset holds and the address it is held under. Both are selected by one table, [cache.assets], which CacheSettings lists after [cache.robots-txt], so a deployment names the module whose loading cache holds its assets as it does for robots.txt, and a build that links one takes it without a line in the document. Nothing holds an asset yet. The publisher path does in the next commit.
Every GET that is not a page is looked for in the asset cache before the origin is fetched, under the publisher's prefix and the asset's address, and a copy found is served as the origin's response would be, with an Age that counts from the origin's. A miss loads once however many requests ask at the same moment, keeps the response as the origin sent it when it may be shared, and hands a response that may not be shared back to the request whose load fetched it, so that request is served from one fetch and the requests that follow fetch their own for a minute rather than wait. What is never kept: anything but a 200, HTML, a response carrying Set-Cookie, one whose cache policy says private, no-store or no-cache or gives no lifetime, one that varies on * or on Cookie, one larger than a copy holds, and the answer to a request carrying Authorization. The record of what an address varies on and the copies are two caches selected by the one [cache.assets] table, and a deployment whose selected cache keeps nothing asks the origin as before.
…isher's assets are A file served through an asset route, under [[proxy.asset_routes]], streamed from its origin on every request, however many readers and instances asked for it, while a file the publisher's own origin sends was held through the loading cache. The route handler now looks for a copy under the same two caches [cache.assets] selects before it asks the origin, and keeps the origin's answer the way the publisher path keeps an asset: under what the origin says it varies on, for the lifetime its cache policy gives, served with the headers that describe it and an Age that counts from the origin's. The key carries the route's origin and prefix, so two routes to one address keep their own copies. Nothing is kept that may not be shared, by the same rules, and a request a copy cannot answer is not served one: anything but a GET, a request carrying Authorization, a conditional header or a range, one that names no host, and any request while the selected cache keeps nothing. An answer the platform's image optimizer makes is not kept either, because the platform holds it. An answer that may not be shared is served to the request whose load fetched it, from that one fetch. The publisher path's asset helpers are open to the rest of core so the route handler shares them, and the memory loading cache the publisher's tests used lives in the platform's test support so the route's tests use it too. Four tests cover an answer held and served to the next request with one origin call, an answer carrying Set-Cookie fetched for each request, a range request fetched rather than served a copy, and nothing held while no loading cache keeps values. The guide documents [cache.assets] beside [cache.robots-txt], with what is held and what never is, and the changelog says what the asset cache adds.
A body that outgrows a copy while it is read is handed back as what was read and then the rest. A response whose Vary no longer matches its record is served to the request that fetched it. Both were dropped, and that request asked the origin a second time. The asset caches run their loads in the request that started them, and an asset route refuses a copy to a request whose Cache-Control asks for the origin, as the publisher path does. The two lookups share one function, the origin checks the template cache and asset cache share are one helper, the three length-prefixed hashes share one, and the tests use the memory loading cache and the shared test services. The docs describe the copy and its key as they are built today.
Describes the asset cache as the code at f6e598f builds it. It covers the key and what goes into it, when a stored asset is used, what is stored and served, the one table that selects both caches, each adapter, consent and privacy, failure handling and rollout for existing deployments. It lists the tests that exist and the joins that have none.
The design now says that the asset gate does not apply the cookie rules of the page path, so a request that carries a session cookie can read a stored copy or lead a load whose response is stored for every reader. It gives the reason the code gives, states that the cache is on by default with no off switch, and puts the cookie rule and an off switch under Open questions. The design also gains Pragma in the gate, the verdict kept for a response that fails a test, the module default, where the sizes and the assets-vary table stand, the key-value store each adapter uses, how far one fetch reaches, what the caches add on Fastly, the asset rules, the cost and limits, and the tests that are missing. The review log and the revision record name the round.
…tself The asset gate on the publisher path let a request that carries a Cookie header read a kept copy, and lead a load whose answer was then kept for every reader. The origin receives the reader's cookies and nothing in its response shows whether they shaped it, so such a request now neither reads nor leads a copy, and asks the origin as it did before the asset cache. A response that sets a cookie was already kept for no one. A test on the publisher path now shows that such an answer is served only to the request whose fetch made it, and another shows that two readers with different cookies each get their own answer while nothing is kept. An asset route sends its origin no cookie, so its answers stay shared, and a test pins that the route's origin never receives one.
A build that links a loading cache module held assets with no way to stop it short of rebuilding without the module. Naming the module "none" in [cache.assets] now leaves both asset caches unselected, so the publisher path and the asset routes ask the origin for every request as they did before the asset cache, and the other caches are selected as before. Only [cache.assets] reads "none" this way, as [ec] and [geo] do for their own modules. The comments on loading_caches_from and on LoadingCacheConfig::module said a table that names no module keeps no copy. They now say it takes the one loading cache module the build links, and keeps no copy when the build links none or several, which is what the code does. One test selects the caches from settings that name no module and from settings that name none, and checks each asset cache keeps values in the first and nothing in the second, with robots.txt's cache left as it was. Another runs two asset requests through the publisher path with the caches an adapter would make, and counts one origin fetch with no module named and two with none.
CacheSettings::prepare_runtime range checked [cache.robots-txt] and each table a module declares, and not [cache.assets], so a store lifetime of zero, or a lifetime over a year, loaded and was used. The check now runs for [cache.assets] too, wherever settings are loaded or validated for a deploy, and the error names the table and the field. The test of out of range lifetimes now writes each value in both [cache.robots-txt] and [cache.assets].
…ff switch and range check The configuration guide says that a request sending the publisher's origin a cookie always goes to the origin and its answer is not kept, that an asset route sends its origin no cookie, and that module = "none" in [cache.assets] keeps no asset. Its table gives the one linked module as the default module of each cache table, where it said none. The changelog entry for the asset cache says the same, and that a [cache.assets] lifetime outside the ranges of [cache.robots-txt] is refused when the settings load.
The design now states the three fixes to the code. A request on the publisher path that carries a Cookie header neither reads nor leads a copy, module = "none" in [cache.assets] turns both asset caches off, and the range check runs for [cache.assets]. The cookie rule and the off switch leave Open questions, and the question left asks whether a publisher may say that cookies do not shape its assets, as origin_is_cookie_independent says of its HTML. Section 3 drops the off switch and the range check, and Acceptance names the new tests and drops the joins they cover. From the round 2 review, the design says which entries each load writes, no longer says that no test sends a Cookie header through the publisher path, and gives the one linked module as the default. The review log and the revision record name the round.
jwrosewell
marked this pull request as ready for review
October 19, 2026 20:23
The publisher's assets and the answers of the asset routes were held in core, in asset_cache.rs and source_cache.rs and in a gate and a loader in publisher.rs and proxy.rs. That holding moves to a module, behind an interface that core gains next, so core goes back to asking the origin for every file, as it did before. [cache.assets] and [cache.assets-vary] leave with it, with module = "none", their place in declared_caches, the changelog line and the configuration guide's section. The seam probe expects only robots-txt and its own cache again. Tests show that two requests for a file the origin lets be kept are two origin requests, on the publisher path and on an asset route, with a loading cache that keeps values linked, that core declares only robots-txt, and that a [cache.assets] table is refused. MemoryLoadingCache::held stays, for the module's tests.
Core offers each GET for a file, on the publisher path and on an asset route, to the asset cache module that [asset-cache] selects, at the one point where it is about to ask the file's origin. The module answers Serve, which takes the place of the origin's response, OriginFailed, which core answers as its own failed origin request, or AskOrigin, and core then sends the request it prepared. With no module selected, nothing changes. asset_cache.rs holds the interface: AssetCacheModule, AssetAnswer, AssetVia, AssetFile, which carries what core knows of the reader's request, and OriginRequest, the request core would send, read from the request it then sends. A registration supplies a module with with_asset_cache_module. The registry takes it only from the registration of the builder that [asset-cache] selects. It refuses one from any other builder, and one in what any builder registers for the auction plan, which is made whatever [asset-cache] selects. It refuses an [asset-cache] that selects two modules, and one that selects a builder that supplies none, naming that builder. Each adapter hands the module to each request's services. Core offers only a GET that describes no body, because a module's load sends the request with none. A route offers no request the platform's image optimizer answers, and the publisher path offers a file only while no origin request has started, which happens only for a navigation. Tests use a module that records what it is offered. A served answer keeps the origin from being asked and is processed as the origin's response is, a file passed back is asked of the origin once, and a failure is a 502. The reader's facts are reported, among them the scheme, a route's fallback to the Host header and to https, and asks_for_origin as it stands once a middleware that needs the whole file has removed the range. A HEAD, a GET that describes a body and an optimized file are not offered, and the request a module is handed is the one core sends with no module. Registry tests cover each refusal, the auction plan's both when [asset-cache] selects its builder and when it selects another.
origin_response_may_be_kept and origin_shared_ttl decide whether an origin's response may be kept for other readers, and for how long. An asset cache module asks the same questions, so both become public and return KeepRefusal, a small type of their own, in place of the template cache's TemplateCacheBypassReason, whose sixteen variants stay private. The template cache turns a KeepRefusal into its own reason, and a KeepRefusal displays as that reason does, so the log lines read as before. template_cache_ttl calls origin_response_may_be_kept, which checks the status before the cookie and the shared cache headers, so a page response with a status other than 200 and a later fault is reported by its status. The freshness helpers return KeepRefusal. A test covers each refusal of origin_response_may_be_kept, the order of its checks, and the reason each KeepRefusal becomes.
crates/asset-cache/51degrees, the module asset-cache.51degrees, holds the files core held before, as the asset cache module [asset-cache] selects. It keeps each file as its origin sent it, in two loading caches it declares with with_cache_features, 51degrees-assets for the copies and 51degrees-assets-vary for each address's record of what the origin's Vary names, both sized by [cache.51degrees-assets]. It reads no table of its own, so it refuses an [asset-cache.51degrees] table, an empty one included. No stock list links it. The workspace, and the Axum and Fastly aliases, name it. The key, the entry and the source cache move from core with their tests, the gate and the loader move from publisher.rs and proxy.rs, and the module carries its own copies of four small helpers of core rather than core making them public. Four things change in the move. The caches take the names the module gives them, the gate also needs the record cache to keep values, the key holds no digest of the build and the settings, because a copy is the origin's bytes, and the variant digest's tag becomes ts-asset-variant-v1. The module's loads capture the services without the connection, as the Loader contract asks. The tests that covered the copies in core move into the module and drive it through AssetCacheModule::answer, with a stub origin and core's MemoryLoadingCache. A document is passed back however shareable its origin's answer. New tests cover the caches the builder declares, their lifetimes, a record cache that keeps nothing, the refusal of a table naming none, core's refusal of a lifetime out of range in [cache.51degrees-assets], and the refusal of an [asset-cache.51degrees] table, empty or not. The changelog and the configuration guide describe the module.
The records in 51degrees-assets-vary take the lifetimes that [cache.51degrees-assets] sets, so memory_seconds, store_seconds and store_idle_seconds in [cache.51degrees-assets-vary] passed core's range check and were then read by nothing. A setting that nothing reads looks as if it works. The module's validate now refuses each lifetime the vary table sets, naming the table and every lifetime it sets. Loading the settings then fails as it does for any invalid configuration, so the service logs why and answers with its startup error, a 500. Every linked builder validates whether or not a section selects it, so the refusal also holds in a build that links the module and leaves [asset-cache] unset, and the deploy check refuses the same in a tool that links the module. The table keeps module, which selects the loading cache module that holds the records, and core is unchanged. The check names every field of LoadingCacheConfig, so a setting added to that table does not build in the module until the check reads or refuses it. New tests show that each lifetime set alone is refused by loading the settings and by the deploy check, selected or not, and by the registry when selected, that the refusal names every lifetime set, and that a vary table without lifetimes, empty or naming its loading cache module, loads beside an assets table that sets all three. The configuration guide says the lifetimes are refused.
jwrosewell
added a commit
to jwrosewell/trusted-server
that referenced
this pull request
Oct 28, 2026
A lifetime set in [cache.51degrees-assets-vary] is refused when the settings load and by the deploy check, a fix found in review in IABTechLab#1375. The caches the module declares, failure handling, the rollout of a document written for row 34, the module's tests and what no test covers say so.
jwrosewell
added a commit
to jwrosewell/trusted-server
that referenced
this pull request
Oct 28, 2026
This row's copy of the asset routes design carries the date it is proposed, 2026-10-27, and the fixes of its review rounds of 28 October, as the specs branch has them. The changelog says that a route's answers are held by a selected asset cache module, a fix found in review, because the publisher asset cache is a module since IABTechLab#1375.
This was referenced Oct 28, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Changes
crates/asset-cache/51degreescrates/trusted-server-core/srcdocs/superpowersdocs/guideCargo.lockcrates/trusted-server-adapter-fastly/src.cargo/config.tomlcrates/trusted-server-adapter-axum/srccrates/trusted-server-adapter-cloudflare/srccrates/trusted-server-adapter-spin/srcCargo.tomlCHANGELOG.mdCloses
Closes #1324
Spec
Spec: asset-cache
Test plan
CI on this pull request at aeb0aa1 passed all 20 checks.
cargo test-fastly && cargo test-axumcargo clippy-fastly && cargo clippy-axumcargo fmt --all -- --checkcd crates/trusted-server-js/lib && npx vitest runcd crates/trusted-server-js/lib && npm run formatcd docs && npm run formatcargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1fastly compute serveChecklist
unwrap()in production code (clippy deniesunwrap_usedin CI)logmacros as AGENTS.md asks (notprintln!)On top of the geo module called with the request (#1374). Issue: #1324.
What changed after the 51Degrees PR review
Agreed at the review on 23 October, point 2. The 51Degrees loading cache is pluggable through modules, and the publisher asset feature becomes a module.
Fixes found in review
[cache.51degrees-assets-vary]refusesmemory_seconds,store_secondsandstore_idle_seconds, which the module never reads, because the records take the lifetimes of[cache.51degrees-assets]. The table takes onlymodule, and this pull request's copy of the asset cache design says so.Kept current
mainat 7aa5c99, the changes made lower in the stack andfiftyone-caching4.5.13 (Move the loading cache module to the released caching crate #1372).What this adds
What changes.
Core offers each
GETfor a file, on the publisher path and on an asset route under[[proxy.asset_routes]], to the asset cache module that[asset-cache]selects, at the one point where it is about to ask the file's origin, and holds no copy of its own. The 51Degrees module,crates/asset-cache/51degrees, holds each file as its origin sent it, through the loading cache (#1365), so the origin is asked again only when no copy young enough is held, and the copy lasts no longer than the origin's own lifetime for it. On Fastly one load serves every instance at a location that asks for the file at the same moment, as the loading cache's design sets out.No stock build links the module or a loading cache module, so a stock build asks the origin for every file, as before this row. A deployment that wants its files held links a loading cache module and this module, hands both builders to its adapter, through
run_withon Fastly orroutes_with_registrationson Axum, Cloudflare and Spin, and selects the module in[asset-cache].How it works.
crates/trusted-server-core/src/asset_cache.rsholdsAssetCacheModule,AssetAnswer,AssetVia,AssetFile, which carries what core knows of the reader's request, andOriginRequest, the request core would send. It holds no caching code. The module answersServe, which takes the place of the origin's response,OriginFailed, which core answers as its own failed origin request, orAskOrigin, and core then sends the request it prepared.with_asset_cache_module, and the registry takes it only from the builder that[asset-cache]selects. It refuses one from any other builder, an[asset-cache]that selects two modules, and one that selects a builder that supplies none.GETthat describes no body. A route offers no request the platform's image optimizer answers, and the publisher path offers a file only while no origin request has started.51degrees-assetsholds the copies, and51degrees-assets-varyholds each address's record of the request headers the origin'sVarynames, both declared withwith_cache_features, as row 32 (Let a module declare the caches it loads through #1373) lets a module do, and both sized by[cache.51degrees-assets]. A later request for an address reads the record and is served the copy kept under its own values for those headers, as RFC 9111 section 4.1 describes.GET, anything but a200, HTML (a page is a template or nothing), a response carryingSet-Cookie, a response whose cache policy saysprivate,no-storeorno-cacheor gives no lifetime, a response that varies on*or onCookie, a response larger than a copy holds (1 MiB), and the answer to a request carryingAuthorization, aCookieheader on the publisher path, a conditional header, a range, or aCache-Controlthat asks for the origin (no-cache,no-store,min-freshor a nonzeromax-age). A request on the publisher path that carries aCookieheader is not served a copy either, because the origin receives the reader's cookies and nothing in its answer shows whether they shaped it.Agethat counts from the origin's, and the fetch and serve entries of the configuration run on it as they run on a fetched response.origin_response_may_be_keptandorigin_shared_ttldecide whether an origin's response may be kept for other readers, and for how long. They become public and returnKeepRefusal, so the template cache and the module ask the same questions.Why
A deployment with the platform's cache in front of it already has its assets held there. A deployment with nothing in front of it, and one whose fetch phase rewrites an asset before a reader sees it, fetches every asset on every page from the publisher's origin every time, and every instance does. Holding those files is a choice one deployment makes, so the code that holds them belongs in a module, and core offers only the place where a module may answer. At the 51Degrees PR review on 23 October, 51Degrees asked IAB Tech Lab for advice on whether the asset cache should become a module, and the change was decided by 51Degrees after the review.
IAB Tech Lab's own draft design for response caching (#894) holds documents in core, and the module holds no document, so no file is held by both. Section 2.10 of the design sets out how the two sit side by side.
What breaks
[cache.assets]or[cache.assets-vary], which no module declares now, so every build refuses it, andmodule = "none"is no longer read. Such a document adds[asset-cache] module = "51degrees", renames the tables[cache.51degrees-assets]and[cache.51degrees-assets-vary]and drops any lifetime its[cache.assets-vary]table set, which the renamed table refuses, in a build that links the module and a loading cache module.ts-asset-variant-v1, all changed, and the copy ends by its own lifetime.The commits
[cache.assets]Cache-Control, shared test and hashing helpers[asset-cache]selects[cache.51degrees-assets-vary]refusesmemory_seconds,store_secondsandstore_idle_seconds, which the module never readsThe other commits merge the branch below into this one, and through it Tech Lab main at 7aa5c99, apart from "Merge the asset cache module into split/34-asset-cache", which brings the module's four commits listed above.
What is not in this pull request
[publisher.origin_personalization]), which separates copies the origin's ownVarydoes not. Later, with its tests./proxyfirst-party route, which rehosts third party assets named in a page. It streams, and on Fastly the platform's cache holds it.How it was verified
Locally on Windows, on this pull request's tree, these passed:
cargo fmt --all -- --check, clippy-fastly, clippy-axum, clippy-cli, clippy-cloudflare and clippy-spin-native, the core library's tests (2,987), the asset cache module's tests (43),cargo test-axum(1,142, 1 ignored),cargo test-cloudflare(54, 1 ignored),cargo test-spin(93, 1 ignored), the Fastly adapter's library tests under Viceroy (276) and the docs checks.