Files
RustProxy/docs/CACHE.md
T
2026-10-09 13:11:33 +02:00

327 lines
25 KiB
Markdown

# Shared hot/cold cache
See [CACHE_DIAGNOSTICS.md](CACHE_DIAGNOSTICS.md) for practical HIT/MISS/BYPASS diagnosis.
## Scope
RustProxy implements a bounded reverse-proxy cache with a hot RAM tier and an optional
persistent cold disk tier. Version 0.11.5 aligned request-method handling and most logical
hash behavior with Varnish built-in semantics. Since 0.19.1 the logical primary identity
includes the frontend scheme as well as Host + exact request URL, so `http://host/path`
and `https://host/path` cannot reuse the same stored response. RustProxy still isolates
different vhost/config scopes internally and uses response `Vary` to select representation
variants.
This is not a VCL implementation. Generic Varnish grace, ESI, public unauthenticated
BAN/PURGE and distributed cache state are not implemented. RustProxy 0.22.1 adds bounded
foreground request collapsing, authenticated selective PURGE/BAN and optional proactive
refresh while keeping directive-controlled stale delivery through RFC 5861 and request
`max-stale`; it is not an unconditional availability/grace layer. RustProxy also keeps its
validator-based handling of response `no-cache` instead of reproducing Varnish hit-for-miss
behavior exactly.
## Web Server origins (0.29.4)
A Web Service selected as the vhost source now uses the normal RustProxy shared
object-cache pipeline when both global and vhost cache policy permit it.
`Cache-Control: public, max-age=...` (or another eligible freshness policy)
can produce `MISS` followed by `HIT` instead of a browser-only cache header.
Vhost response rules run before cache fill, and transformed output is what is
stored. Private/`Set-Cookie`/`no-store` origin responses remain ineligible,
even if a later response-header rule replaces their visible cache headers.
For Web Server sources, stale entries are refreshed by a new local-source
request rather than the remote-backend revalidation worker. As with reverse
proxy origins, range requests and conditional requests can reuse fresh stored
representations. Do not rely on stale-if-error remote-backend refresh behavior
for local Web Server routes.
## Settings
| Field | Default | Bounds / meaning |
|---|---|---|
| enabled | true | Global master switch; vhosts may disable cache but cannot re-enable a globally disabled cache |
| max_bytes | 268435456 | Hard HOT allocation budget (body backing allocation + charged entry metadata); minimum 64 KiB, no artificial high ceiling |
| max_object_bytes | 16777216 | Maximum logical body accepted by one fill; minimum 1 KiB and at most `max_bytes` |
| max_entries | 4096 | 1..16384 hot objects |
| transient_max_bytes | 67108864 | Hard RAM budget for fill buffers and complete bodies moving between HOT/COLD |
| cold_queue_max_bytes | 33554432 | Byte ceiling for complete demoted objects waiting for the COLD writer; at most `transient_max_bytes` |
| hot_eviction_max_objects | 64 | 1..4096 LRU removals attempted for one HOT admission before the new object passes uncached |
| hot_auto_trim_enabled | true | Periodically trim sufficiently idle HOT objects only while HOT usage is above the target waterline |
| hot_auto_trim_after_seconds | 300 | 5..86400; minimum idle age before an object can be removed by automatic HOT trim |
| hot_auto_trim_target_percent | 80 | 10..95; automatic trim stops once HOT usage reaches this percentage of `max_bytes` |
| cold_enabled | false | Enable persistent disk tier; never implied by hot-cache enable |
| cold_path | cache/cold | Relative path under the private RustProxy data directory |
| cold_max_bytes | 4294967296 | 64 KiB..1 TiB disk budget |
| cold_max_entries | 50000 | 1..1000000 cold objects |
| cold_gc_interval_seconds | 60 | 5..86400; interval between asynchronous pressure-GC checks |
| cold_gc_max_delete | 256 | 1..10000; maximum objects removed by one automatic GC pass |
| cold_after_seconds | 300 | 5..604800; idle hot objects are eligible after this delay |
| cold_on_idle | true | Move idle eligible hot objects to cold storage |
| cold_max_hot_seconds | 3600 | 5..2592000; total RAM-residency age used when `cold_on_age=true` |
| cold_on_age | false | Demote objects after the maximum hot-tier age even if they remain frequently accessed |
| cold_on_lru | true | Move objects displaced by hot-tier LRU pressure to cold storage |
| cold_on_shutdown | true | Best-effort direct hot-to-cold flush during ordinary graceful shutdown/restart |
| default_ttl_seconds | 0 | Only explicit public responses without origin freshness |
| max_ttl_seconds | 86400 | 1..604800; maximum configured retention/freshness cap |
| bypass_query | false | Bypass requests containing a query when enabled |
| bypass_prefixes | [] | Up to 64 normalized absolute path prefixes |
| static_ignore_cookies | true | For configured static extensions only, ignore request Cookie for eligibility/keying |
| expose_status | false | Emit `X-Cache: HIT`, `MISS` or `BYPASS` |
| expose_hits | false | Emit per-object `X-Cache-Hits` |
| expose_cache_status | false | Emit standardized `Cache-Status` (RFC 9211) |
| expose_resident_age | false | Emit `X-Cache-Resident-Age` on reused stored responses |
| via_header | `RustProxy` | Append this `Via` response value; `null` disables it |
| collapse_enabled | true | Collapse concurrent foreground fills for the same primary cache key |
| collapse_wait_timeout_ms | 1500 | 10..60000 ms follower wait before fallback |
| collapse_timeout_fallback | origin | `origin` or `stale_if_available` after follower timeout |
| stale_if_error_timeout | true | Allow RFC 5861 stale fallback for timeout failures |
| stale_if_error_connection | true | Allow stale fallback for connection failures |
| stale_if_error_dns | true | Allow stale fallback for DNS failures |
| stale_if_error_tls | true | Allow stale fallback for TLS failures |
| stale_if_error_statuses | [500,502,503,504] | Up to 32 unique eligible HTTP 5xx statuses |
| proactive_refresh_enabled | false | Schedule sampled cached objects for validator refresh before expiry |
| proactive_refresh_before_expiry_seconds | 15 | 1..604800 seconds before expiry |
| proactive_refresh_percent | 10 | 0..100 deterministic percentage of primary keys |
| proactive_refresh_max_inflight | 4 | 1..64 concurrent proactive refreshes |
The hot tier enforces `max_bytes` and `max_entries` before publication. HOT accounting uses the retained body allocation (the fill buffer capacity transferred into `Bytes`) plus directly owned entry metadata and a conservative per-entry charge for the HOT hash/LRU/variant indexes, while `hot_logical_bytes` reports only visible body lengths. Rust container/allocator APIs do not expose every bucket/node allocation exactly, so `hot_allocated_bytes` is intentionally a conservative charged estimate rather than a promise that it equals process RSS. In-progress fills reserve a separate bounded transient budget before growing their buffers. When HOT pressure would exceed the budget plus a small allocator-rounding headroom (roughly 1/64 of HOT, capped at 1 MiB), RustProxy removes LRU objects first; if admission still cannot be made safe within `hot_eviction_max_objects`, the response continues normally without being cached. `max_object_bytes` is checked incrementally before every body-buffer growth, so an oversized response is not fully accumulated before rejection.
Automatic HOT trim is a separate low-pressure maintenance path. Every maintenance pass first checks whether HOT allocation is above `hot_auto_trim_target_percent` of `max_bytes`; if so, only least-recently-used objects that have been idle for at least `hot_auto_trim_after_seconds` are eligible. One pass is capped by `hot_eviction_max_objects`. Active/recent objects are never removed by this mechanism. Eligible objects follow the existing COLD-on-LRU policy when enabled; otherwise they are evicted. This is intended to release stale working-set memory after traffic bursts without periodically purging the cache or reloading vhosts.
When cold storage is enabled,
`cold_on_lru`, `cold_on_idle` and `cold_on_age` independently choose which hot objects are demoted.
`cold_after_seconds` measures time since the last access. `cold_max_hot_seconds` measures total
hot-tier residency, so it can move a frequently accessed object to disk after a configured age.
The administration UI exposes these as separate policies to avoid conflating "old" with "unused".
Demotions use an asynchronous queue bounded by both object count and `cold_queue_max_bytes`. A demoted `Entry` is removed from HOT accounting and moved, not copied, into a `ColdDraft`; its `Bytes` payload remains charged to `transient_max_bytes` until persistence completes and the draft is dropped. The persisted `ColdEntry` contains compact lookup/invalidation/LRU metadata rather than the body allocation. Since 0.27.3 it also does not retain the full response `HeaderMap` or origin validator values per COLD object; those are validated and loaded from the existing metadata JSON only for an actual COLD read. The in-memory body digest is 32 raw bytes and the map-owned cache key is not duplicated inside the entry. This reduces long-lived COLD index RAM without changing Cold format 3 on disk. This prevents the COLD writer queue from becoming a second unbounded RAM cache. If an HTTP response is already using a clone of the same `Bytes`, that last transport-owned reference can keep the backing allocation alive until the response finishes; the cache-owned HOT/COLD reference itself is released after persistence. Process RSS is therefore monitored separately from cache accounting. Supported
glibc/Linux builds ask the allocator to release unused pages after bulk demotion. During
ordinary graceful shutdown, `cold_on_shutdown` performs a best-effort direct flush of remaining hot
objects before the store is closed. A **managed software update** (`update.sh` / `scripts/deploy/deploy.py`)
uses a private one-shot marker and, on RustProxy 0.13.2+, forces this hot-to-cold handoff whenever
the cold tier is enabled even if `cold_on_shutdown=false`. The updater gives the process a larger
stop budget and removes the marker before backing up state. If the cold tier is disabled there is
nowhere persistent to hand the hot objects, so RAM-only cache is still lost on process exit. The
body is stored as a raw `.body` file and metadata as JSON with a SHA-256 body digest. A fresh cold hit is integrity-checked and promoted to
RAM. Cold format 3, introduced in 0.21.0, stores objects below the first two hexadecimal
characters of the SHA-256 cache key, for example `cache/cold/7f/<hash>.body` and
`cache/cold/7f/<hash>.json`. Shard directories are created lazily. The previous flat
Cold Cache layout is deliberately not migrated; legacy flat objects are removed and can
be refilled from origin. Format 3 metadata also carries RFC 5861 windows, revalidation
flags, origin validators, last access and hit count.
Automatic Cold Cache GC is asynchronous. When either byte or entry usage reaches about
90% of its configured limit, it selects old/rarely used candidates and cleans toward
about 80%, removing at most `cold_gc_max_delete` objects in one pass. Metadata is
snapshotted in small batches and disk deletion is queued instead of holding request-path
state across a full-cache sort/delete loop.
## Varnish-style identity and variants
The logical primary cache identity is:
- a stable cache-compatibility revision derived only from settings that can change
cached representation/origin semantics, plus vhost scope (internal isolation),
- normalized frontend scheme (`http`/`https`),
- normalized original Host authority, including an explicit port,
- exact request path and query.
Selected backend-pool member, route/backend IDs and unrelated request headers are not
part of the representation hash. This means two healthy members of the same pool can
serve/fill the same public cache object, while HTTP and HTTPS remain isolated. Headers
such as User-Agent, X-Forwarded-For or browser client hints no longer create unnecessary
variants by themselves.
The response `Vary` header provides the secondary representation identity. For example,
`Vary: Accept-Language` stores independent language variants under the same scheme + Host + URL
primary hash. `Vary: *`, malformed Vary fields or an excessive Vary field set are not
stored. If an origin changes the Vary schema for a URL, incompatible old variants are
removed so an obsolete no-Vary object cannot shadow a newer Vary representation (or
vice versa).
Cookie and Authorization requests still pass by default, matching Varnish built-in
safety behavior. RustProxy's static-cookie optimization can ignore Cookie only for the
configured static file extensions; response `Set-Cookie`, `private`, `no-store` and
`Vary: Cookie` still prevent unsafe shared reuse.
### Per-vhost forced-cache targets
A vhost can opt into an explicitly unsafe `cache.unsafe_force` list for narrowly selected public request targets. It is intentionally not available as a global cache setting. Each configured target starts with `/`; a target without `?` matches by normalized path prefix, while a target containing `?` matches the original path+query prefix.
For matching GET/HEAD requests, forced mode may ignore request-side bypasses caused by a query string, Cookie, Authorization, `Cache-Control: no-cache`/`no-store`/`max-age=0`, and `Pragma: no-cache`. Protocol-safety exclusions such as Upgrade, unsupported Range/preconditions and request bodies remain in force. Cookie and Authorization values are added to the cached representation's `Vary` identity in forced mode so different credentials/cookies cannot reuse each other's object.
Forced mode does **not** override response-side privacy policy. `Set-Cookie`, `Cache-Control: private`, `no-store`, `Vary: *`, partial/trailer/SSE/gRPC responses and the normal size/lifetime validation still prevent storage. Use this only for URLs whose origin semantics are understood; ordinary cache defaults remain safer.
## Request methods and invalidation
GET and HEAD participate in lookup. They share one representation identity. On a
cacheable HEAD miss RustProxy fetches the backend representation as GET, may populate
the shared object, and strips the response body before delivering the original HEAD
request.
POST, PUT, PATCH, DELETE, OPTIONS and TRACE pass to the backend and are not stored.
They do **not** implicitly purge the whole cache. This fixes the pre-0.11.5 behavior
where any successful unsafe request could erase unrelated static objects and destroy
the hit ratio.
Cache invalidation remains explicit:
- authenticated full purge can clear all objects or one vhost scope,
- selective PURGE supports exact URL, URL prefix, `Cache-Tag`, safe regex and primary cache key, with optional host and vhost restrictions,
- exact/prefix matching uses the stored request target including query; a match removes the whole `Vary` family under that primary cache key,
- selective PURGE operates on RAM and Cold Cache and advances a publication generation so no fill that started before the operation can publish afterward, even if the selector depended on response metadata such as `Cache-Tag`,
- BAN records a bounded TTL rule without synchronously scanning the entire store; objects created before a matching active BAN are rejected during lookup/insertion and are removed later by bounded GC,
- new fills created after a BAN rule are allowed, so BAN invalidates the old representation set rather than permanently blocking a URL,
- regex rules and prefix `/` are treated as broad operations and require explicit confirmation,
- only cache-compatibility changes advance the normal cache generation/revision; unrelated logging/TLS/admin/metrics/response-presentation saves retain objects, and cache request-bypass edits plus response-only `X-Cache`/`Cache-Status`/resident-age/`Via` diagnostics also apply without purging stored objects.
There is no public unauthenticated PURGE/BAN endpoint. If an application changes a GET
resource after POST/PUT/PATCH/DELETE, use origin freshness/validators or the authenticated
administration invalidation path; RustProxy does not guess the affected URLs.
## Eligibility and freshness
Hard request bypasses include Cookie (outside the static exception), Authorization,
Upgrade, `If-Match`, `If-Unmodified-Since`, nonempty request bodies, configured path/query
exclusions and request `Cache-Control: no-store`.
A single `Range: bytes=...` request is special rather than a normal bypass. When a complete
fresh cached representation is available, RustProxy evaluates `If-Range` and can return
`206 Partial Content` directly with `Content-Range`, `Accept-Ranges`, the correct sliced
`Content-Length`, validators and normal cache `Age`; an unsatisfied range returns `416`
with `Content-Range: bytes */<length>`. A mismatching `If-Range` returns the complete cached
representation. If origin access is needed, RustProxy forwards the Range request as an
identity, non-transformed stream. Multiple/multipart, duplicate or otherwise unsupported
Range forms are passed through to origin and are never stored as separate range objects.
A fresh cached representation can satisfy `If-None-Match`/`If-Modified-Since` locally.
Request `no-cache`, `max-age=0` and legacy `Pragma: no-cache` require validation when a
safe stored validator exists. `max-age=N` and `min-fresh=N` are freshness constraints:
they reuse the cached response when its current Age/remaining freshness satisfies the
client limit and otherwise drive normal revalidation/fetch behavior. Request `max-stale`
can explicitly allow a retained stale response: bare `max-stale` accepts any retained
staleness, while `max-stale=N` bounds it. Freshness expiry does not itself physically
delete an object; normal RAM LRU/Cold Cache GC may still evict it. `must-revalidate` and
`proxy-revalidate` override client stale permission. With a safe validator, an origin
304 refreshes metadata and reuses the stored body.
Responses with `Set-Cookie`, `private`, `no-store`, `Content-Range`, trailers,
SSE/gRPC, `Vary: *`, invalid freshness metadata or unusable size/lifetime are not
stored. Response `Cache-Control: no-cache` may be retained only with ETag or
Last-Modified and must validate before every reuse. Both origin and transformed response
policy must remain eligible.
`s-maxage` takes precedence over `max-age`, then Expires/Date. `Age` uses the RFC 9111
current-age model and includes origin age; it is not a measure of how long the object has
been in RustProxy. Cache metadata keeps normal TTL, `stale-while-revalidate`,
`stale-if-error`, `must-revalidate` and `proxy-revalidate` separately. A representation
is therefore **fresh**, **stale-but-servable** only under an applicable directive, or
**expired/unusable**.
Inside `stale-while-revalidate=N`, RustProxy may return stale immediately and starts one
deduplicated background refresh for that cache identity. Inside `stale-if-error=N`, stale
is used only after an eligible origin failure. Version 0.22 makes timeout, connection, DNS
and TLS classes independently configurable and uses an explicit configurable 5xx status
allowlist; defaults preserve the previous behavior (`500`, `502`, `503`, `504` plus all four
transport classes). After TTL plus the corresponding stale window, that mode is unavailable.
`must-revalidate` and `proxy-revalidate` still permit storage, but prohibit all stale delivery
after expiry, including RFC 5861 and request `max-stale`.
Optional proactive refresh schedules a deterministic percentage of cache keys before normal
expiry. It reuses the same validator-based refresh logic and background-revalidation guard,
keeps only one schedule per key, and enforces a global concurrent-refresh bound. A newly
completed fill can schedule this work immediately, so sampled objects do not require another
client request to start the pre-expiry timer. The feature is disabled by default.
## Resident age
`X-Cache-Resident-Age` is the RustProxy-specific whole-second clock for how long the
stored representation has remained in this cache. In 0.11.5 that clock is independent
from the HTTP freshness `Age` clock and survives:
- ordinary hot-cache HITs,
- successful backend 304 revalidation,
- hot-to-cold demotion,
- cold-to-hot promotion.
A value of `0` is valid only during the first sub-second of residency. Revalidation or
tier movement must no longer reset a long-lived object to zero.
## Streaming and capacity
RustProxy tees response frames while delivering them; it does not wait for the complete
origin body before sending the first bytes. Only a complete successful body is
published. Oversize, body error, trailer and cancelled-fill paths discard the candidate.
Large buffers grow incrementally and consume multiple bounded fill permits.
The configured resident byte limit accounts for body bytes, estimated header storage
and fixed per-entry overhead; it is not total process RSS. Active HIT references,
in-flight origin responses, compression buffers and runtime allocations also use memory.
With `collapse_enabled=true`, the first foreground MISS for a primary cache key owns the
fill while concurrent followers wait for publication. The fill guard is held through the
actual response-body tee, so followers cannot wake merely because origin headers arrived.
Leader error/cancellation releases waiters. A follower that wakes to a different `Vary`
representation re-enters the single-flight decision instead of immediately stampeding the
origin. After `collapse_wait_timeout_ms`, the configured fallback is either an independent
origin request or an eligible stale response. SWR/background revalidation keeps its separate
existing deduplication guard.
## Diagnostics and API
`GET /api/cache` reports settings, HIT/MISS/BYPASS, hot/cold counts and bytes,
configured COLD byte/object limits, promotions/demotions, errors, fill pressure, object
views and per-domain reason counters. The `/cache` dashboard shows HOT and COLD
occupancy against the configured object-count and byte limits with progress bars and
explicit percentages. HOT byte occupancy uses the charged/allocated HOT value rather
than logical body bytes; COLD byte occupancy uses persisted storage bytes. Hit ratio is
`hits / (hits + misses)` and intentionally excludes BYPASS traffic.
`POST /api/cache/purge` requires the existing administrator session, same-origin marker
and CSRF token. `?vhost=<id>` purges only that vhost scope; omitting it purges all cache
objects. Purge is audited.
`POST /api/cache/invalidate` performs authenticated selective `purge` or `ban` using
`exact`, `prefix`, `tag`, `regex` or `cache_key`. Optional `host` and `vhost` restrict scope;
BAN accepts a bounded TTL. Regex and prefix `/` require `confirm_wide=true`. The result
reports the selector/scope, affected entry count, elapsed time, status and BAN id when one
was created. `GET /api/cache/bans` lists active rules and `DELETE /api/cache/bans/{id}`
removes one. These operations use the normal administration audit path. The Cache panel
exposes the same controls plus per-object **Purge URL**.
`POST /api/cache/cold/cleanup` starts a selective asynchronous Cold Cache cleanup using
the same candidate/removal machinery as automatic GC. Criteria can select entries unused
for a duration, cap removal to the oldest N objects, and/or require `hits == 0`.
`GET /api/cache/cold/cleanup/{id}` reports job status, removed object count and freed
bytes. Purge-all and purge-vhost remain the full-clear mechanisms; selective invalidation
is deliberately separate.
`X-Cache`, `X-Cache-Hits`, `Cache-Status`, `X-Cache-Reason` and
`X-Cache-Resident-Age` are optional diagnostics. `Via` is a separate configurable response header, enabled by default with value `RustProxy`; RustProxy appends it instead of deleting an upstream `Via` chain. `Cache-Status` is the standardized
RFC 9211 field name; RustProxy does not rename it to the legacy/custom `X-Cache-Status`.
HTTP/2 and HTTP/3 serialize field names in lowercase, so `cache-status:` on the wire is
normal. When the cached representation was produced by the transform pipeline,
RustProxy appends the extension parameter `transformed`, for example:
```http
Cache-Status: RustProxy; hit; ttl=556488; detail="cache_hit"; transformed
```
Origin lookalike values (`Cache-Status`, `X-Cache-Status` and the other RustProxy
cache diagnostics) are removed before RustProxy emits its own diagnostics. HTTP `Age`
is generated from cache freshness metadata on reused responses. For 0.26.6 configuration
compatibility an explicit response-header rule may set/append/remove `Age`, but that rule
runs only after cache lookup/revalidation/storage decisions and therefore cannot alter the
internal freshness clock. The Cache dashboard also marks transformed objects explicitly.
## Pool behavior
Backend selection and health policy still run before an origin fetch. The selected pool
member no longer fragments the cache identity in 0.11.5, so a fresh public object can be
reused regardless of which healthy member would have been selected for a MISS. A pool
with no eligible member still returns 503 before RustProxy can use the cache; this cache
is not a stale-availability layer.
Cookie affinity and protected/authenticated traffic keep their existing privacy bypass
rules. Per-vhost response/security policy also applies to HIT responses.