Bramka regresyjna na N+1 w adminie (FETCH_RAISE) + dwa realne N+1 - #736
Merged
Conversation
Dwie deklaracje `list_select_related` byly martwe — kod je deklarowal, a Django ich do zapytania nie wstawialo. Objaw: N+1 na changelistach, maskowany od #733 przez FETCH_PEERS (2 zapytania zamiast 2N), ale nadal falszywa deklaracja. 1. JednostkaAdmin — pulapka warunkowego `apply_select_related`. `ChangeList.get_queryset` (django/contrib/admin/views/main.py) aplikuje deklaracje WARUNKOWO: if not qs.query.select_related: qs = self.apply_select_related(qs) czyli TYLKO gdy queryset bazowy admina nie ma jeszcze zadnego `select_related`. A `JednostkaManager.get_queryset()` dokłada `.select_related("wydzial")` (zdenormalizowany self-FK), wiec warunek jest falszywy i CALA deklaracja admina przepada — do zapytania szedl sam `wydzial`. Leniwie, per wiersz, leciały wiec: * `rodzaj` — kolumna `list_display`, * `uczelnia` — czytana przez `Jednostka.__str__` (sprawdza `uzywaj_wydzialow`), czyli w KAZDYM wierszu, * `parent` — kolumna `parent_nazwa` (`item.parent.nazwa`), ktorej w deklaracji w ogole nie bylo. Nie ma tu zadnego bledu ani ostrzezenia — z samego diffa/kodu admina tego nie widac, bo deklaracja wyglada poprawnie. Nadpisujemy wiec `get_queryset` i dokladamy `select_related` wprost (select_related sie scala, wiec `wydzial` z managera nie ginie) oraz dopisujemy `parent`. Przeglad calego `src/`: to JEDYNY manager w projekcie, ktory dokłada domyslny `select_related`, wiec pulapka wystepuje tylko tutaj. Pozostale `get_queryset` z `select_related` siedza na `ModelResource` (django-import-export) albo na adminach bez `list_select_related` — tam nie ma czego zgubic. 2. Wydawnictwo_ZwarteAdmin — deklaracja pod zla nazwa kolumny. BPP uzywa slownikowego dialektu `list_select_related` (django-dynamic-admin-columns): JOIN wchodzi tylko, gdy kolumna o danej nazwie jest widoczna. Wpis `"wydawca": ["wydawca"]` celowal w kolumne `wydawca`, ktorej NIE MA w `list_display_default`. Tymczasem po wydawce siega domyslnie widoczna kolumna `wydawnictwo` — property modelu `get_wydawnictwo()` sklada `self.wydawca.nazwa` z `wydawca_opis`. JOIN wchodzil wiec tylko przy recznie wlaczonej kolumnie `wydawca`, czyli praktycznie nigdy. Dokladamy `"wydawnictwo": ["wydawca"]`. Oba przypadki znalezione przez bramke FETCH_RAISE (kolejny commit). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F134BWb3YoYPQ6zjzqoqds
Istniejacy `test_fetch_peers.py` sprawdza wylacznie, ze tryb FETCH_PEERS jest ustawiony i przezywa `_clone()`. To dowodzi, ze mechanizm jest podpiety — ale NIE dowodzi, ze N+1 faktycznie zniklo, i nie zatrzyma jego powrotu. Ktos dokłada kolumne do `list_display` albo relacje do `__str__`, liczba zapytan rosnie liniowo z liczba wierszy, a testy zostaja zielone. Nowy plik przybija KSZTALT ZAPYTAN czterech najgoretszych changelist (wydawnictwa ciagle i zwarte, autorzy, jednostki) trzema bramkami: 1. FETCH_RAISE. Renderujemy prawdziwa changeliste przez klienta HTTP, podmieniajac `bpp.admin.core.FETCH_PEERS` na FETCH_RAISE (monkeypatch na czas jednego testu — kod produkcyjny i DEFAULT_FETCH_MODE zostaja nietkniete). Wg kodu Django 6.1 tryb wedruje z `QuerySet._fetch_mode` przez `Model.from_db(fetch_mode=...)` do `instance._state.fetch_mode` (dziedzicza go tez obiekty z select_related i prefetch_related), a trzy deskryptory — DeferredAttribute, ForwardManyToOneDescriptor i ReverseOneToOneDescriptor — zamiast dociagac dane wołaja `fetch_mode.fetch(...)`. FetchRaise rzuca tam FieldFetchBlocked (podklasa FieldError) z komunikatem "Fetching of <Model>.<pole> blocked." Efekt: kazde leniwe dotkniecie relacji wywala test natychmiast, z nazwa pola — zamiast po cichu dolozyc N SELECT-ow. 2. Liczba zapytan niezalezna od liczby wierszy. Ta sama changelista z 2 i z 8 wierszami musi kosztowac DOKLADNIE tyle samo zapytan. To operacyjna definicja braku N+1 i — w odroznieniu od przybicia konkretnej liczby — nie wymaga aktualizacji przy niewinnych zmianach. Lapie tez to, czego FETCH_RAISE z zasady nie widzi: menedzery relacji odwrotnych (`obj.cos_set.all()`) i M2M tryb tylko DZIEDZICZA, nie sa przez niego blokowane. 3. Deklaracja `list_select_related` naprawde trafia do zapytania. Django aplikuje ja warunkowo (`if not qs.query.select_related`), wiec sama jej obecnosc w kodzie niczego nie gwarantuje — to wlasnie ta pulapka zjadla trzy JOIN-y na liscie jednostek (poprzedni commit). Test przechodzi po wszystkich adminach `bpp` i flaguje kazdego, ktorego queryset bazowy ma wlasny select_related, a zadeklarowanych sciezek w nim brakuje. Dane testowe maja WYPELNIONE FK widocznych kolumn — FETCH_RAISE odpala sie tylko dla relacji o niepustym kluczu (`has_value`), wiec rekordy z samymi NULL-ami przepuscilyby bramke na pusto. Bramka pilnuje tez samej siebie: `test_bramka_fetch_raise_faktycznie _gryzie` kasuje deklaracje `list_select_related` i wymaga, zeby FieldFetchBlocked FAKTYCZNIE poleciał. Bez tego caly plik moglby zrobic sie zielony na pusto, gdyby podmiana trybu przestala dzialac. Autouse fixture czysci `DynamicColumnsMixin._modeladmin_enabled` — to `cached_property` na singletonie admina, wiec przezywa rollback bazy i sprawia, ze kolejny test w module widzi UBOZSZY uklad kolumn, niz sadzi (czyli bramka cicho sie rozbraja). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F134BWb3YoYPQ6zjzqoqds
mpasternak
added a commit
that referenced
this pull request
Aug 7, 2026
#738) Cztery N+1 w adminie znalezione pomiarem na kopii bazy produkcyjnej. Wszystkie to czysty select_related, dzialaja na Django 5.2. Zmierzone (bench_prod, produkcyjne reguly CACHEOPS, Django 5.2.16): changelist jednostek 66 -> 16 zapytan changelist wyd. zwartych 70 -> 35 zapytan changelist autorow 102 -> 45 zapytan (mierzone bez cacheops) changelist zrodel (kontroler, nietkniety) 16 -> 16 Na 5.2 wychodzi o jedno zapytanie lepiej niz 6.1 z FETCH_PEERS (17 i 36): FETCH_PEERS musi dorzucic zapytanie hurtowe na relacje, JOIN nie musi. Pozycje 3 i 4 przeniesione z #736, zmergowanego do django-6.1 -- same poprawki nie wymagaja 6.1, wiec nie musza czekac na caly upgrade. Bramki regresyjne przybijaja niezmiennik 'liczba zapytan nie rosnie z liczba wierszy'; zweryfikowane, ze bez poprawek płoną.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Uzupełnienie #733. Tamten PR włączył
FETCH_PEERSw adminie i dodałtest_fetch_peers.py, który sprawdza wyłącznie, że tryb jest ustawiony(
queryset._fetch_mode is FETCH_PEERS) i przeżywafilter()/order_by()/slicing. To dowodzi, że mechanizm jest podpięty — ale nie dowodzi, że N+1
faktycznie zniknęło, i nie zatrzyma jego powrotu. Ktoś dokłada kolumnę do
list_displayalbo relację do__str__, liczba zapytań rośnie liniowoz liczbą wierszy, a testy zostają zielone.
Ten PR przybija kształt zapytań czterech najgorętszych changelist i przy
okazji naprawia dwa realne N+1, które ta bramka od razu wyłapała.
1. Jak dokładnie działa
FETCH_RAISE(wg kodu Django 6.1, nie domysłów)Prześledzone w
django/db/models/:fetch_modes.py— trzy singletony trybu.FetchRaise.fetch()todosłownie:
FieldFetchBlockedjest zdefiniowany wdjango/core/exceptions.pyjako podklasa
FieldError(czyliException, nieObjectDoesNotExist— dzięki temu nie łapią go
except ObjectDoesNotExistrozsiane poadmin_list.items_for_result). Niesie tylko komunikat — ale komunikatzawiera nazwę modelu i nazwę pola, więc jest samo-diagnozujący.
Jak tryb dociera do instancji.
QuerySet.fetch_mode(tryb)robi_chain()i ustawiaclone._fetch_mode. Przy materializacji wierszyModelIterable.__iter__czytaqueryset._fetch_modei przekazuje go doModel.from_db(..., fetch_mode=...), skąd ląduje winstance._state.fetch_mode. Tryb dziedziczą również obiektyz
select_related(RelatedPopulatordostajefetch_mode=self.fetch_mode)oraz z
prefetch_related(deskryptory robią.fetch_mode(instance._state.fetch_mode)wget_queryset)._clone()przenosi
_fetch_mode(c._fetch_mode = self._fetch_mode), więc trybprzeżywa filtry i sortowania.
Kiedy dokładnie się odpala. Dokładnie trzy miejsca wołają
instance._state.fetch_mode.fetch(...):DeferredAttribute.__get__(pole odroczone przezonly()/defer()),ForwardManyToOneDescriptor.__get__(FK/O2O „w przód", gdy nie mawartości w cache i klucz jest niepusty) oraz
ReverseOneToOneDescriptor.__get__.Czego
FETCH_RAISENIE łapie (istotne ograniczenie): menedżery relacjiodwrotnych (
obj.cos_set.all()) i M2M. One tryb tylko dziedziczą(
queryset._fetch_mode = self.instance._state.fetch_modew_apply_rel_filters), nie są przez niego blokowane. Dlatego bramka nr 2poniżej nie jest ozdobna.
Warunek
has_value:ForwardManyToOneDescriptorw ogóle nie wołafetch(), gdy klucz jestNULL. Dane testowe muszą więc mieć wypełnioneFK widocznych kolumn — rekordy z samymi NULL-ami przepuściłyby bramkę na
pusto. Fixture'y w tym PR-ze dbają o to jawnie.
2. Przed czym chronią testy (
src/bpp/tests/test_admin/test_fetch_raise_gate.py)Trzy uzupełniające się bramki, 11 testów:
(a)
test_changelist_nie_dociaga_relacji_spoza_deklaracji— renderujeprawdziwą changelistę przez klienta HTTP (pełny stack:
ChangeList,list_display, szablon,__str__), podmieniającbpp.admin.core.FETCH_PEERSna
FETCH_RAISEprzezmonkeypatch. Każde leniwe dotknięcie relacji =natychmiastowa porażka z nazwą pola.
(b)
test_liczba_zapytan_nie_zalezy_od_liczby_wierszy— ta samachangelista z 2 i z 8 wierszami musi kosztować dokładnie tyle samo
zapytań. To operacyjna definicja braku N+1; w odróżnieniu od przybicia
konkretnej liczby (
assert n == 17) nie wymaga aktualizacji przy niewinnychzmianach. Łapie to, czego
FETCH_RAISEz zasady nie widzi (relacje odwrotne,M2M).
(c)
test_deklaracja_list_select_related_trafia_do_zapytania— sweep powszystkich adminach
bpp, patrz sekcja 3.Plus meta-test
test_bramka_fetch_raise_faktycznie_gryzie: kasujedeklarację
list_select_relatedi wymaga, żebyFieldFetchBlockedfaktyczniepoleciał. Bez tego cały plik mógłby zrobić się zielony na pusto, gdyby
podmiana trybu przestała działać (np. ktoś nadpisze
get_querysetbezsuper()).Dlaczego akurat te changelisty: wydawnictwa ciągłe i zwarte to dwa główne
typy publikacji (setki tysięcy rekordów, najczęściej otwierane listy
w systemie); autorzy i jednostki to słowniki, po których redakcja nawiguje bez
przerwy i których
__str__sięga po FK (Autor.tytul,Jednostka.uczelnia).To dokładnie te trzy listy, dla których #733 raportował pomiary
(66→17, 220→40, 190→38 zapytań).
Co się stanie, gdy ktoś doda pole do
list_display: jeśli kolumna sięga porelację, bramka (a) wywali się natychmiast komunikatem
Fetching of Model.pole blocked.. Naprawa jest wskazana wprost w docstringu:dopisać relację do
list_select_related— w BPP zwykle do dialektusłownikowego
{"nazwa_kolumny": ["relacja"]}obsługiwanego przezdjango-dynamic-admin-columns(JOIN płaci tylko wtedy, gdy kolumna jestwidoczna). Jeśli kolumna nie sięga po relację, nic się nie dzieje.
3. Znalezione realne N+1 — dwa, oba naprawione
3a.
JednostkaAdmin— pułapka warunkowegoapply_select_relatedChangeList.get_queryset(django/contrib/admin/views/main.py) aplikujedeklarację warunkowo:
czyli tylko gdy queryset bazowy admina nie ma jeszcze żadnego
select_related. AJednostkaManager.get_queryset()dokłada.select_related("wydzial")— więc warunek był fałszywy i cała deklaracjaadmina przepadała. Do zapytania szedł sam
wydzial; leniwie, per wiersz,leciały:
rodzaj— kolumnalist_display,uczelnia— czytana przezJednostka.__str__(sprawdzauzywaj_wydzialow),czyli w każdym wierszu,
parent— kolumnaparent_nazwa(item.parent.nazwa), której w deklaracjiw ogóle nie było.
Ani błędu, ani ostrzeżenia — z samego kodu admina tego nie widać, bo
deklaracja wygląda poprawnie.
Przegląd całego
src/: to jedyny manager w projekcie dokładający domyślnyselect_related, więc ta konkretna pułapka występuje tylko tutaj. Pozostałeget_querysetzselect_relatedsiedzą naModelResource(django-import-export, nie dotyczy changelisty) albo na adminach bez
list_select_related(nie ma czego zgubić). Mimo to bramka (c) sprawdza togenerycznie, żeby przypadek nie mógł wrócić w nowym adminie.
3b.
Wydawnictwo_ZwarteAdmin— deklaracja pod złą nazwą kolumnyWpis
"wydawca": ["wydawca"]celował w kolumnęwydawca, której nie maw
list_display_default. Tymczasem po wydawcy sięga domyślnie widocznakolumna
wydawnictwo— property modeluget_wydawnictwo()składaself.wydawca.nazwazwydawca_opis. JOIN wchodził więc tylko przy ręczniewłączonej kolumnie
wydawca, czyli praktycznie nigdy. Dodane"wydawnictwo": ["wydawca"].Skala: od #733 oba N+1 były maskowane przez
FETCH_PEERS(2 zapytaniazamiast 2N zamiast N+1), więc to nie jest regresja produkcyjna — ale
deklaracje były fałszywe, a bez tych JOIN-ów każda z tych list nadal robiła
kilka zbędnych round-tripów na stronę.
4. Zakres
FETCH_RAISEjest tu wyłącznie narzędziem testowym, podmienianym przezmonkeypatchna czas jednego testu. Kod produkcyjny dalej używaFETCH_PEERS,DEFAULT_FETCH_MODEpozostaje nietknięty.5. Testy
src/bpp/tests/test_admin/test_fetch_raise_gate.py— 11 passed(3× pod losową kolejnością pytest-randomly, stabilnie)
src/bpp/tests/test_admin/— 654 passedsrc/bpp/tests/bez Playwrighta — 2936 passed, 2 skippedpre-commit(bez argumentów) — czysto6. Uwagi / wątpliwości
wierszami. Gdyby okazała się wrażliwa na warm-up cache'ów (jest rozgrzewka
przed każdym pomiarem, ale rezerwy nie ma), można ją zluzować do
<=—na razie 3 przebiegi pod losową kolejnością są stabilne.
DynamicColumnsMixin._modeladmin_enabled.To
cached_propertyna singletonie admina, więc przeżywa rollback bazymiędzy testami; bez czyszczenia kolejny test w module widział uboższy układ
kolumn niż sądził i bramka cicho się rozbrajała (znalezione w praktyce:
meta-test przechodził w izolacji, a padał w przebiegu całego modułu).
Ten cache może po cichu osłabiać także inne testy adminowe w projekcie —
osobny wątek, nie ruszam go w tym PR.
list_display_allowed(włączane ręcznie przez użytkownika) nie są pokryte — świadomie: dla nich
list_select_relatedteż jest warunkowe i włączenie każdej z osobnawysadzałoby bramkę bez realnej wartości.
🤖 Generated with Claude Code
https://claude.ai/code/session_01F134BWb3YoYPQ6zjzqoqds