Manual contentsDigging DeeperBrowse 113 chapters
Manual 13 min read

RenderCache Generations

Most caches expire. RenderCache expires too, but expiry is the backstop rather than the mechanism. The mechanism is a generation: every piece of data a render read has a counter in the database, the render stores the counters it saw, and a write advances the counter for whatever it changed. A stored representation is current when the counters it saw still match the counters the database holds now. You write no invalidation rule for your own data, because an ordinary model.save() already is one.

This chapter is about that machinery from the outside: what a render is recorded as depending on, how coarse those dependencies really are, what the framework cannot see and therefore cannot invalidate, how the coherence check is paid for on a hit, which request rebuilds when several want the same entry at once, and what a visitor is served in the window between "no longer current" and "rebuilt". Every claim below is held down by a named test or a checked-in measurement; the dogfood examples are routes in app/src/live/mod.rs proved by app/tests/live_render_cache.rs.

What a render is recorded as depending on

While a render runs, a request-scoped collector records each dependency it can name: a table read, a record read by primary key, a query class, a relation, a configuration identity, a feature, a locale, a route, and one always-present Broad identity that every representation observes. Reads through the ORM and the query builder record themselves; you write nothing.

/live/todos is the whole pattern in one handler:

pub async fn todos(_request: Request) -> Response {
    let result: Result<HttpResponse, FrameworkError> = async {
        let titles: Vec<String> = Todo::all()
            .await?
            .into_vec()
            .into_iter()
            .map(|todo| todo.title)
            .collect();
        html(&TodosView { count: titles.len(), titles })
    }
    .await;
    result.map_err(failed)
}

Todo::all() records the todos table. Nothing else in the handler or the template reads the session, the signed-in visitor, or a translation, which is what lets the route stay a shared representation at all.

An ordinary write is the invalidation

an_orm_write_invalidates_the_todos_document_through_generations walks the whole cycle through the running application:

  1. The first GET /live/todos renders and publishes.
  2. The second is a hit: it never reaches the handler, carries no Warning, and schedules nothing.
  3. A POST /todos/random writes a row - through the application's own route, with the session and CSRF token a browser would send.
  4. The next GET is served with Warning: 110 - "Response is Stale" and schedules exactly one background rebuild. Its five fresh minutes have barely started, so the advanced generation of the todos table is the only thing that can explain either.
  5. That rebuild really runs: the test waits on the render counter - a state barrier, not a timed wait - until a render the test itself did not dispatch has happened.
  6. The written row really is in the listing. This is a separate step and deliberately not an assertion about the background rebuild's own output: the test drops L0 first and renders again, because the rebuild's publication lands at a moment nothing reachable from the application makes observable, so asserting on whichever request happened to catch it would be a race.
  7. And the route settles back to a plain hit against the republished entry.

No cache key was named anywhere in that sequence. An ORM write inside a DB::transaction advances its generations inside that same transaction, so a rolled-back write advances nothing at all.

How narrow invalidation is

This is the single most important thing to know before you size a cached route.

A primary-key point read that returns a row records that row's identity and the table's unkeyed-write identity, not the table itself. Model::find, Model::find_or_fail, and Model::find_many each observe one record identity per row they hydrated and one unkeyed-write identity beside them, so a row-level write elsewhere in the table leaves the entry current, while a bulk update_all or delete_all, a DB::table(..) write, or a raw statement on the table still reaches it. A point read that returns no row observes the table instead, because inserting the missing row is what would change the answer.

Every other read is table-granular: Model::all, every Builder terminal, and every relation load record the whole table, so any write to that table invalidates every cached entry that read from it. That is safe - it can only invalidate too much, never too little - and it is measured rather than assumed. The invalidation-storm workload in framework/benches/render_cache_workloads.rs publishes 64 keys over 12 record identities, drives 1,000 writes, and records the fan-out it observed in crates/suprnova-live/benchmarks/render-cache-workloads-v1.json (abridged; the recorded object also carries the burst, sweep, hit, rebuild, statement, and latency fields):

"invalidation_storm": {
  "keys": 64,
  "identities": 12,
  "writes": 1000,
  "every_write_invalidates_every_key": false,
  "point_read_invalidation_ratio": 0.09375,
  "rebuilds_per_write": 1.28,
  "final_bodies_coherent": true
}

every_write_invalidates_every_key is false because a point read no longer depends on its whole table; point_read_invalidation_ratio is what replaced it as the number worth watching.

Design around it. A cached route backed by a table your application writes to constantly will rebuild constantly, whatever its freshness window says. A cached route backed by a table that changes when an editor publishes something will sit still for hours. If you need finer granularity than the table, the honest answer today is that you do not have it.

What the framework cannot see

A dependency that cannot be named cannot be invalidated, and the framework is deliberate about which of those it declines to store and which it lets through.

