Skip to content

Move the loading cache module to the released caching crate - #1319

Closed
Jamesr51d wants to merge 703 commits into
IABTechLab:mainfrom
jwrosewell:split/31-caching-release
Closed

Jamesr51d wants to merge 703 commits into
IABTechLab:mainfrom
jwrosewell:split/31-caching-release

Conversation

@Jamesr51d

@Jamesr51d Jamesr51d commented Oct 12, 2026 •

Copy link
Copy Markdown

Stacks on the Google tag module (#1318). The additions that follow the seven pull requests open here are listed in #777.
Compare split/30-google-tag with split/31-caching-release
to see only this pull request's change.

What this pull request does

fiftyone-caching moves from the pre-release 4.5.8-49 to the release 4.5.8 in the root manifest and in the lock. The crate's source is the same at both versions, so nothing else moves. The pin was placed by the loading cache module (#1312) when no release carried the crate.

How it was verified

CI passed on 302094def on the fork.

References #1282.

jwrosewell and others added 30 commits October 11, 2026 09:31
The section says when a middleware prepares, what it is called with, where
its answer goes, how a refusal is answered, and what each phase's
preparation must not depend on. The changelog records the addition.
`PlatformLoadingCache` in `platform/loading_cache.rs` takes a key and a
loader and returns the value, running the loader at most once per key at a
time, so however many requests ask for a missing value at the same moment
the source is asked once and every one of them continues with its value or
its error. A failed load is not stored, so the next caller loads again.
`NoLoadingCache` holds nothing and runs every load, and is what
`RuntimeServices` carries when no module supplies a cache.

Every key is a `StoreKey`, being the publisher's `ServerKeyPrefix`, the
cache's name and the caller's key. The prefix is made in one place,
`platform/server_key.rs`, from the publisher's domain in the loaded settings
with a single leading `www.` folded and `ts.` in front, so two publishers
sharing a store never share an entry.

`PlatformValueCache` is the host's shared cache of small values with
lifetimes, which a host supplies through `RuntimeServices` when it has one,
with `lookup_or_reserve` handing the caller a `ValueReservation` where the
host can make other callers for a key wait, and `remove`. A loading cache
keeps its shared copies there where the host has one and in the key-value
store otherwise.

An `IntegrationBuilder` offers a loading cache with `with_loading_cache`,
and `loading_cache_from` gives an adapter the one its linked builders offer,
refusing two, so no adapter names the crate that supplies it. Core's tests
get `MemoryLoadingCache`, which keeps what it loads for the lifetime the
load gave and records every call.
A contributor's answer is loaded through `RuntimeServices::load_cached`,
so the contributor is asked once however many requests want its rules at
the same moment, and the copy in the host's shared store answers every
instance until the contributor's refresh is due. The answer's lifetime in
the cache is the contributor's refresh, and `[cache.robots-txt]` sets how
long the copies last with `memory_seconds`, `store_seconds` and
`store_idle_seconds`, defaulting to a minute, a week and 30 days. A memory
lifetime may be 0, the other two must be at least 1, and none may be more
than a year, which `LoadingCacheConfig::check` refuses as the settings load,
naming the setting.

The last answer a contributor gave is also kept apart from the cache for 30
days, in the host's value cache where it has one and in the key-value store
otherwise, and read only when the contributor fails, because yesterday's
rules are a better answer than none. Every key starts with the publisher's
`ServerKeyPrefix`, so two publishers sharing a store never share an answer.
The handler hands the settings through in place of the publisher's domain
and the time, and a selection carries the name the contributor was declared
under.

The tests use `MemoryLoadingCache` and cover the one load for a held
answer, the key naming the publisher, changed settings asking again, the
kept answer served when the contributor fails, in a second instance and
from a value cache, and a failure with nothing kept being an error that is
not held.
Each adapter takes the cache from the builders it is given with
`loading_cache_from` when it builds its state, and hands it to every
request's services, so every request in a process shares the cache's
memory copy and waits for one load. Axum, Cloudflare and Spin take it from
the stock list followed by the builders a deployment passes, as they take
every vendor module, and hold nothing without one. No adapter names the
crate that supplies it.

On Fastly the finishing steps' services carry the same cache as the
request, so a lookup there shares the request's memory copy, and the entry
point builds those services once from the state it has, or with the
unavailable store and a cache that holds nothing when no state was built.

Fastly supplies `FastlyValueCache` over Fastly's cache as the value cache,
which needs nothing created or linked on the service. A transaction lookup
reserves a key nothing holds for this instance and makes every other
instance at the location wait for it to be filled, a stale entry is
reserved for a fresh load rather than served, and a reservation dropped
unfilled is released so a waiting instance takes it over. Each entry is
written with a surrogate key, a hash of its key, which `remove` purges. Its
tests run under Viceroy and cover the reservation, its release, a stale
entry and the purge.
`crates/loading-cache/51degrees` implements `PlatformLoadingCache` with the
published `fiftyone-caching` package, version 4.5.8-49, with its default
features off so it builds for every host. Each cache is a least recently
used copy in process memory with a short lifetime over the host's shared
store, being its value cache where it has one and its key-value store
otherwise, written in the package's entry format so a store that deletes
late or keeps no lifetimes never returns an entry past its drop time. The
memory layer is shared by every request in a process and loads a missing
key once, covering the store read and the source together. The store layer
is made for each call from that request's stores. A load a cache allows to
run apart from its caller runs on a pool of threads on a native host and
with the request's `wait_until` on Cloudflare, and every load runs in its
caller on Fastly and Spin.

The crate is the module `loading-cache.51degrees`, named by its folder, and
`builder()` offers the cache whatever the settings say, because a loading
cache is how a deployment caches rather than a module a document selects.
The stock list carries the builder, so every adapter takes the cache from
it with `loading_cache_from` and names no crate, and a test holds the list
to offering one cache that keeps what it loads. The crate carries the
maintainers table, the workspace lists it, and the Axum aliases lint and
test it, where its own tests run natively.

The crate's tests ask for one key from 16 callers at once on 16 threads
with the pool, on 16 threads with loads in the caller, and on one thread
with each load a task of its own, and the source runs once each time. Other
tests cover a failure reaching every waiter and not being kept, the store
answering a second process, the brief memory copy, idle expiry, a value's
own lifetime, a reserving value cache being filled and released, two
publishers kept apart, removal, the module's name and the builder's offer.
Spin's key-value store refuses a write with a lifetime, so a robots.txt
answer kept for a later failure, and a loading cache entry in the module's
key-value layer, are written without one there, as the held answer was
before. The entry format carries each entry's own drop time, so a copy in a
store that keeps no lifetimes still ends when it should, and a kept answer
stays until it is replaced, which is what the configuration guide says of
Spin.

The robots.txt load carries the name the contributor was declared under as
the static text the module context takes, and the handler hands it over
from the selection. Tests cover the kept answer's lifetime where the store
takes one, the answer kept where it refuses one, and a store that refuses
lifetimes still answering a second process through the module.
The configuration guide gains "Loading caches" under the cache section,
saying what is held, where the two copies live, how the keys start, which
crate a stock build caches with and the `[cache.robots-txt]` lifetimes, and
its robots.txt section says each answer is held through it. The integration
guide gains "Supplying a loading cache", being how a crate offers one with
`with_loading_cache` and how code loads through one with `load_cached`, a
`CacheSpec`, a `StoreKey` and a `Loader`, and the builder table lists the
offer. The example configuration shows `[cache.robots-txt]`, and the
changelog records the cache, the lifetimes, the key prefix and the value
cache under Unreleased.
The crate keeps a load running with the request's `wait_until` only where
the Worker's handling of the request is wrapped in `scope_request`, and
nothing on an adapter wraps it, so the crate's documentation and the
changelog say so rather than that the load keeps running after the
response on Cloudflare.
The item exists only on the Cloudflare target, so a link to it from the
crate's documentation is reported broken on every other target's
documentation build. The name is written as plain code instead.
The first build adds the published fiftyone-caching crate, hashlink and
tokio-util to the lock, which a build with --locked refuses until they are
committed.
A feature that holds answers in a loading cache names, in its own
`[cache.<name>]` table, the module whose cache holds them, as the module's
short name or its name in full. `loading_caches_from` resolves every
feature's selection against the caches the linked builders offer, makes
each module's cache once however many features select it, and refuses a
name no builder offers a cache from, naming the feature and the modules
that do. The services carry the selections as `LoadingCaches`, and a
feature that selected none loads through `NoLoadingCache`.

Before this the stock build's one offered cache held every feature's
copies, and a deployment linking two caches was refused. Now each feature
decides, so the asset cache and any later feature can choose a cache of
its own.

`[cache.robots-txt] module = "51degrees"` selects the stock cache for the
robots.txt modules' answers. The guides, the example configuration and
the changelog say so, and the adapters pass the selections where they
passed the one cache.
Core's own middleware `robots_txt`, in `robots_txt/middleware.rs`, makes
the file `[robots-txt]` describes where a `[[fetch]]` or `[[serve]]`
entry for `text/plain` on `/robots.txt` names it. Its preparation does
what the route's handler did, a refusal of every crawler from nothing and
otherwise every selected contributor's held answer assembled, and answers
a refusal with `503` and `Retry-After` when a contributor fails with
nothing held, so nothing is stored and no empty file is served. Its
action replaces whatever the origin sent with the file, and the creating
rule makes the file where the origin has none.

The registry makes the middleware itself once every registration is in,
because the contributors it asks are known only then and a middleware
cannot reach the registry. The settings refuse a `[robots-txt]` section
that no entry places, because the file would never be made.

The route goes from the four adapters, with `handle_robots_txt`, the
response builder and the route's own cache lifetime. The file takes the
deployment's cache policy for the path, as every file does. The adapters'
route tests go with it, and each now proves the path is no route of this
server; the file itself is proven through the publisher path in core.
The stock list carries the middleware on `text/plain`, and the example
configuration places it.
The configuration guide's robots.txt section says the file is made by
the `robots_txt` middleware where an entry names it, in place of the
origin's file or where the origin has none, that a section no entry
places is refused, and that the response takes the deployment's cache
policy for the path. Its example carries the entry, and the placing table
has the middleware's row. The changelog records the middleware and the
route's removal.
`[shared.<vendor>]` holds what every module of one vendor reads, such
as an endpoint and a license key, so the key is written once. Core
declares the tables and reads none of them. A vendor's crates read
their own as typed settings through `Settings::shared_config`, which
refuses a table that cannot be read as the type or fails its
validation, naming the table. A table whose name is not a folder name
is refused when the settings load.

A builder marked with `with_shared_settings` is run by its vendor's
table rather than by a selection, so a document without the table runs
none of that vendor's modules, and a selection naming one of them is
refused with the absent table named. A table that no builder reads is
refused when the registry is built, the one place every builder is
known. A secret in the table is declared with the builder's secret
settings at its path inside the table and resolved when the table is
present, as a module's own secrets are.

The seam probe gains a second builder its table runs, whose route
answers the table's marker, so the Axum adapter proves the table from
outside core.
The configuration guide lists `[shared]` among the sections and
describes the tables, what runs a builder, the refusals and how a
secret in a table is written. The integration guide's builder table
gains `.with_shared_settings(...)` and a section on reading the table
as typed settings. The example configuration notes the table beside
its list of sections, and the changelog records the addition.
Core gains a prefix module, `PrefixModule`, which gives two things for the
publisher a request resolved to: a public prefix that starts every name
the browser holds, being cookies and first-party storage keys, and a
server key prefix that starts every key in a store publishers share.
Core's own module gives `ts` and `ts.<publisher.domain>` and runs when
`[prefix] module` selects nothing, so a stock build's names are what they
were.

The registry resolves the selection as it does geo and device, refuses a
name nothing this deployment runs supplies, and asks the module once when
the registry is built, so a module naming a value its call never carries
is refused at startup. Every adapter puts the resolved prefixes on the
request's services, and the Fastly entry point keeps what its request
resolved to for the finishing steps that run after the request has been
handed to the router. The robots.txt holding and the loading cache take
the prefix from the request's services rather than from the settings.
The cookie constants lose their fixed `ts-` head and name the entry alone,
`ec`, `ecr`, `ec-pull-complete`, `eids` and `tester`, and every read and
write names the cookie through the request's public prefix, so a
deployment whose module gives `_ex` reads and writes `_ex-ec`. The Edge
Cookie state carries the prefix it was read under, and a module's response
header naming a cookie under that prefix is refused as a cookie under
`ts-` was.

The page's bundle tag tells the client script the prefix, as
`data-ts-public-prefix`, so the script names its own cookies after it and
holds no copy. The template fingerprint includes the prefix, so a page
stored under one is never served under another, and a key cookie naming
one of the request's own identity cookies shares no template.
A prefix's first part starts with a letter or a single `_`, so no cookie
name starts with `__`, where browsers give names such as `__Host-` rules
of their own and drop a cookie that breaks them. The Edge Cookie test
reads and writes under a prefix carrying a publisher's name as well, and
a test holds the client script to the attribute the server writes.
The probe declares a prefix module on its registration, giving `_probe`
and `probe.<publisher.domain>`, and its report route answers the prefixes
the request's services carry, so the Axum adapter proves from outside core
that `[prefix] module` resolves the module and that a request's services
carry what it gives.
The configuration guide lists `[prefix]` among the sections and describes
the selection and the names it changes. The integration guide's
registration table gains `.with_prefix_module(name, ...)` and a section on
giving the deployment its prefixes. The example configuration notes the
section, and the changelog records the addition and the renamed cookie
constants.
`DeviceSignals` gains `human_confidence`, one of `High`, `Medium`, `Low`
and `Unknown`, which a device module that scores the request writes and
every other module leaves `Unknown`. `Unknown` is kept apart from `Low`
because a request alone cannot establish a person, so a first-time
visitor is not called a machine. Nothing in core gates on the answer;
`looks_like_browser` still gates the Edge Cookie as it did, and the
answer is there for a module that reads it.
`DeviceAttributes` carries what a bidder is told about the device, being
the OpenRTB device type, make, model, operating system and version, and
screen size, every field optional because every source is partial. It is
kept apart from `DeviceSignals`, which answer whether the request looks
like a browser and are stored in the identity graph, because these
answer what a bidder prices and are stored nowhere.

`DeviceModule::advertising_attributes` answers them from the module call
`detect` is handed, with a default of nothing so no existing module
changes. `attach_device_attributes` asks the selected module after each
auction request is built, on the auction endpoint and the publisher's
server-side path and not the consent-denied path, which makes no outbound
call. A module that resolves nothing adds no device object, because an
absent object and an empty one say the same to a bidder and the absent
one is smaller. The OpenRTB device object carries the attributes, each
absent where nothing resolved it, and `device_type_from_name` maps a
detection name to the OpenRTB code and maps nothing for a name it does
not know, because a wrong type is worse for a bidder than an absent one.
The probe's device module scores the request `Medium` and answers fixed
attributes, a tablet by name, so the Axum adapter proves from outside
core that the module `[device] module` resolves answers both through the
call it is handed, and that a tablet maps to OpenRTB device type 5.
The integration guide's table of what a module is handed gains the
device module's second answer, the attributes for the bid request, and
the configuration guide's device section says a module may score the
request and tell a bidder what the device is. The changelog records the
addition.
`CreativeOpportunityFormat` gains optional `min_viewport_width` and
`max_viewport_width`, in CSS pixels. A slot with a bounded format carries
`viewport_sizes` in its page data, one band per width at which the
formats on offer change, widest first, and the Google ad tag's head
bootstrap and the bundle's slot definition apply it as a size mapping
when they define the slot, so a slot asks for a large size only where
the page has room for one. A slot whose formats name no bound behaves
exactly as before, and a server-side auction is still offered every
format. A binary without the fields refuses a document carrying them,
so it is promoted before any configuration uses them.
`MiddlewareAction` gains `body_open_inserts`, markup written once per
document immediately after the opening `<body>` tag, in the order given,
beside the places it had for the head and for straight after the script
bundle. A vendor's two-part installation may put its second part there
and nowhere else, as Tag Manager's noscript frame is, and until now a
middleware had no way to reach that place. A document with no body gets
none, and a second body tag in a template fragment gets nothing.
A publisher whose page templates forbid third-party scripts cannot put a
Google tag on the page, so it has to come from the proxy, and nothing did
that: the Tag Manager module re-serves a tag the page already carries,
which needs a page that already loads Google.

`crates/tag/google-tag`, selected by `[tag] modules` with `[tag.google-tag]`,
writes Google's own snippets. At the start of head, the consent mode
defaults, then Tag Manager's container script, then the gtag.js snippet,
then the AdSense loader. Immediately after the opening body tag, Tag
Manager's noscript iframe. Each is copied from Google's documentation
rather than paraphrased and carries the page it came from, so a reader
can compare the two. Consent defaults come first because Google's consent
guide requires the default command before anything that sends measurement
data, and all four types default to denied because no Google identifier
may be stored before a permission signal has been processed.

Where each loader points is settled at startup, because head and body
insertions never reach the attribute rewriters. When the Tag Manager
module serves the same tag or container, the loader names the first-party
path, otherwise Google's own address, and the Tag Manager crate offers
the two paths and the two id shapes it accepts so the two modules cannot
disagree about an id. The noscript iframe always names Google, because
`ns.html` has no first-party route behind it. A page also loading gtag.js
for the same tag has that duplicate loader removed, which is why the
module registers before Tag Manager in the stock list, since a removal
short-circuits the rewriter chain. An inline configuration call in the
publisher's own page is not removed and still sends a second page view,
which the guide says plainly, because removing it would mean buffering
every response.
The published pre-release the module was pinned at is superseded by the
released 4.5.8, so the module reads `^4.5.8` and the lock file names it.
Nothing else changes.
…once

A route whose parameter the request's permissions withhold answers 403 as
before. A value absent where the route runs, or needing permissions that are
not resolved there, is a fault in how the module was written or placed, and
answers 500 rather than 403.

The injection rule is stated in the module_context rustdoc and the trait
methods link to it. The route helpers' repeated doc sentence, three repeats of
the Edge Cookie snapshot comment and the guide's copy of the parameter table
are removed. A misplaced test helper doc is moved back to its function, and
the device-signal gate's limit for a module the adapter builds is stated.
A page that carries no GPT loader of its own got the bootstrap, the shim
and the slot handoff from this module and nothing that loads GPT, so its
slots never filled. `[ad-tag.google] write_loader = true` writes the
loader tag into the head, last among the module's inserts, after the shim
and the bootstrap it relies on. The tag asks for the loader on the
module's own route, `/integrations/gpt/script`, so the whole chain stays
first party. It is off by default, because a page that already carries
the loader would otherwise load it twice.

The guide's two tables and the GPT page name the setting, the example
settings file shows it, and the changelog says what it adds. Three tests
cover the tag written when asked, written last beside the slim-Prebid
address, and absent by default.
rustfmt breaks the two assertions over several lines, and prettier pads
the middleware table's row to the table's width.
@jwrosewell

Copy link
Copy Markdown
Collaborator

Moved into this repository as #1372, one branch of the stack, so the review continues there. This pull request is closed in its favor and its comments stay here for the record.

@jwrosewell jwrosewell closed this Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants