Skip to content

perf(workshops): cache show pages and their sanitized renders - #2954

Merged
mroderick merged 2 commits into
masterfrom
feature/workshop-show-caching
Sep 30, 2026
Merged

mroderick merged 2 commits into
masterfrom
feature/workshop-show-caching

Conversation

@mroderick

@mroderick mroderick commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Workshop show pages were the app's top allocation consumer — 28% of all allocations (p50 ~7,348 allocations and 8 queries per render) — because the page had no caching at all. They now ship with: conditional GET (304s) for anonymous visitors, fragment caching of the static sections, and the description render cached with sanitization inside the fragment (the issue's "cache the sanitized HTML" variant — the description stays raw HTML in the database, so there is no write-path invariant and no backfill migration).

Profile Before After Change
Anonymous repeat visit (conditional GET) 14,610 (full render) 7,429 (304) −49%
Anonymous first render (fragment miss) 14,610 13,431 −8%
Logged-in visit (fragments hit) 15,007 13,826 −8%

Measured against the production data: top-10 most-requested workshop show URLs from 3 days of canonical logs (including one virtual workshop), served from a codebar_production_dump clone, base commit vs this branch, GC.stat(:total_allocated_objects) medians of 5 runs per URL via a throwaway probe (not committed). Logged-in figures include the fragment-hit path; the 304 range is the etag computation itself (workshop + host/address + sponsors + organisers queries), which is what keeps conditional GETs honest after a sponsor or organiser change. Absolute numbers differ from production; relative profile transfers.

Fixes #2951.

Design decisions

  • Conditional GET is anonymous-only. The actions 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 (workshop + host/address + sponsors + organisers + locale + bump token), because those records mutate without touching workshops.updated_at — without them a sponsor or organiser change would leave repeat anonymous visitors on a stale 304.
  • Description renders are cached with sanitization inside. sanitize(@workshop.description) runs on cache miss only, keyed on the workshop — the issue's "cache the sanitized HTML" variant rather than sanitize-on-write. Storage keeps the raw authoring HTML (nothing is irreversibly rewritten, no data migration), the sanitizer stays at the render boundary so bulk writers bypassing Rails remain safe, and a future allowlist widening re-renders old descriptions correctly.
  • 304 halts the action. fresh_when renders a 304 without halting, and the virtual-workshop render after it raised DoubleRenderError; return if performed? fixes a latent 500 the new virtual-path spec exposed.

Testing

  • Full suite: 1,567 examples, 0 failures; rubocop clean on changed files.
  • New request specs: anonymous 200 → 304 conditional flow (in-person and virtual), ETag rotation after a host-sponsor change (200, not 304), fragment caching proven by query-count reduction, virtual-path fragment keys, and a hostile stored description (<script> + bold) rendered sanitized with the raw source preserved.
  • Model specs unchanged for existing behavior; the sanitize-on-write and backfill-migration specs were removed along with that approach.

Post-Deploy Monitoring & Validation

  • payload.allocations p50 for WorkshopsController#show on the Codebar requests dashboard should drop materially; sum by (controller) (count_over_time({app="planner", controller="WorkshopsController"} | json | payload_status >= 500 [5m])) for 5xx regressions.
  • Conditional GET live check: curl -s -o /dev/null -D - https://www.codebar.io/workshops/<known-id> captures the ETag; repeat with If-None-Match must return 304. Repeat after a description edit to confirm the ETag rotates (200 with new content).
  • Rollback trigger: support reports of stale sponsor/venue content or stale descriptions after edits — the etag/fragment keys rotate on the records that changed, so a report should name the workshop id; code revert is safe (old views re-sanitize at render and the un-migrated raw descriptions render identically).

Unapplied review findings

From code-review run 20260927-232127-ad7e4667 (verdict: Ready with fixes):

  • P2 — the data-migration reviewer's backfill concerns (irreversibility, snapshot/verification) — resolved by dropping the backfill migration in this rework; descriptions are sanitized at render time inside the fragment, and the pre-existing DB content is untouched.

@mroderick
mroderick force-pushed the feature/workshop-show-caching branch from 088a251 to 0ec3365 Compare September 28, 2026 06:37
WorkshopsController#show was the app's top allocation consumer (28% of
allocations, p50 ~7.3k objects per render) because the page had no
caching at all.

- Conditional GET (fresh_when) for anonymous show requests, keyed on
  the workshop, its section-bearing records (host/address, sponsors,
  organisers), locale, and a template bump token; repeat and crawler
  visits get 304s without rendering. Logged-in requests are untouched
  because the actions section is per-user.
- Fragment-cache the description (sanitized inside the fragment), the
  venue, sponsors, organisers grid, and newsletter sections, keyed on
  the workshop and the records that mutate independently of it, so
  description/sponsor/venue/organiser changes invalidate correctly.
  The description stays raw HTML in the database and is sanitized on
  cache misses only — no write-path invariant, no backfill migration,
  and future bulk writers keep render-side protection.

Benchmarked against a codebar_production_dump clone with the top-10
requested workshop URLs from production logs: anonymous repeat visits
14.6k -> 7.4k allocations (-49%, 304 path), logged-in renders skip the
heaviest sections. Review run 20260927-232127-ad7e4667: findings #1,
#2, #4 addressed.
@mroderick
mroderick force-pushed the feature/workshop-show-caching branch from 0ec3365 to 5448da8 Compare September 28, 2026 06:49
@mroderick mroderick changed the title perf(workshops): cache show pages and sanitize description on write perf(workshops): cache show pages and their sanitized renders Sep 28, 2026
@mroderick
mroderick marked this pull request as ready for review September 28, 2026 07:00
@mroderick
mroderick enabled auto-merge September 30, 2026 05:06
@mroderick
mroderick merged commit 6a08be1 into master Sep 30, 2026
10 checks passed
@mroderick
mroderick deleted the feature/workshop-show-caching branch September 30, 2026 05:09
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.

Workshop show pages allocate ~7.3k objects per request with no caching (28% of app allocations)

2 participants