24 KiB
Shared hot/cold cache
See 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.
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
Varyfamily 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/Viadiagnostics 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:
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.