perf(chapters): cache show pages and fix most_recent full-table load - #2964
Merged
Merged
Conversation
ChapterController#show was the app's second-biggest allocation consumer (p50 ~20.9k allocations, 15-20 queries per render, 18% of app total) because nothing on the page cached except the per-card EventCardComponent fragments. - Conditional GET (fresh_when) for anonymous show requests. The ETag is the full cache-key list of every rendered record — chapter, chapter organisers, recent sponsors, the eager-loaded upcoming records with their sponsors/venue/host/organisers, the most recent past workshop with the same — plus locale and a bump token. Collapsing the list to a lexicographic max would miss changes to non-max records and serve stale 304s; keeping the list also makes a workshop crossing the upcoming/past boundary (a time-only change) rotate the ETag, which a regression spec pins. - Fragment-cache the sponsors and organisers sections on the chapter and their section records. The organisers grid previously shuffled per render; the order is stable within a fragment lifetime instead. The newsletter partial is deliberately not cached: it registers the Flodesk loader through content_for(:head), which a cache hit would swallow, leaving a signup form without its script. - Listable#most_recent materialised every past workshop of the chapter (.load.first) just to keep the latest one — 466 rows for brighton; it now issues a single ordered LIMIT 1 query with a deterministic id tie-break. - Query review on a production-dump london render (22 -> 13 queries cold, 15 -> 10 warm): eager-loading :sponsors and :workshop_host in one join drops the scoped has_one host (alias collision through workshop_sponsors — latent on master too), so the controller preloads the host; :sponsorships and :permissions were unused by the page; ChapterPresenter#organisers re-ran its permissions find_by on every call and is memoised now; recent sponsors are materialised once so the view guard, fragment key and ETag share one load. Benchmarked against a codebar_production_dump clone with the 10 most-requested chapter show URLs from production logs: cold renders -77%, fragment-hit repeats -78%, conditional repeats -82% (medians of 5 runs per URL). Focused review (correctness + independent adversarial read) — findings fixed in-tree; the one deferred finding (pre-existing EventCardComponent card-fragment staleness) is tracked on the PR.
mroderick
force-pushed
the
feature/chapter-show-caching
branch
from
September 28, 2026 09:57
ca6d51f to
05ff710
Compare
mroderick
marked this pull request as ready for review
September 28, 2026 10:13
KimberleyCook
approved these changes
Sep 28, 2026
olleolleolle
enabled auto-merge
September 29, 2026 18:40
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Chapter show pages were the app's second-biggest allocation consumer — p50 20,880 allocations and 15-20 queries per render, 131.7M allocations (18% of app total) over 7 days from 4,308 renders — because nothing cached except the per-card
EventCardComponentfragments. This PR adds anonymous conditional GET, fragment-caches the remaining static sections, and fixes the query work itself. Fixes #2952.Measured against the production dump: the 10 most-requested chapter show URLs from 3 days of canonical logs (brighton, london, berlin, barcelona, norwich, nottingham, west-london, helsinki, cambridge, shanghai — brighton is the top talker),
GC.stat(:total_allocated_objects)medians of 5 runs per URL via a throwaway probe (not committed). Absolute numbers differ from production; relative profile transfers. Per-chapter detail:Related: #2954 (workshop show pages — same recipe and measurement method), #2965 (deferred follow-up: EventCardComponent card-fragment staleness, tracked separately).
The largest single win is not the caching:
Listable#most_recentmaterialised every past workshop of the chapter (.load.first) just to keep the latest one — 466 rows for brighton, 439 for london — on every render. It now issues a single orderedLIMIT 1query.Design decisions
fresh_whenruns behind!logged_in?and logged-in members always get a full render.chapters.updated_at(chapter organisers' permission membership, recent sponsors, per-card sponsors/venue/host/organisers), so the ETag is built from[chapter, organisers, recent sponsors, max cache_key over eager-loaded card records, most recent past workshop, locale, bump token]. The card records are eager-loaded, so flattening them for the ETag issues no extra queries.eager_load(:workshop_host)loaded the join row but not the sponsor (nowworkshop_host: :sponsor), the unused:permissionseager load is dropped,ChapterPresenter#organisersre-ran its permissionsfind_byon every call (memoised now), and the recent-sponsors relation was loaded/aggregated separately by the view'sany?, the fragment key, and the ETag expansion (materialised once in the controller). The existing per-card N+1 guard (event_card_render_query_cost_spec) still passes under the reworked loads..shuffleper render; the shuffled order is now frozen per fragment. Re-shuffling per render is incompatible with caching the section.Post-Deploy Monitoring & Validation
payload.allocationsp50/p95 forChapterController#show(200s) on the Codebar requests dashboard should drop materially;sum by (controller) (count_over_time({app="planner", controller="ChapterController"} | json | payload_status >= 500 [5m]))for 5xx regressions.curl -s -o /dev/null -D - https://www.codebar.io/brightoncaptures the ETag; repeat withIf-None-Matchmust return 304. Repeat after a sponsor or workshop change to confirm the ETag rotates (200 with new content).