Skip to content

Cache the rendered /sponsors page body - #2897

Open
mroderick wants to merge 2 commits into
masterfrom
performance/cache-sponsors-index
Open

mroderick wants to merge 2 commits into
masterfrom
performance/cache-sponsors-index

Conversation

@mroderick

@mroderick mroderick commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

/sponsors re-rendered all 721 sponsor fragments on every request (~1.3s CPU-bound median, ~2.5M allocations), even when every fragment was a cache hit. The page body is now cached in the shared cache store, keyed on the latest active sponsor's updated_at. A warm request costs one maximum query plus one cache read; the sponsor load and render only run on a miss.

Correction to the original diagnosis (#2885): the per-sponsor fragments already lived in Solid Cache and survived deploys and restarts, so a wiped store was never what caused the 17-21s cold renders. The likely mechanism is eviction or expiry in Solid Cache forcing a full rebuild. This change doesn't make those rebuilds cheaper — a miss still costs the full ~17-21s render — it makes them rare: one long-lived body entry instead of 721 fragments under constant eviction pressure.

Metric Before After
Median view_runtime (warm) ~1,337ms one cache read (~10-20ms)
Cache miss (sponsor edit or eviction) full 721-fragment build same, but rare — one entry instead of 721
Invalidation none key changes the moment any sponsor is saved

Design decisions

  • The key is sponsors/index/v1/{Sponsor.active.maximum(:updated_at)&.to_fs(:usec)}. The manually bumped v1 segment lets a reviewer invalidate the body when the view or partials change; without it, a deploy that changes the template keeps serving the cached body until the next sponsor save.
  • The key is computed before the records load, so a save committing between the two queries caches a body newer than its key, which self-heals on the next request. The reverse order could pin a stale body under the new key permanently.
  • The sponsor load sits inside the cache block, so warm requests instantiate no sponsor records.
  • The layout still renders per request (render_to_string(layout: false), then render html:), so CSRF meta tags and navigation stay fresh.
  • Writes that bypass updated_at (update_columns, update_all) leave the key unchanged and the page stale. No in-app caller does this today; a request spec documents the accepted gap.

Test plan

  • New request spec (spec/requests/sponsors_spec.rb) covers rendering, the cache hit (body unchanged when no sponsor's updated_at changed) and the re-render after a sponsor save.
  • Full suite: 1,449 examples, 0 failures. RuboCop clean.

Post-Deploy Monitoring & Validation

  • Scout APM: the /sponsors endpoint's mean and 95th-percentile view runtime should drop from ~1.3s median to tens of milliseconds once the key is warm. Warm requests issue 1 query.
  • Across releases, /sponsors view_runtime should stay low. An eviction or expiry of the body entry still costs a full build on the next request; expect that only after a sponsor edit.
  • Failure signal: /sponsors serving stale content for more than a few minutes after a sponsor edit would mean the key is not changing; investigate Sponsor.active.maximum(:updated_at) and Solid Cache health. Rollback is a revert; the previous uncached behaviour is restored.
  • Owner: whoever reviews this PR.

Fixes #2885
Related: #2803, #2883

Cache the rendered page in the shared cache store, keyed on
"sponsors/index/v1/#{Sponsor.active.maximum(:updated_at)&.to_fs(:usec)}",
so a warm request costs one cache read instead of re-rendering 721
sponsor fragments (~1.3s CPU-bound per render). Solid Cache is
DB-backed, so the body also survives deploys and dyno restarts.

The key is computed before the records load so a save committing
between the two queries self-heals on the next request, and the v1
segment lets a reviewer invalidate the body when the view changes.
@mroderick
mroderick force-pushed the performance/cache-sponsors-index branch from cfcac00 to 7b57f49 Compare September 17, 2026 09:06
@mroderick
mroderick marked this pull request as ready for review September 17, 2026 09:12
@mroderick

Copy link
Copy Markdown
Collaborator Author

The numbers are no longer accurate, since we merged #2896 ... but the idea is still the same.

Warm requests no longer instantiate 721 AR objects they never use; the
load only runs on a cache miss, so the warm path is one `maximum` query
plus one cache read. Matches the corrected proposal in #2885.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cache the rendered /sponsors page body — recurring 1.3s render stalls dyno threads

2 participants