Skip to content

perf(chapters): cache show pages and fix most_recent full-table load - #2964

Merged
olleolleolle merged 2 commits into
masterfrom
feature/chapter-show-caching
Sep 30, 2026
Merged

olleolleolle merged 2 commits into
masterfrom
feature/chapter-show-caching

Conversation

@mroderick

@mroderick mroderick commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

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 EventCardComponent fragments. This PR adds anonymous conditional GET, fragment-caches the remaining static sections, and fixes the query work itself. Fixes #2952.

Profile Base median This branch Change
Anonymous first render (cache miss) 129,633 29,774 −77%
Anonymous repeat (fragment hit) 128,916 28,284 −78%
Anonymous repeat (conditional GET) 128,908 23,625 −82%

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:

Chapter Cold before → after Repeat before → after
brighton 428,213 → 54,603 422,528 → 45,045
london 388,395 → 30,972 397,300 → 28,719
berlin 159,017 → 30,971 158,913 → 29,661
barcelona 166,861 → 29,924 167,429 → 28,495
norwich 93,049 → 34,591 85,443 → 31,668
nottingham 56,841 → 29,375 51,325 → 28,072
west-london 176,089 → 27,300 177,966 → 26,700
helsinki 84,713 → 29,623 80,430 → 27,614
cambridge 98,586 → 23,071 97,217 → 23,319
shanghai 100,249 → 24,897 98,919 → 24,941

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_recent materialised 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 ordered LIMIT 1 query.

Design decisions

  • Conditional GET is anonymous-only. The subscriptions section is per-user, so fresh_when runs behind !logged_in? and logged-in members always get a full render.
  • The ETag mirrors the fragment keys. The page renders records that mutate without touching 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.
  • Queries were the bigger half. Cold renders went 22 → 13 queries, warm renders 15 → 10: eager_load(:workshop_host) loaded the join row but not the sponsor (now workshop_host: :sponsor), the unused :permissions eager load is dropped, ChapterPresenter#organisers re-ran its permissions find_by on every call (memoised now), and the recent-sponsors relation was loaded/aggregated separately by the view's any?, 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.
  • The organisers grid order is stable within a fragment lifetime. The view previously called .shuffle per render; the shuffled order is now frozen per fragment. Re-shuffling per render is incompatible with caching the section.

Post-Deploy Monitoring & Validation

  • payload.allocations p50/p95 for ChapterController#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.
  • Conditional GET live check: curl -s -o /dev/null -D - https://www.codebar.io/brighton captures the ETag; repeat with If-None-Match must return 304. Repeat after a sponsor or workshop change to confirm the ETag rotates (200 with new content).
  • Logged-in regression check: sign in and load a chapter page — subscription group buttons must render as before.
  • Rollback trigger: reports of stale sponsor/organiser content or stale event cards after edits — code revert is safe (the page recomputes per render as before; no data migration involved).

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.

@olleolleolle olleolleolle left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth it!

@olleolleolle
olleolleolle merged commit d7bfbe3 into master Sep 30, 2026
10 checks passed
@olleolleolle
olleolleolle deleted the feature/chapter-show-caching branch September 30, 2026 05:18
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.

Chapter show pages allocate ~21k+ objects per render for large chapters (18% of app allocations)

3 participants