A cache you cannot see is a cache you cannot trust. RenderCache answers two
operator questions directly and without ever printing a stored page: what
is this node holding under this key, and is it still current? and how do
I make everything stop? It answers a third - "is this route being served
from a stored copy at all?" - through telemetry and through the Age header
rather than through a command, because that question is about traffic rather
than about one entry. There are two console commands, nine telemetry
counters, one bounded disk sweep, and one emergency lever.
This chapter is the operating surface: the commands, exactly what they print
and what they can see; the counters and their closed outcome sets; how the
file tier reclaims disk; how to test a cached route so the test proves
caching rather than merely responding; what to do when something is wrong,
including the multi-node procedure a database restore needs; and how the
cache's own performance is measured and what those numbers are honestly
worth. The command examples are the ones
the_operator_commands_inspect_without_a_body_and_advance_the_epoch drives
through this repository's own console entry point in
app/tests/live_render_cache.rs.
The two console commands
Both are hidden commands, registered by the framework and reachable through
your project's console binary like any other. Neither ever prints a stored
body or a raw dependency identity.
render-cache:inspect <key> reports one stored entry's shape: its
representation class, its body_bytes, its other metadata, and the current
authority epoch beside it, so you can tell whether the entry you are looking
at is still live authority or has already aged out from underneath. It
prints no entry (current epoch: {epoch}) when the key names nothing it can
see, and it fails - it does not report success - on an unparseable key or
with no runtime installed.
It reads this process's in-process L0 and nothing else.
RenderCache::inspect looks the key up in L0 alone; it never consults the
L1 tier. On the Database
or Redis profile that matters: an entry that is live in
suprnova_render_entries or in Redis, published by another node or by this
one before a restart, prints no entry here unless this process has served
it since it started. Read the report as "what this node has in memory",
never as "what the deployment has stored". The same is true of
RenderCache::store_inspection, which reports L0 occupancy and the current
epoch.
The key is the text the lookup itself uses: rk1. plus 43 base64url
characters, which is what your application's logging and telemetry can
surface. It is not a second hash of anything, so a key an operator holds
names exactly one entry.
That body-free claim is checked, not merely stated. The test takes the document that was actually served, splits it into lines, and requires that every non-trivial line of it is absent from what the inspect report printed.
render-cache:epoch-advance is the emergency invalidation. It advances
the authority epoch and prints epoch advanced to {epoch}. Because the
epoch is baked into every lookup key, this puts stored entries out of reach
with nothing to enumerate and nothing to delete. The test asserts the
printed line and then the consequence that matters: after the command, the
route renders again.
On the node that runs it, the effect is immediate: the command drops
that process's epoch lease and clears its in-process tier, so its very next
request derives keys under the new epoch and finds nothing. (That last
clause holds while the epoch only moves forward, which is the ordinary case;
after a database restore the advanced value may be one the deployment has
used before, so see "Restoring the database" below.) On any other
node, the ledger has moved but that process still holds its old leased
epoch and its own L0, and it catches up at its next authority read -
immediately under CoherenceMode::Authority, and up to max_age_ms later
under CoherenceMode::Lease. Run the command on each node, or restart the
others. "Restoring the database" below has the full procedure and the tests
behind it.
Reach for it when something is wrong with cached content and you cannot wait for individual entries to expire, and after a job that changed what cached pages show (see "known gaps" in RenderCache Generations).
Permission changes
RenderCache::bump_permission_version().await? is the one invalidation call
an application makes by hand, and it is not really an operations command -
it belongs in the code path that changes what a signed-in user is allowed to
do. It advances a persisted generation that every principal-keyed render
observes, it survives a restart, and it joins the transaction the role
change runs in when there is one. Without it, a user whose permissions just
changed keeps matching whatever was cached under their prior permission set.
Telemetry
Nine closed counter names, and nothing in any of them names a tier, a provider, or a backend:
| Counter | Attribute |
|---|---|
suprnova.render_cache.lookups |
outcome, and reason when outcome="declined" |
suprnova.render_cache.hits |
outcome |
suprnova.render_cache.publications |
none |
suprnova.render_cache.rebuilds |
none |
suprnova.render_cache.stitch.assemblies |
outcome |
suprnova.render_cache.stitch.slots |
outcome |
suprnova.render_cache.stitch.nested |
outcome, cause |
suprnova.render_cache.hints |
outcome |
suprnova.render_cache.epoch_rewinds |
none |
lookups and hits carry the same closed set of eight outcomes:
-
l0,l1- a fresh entry served from the in-process or the shared tier. -
conditional- a fresh hit whoseIf-None-Matchmatched, answered304. -
stale- a stale-servable entry served immediately, or the stale-on-error fallback after a foreground rebuild failed. -
miss- nothing found, a stale-on-error rebuild in progress, or a dead entry. -
bypass- an undeclared query parameter, an unresolvable declared variance dimension, or an exhausted waiter list. -
moved- the reread after rendering found a dependency or the epoch had changed; the candidate was discarded, never published. -
declined- the render was not storable, for one of the thirty-eight reasons below, carried in thereasonattribute besideoutcome.reasonis emitted only alongsideoutcome="declined"; every other outcome carries none. The reason is computed from a typed value at the exact branch that declined, never reconstructed from the response afterwards, so it names the contract that actually refused the render:- Eligibility (
policy.eligibility, mirroring the engine's ownDeclineReason):policy_uncacheable,method,status,streaming,sets_cookie,unsafe_header_name. - Observation (the collector's report and the in-transaction ledger
read):
observation_overflowed,ledger_read_failed,handler_not_begun. - Classification narrowed to
Uncacheable:session_value_read,secret_context_read,undeclared_context. - Live document facts:
identity_bound_without_stitching,invalid_stitch_capture,no_store_intent,unresolvable_seed_deadline. - Invariants over the key (whether the render's own observations agree
with the values the lookup key was already built from):
unreasoned_private_class,principal_undeclared,principal_divergent,tenant_undeclared,tenant_divergent,locale_undeclared,locale_divergent. - Publication:
seed_deadline_elapsed,unsafe_header_value,composite_capture_invalid,composite_slot_count_mismatch,composite_too_many_slots,composite_digest_mismatch,composite_empty_slot,composite_slot_not_found,composite_slot_ambiguous,composite_nested_unauthorizable,composite_nested_wider_class,composite_nested_longer_freshness,composite_nested_depth_exceeded,composite_nested_cycle,composite_nested_unresolvable.
- Eligibility (
hits increments only for l0, l1, conditional, and stale.
publications counts only a store answering "published", never a fenced or
rejected attempt. rebuilds counts one per spawned background rebuild.
The two island-stitch counters carry their own sets: assembled and
fail_document for assemblies; rendered, omitted, fallback, and
failed for slots.
suprnova.render_cache.stitch.nested distinguishes a named inner cached
segment's own outcome from an island slot's, one increment per
Segment::Nested resolution attempt. Its outcome attribute takes exactly
one of resolved, omitted, fallback, and failed; its cause
attribute takes exactly one of none (used only when outcome="resolved"),
fetch_failed, version_mismatch, length_mismatch, depth_exceeded,
cycle, and unauthorized. Neither attribute ever carries a key, a route
name, or an identity digest. A failed or degraded segment always resolves
through the policy the including graph declared for it
(FailDocument/Omit/Fallback), exactly as an island slot's own failure
does; outcome="failed" (from a FailDocument policy) abandons assembly
for the whole document and falls through to the route's own uncached
handler.
epoch_rewinds counts detections, not entries: one increment each time a
node meets an entry or a leased epoch stamped above the authority's own,
lifts the ledger's epoch past that stamp, and clears its own L0. A non-zero
value after a database restore is the signal that the restore was noticed. A
non-zero value at any other time means an authority moved backwards for a
reason nobody intended.
hints counts credible generation hints this node handled on the Tier 2
pub/sub channel: one increment per message received, one per subscription
ending, and one per announcement a full publish queue kept this node from
sending. It is the one counter here a deployment can leave permanently at
zero by choice: hints are off unless the Redis profile, or
RENDER_CACHE_HINTS=redis, turns them on, and a node with them off serves
exactly what a node with them on serves. Its outcome attribute takes
exactly one of applied (the message named a digest a validation lease on
this node observes, and every such lease was shortened),
ignored_unknown_key (it named nothing this node holds a lease against,
which includes a message this node cannot read at all), dropped_over_bound
(it carried more than 64 digests and was dropped whole rather than
truncated, because a truncated hint is a silently wrong hint),
subscriber_dropped (this node's subscription ended, because it fell behind
or the connection failed, and is being re-established), and
dropped_publish_queue_full (this node had an advance to announce and its
own publish queue was full, so the message was dropped rather than made to
wait on the write that produced it, one count for every message that went
unannounced). No attribute ever carries a route, a key, or a dependency
identity.
A hint can only shorten a validation lease this node already holds. It can
never extend one, create one, or stand in for the generation ledger, and
every hit still reads that ledger. So a rising subscriber_dropped means
this node is revalidating later than it could - at worst as late as the
lease's own max_age_ms, which is the staleness bound the route already
declared - and never that anything is being served that the coherence check
would have refused.
A high declined rate is the signal worth alerting on. It means routes
you opted in are rendering and serving correctly while never being stored,
and the response looks identical either way. The fastest local check is two
requests in a row: if the second carries no Age header, nothing was
stored.
Disk hygiene
Only the file tier needs sweeping, and it mostly sweeps itself.
FileRenderStore stores one file per key, flat under RENDER_CACHE_L1_DIR.
An entry is dead when its age since publication reaches the retention it was
published with, or when its fence epoch is older than the current epoch.
Retention comes from the same class-aware dead edge the live freshness check
uses, so a private entry's file is retired earlier than a public one's and a
sweep can never disagree with a freshness check about whether an entry is
truly dead.
sweep removes at most 64 entries per call, oldest publication first, and
returns whether more remain. It runs automatically on every 256th
publication, so a healthy directory needs no attention. RenderCache::sweep()
drives it explicitly when you want to, and a backlog larger than one call's
limit drains across later triggers rather than blocking on one long scan.
Two things the sweep is not:
- An epoch advance does not touch L1. It clears L0 outright, because that is in-process memory with nothing to reconcile against, and leaves every pre-epoch file on disk until a sweep reclaims it. That is disk hygiene, not a correctness concern - the files are already unreachable by lookup.
- The database tier has no automatic sweep, and is reclaimed only
through
RenderCache::sweep(). The Redis tier needs none: every entry it stores carries an expiry and Redis reclaims the bytes itself.
Publication is crash-safe. It writes a temporary file, fsyncs it, renames it over the target, and fsyncs the parent directory, so a reader only ever sees the previous complete file or the new complete file. On open, the store removes any leftover temporary file and any file that fails its frame check, treating a torn write as self-healing rather than a permanently poisoned entry.
Testing a cached route
A test that asserts a cached route responds correctly passes whether the
response came out of the store or out of a fresh render. Every claim has to
be made against something only a stored entry actually being served can
produce. Four patterns do that, and this repository's own dogfood tests use
all four: app/tests/live_render_cache.rs with the harness in
app/tests/live_support/mod.rs.
1. Count renders on the handler side of the cache. Register a counting
middleware after RenderCache::install. Registration appends, so it lands
closer to the handler than RenderCacheMiddleware, and a request the cache
answers returns before calling it:
let router = routes_with_render_cache_with_config
.await
.expect;
// After the install, so it only sees requests the cache forwarded.
register;
The difference between two readings of render_counter::renders() is then
the number of renders the cache did not avoid, and nothing else - unlike
identical bodies or an Age header, both of which have honest non-cache
explanations. Every hit assertion in
an_orm_write_invalidates_the_todos_document_through_generations rests on
it. Wait for a render you did not dispatch (a background rebuild) with the
counter's own barrier, wait_until_renders_at_least, never with a sleep.
The exception is a PublicShellStitched route, and it is not a small
one. A stitched hit is deliberately forwarded through the route's whole
chain - its authorization guard has to run again, and only the Live
completion middleware at the end of that chain serves the hit. A counting
middleware registered globally after the install sits outside the route's
own chain, so it is reached on a stitched hit exactly as on a miss. On such
a route the counter cannot carry the "no handler ran" claim at all.
Assert instead on what the store holds, what the served document is made of,
and how old it is - which is what
the_dashboard_is_stitched_per_principal_from_one_shared_shell does: the
stored entry is EntryKind::Composite with the expected slot count
(inspect_route_for_test), two principals' documents differ in their island
tags and nowhere else, and the second principal's response reports an Age
of the whole seconds that have passed since the shell was published.
That last one is the proof the test turns on, and it is the local proof of
store service in its exact form. The Age header on its own is the weak
signal this chapter warned about above, because a render sets one too - at
zero. The number is not weak: a render publishes its response and its entry
at the same instant, so a rendered response reports zero however far the
clock has moved, while an assembly reports the age of the shell it was
assembled from. The test drives an adjustable clock, well inside the route's
fresh window, so what it reads is exact rather than incidental.
The response also carries Cache-Control: private, no-store, but read that
for what it is: the directive a slotted route in this class carries, pinned
on the render that publishes the shell as much as on every assembly after
it, because it follows what the bytes hold and not which path produced them.
Slotted is the operative word: a zero-slot Composite keeps the class's
private max-age instead, so the directive says something about a route
with islands in it and nothing about one without. That test asserts
renders() == before + 1 on a hit, and says in its own note why that is the
honest reading rather than a failure.
2. Read the entry back. Two facade calls are ordinary public API:
RenderCache::store_inspection() reports L0 occupancy, bytes, and the
current epoch, and RenderCache::inspect(key_text) reports one entry's
body-free metadata. Alongside them the framework exposes hidden test seams -
#[doc(hidden)], and named _for_test so nothing mistakes them for
application API:
| Seam | What it gives a test |
|---|---|
RenderCache::key_for_route_for_test(pattern, params, login) |
the key text the middleware derives at epoch 1, the value the migration seeds |
RenderCache::key_for_route_at_epoch_for_test(pattern, params, login, epoch) |
the same, under an epoch you name |
RenderCache::inspect_route_for_test(pattern) |
that epoch-1 key's L0 entry: class, kind, status, body_bytes, slots |
RenderCache::inspect_l1_for_test(pattern, params, login) |
the same, out of the configured L1 tier |
RenderCache::clear_l0_for_test() |
empties L0 and leaves L1, the epoch, and the coordinator alone |
The epoch matters because it is part of the key. key_for_route_for_test
hardcodes epoch 1, so a test that has advanced the epoch - on this node or,
through the ledger, on another - must name the new one with
key_for_route_at_epoch_for_test or it will look up a key nothing was
published under.
the_public_document_is_a_hit_whose_seed_still_promotes uses
store_inspection and inspect_route_for_test to assert the entry exists
and is stored under the declared class;
the_database_profile_serves_a_hit_through_the_sql_stores uses
inspect_l1_for_test and then clear_l0_for_test, which is the only way to
prove a later request came out of L1 rather than out of memory.
3. Move the clock instead of waiting. The clock the runtime reads is
settable on a RenderCacheConfig and never by from_env, so a test that
needs a freshness band installs its own:
let clock = new;
// Bound to its own name first: passing `Arc::clone(&clock)` inline leaves
// the compiler inferring the trait object as the clone's return type.
let for_runtime = clone;
let config = from_env?.with_clock_for_test;
// ... install through the application's own configuration seam, then:
clock.advance_ms;
AdjustableTestClock comes from suprnova::live::testing, and unix_now_ms
is the harness's own wall-clock reading, so an adjustable clock starts where
the system one is rather than at a time origin the rest of the process would
disagree with. (That is a clock zero, not the authority epoch this chapter
otherwise means by the word.) The harness wraps the pair as
setup_app_with_clock and advance_clock_ms, the second of which panics
rather than silently doing nothing when the boot took the system clock.
stale_service_is_marked_and_rebuilt_in_the_background is the test.
4. Count SQL statements. A cache that skipped the handler but still
consulted the database on every hit satisfies every handler-side counter and
still costs a round trip. DbConnection::observe_statements_for_test points
SeaORM's metric callback at a counter of your own, and it sees statements on
the pool and on every transaction started from it:
// Immediately after connecting, before the connection is cloned or bound
// into the container: installing needs sole ownership of the pool, and the
// call reports `false` rather than counting nothing silently.
let installed = conn.observe_statements_for_test;
assert!;
The callback is told nothing about the statement - no SQL text, no bound
value - because a count is the whole point.
framework/tests/render_cache/bypass.rs is written entirely on this
pattern: a_lease_mode_hit_runs_nothing_and_issues_no_statement holds a
lease-mode hit to zero statements,
an_authority_mode_hit_issues_exactly_one_statement holds an
authority-mode hit to one, and
the_epoch_is_read_once_at_first_use measures two misses against each other
to show the epoch costs one read per runtime.
Two habits worth keeping. Boot the harness through your own application's configuration seam rather than through a hand-built router, so the test installs the same routes, policies, and middleware ordering the server does. And never add a timing wait: every barrier above is a state barrier on a counter, which is what makes these tests reproducible rather than flaky.
When something is wrong
- A page is showing content you know is old. Check whether the route is
storing at all (two requests, look for
Age). Every process whose configuration enables RenderCache and whose database holds the RenderCache migration advances generations for its own writes, so a queue worker, a scheduled task, or a console command invalidates the same generations the serving process would; confirm the writing process actually has RenderCache enabled and migrated, since one that does not writes nothing. UnderCoherenceMode::Lease, a stale-but-stored entry still catches up withinmax_age_msrather than immediately. For everything else, runrender-cache:epoch-advance(per node - see the last bullet). - A page you expected to cache never carries an
Ageheader. It is being declined, not failing. Read thereasonlabel on thedeclinedlookup first - it names the exact contract that refused the render, from the closed set in "Telemetry" above - then, for one of the classification-narrowed reasons, work through the classification list in RenderCache: a session read, an identity read on a route with noPrincipalvariance, a locale read with noLocalevariance, an authorization check, or a raw SQL read. - A backend is unreachable.
RENDER_CACHE_FAILUREdecides:open(the default) serves the route uncached,closedanswers a bare503. A backend missing at boot stops the boot instead, with a sentence naming the migration or the variable to fix. - Redis was flushed or restarted. Entries miss and are re-rendered. Nothing stale can be proven current: currency is proved against the database generation ledger, never against the tier that held the bytes.
- A rebuild leader died mid-rebuild. Its lease is taken over once store time passes the expiry, and the former leader's own publication is fenced out rather than racing the new one. It publishes nothing; its request's response is still served.
- An L1 file was torn by a crash or a full disk. Nothing serves it. Each file carries a digest over its own frame, so a truncated or altered file fails that check and is a miss; the store removes it, and any leftover temporary file, the next time it opens. A torn write is self-healing here rather than a permanently poisoned entry.
- The database was restored from a backup. This one has a procedure rather than a sentence; see "Restoring the database" below.
- You need everything gone, now.
render-cache:epoch-advance. On more than one node, run it on each, or restart the ones you did not run it on: the advance moves the ledger's epoch for the whole deployment, but it clears L0 and drops the leased epoch only in the process that ran it. The restore procedure below spells out why.
Restoring the database
The generation ledger is the authority every hit is proved against, so restoring the database changes what "current" means for every entry already stored. Two things decide what a stored entry does next, and neither of them is "it is quietly dropped".
It is handled for you. The first authority read after the restore that
meets an epoch or an entry stamped above the restored value refuses that
entry outright - not served once under Warning, not at any age, whatever
the route's freshness policy says - rebuilds it, lifts the ledger's epoch to
one past the highest stamp it saw, replaces that node's epoch lease with the
lifted value, and clears that node's L0. Every other node sees the lifted
epoch at its own next authority read: immediately under
CoherenceMode::Authority, and within max_age_ms under
CoherenceMode::Lease. suprnova.render_cache.epoch_rewinds counts each
detection.
That is the whole of it, and it is the same convergence an operator's
render-cache:epoch-advance produces, reached without the operator. The
three an_epoch_advanced_by_another_node_* tests in
framework/tests/render_cache/middleware.rs measure the propagation bound,
and a_rewound_epoch_refuses_the_entry_rebuilds_and_lifts measures the
refusal.
One optional step remains. Empty the shared L1 tier if a route with a
stale-servable window must not serve a pre-restore representation once
before its rebuild. The lift is what makes that reachable: an L1 entry
stamped below the lifted epoch is an ordinary moved entry again, and a
moved entry on such a route is served once under Warning while the rebuild
runs behind the request. Delete the file tier's directory contents,
DELETE FROM suprnova_render_entries, or delete the Redis keys matching
<prefix>entry:* - whichever tier the profile configures. Skip it and the
worst case is one Warning-marked pre-restore body per such key.
Measuring it
RenderCache ships two benchmarks, and they are on-demand tools, never gate steps:
That runs the engine bench (render_cache_budget, the hot-hit and composite
assembly measurements with a counting allocator), then the framework
workload bench (render_cache_workloads, the same route through the whole
middleware), then the contract test over the checked-in results. Both are
pinned to SUPRNOVA_LIVE_S1_CPUSET.
A full run needs a disposable PostgreSQL (PG_TEST_URL) and a disposable
Redis (REDIS_TEST_URL), because the checked-result contract requires all
three recorded profiles. A partial run must redirect both result files
with SUPRNOVA_LIVE_BENCH_RESULT and SUPRNOVA_LIVE_WORKLOADS_RESULT under
benchmarks/local/; without that it overwrites the checked-in results with
a shorter file and then fails its own contract.
The checked-in numbers, from
crates/suprnova-live/benchmarks/render-cache-budget-v1.json and
render-cache-workloads-v1.json:
| Measurement | Value |
|---|---|
Engine work for a fresh Complete L0 hit, p95 |
0.76 microseconds |
| Heap allocations, fresh hit | 3 |
Heap allocations, conditional 304 hit |
3 |
| Heap allocations, hit bounded by a seed deadline | 4 |
| Body copies on any of them | none; the buffer is shared |
| The same route through the middleware, server side, p95 | 14.4 microseconds |
| The same request over a loopback HTTP round trip, p95 | 109 microseconds |
| SQL statements per hot hit (lease mode) | 0 |
The middleware figures are for a 65,536-byte body whose render read 12 rows, recorded as 14 observed dependency identities.
Read those as exploratory, not as qualified evidence. Every checked-in
result carries "classification": "local_exploratory" and
"s1_requirements_met": false: they were produced on a developer
workstation with a shared CPU, a powersave governor, and loopback
providers. They are useful for catching a regression of a whole order of
magnitude and for nothing finer. A number is qualified evidence only when it
was produced on the dedicated runner with its attestation set, and these
were not.
Why Suprnova diverges
Laravel's response-caching packages leave operations to the cache store underneath. Inspecting an entry means finding its key by hand and reading the value - which is the rendered page, so looking at it means printing somebody's HTML to a terminal - and invalidating everything means flushing a store that also holds your sessions, your rate limits, and your queue. Observability is whatever the store driver happens to emit.
Suprnova gives the cache its own operating surface, deliberately narrow. Inspection is body-free by construction, so an operator can confirm an entry exists, what class it is stored under, and how large it is, without ever being shown its contents. Invalidation is an epoch bump that costs nothing to apply and touches only this cache - your sessions and your queue are not in the blast radius. Telemetry is a closed set of nine counters with closed attribute sets, which is what makes a dashboard over them stable across releases rather than a set of strings that drift. The trade is that there is no "delete this one key" command: the levers are per entry read-only, or epoch-wide.
Next
- RenderCache - the declarations these commands operate on
- Observability - where the counters above are exported
- Testing - the surrounding test conventions the patterns above sit inside
- Deployment - the production checklist around them
