Gemessen gegen die Dev-Instanz (3,3 Mio. Posts):
* GET /api/profile/<handle> 9,5 s → 0,04 s
* Cold-Start-Timeline 7,4 s → 0,006 s
* Timeline mit 2300 Follows 28 s → 0,02 s
Drei unabhängige Ursachen, alle drei ein Seq-Scan über die posts-Tabelle:
1. resolve_profile sucht die DID über profiles.LOWER(handle) und, als
Fallback, über posts.handle. Für beides gab es keinen Index. Auf
profiles hatte Migration 0007 genau diesen Index entfernt, mit der
Begründung, jeder Aufrufer leite ohnehin zuerst eine DID ab — das
stimmt nicht mehr, seit resolve_profile den profiles-Cache zuerst
befragt.
2. Der Cold-Start-Feed filtert `collection IN (…)` und sortiert nach
indexed_at. Der vorhandene (collection, indexed_at, uri)-Index taugt
dafür nicht: mit zwei führenden Werten liefert er keine
indexed_at-Ordnung mehr. Ein partieller Index über genau das
Prädikat schiebt den Filter in die Definition und lässt
(indexed_at DESC, uri DESC) als Sortierschlüssel übrig.
3. Genau dieser neue Index wurde dann zur Falle für den Graph-Zweig:
der Planer sah einen Index, der schon in indexed_at-Ordnung liefert,
und nahm an, er treffe früh genug auf n passende Zeilen — bei dünn
besetzten Followees hieß "früh" 2,87 Mio. verworfene Zeilen. Je nach
Anzahl bisheriger Ausführungen des Prepared Statements kippte er
zwischen diesem und dem guten Plan, was intermittierend aussah.
Der Graph-Zweig formuliert die Absicht jetzt aus: pro Followee die
neuesten Posts über ein LATERAL, dann mergen. Damit ist der globale
Scan kein wählbarer Plan mehr, und jede Iteration ist ein begrenzter
Range-Scan auf posts_did_indexed_at_uri_idx. Korrekt ist das, weil die
globalen Top-N immer eine Teilmenge der Vereinigung der Top-N je
Followee sind — deshalb wird pro Followee limit+1 geholt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX