Skip to content

Bramka regresyjna na N+1 w adminie (FETCH_RAISE) + dwa realne N+1 - #736

Merged
mpasternak merged 2 commits into
django-6.1from
feat/fetch-raise-gate
Aug 7, 2026
Merged

Bramka regresyjna na N+1 w adminie (FETCH_RAISE) + dwa realne N+1#736
mpasternak merged 2 commits into
django-6.1from
feat/fetch-raise-gate

Conversation

@mpasternak

Copy link
Copy Markdown
Member

Uzupełnienie #733. Tamten PR włączył FETCH_PEERS w adminie i dodał
test_fetch_peers.py, który sprawdza wyłącznie, że tryb jest ustawiony
(queryset._fetch_mode is FETCH_PEERS) i przeżywa filter()/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_display albo relację do __str__, liczba zapytań rośnie liniowo
z 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() to
    dosłownie:

    def fetch(self, fetcher, instance):
        klass = instance.__class__.__qualname__
        field_name = fetcher.field.name
        raise FieldFetchBlocked(f"Fetching of {klass}.{field_name} blocked.") from None
  • FieldFetchBlocked jest zdefiniowany w django/core/exceptions.py
    jako podklasa FieldError (czyli Exception, nie ObjectDoesNotExist
    — dzięki temu nie łapią go except ObjectDoesNotExist rozsiane po
    admin_list.items_for_result). Niesie tylko komunikat — ale komunikat
    zawiera nazwę modelu i nazwę pola, więc jest samo-diagnozujący.

  • Jak tryb dociera do instancji. QuerySet.fetch_mode(tryb) robi
    _chain() i ustawia clone._fetch_mode. Przy materializacji wierszy
    ModelIterable.__iter__ czyta queryset._fetch_mode i przekazuje go do
    Model.from_db(..., fetch_mode=...), skąd ląduje w
    instance._state.fetch_mode. Tryb dziedziczą również obiekty
    z select_related (RelatedPopulator dostaje fetch_mode=self.fetch_mode)
    oraz z prefetch_related (deskryptory robią
    .fetch_mode(instance._state.fetch_mode) w get_queryset). _clone()
    przenosi _fetch_mode (c._fetch_mode = self._fetch_mode), więc tryb
    przeżywa filtry i sortowania.

  • Kiedy dokładnie się odpala. Dokładnie trzy miejsca wołają
    instance._state.fetch_mode.fetch(...):
    DeferredAttribute.__get__ (pole odroczone przez only()/defer()),
    ForwardManyToOneDescriptor.__get__ (FK/O2O „w przód", gdy nie ma
    wartości w cache i klucz jest niepusty) oraz
    ReverseOneToOneDescriptor.__get__.

  • Czego FETCH_RAISE NIE łapie (istotne ograniczenie): menedżery relacji
    odwrotnych (obj.cos_set.all()) i M2M. One tryb tylko dziedziczą
    (queryset._fetch_mode = self.instance._state.fetch_mode w
    _apply_rel_filters), nie są przez niego blokowane. Dlatego bramka nr 2
    poniżej nie jest ozdobna.

  • Warunek has_value: ForwardManyToOneDescriptor w ogóle nie woła
    fetch(), gdy klucz jest NULL. Dane testowe muszą więc mieć wypełnione
    FK 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 — renderuje
prawdziwą changelistę przez klienta HTTP (pełny stack: ChangeList,
list_display, szablon, __str__), podmieniając bpp.admin.core.FETCH_PEERS
na FETCH_RAISE przez monkeypatch. Każde leniwe dotknięcie relacji =
natychmiastowa porażka z nazwą pola.

(b) test_liczba_zapytan_nie_zalezy_od_liczby_wierszy — ta sama
changelista 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 niewinnych
zmianach. Łapie to, czego FETCH_RAISE z zasady nie widzi (relacje odwrotne,
M2M).

(c) test_deklaracja_list_select_related_trafia_do_zapytania — sweep po
wszystkich adminach bpp, patrz sekcja 3.

