Skip to content

perf(attendees): eliminate /attendees N+1 — 83 → 36 queries (-57%)#550

Open
smarcet wants to merge 8 commits into
hotfix/cache-optimizationsfrom
perf/attendees-n-plus-1
Open

perf(attendees): eliminate /attendees N+1 — 83 → 36 queries (-57%)#550
smarcet wants to merge 8 commits into
hotfix/cache-optimizationsfrom
perf/attendees-n-plus-1

Conversation

@smarcet
Copy link
Copy Markdown
Collaborator

@smarcet smarcet commented May 25, 2026

Summary

Same profiling-driven methodology as #549. Stacked on hotfix/cache-optimizations.

Baseline After
Queries 83 36 (-57%)
DB time 1061ms ~930ms
Serializer 120ms 22ms (-82%)
Total 1182ms ~1040ms

Full notes in adr/003-attendees-endpoint-n-plus-1-elimination.md.

What changed

Reusable infra:

  • ParametrizedGetAll::_getAll gains an optional callable $afterQuery = null hook. Fires between data load and $response->toArray() — lets callers warm caches / batch-load related entities without touching the trait body. Backward-compatible.

Targeted fixes:

Fix Queries saved
Summit::getSpeakerByMember per-instance memo + preloadSpeakersByMemberIds batch (3 lookups × N members → 3 batch queries with member fetch-join) ~24 → 3
Batch-preload Notes (SELECT a, n) 10 → 1
Batch-preload Tickets + Badges fetch-join (SELECT a, t, b) 22 → 1
Batch-preload Tags ManyToMany (SELECT a, tg) 10 → 1
Batch-preload Member fetch-join (SELECT a, m) 8 → 1

All five batch queries run from one closure passed to the trait's new afterQuery parameter.

Why total time only dropped 12% despite -57% queries

DB latency on the model database is ~25-30ms per query. We removed 47 queries' worth of latency but the remaining 36 still cost ~1000ms collectively. DB latency, not N+1 count, is now the dominant component — next investigation would be at the infrastructure layer (connection pool, replica targeting, query batching), not application code.

What was intentionally NOT fixed

  • PresentationSpeaker SELECT × 7 — deeper-chain access; ~6ms savings
  • COUNT(MemberID) × 6 — non-current-user belongsToGroup
  • PromoCode × 4 — per-ticket discount code lookup
  • SET TRANSACTION × 3 — connection lifecycle

Each would save 10-50ms; diminishing returns.

Stacked on #549

Includes a merge commit from hotfix/cache-optimizations (events PR). The trait afterQuery hook is technically introduced here but applies cleanly to both branches and will only flow into main once events PR merges first.

smarcet added 8 commits May 24, 2026 23:01
…ts/{id}/attendees

Same instrumentation pattern as /events (see adr/002):
  - ServerTimingDoctrine on the route (before auth.user)
  - timing markers in the shared ParametrizedGetAll trait
  - re-enabled temporary SQL pattern logger in QueryTimingCollector +
    'N+1 candidate' log entries when db_count >= 20

Diagnostic-only — will be removed in cleanup once N+1s are identified
and fixed.
… — eliminates ~24 queries

/attendees profiling showed 3 DISTINCT PresentationSpeaker patterns firing
~8 times each per request (24 total). Traced to SummitAttendeeSerializer:133
$summit->getSpeakerByMember($member), which calls getSpeakerByMemberId()
which runs THREE separate queries per call (moderator check, speaker check,
assistance check).

Two-part fix:

1. Summit gains a request-scoped $speakerByMemberIdCache (unannotated so
   Doctrine ignores it) and getSpeakerByMemberId() now checks/writes the
   cache at every return point. By itself this is just insurance against
   repeated lookups within a request.

2. New Summit::preloadSpeakersByMemberIds(array $ids) runs the same 3
   lookup steps but with WHERE mb.id IN (:ids), then populates the cache
   for every member id (with null for the ones not found). Result: 3
   batch queries instead of N×3 per-attendee queries.

3. ParametrizedGetAll trait grows an optional $afterQuery callable param
   that fires between the data load and the toArray() call so callers can
   warm caches without modifying the trait body for each endpoint.

4. OAuth2SummitAttendeesApiController::getAttendeesBySummit passes a
   closure that collects the page's attendee member ids and invokes
   $summit->preloadSpeakersByMemberIds($ids) before serialization.
… reset on auth context change

- DoctrineSummitEventRepository: change 'SELECT sp, p' to 'SELECT sp ... JOIN FETCH sp.presentation p'
  so getResult() returns SummitSelectedPresentation[] instead of mixed arrays.
  The prior query caused $sp->getPresentation() to fail on every request,
  silently falling through the try/catch and leaving the getSelectionStatus()
  N+1 optimization inactive.

- ResourceServerContext: reset cachedCurrentUser/cachedCurrentUserResolved in
  setAuthorizationContext() and updateAuthContextVar() so a context change
  mid-request (or between requests in tests) does not return a stale member.
…rQuery hook

Collapses 4 more per-attendee N+1 patterns into 4 batch fetch-join queries:

  - Notes (SummitAttendeeNote, 10 q on a 10-row page)
  - Tickets (SummitAttendeeTicket, 10 q)
  - Tags ManyToMany (10 q)
  - Member ManyToOne fetch-join (8 q)

Each runs as 'SELECT a, X FROM SummitAttendee a LEFT JOIN a.X X WHERE a.id
IN (:ids)' which fetch-joins the inverse collection / association onto each
already-loaded SummitAttendee in the UnitOfWork. Subsequent serializer
accesses read from memory.

Expected: 63 -> ~25 queries on /summits/{id}/attendees, db time roughly
halved.
Two follow-up N+1s exposed by removing the upstream lazy loads:

1. Speaker member fetch — the preloadSpeakersByMemberIds queries joined
   ps.member mb but did not addSelect('mb'), so Doctrine used the join only
   for filtering and the resulting speakers had unloaded Member proxies.
   Add ->addSelect('mb') to all three (moderator/speaker/assistance) batch
   queries. Saves ~8 Member queries per request.

2. SummitAttendeeBadge — the per-ticket badge lookup (12 q on a 10-row page)
   fired once per ticket. Extend the tickets preload from
   'SELECT a, t' to 'SELECT a, t, b' with an extra LEFT JOIN t.badge b,
   fetch-joining the badge alongside the ticket.

Expected: another ~20 queries removed, total drops from 49 to ~29.
- adr/002: fix heading ADR-0001 → ADR-0002 to match filename
- PresentationSpeaker: add clearPreloadedAssignmentOrder(id) and
  clearAllPreloadedAssignmentOrders() so write paths can invalidate
  the preloaded assignment-order cache
- PresentationSpeakerCacheTest (8 tests, no DB): covers cache hit,
  null order, clear-single, clear-all, and Presentation preloaded
  selection-status path (memoization + reset)
- ResourceServerContextTest: add setAuthorizationContextResetsUserCache
  asserting cachedCurrentUserResolved is cleared by setAuthorizationContext()
Follows the same methodology and structure as ADR-002. Records the
afterQuery trait hook (reusable for future endpoints), the Summit-level
speaker memo + batch preload, and the per-attendee notes/tickets+badges/
tags/member fetch-join preloads. Includes honest reading of the result —
query count down 57% but wall-clock only down 12% because DB latency now
dominates over N+1 count.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 25, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8eba96f2-f44b-4d15-bd7c-594864394076

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/attendees-n-plus-1

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-550/

This page is automatically updated on each push to this PR.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR targets performance on GET /api/v1/summits/{id}/attendees by collapsing serializer-driven N+1 query patterns into a small set of batch preloads, leveraging a new _getAll “afterQuery” hook to warm Doctrine associations/caches before serialization.

Changes:

  • Added an optional afterQuery hook to ParametrizedGetAll::_getAll() and used it on the attendees endpoint to batch preload Notes, Tickets+Badges, Tags, and Member associations.
  • Added request-scoped memoization plus batch preloading for Summit::getSpeakerByMemberId() to avoid repeated per-attendee speaker lookups.
  • Extended Doctrine query timing tooling to optionally bucket SQL patterns for N+1 discovery and added route-level Server-Timing instrumentation to /attendees.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
routes/api_v1.php Enables server.timing.doctrine middleware on the attendees listing route for profiling/timing visibility.
app/Models/Foundation/Summit/Summit.php Adds per-request speaker lookup cache and a batch preloader for member→speaker resolution.
app/Http/Middleware/ServerTimingDoctrine.php Adds temporary N+1 pattern logging based on collected SQL timing/pattern stats.
app/Http/Middleware/Doctrine/QueryTimingMiddleware.php Passes SQL text into the timing collector for per-pattern bucketing.
app/Http/Middleware/Doctrine/QueryTimingCollector.php Implements per-pattern aggregation (normalize + topPatterns) for N+1 detection.
app/Http/Controllers/Apis/Protected/Summit/Traits/ParametrizedGetAll.php Adds the afterQuery hook and extra timing markers around controller/serializer phases.
app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitAttendeesApiController.php Uses afterQuery to batch preload associations and speaker cache before serialization.
adr/003-attendees-endpoint-n-plus-1-elimination.md Documents the profiling methodology, decisions, and measured impact for /attendees.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 40 to 47
public function query(string $sql): DBALResult
{
$start = microtime(true);
try {
return parent::query($sql);
} finally {
QueryTimingCollector::record($start);
QueryTimingCollector::record($start, $sql);
}
Comment on lines +18 to +38
/**
* Per-pattern bucket for finding N+1s during profiling.
*
* @var array<string, array{count:int, totalMs:float, sample:string}>
*/
public static array $patterns = [];

public static function record(float $startedAt, ?string $sql = null): void
{
self::$totalMs += (microtime(true) - $startedAt) * 1000.0;
$ms = (microtime(true) - $startedAt) * 1000.0;
self::$totalMs += $ms;
self::$count++;

if ($sql !== null) {
$pattern = self::normalize($sql);
if (!isset(self::$patterns[$pattern])) {
self::$patterns[$pattern] = ['count' => 0, 'totalMs' => 0.0, 'sample' => $sql];
}
self::$patterns[$pattern]['count']++;
self::$patterns[$pattern]['totalMs'] += $ms;
}
Comment on lines +57 to +66
// Temporary N+1 candidate logger (profiling-only — remove on cleanup).
if ($dbCount >= 20) {
foreach (\App\Http\Middleware\Doctrine\QueryTimingCollector::topPatterns(10) as $row) {
Log::warning('N+1 candidate', [
'count' => $row['count'],
'totalMs' => $row['totalMs'],
'sample' => mb_substr($row['sample'], 0, 240),
]);
}
}
Comment on lines +716 to +719
if (method_exists($attendee, 'getMember')) {
$m = $attendee->getMember();
if ($m !== null && method_exists($m, 'getId') && $m->getId()) {
$memberIds[] = $m->getId();
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.

2 participants