Declined outright. Raw SQL through DB::select, DB::select_one, DB::scalar, or DB::select_on cannot name the tables its statement read, so the render is marked unobservable and never stored. The response is still served, correctly, every time. The framework's own RBAC role and permission checks name the five tables they read - roles, permissions, role_permissions, model_roles, and model_permissions - so a cached route that evaluates one is observed precisely and cached normally. Reads through DB::table(..) know their table and cache normally.

Invisible, and your responsibility. A request header read through Request::header and a Config::get call both change what a render produces without the collector seeing anything. Declare the matching variance dimension on such a route; nothing here can catch the omission for you.

Global scopes. An Eloquent global scope declares what its filter depends on. A GlobalScope returning ScopeDependency::Constant records nothing and costs no cache hits. The default, ScopeDependency::PerRequest, requires the scope's apply to read that state through an instrumented accessor - suprnova::live::current_tenant(), Auth::id(), Lang::locale(). A per-request scope whose evaluation reads none of them narrows the render to Uncacheable and names itself in the decline, so an invisible tenant filter costs you the cache rather than costing your visitors each other's rows.

Feature flags. A read of a flag the features table holds - at any scope key, the global default included - observes that flag's own generation. DatabaseEvaluator::set_flag advances it after the new value is visible to readers, and DatabaseEvaluator::reload() advances it for every flag whose stored rules changed and tells the cached evaluator which ones those were. A flag the table does not hold records nothing: that render depended on the default compiled into is_enabled!, not on stored state.

The write side. Every process whose configuration enables RenderCache and whose database holds the RenderCache migration advances generations, so a write made by a queue worker, a scheduled task, or a console command invalidates exactly what the same write invalidates in the server, and RenderCache::bump_permission_version() works from any of them. A process with RENDER_CACHE_ENABLED=false, or one whose database does not hold the migration, advances nothing and issues no RenderCache SQL at all. See RenderCache Operations.

What a hit costs

The coherence check is what turns "we have bytes" into "these bytes are current", and it is the only work a hit does.

A hit runs no handler, no ORM query, no template, and no serializer, and copies no body bytes: the bytes the server writes to the socket are the bytes the store holds, proved by address rather than by value in framework/tests/render_cache/bypass.rs. What is left is the database read that proves currency, and how often you pay for it is the policy's CoherenceMode:

Mode SQL statements per hot hit What it trusts
Authority (default) exactly 1 the ledger, reread every hit
Lease { max_age_ms } 0 a locally granted validation lease, until it expires

an_authority_mode_hit_issues_exactly_one_statement holds authority mode to one round trip: the observed generations and the authority epoch are read together in a single UNION ALL, not as two reads. a_lease_mode_hit_runs_nothing_and_issues_no_statement holds lease mode to zero, because the epoch the key was derived under is leased alongside the generations rather than read per request.

The epoch itself is read once per process, not once per request. the_epoch_is_read_once_at_first_use measures the first miss of a fresh runtime against an otherwise identical second one and finds the first pays exactly one statement more - the single authority read that fills the epoch lease. Every request after that pays nothing for it.

When the epoch moves

render-cache:epoch-advance is the emergency invalidation, and the epoch is baked into every lookup key, so what happens next depends on where you are standing:

  • On the node that ran the command, the very next request sees the new epoch. L0 is cleared outright at the same instant, and the cache is invalidated immediately.
  • On another node, an Authority-mode route learns at its very next hit. A Lease-mode route learns at its next authority reread, which is at most max_age_ms later.

A route with a stale-servable window serves the moved entry once under Warning while the rebuild runs behind the request; a route without one rebuilds in the foreground and the requester waits for it. That difference is the whole reason to declare a stale-servable window, and it applies to any move, not only an epoch advance.

Three tests in framework/tests/render_cache/middleware.rs hold those paths down by name: an_epoch_advanced_by_another_node_reaches_an_authority_mode_route_on_its_next_hit, an_epoch_advanced_by_another_node_reaches_a_lease_mode_route_when_its_lease_expires, and an_epoch_advanced_by_another_node_serves_a_stale_servable_entry_once_then_rebuilds.

One rebuild per key: singleflight and waiters

When an entry is missing or no longer current, the requests that arrive for it do not all render. They are admitted through a rebuild coordinator, which picks exactly one of them:

  • The leader is the one request that renders and may publish. It holds a lease on that key for the length of its render.
  • Waiters are the requests that arrive for the same key while the leader holds it. They wait in process, and when the leader releases they re-evaluate what is now stored and serve that. A waiter never trusts the wait: if the leader's cycle failed to publish, or published something the waiter's own freshness check finds dead, the waiter renders too, rather than serving what it found. a_singleflight_waiter_never_serves_a_superseded_entry_as_fresh in framework/tests/render_cache/middleware.rs is that rule.
  • A request that arrives once RENDER_CACHE_MAX_WAITERS (default 128) are already waiting bypasses: it renders and publishes nothing, rather than growing an unbounded queue.

