Summary
/sponsors re-renders 721 sponsor fragments on every request, even when the per-sponsor fragments are cache hits. That costs ~1.3s of CPU-bound Ruby per render (~123 renders in a 3h window). On production dynos (puma, 1 worker × 3 threads) a CPU-bound render holds the GVL and stalls unrelated requests on the same dyno. And after a deploy or dyno restart the in-process fragment cache is empty, so the first render takes 17–21s.
Proposal: cache the rendered page body in the shared cache store (Solid Cache), keyed on the latest sponsors.updated_at, so a warm request costs a single cache read.
Measured
From production canonical logs (3h window, 123 requests) and a local reproduction against a production dump:
| Metric |
Value |
| median view_runtime (warm, fragments all hits) |
1,337ms |
| allocations per render |
~2.5M (17.7M worst) |
| db_runtime / queries |
41ms / 5 queries |
| cold render after dyno restart |
17.8s local repro, 21.3s in production |
| page size |
~210KB HTML, 721 logos |
Local reproduction: cold 17.8s, warm ~1.1s — matching production's max and median.
Why #2803 didn't fix it
#2803 fragment-cached each sponsor partial (cached: true) and removed the CarrierWave allocation hot spot. But the per-request cost is now dominated by the collection machinery itself: building 721 fragment keys, reading 721 fragments, and assembling a 210KB page — all CPU-bound Ruby work, per render. It also lives in a per-process MemoryStore (ActionView's own collection_cache), which is wiped by every restart.
Proposed fix
Cache the whole rendered body, keyed on the most recent sponsor change:
def index
key = "sponsors/index/#{Sponsor.active.maximum(:updated_at)&.to_fs(:usec)}"
body = Rails.cache.fetch(key) { render_to_string }
render html: body.html_safe
end
Properties:
- Solid Cache is DB-backed, so the body survives dyno restarts and deploys — no more 17s cold renders
- Any sponsor edit bumps
updated_at → new key → one re-render, then warm again
- Stale window: none (key changes the moment a sponsor is saved)
Expected impact
Median /sponsors view drops from ~1,337ms to ~10–20ms (one cache read + one render-to-string every sponsor edit), and the recurring GVL stall disappears from both dynos.
Related: #2883, #2803
Summary
/sponsorsre-renders 721 sponsor fragments on every request, even when the per-sponsor fragments are cache hits. That costs ~1.3s of CPU-bound Ruby per render (~123 renders in a 3h window). On production dynos (puma, 1 worker × 3 threads) a CPU-bound render holds the GVL and stalls unrelated requests on the same dyno. And after a deploy or dyno restart the in-process fragment cache is empty, so the first render takes 17–21s.Proposal: cache the rendered page body in the shared cache store (Solid Cache), keyed on the latest
sponsors.updated_at, so a warm request costs a single cache read.Measured
From production canonical logs (3h window, 123 requests) and a local reproduction against a production dump:
Local reproduction: cold 17.8s, warm ~1.1s — matching production's max and median.
Why #2803 didn't fix it
#2803 fragment-cached each sponsor partial (
cached: true) and removed the CarrierWave allocation hot spot. But the per-request cost is now dominated by the collection machinery itself: building 721 fragment keys, reading 721 fragments, and assembling a 210KB page — all CPU-bound Ruby work, per render. It also lives in a per-processMemoryStore(ActionView's owncollection_cache), which is wiped by every restart.Proposed fix
Cache the whole rendered body, keyed on the most recent sponsor change:
Properties:
updated_at→ new key → one re-render, then warm againExpected impact
Median
/sponsorsview drops from ~1,337ms to ~10–20ms (one cache read + one render-to-string every sponsor edit), and the recurring GVL stall disappears from both dynos.Related: #2883, #2803