Registry and resolvers
How an asset ID becomes a loaded, servable PackagedAsset. Three modules cooperate: resolver says where the media is, source reads it, and registry caches the answers and schedules the work. The design is in TDD 0002; the wire contract mapper authors implement is in the Mapper API reference.
flowchart LR
H[HTTP handler] -->|state.asset id| R[AssetRegistry]
R --> RC[Resolution cache]
R --> LC[Loaded cache]
R -->|resolve| RES{AssetResolver}
RES --> ST[StaticResolver<br/>config file]
RES --> HR[HttpResolver<br/>mapper client]
HR --> M[(Mapper service)]
R -->|open location| OP[SourceOpener]
OP --> LS[LocalMediaSource]
OP --> RS[HttpMediaSource]
RS --> O[(Media origin)]
OP --> PA[PackagedAsset::load]
resolver/ : where is the media?
resolver/mod.rs defines the vocabulary.
| Type | Meaning |
|---|---|
AssetLocation | File(PathBuf) (beneath storage.media_root) or Http(Url) |
ResolvedAsset | Location, a required version, valid_until (when to revalidate), and optional hard_expiry (when the location itself dies) |
Resolution | Resolved(ResolvedAsset) or Unchanged { valid_until } (a mapper 304) |
ResolveError | NotFound, Unavailable (retry later), Rejected (the answer is invalid or forbidden) |
AssetResolver | An enum, Static or Http, with an async resolve(id, known_version). An enum rather than a trait object because native async traits are not dyn-compatible and this avoids a dependency |
catalog.rs answers from the [assets.*] tables. Every answer has the constant version "static" and effectively never expires. It also lists its IDs so the registry can preload them.
mapper.rs is the mapper client. For each lookup it sends GET {base_url}/v1/assets/{id} with Accept, an optional bearer token, X-Request-Id, and If-None-Match when it holds a usable answer. It maps statuses to errors, retries Unavailable failures with a short backoff (honoring a capped Retry-After), reads the body with a hard size cap, then validates everything in interpret: the echoed ID, the version’s shape, the location (relative-path rules, or URL policy), the TTL (explicit, else Cache-Control, else default, clamped), and expires_at. Unknown JSON fields are ignored; an unknown location type fails deserialization and is rejected. Secrets stay in Secret, whose Debug output is redacted.
policy.rs holds LocationPolicy (scheme, credentials, host allow-list, literal-IP check) and validate_relative_path. Both are pure and heavily unit-tested because they are the trust boundary.
source/ : reading the media
MediaSourceKind is Local(Arc<LocalMediaSource>) or Http(Arc<HttpMediaSource>), with async read_range and verify_unchanged. Local reads run on the blocking pool, one chunk at a time; remote reads are ranged HTTP requests.
http.rs contains RemoteReader (one pooled reqwest client shared by all remote sources, an in-flight semaphore, retry settings) and HttpMediaSource.
- Open sends
Range: bytes=0-0and requires206, aContent-Rangegiving the total length, and a validator: a strongETag, elseLast-Modified. A200(no range support), no validator, or only a weakETagis refused. - Read sends the range with
If-Range: <validator>. It requires206, an exact matchingContent-Rangeand total length, and, for anETag, the same tag on the response. A200or412means the object changed and fails the read as invalid media instead of returning mixed bytes. Transient failures (timeouts, connection errors,5xx,429) are retried up tomax_retriesand surface asUpstreamUnavailable; everything else isUpstream. - Bounded body. The response body is accumulated only up to the requested length; more is an error.
- Address filtering. The client resolves names through
FilteringResolver, which drops loopback, private, link-local, shared, multicast, unspecified, and unique-local addresses (including IPv4-mapped forms) unlessallow_private_addressesis set. Filtering at connect time defeats DNS rebinding. Literal IPs skip resolution, soLocationPolicychecks them separately. Redirects are disabled. - Verify re-probes the object and compares length and validator.
metadata.rs is what makes metadata parsing independent of where a file lives. Metadata::fetch walks the top-level box headers with small reads and fetches moov whole; nothing else is read, because every sample’s location and timing is in moov and the payload is only read later, one segment at a time. A remote parse therefore costs a few small range requests, not a download. It caps the top-level box count so a hostile file cannot cause millions of round trips.
mp4::parse (in Media pipeline) runs fetch, parses on the blocking pool, then re-verifies the source and re-hashes moov to detect a change during parsing.
registry/ : caches and scheduling
AssetRegistry::get(asset_id) is the only call the HTTP layer makes per request.
Fast path
- Reject syntactically invalid IDs (they never reach a resolver).
lookuptakes two shortstd::sync::Mutexsections and no I/O: a cached recent failure, a negative entry, or a fresh resolution whose asset is in the loaded cache returns immediately.
Slow path (single flight)
enter_flighttakes a per-assettokiomutex, creating it on demand. A request that finds it busy is a coalesced waiter (counted in metrics). TheFlightguard removes the map entry on drop when nobody else holds it, so the map cannot grow without bound.- After the lock the request looks up again; another request may have finished the work.
- Otherwise it spawns a task that owns the guard and runs
resolve_and_load. The work therefore continues if the requesting client disconnects, and the waiters get its result from the cache.
ensure_resolved:
- A fresh resolution is used as is. A stale one is revalidated with its version as
If-None-Match(only if its location has not hithard_expiry). Resolvedreplaces the entry. If the version or the object (AssetLocation::same_object: scheme, host, port, and path) changed, every loaded copy of the asset is dropped first, with no grace period. If only the URL’s query changed (a re-signed URL), the loaded asset is kept andPackagedAsset::update_locationpoints its remote source at the new URL (in-place rotation).Unchangedextendsvalid_until, never beyondhard_expiry.NotFoundrecords a negative entry and evicts loaded copies.Unavailableserves the previous answer if it is still insidestale_if_errorand its location is not hard-expired, and sets a short backoff so the mapper is not asked again for every request. Otherwise the failure is cached forerror_ttland returned as503.RejectedbecomesBadUpstream(502) and is cached briefly.
load opens the location through SourceOpener, acquires a slot from the load semaphore (max_startup_parses, waiting at most load_queue_timeout, else 503), runs PackagedAsset::load, and inserts the result. A failed load is cached briefly so a broken file is not reparsed on every request. SourceOpener confines file locations: join to the media root, canonicalize (resolving symlinks), require a regular file, and require the result to still be under the root.
Signed URLs
Three mechanisms keep a remote asset readable as its signed URL changes:
- Refresh ahead (
mapper.rs::interpret): for an answer withexpires_at,valid_untilis set tomax(remaining - refresh_margin, remaining / 2)from now, so the next request after that point revalidates while the old URL still works. - In-place rotation (
store_resolution): described above.HttpMediaSourcekeeps its URL in a mutex, soset_urltakes effect on the nextfetch. - Recovery on rejection:
HttpMediaSource::read_rangemaps origin401/403/410toError::LocationRejected. If the source has aLocationRefresher, it calls it once (refreshedguards against loops), swaps the URL, and retries. The registry suppliesAssetRefresher, which holds aWeak<AssetRegistry>(no reference cycle) and callsAssetRegistry::refresh_location. That takes the asset’s single-flight lock, reuses a location fetched within the last two seconds (so a burst of rejections causes one mapper call), and otherwise asks the mapper unconditionally. The refresher is disarmed while the asset is loading, because the loading task already holds the flight lock and waiting on it would deadlock.
Caches
- Resolution cache:
id -> Slot, eitherFound { resolved, backoff_until }orMissing { until }, bounded bymax_cached_resolutions(expired entries are swept first). - Loaded cache (
cache.rs): a byte-weighted LRU keyed by(asset_id, version). Weight isPackagedAsset::index_bytes; inserting evicts the least recently used entries above the budget (limits.max_index_bytes). An asset heavier than the whole budget is served but not retained. Only one version of an asset is ever resident. Requests already holding anArcare unaffected by eviction.
Errors
RegistryError maps to HTTP in one place (http/error.rs): NotFound to 404, Unavailable to 503, BadUpstream to 502, LoadFailed (unsupported or invalid media) to 500.
Preload
preload loads every ID the resolver already knows (the static catalog) concurrently, logs each, and fails startup with asset \x` could not be loaded: …if any fails. It also fails if their combined index size exceedslimits.max_index_bytes`. With a mapper there is nothing to preload.
Configuration touched
[resolver], [resolver.http], [registry], and [remote_media] in config/resolver.rs; see vod.example.toml and Operating the origin.
Contributing
- Keep the resolver and registry free of blocking calls; blocking work goes through
spawn_blocking. - Every value that comes from a mapper is untrusted. Add checks to
interpretorpolicy.rs, with a test inregistry/tests.rsthat feeds the bad answer through the mock mapper and asserts502. - New failure modes need a
RegistryErrormapping and a decision about whether to cache the failure. - Tests use real in-process servers (
testutil.rs):MockMapper(scriptable status, delay, body, token, health) andMockOrigin(range and validator behavior, request and byte counters). Prefer extending them to faking a trait.