concurrent_misses_render_once_and_waiters_reuse_the_publication proves the ordinary case end to end - two concurrent misses, one render, identical bodies - and one_leader_per_key_and_fence_with_bounded_waiters in crates/suprnova-live/tests/render_cache_singleflight.rs proves the cap directly against the coordinator: past its waiter limit, admission answers Bypass.

Two publications for one key can never both be accepted, whatever the coordinator decided. A leader mints a publication token under its lease, and the store compares that fence before it writes: an older epoch, or an equal epoch with a lower token, loses. That is what makes duplicate rendering safe to accept while duplicate publishing is not, and it is why there is no cross-node waiting at all - a key another node is rebuilding is a bypass here. See RenderCache Deployment.

Serving something while it is rebuilt

The four freshness states, the bands FreshnessPolicy sets, and the Warning and Age a stale response carries are defined in RenderCache Representations. What matters here is that a generation move puts an entry into those bands early: a moved entry is evaluated at an effective age of at least its fresh interval, whatever its real age. Its real age still decides which band that lands it in:

  • Real age below fresh_ms + stale_servable_ms, on a route that declares a stale-servable window: stale-servable. The stored copy is served once under Warning and the rebuild runs behind the request. That is step 4 of the write test above, on an entry whose five fresh minutes had barely started.
  • Real age past that, but not yet at the dead edge: stale-on-error. The request waits for a foreground rebuild and sees the stored copy only if that rebuild fails.
  • On a route with no stale-servable window at all, and on every PrivateCached route (whose dead edge is its fresh edge), a move is Dead: the request rebuilds in the foreground and waits.

stale_service_is_marked_and_rebuilt_in_the_background shows the same handoff driven by the clock rather than by a write: past /live/todos's 300,000 fresh milliseconds and inside its 60,000 stale-servable ones, the visitor is handed the copy on hand under Warning: 110 - "Response is Stale" and Age: 300, exactly one rebuild is scheduled, and that rebuild really runs.

The stale-on-error fallback covers the request that leads a rebuild and a waiter behind a leader whose rebuild failed. Both are answered the same way: the stale bytes under Warning, rather than the failure. framework/tests/render_cache/races.rs proves each arm separately - a_waiter_behind_a_failed_leader_is_served_the_stale_entry_it_was_waiting_on and a_waiter_that_re_evaluates_onto_a_stale_on_error_entry_falls_back_to_it - and the second one by revert: dropping the fallback from the waiting arm turns its final assertions from 200 into 500.

Stitched routes are the exception, and it is a deliberate one. A Composite entry is never served by the stale-on-error fallback and never triggers a background rebuild: serving a stored shell after a failed rebuild would answer a request the route's own authorization chain never got to gate, and a background rebuild carries none of the request's authorization state, so its shell would be whatever the page renders for nobody. On a stitched route, a failed rebuild's own outcome is what the client sees.

Cached routes are read paths

The leader's render runs inside a database transaction, opened at REPEATABLE READ on PostgreSQL and MySQL, so the generations it records and the data it read share one snapshot. Two consequences follow.

A cached route's handler that writes competes with concurrent writers for the same rows, and on PostgreSQL a handler that updates a row another transaction changed after the render began sees a serialization failure. Design cached routes as read paths.

A write made outside any transaction - model.save() on its own - commits its row first and advances its generations in an immediately following transaction. The moment between the two is "new data, old generation": it costs one extra rebuild and never serves stale content.

Finally, after the render finishes, the observed dependencies and the epoch are read again, outside the render's own transactional view. Anything that moved during the render discards the candidate rather than publishing it. That is why a write landing mid-render costs a rebuild instead of a wrong page.

Why Suprnova diverges

Laravel's cache is a key-value store and its response-caching packages are built on top of it, so invalidation is something you write. You call Cache::forget, or you tag entries and flush a tag, or you register a model observer that clears the keys you believe that model feeds. Every one of those is a mapping you maintain by hand, and the failure mode is silent: the page that nobody remembered to forget keeps serving until its TTL runs out.

Suprnova inverts the direction. The render records what it read, the write advances what it changed, and the two meet in a database ledger rather than in your head. There is no forget call to forget. The price is that the recorded dependency is a table rather than a row, so a busy table rebuilds its dependents often, and that reads through raw SQL are declined from storage rather than cached with a dependency nobody can name. Both of those are visible and measured - the fan-out in the checked-in storm workload, the decline in your own missing Age header - rather than a stale page you find out about from a customer.

Next