Skip to content

Reduce allocation churn of events#past - #2973

Merged
mroderick merged 5 commits into
masterfrom
perf/events-past-allocations
Oct 1, 2026
Merged

mroderick merged 5 commits into
masterfrom
perf/events-past-allocations

Conversation

@mroderick

Copy link
Copy Markdown
Collaborator

Summary

Reduce per-request allocation churn of GET /events/past by 15.8% (87,196 -> 73,401 GC objects on the cache-miss render) and 7.3% on the steady-state warm render (46,830 -> 43,426), with SQL statements per request down from 9 to 6. Rendered output is unchanged. Three changes in EventsController:

  • Trim redundant eager associations in load_events: root-level :permissions on Workshop/Meeting/Event and :sponsorships on Event are never read on the render path. organisers is a through-permissions association (its join already covers permissions), sponsors goes through sponsorships, and the chapter-organisers fallback reads chapter.permissions, which stays eager-loaded. Dropping them shrinks the cartesian join fan-out per workshop row.
  • Collapse latest_model_updated from four MAX(updated_at) queries (Workshop, Meeting, Event, Member) into one UNION ALL subquery with a single MAX. Same nil-when-empty semantics, one round trip instead of four; this also feeds the ETag.
  • Skip the fetch pipeline after a performed 304: fresh_when renders a 304 without halting, so the fetch methods previously ran on top of the already-performed response. Mirrors the workshops#show guard.

Measurements

GC.stat(:total_allocated_objects) deltas through the full request stack, median over 25 requests per phase, anonymous GET over a seeded 20-card page backed by ~1,850 records. Run-to-run noise within a process is ±2 objects; the benchmark is sensitive to Postgres plan changes in the eager queries, so each comparison below was taken from back-to-back runs inside a single plan window.

phase before after
cold render (fragments cleared) 87,196 73,401 (−15.8%)
warm fragments (steady state) 46,830 43,426 (−7.3%)
SQL statements, cold 9 6

Production drain logs (2026-09-24..30) put the warm path at the site's heaviest controller endpoint per request, so the warm column is the steady-state win.

Verification

  • full test suite: 1,593 examples, 0 failures
  • RuboCop clean across app/
  • rendered HTML verified identical: the page was rendered with the old and new pipeline in one process over the same fixtures (including a workshop with no organisers, exercising the chapter-organisers fallback), byte-for-byte equal

Review notes

  • The dropped associations are the riskiest part: anything reading workshop.permissions or event.sponsorships directly would now lazy-load. The render path reads only organisers, sponsors, venue, chapter and the chapter-organisers fallback, all still eager-loaded.
  • The UNION query's value feeds fresh_when; semantics (including nil when all tables are empty) match the previous four-query form.
  • No schema, data or Gemfile changes; the eager lists on other pages are untouched.

The past/upcoming event listings eager-load associations the card render
path never reads: root-level :permissions on Workshop, Meeting and Event
(organisers is a through-permissions association, so its join already
covers permissions; the chapter-organisers fallback reads
chapter.permissions, which stays eager-loaded via the chapter subtree),
and :sponsorships on Event (sponsors goes through sponsorships). Dropping
them shrinks the cartesian join fan-out per workshop row.

Rendered HTML is unchanged: organisers, sponsors, venue and the
chapter-organisers fallback render identically, verified by rendering
/hosts/past with the old and new eager lists in one process over the
same fixtures. Cuts GC allocations per cache-miss render of
GET /events/past by ~15% (87,196 -> 74,089 objects); steady-state warm
renders drop ~6%.
The past and upcoming event actions run four separate MAX(updated_at)
queries (Workshop, Meeting, Event, Member) on every request to build the
ETag source. One UNION ALL subquery with a single MAX returns the same
value in one round trip, with the same nil-when-empty semantics as the
compact.max form it replaces.

Cuts SQL statements per request from 9 to 6 and saves ~1.5% of GC
allocations on both cache-miss and warm renders of GET /events/past.
fresh_when renders a 304 response without halting, so fetch_past_events
and fetch_upcoming_events still ran on top of the already-performed
response. Return early instead, mirroring the workshops#show guard.

Production conditional-GET share is negligible (2 of 1,805 requests),
so this is correctness hygiene rather than a measured win.
@mroderick
mroderick marked this pull request as ready for review October 1, 2026 09:48
@mroderick
mroderick requested a review from olleolleolle October 1, 2026 09:48
Comment thread app/controllers/events_controller.rb Outdated
Comment thread app/controllers/events_controller.rb Outdated
mroderick and others added 2 commits October 1, 2026 11:54
Co-authored-by: Olle Jonsson <olle.jonsson@gmail.com>
Co-authored-by: Olle Jonsson <olle.jonsson@gmail.com>
@mroderick
mroderick enabled auto-merge October 1, 2026 09:54
@mroderick
mroderick merged commit b295bfc into master Oct 1, 2026
10 checks passed
@mroderick
mroderick deleted the perf/events-past-allocations branch October 1, 2026 09:58
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.

3 participants