Plus meta-test test_bramka_fetch_raise_faktycznie_gryzie: kasuje
deklarację list_select_related i wymaga, żeby FieldFetchBlocked faktycznie
poleciał. Bez tego cały plik mógłby zrobić się zielony na pusto, gdyby
podmiana trybu przestała działać (np. ktoś nadpisze get_queryset bez
super()).

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 po
relację, 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 dialektu
słownikowego {"nazwa_kolumny": ["relacja"]} obsługiwanego przez
django-dynamic-admin-columns (JOIN płaci tylko wtedy, gdy kolumna jest
widoczna). Jeśli kolumna nie sięga po relację, nic się nie dzieje.

3. Znalezione realne N+1 — dwa, oba naprawione

3a. JednostkaAdmin — pułapka warunkowego apply_select_related

ChangeList.get_queryset (django/contrib/admin/views/main.py) aplikuje
deklarację warunkowo:

if not qs.query.select_related:
    qs = self.apply_select_related(qs)

czyli tylko gdy queryset bazowy admina nie ma jeszcze żadnego
select_related. A JednostkaManager.get_queryset() dokłada
.select_related("wydzial") — więc warunek był fałszywy i cała deklaracja
admina przepadała
. Do zapytania szedł sam wydzial; leniwie, per wiersz,
leciały:

  • rodzaj — kolumna list_display,
  • uczelnia — czytana przez Jednostka.__str__ (sprawdza uzywaj_wydzialow),
    czyli w każdym wierszu,
  • parent — kolumna parent_nazwa (item.parent.nazwa), której w deklaracji
    w 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ślny
select_related
, więc ta konkretna pułapka występuje tylko tutaj. Pozostałe
get_queryset z select_related siedzą na ModelResource
(django-import-export, nie dotyczy changelisty) albo na adminach bez
list_select_related (nie ma czego zgubić). Mimo to bramka (c) sprawdza to
generycznie, żeby przypadek nie mógł wrócić w nowym adminie.

3b. Wydawnictwo_ZwarteAdmin — deklaracja pod złą nazwą kolumny

Wpis "wydawca": ["wydawca"] celował w kolumnę wydawca, której nie ma
w list_display_default
. Tymczasem po wydawcy sięga domyślnie widoczna
kolumna wydawnictwo — property modelu get_wydawnictwo() składa
self.wydawca.nazwa z wydawca_opis. JOIN wchodził więc tylko przy ręcznie
włączonej kolumnie wydawca, czyli praktycznie nigdy. Dodane
"wydawnictwo": ["wydawca"].

Skala: od #733 oba N+1 były maskowane przez FETCH_PEERS (2 zapytania
zamiast 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_RAISE jest tu wyłącznie narzędziem testowym, podmienianym przez
monkeypatch na czas jednego testu. Kod produkcyjny dalej używa
FETCH_PEERS, DEFAULT_FETCH_MODE pozostaje nietknięty.

5. Testy

  • src/bpp/tests/test_admin/test_fetch_raise_gate.py11 passed
    (3× pod losową kolejnością pytest-randomly, stabilnie)
  • src/bpp/tests/test_admin/654 passed
  • src/bpp/tests/ bez Playwrighta — 2936 passed, 2 skipped
  • pre-commit (bez argumentów) — czysto

6. Uwagi / wątpliwości

  • Bramka (b) porównuje dokładną równość liczby zapytań między 2 a 8
    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.
  • Dodałem autouse-fixture czyszczący DynamicColumnsMixin._modeladmin_enabled.
    To cached_property na singletonie admina, więc przeżywa rollback bazy
    mię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.
  • Bramka (a) mierzy domyślny układ kolumn. Kolumny z list_display_allowed
    (włączane ręcznie przez użytkownika) nie są pokryte — świadomie: dla nich
    list_select_related też jest warunkowe i włączenie każdej z osobna
    wysadzałoby bramkę bez realnej wartości.

🤖 Generated with Claude Code

https://claude.ai/code/session_01F134BWb3YoYPQ6zjzqoqds

mpasternak and others added 2 commits August 7, 2026 15:47
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
mpasternak merged commit 5635e03 into django-6.1 Aug 7, 2026
1 check passed
@mpasternak
mpasternak deleted the feat/fetch-raise-gate branch August 7, 2026 14:23
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ą.
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.

1 participant