Skip to content

Włącz FETCH_PEERS (Django 6.1) w adminie - #733

Merged
mpasternak merged 5 commits into
django-6.1from
django-6.1-optimizations
Aug 7, 2026
Merged

Włącz FETCH_PEERS (Django 6.1) w adminie#733
mpasternak merged 5 commits into
django-6.1from
django-6.1-optimizations

Conversation

@mpasternak

@mpasternak mpasternak commented Aug 7, 2026

Copy link
Copy Markdown
Member

Włącza tryb pobierania relacji z Django 6.1 (FETCH_PEERS) w adminie.

Zakres tego PR-a zawęził się po Waszej uwadze: trzy poprawki, które
działają też na Django 5.2, poszły wprost na dev
(f509c7bdc, 5ffc83f50, 6416e2ac0 — już wypchnięte). Tutaj został
wyłącznie commit wymagający Django 6.1, plus merge dev, który te
poprawki wciąga. Dzięki temu żadna zmiana nie istnieje w dwóch miejscach
z różnymi SHA.

Własny wkład tego PR-a: FETCH_PEERS w BaseBppAdminMixin

Django 6.1 wprowadziło fetch modes. FETCH_PEERS sprawia, że pierwsze
leniwe dotknięcie relacji (albo pola odroczonego) na obiekcie z querysetu
dociąga ją hurtem dla całego rodzeństwa z tego samego pobrania
(prefetch_related_objects pod spodem) — N+1 zamienia się w 2 zapytania,
bez deklarowania select_related z góry.

Changelisty admina to najgęstsze w BPP skupisko tego wzorca: list_display
i __str__ modeli sięgają po FK, których nikt nie zadeklarował w
list_select_related. Tryb ustawiamy w jednym miejscu, dla 31 adminów.

Zmierzone na kopii bazy produkcyjnej (122 232 rekordy, 68 355 autorów,
504 jednostki), z produkcyjnymi regułami CACHEOPS:

scenariusz                        bez FETCH_PEERS   z FETCH_PEERS
admin: jednostka (changelist)            66 zap.         17 zap.
admin: wyd. zwarte (changelist)         220 zap.         40 zap.
admin: wyd. ciągłe (changelist)         190 zap.         38 zap.

Dlaczego tu, a nie globalnie

track_peers trzyma weakref do każdej instancji z pobrania, więc
podstawienie DEFAULT_FETCH_MODE obciążyłoby każdy queryset
w aplikacji, a zysk jest skoncentrowany w adminie. Samo get_queryset
wystarcza, bo QuerySet._clone() przenosi _fetch_mode — tryb przeżywa
filtry, sortowanie i slicing dokładane przez dalsze mixiny i przez sam
ChangeList. Test pilnuje obu tych własności osobno.

Bezpieczeństwo

FETCH_PEERS nie zmienia semantyki managerów: fetch_one
i fetch_many idą tą samą ścieżką _base_manager (dla pól odroczonych
_base_manager…in_bulk()), więc tryb nie zaczyna odfiltrowywać rekordów —
istotne przy soft-delete. Sprawdzone też empirycznie na danych
produkcyjnych: 15 stron (publiczne, admin, API) zwróciło wynik identyczny
bajt w bajt w obu trybach.

Jedyna znaleziona różnica, warta wiedzy: dla pola odroczonego fetch_many
robi value_by_pk[instance.pk] — goły lookup w dict — więc gdyby wiersz
zniknął między pobraniem listy a dotknięciem pola, poleci KeyError
zamiast DoesNotExist. Wyścig egzotyczny, ale to inny typ wyjątku.

O pomiarach — czytaj przed oceną liczb

Liczby zapytań są dokładne i powtórzyły się identycznie w pięciu
niezależnych przebiegach. Na nich opieram wnioski.

Czasów NIE podaję jako wartości. Host pomiarowy jest współdzielony
i był obłożony (load ~6,5, 26 kontenerów). Dowód, że to szum hosta, a nie
kod: admin: źródło (changelist) ma te same 16 zapytań przed i po
(żadna zmiana go nie dotyka), a zmierzony czas skakał 320 → 420 ms.
Czasy w wiadomości commita pochodzą z wcześniejszego, spokojniejszego
okna — rząd wielkości, nie pomiar. Warto powtórzyć na maszynie bez obcego
obciążenia.

Testy

src/bpp/tests/test_admin/ + src/bpp/tests/test_views/
1016 passed, 1 skipped in 182.51s

Te same testy na dev (Django 5.2, bez test_fetch_peers.py):
1012 passed, 1 skipped — czyli poprawki przeniesione na dev faktycznie
działają na 5.2, a nie tylko „powinny".

Skąd te znaleziska

Nie z przeglądu kodu, a z pomiaru: nowy FetchMode z 6.1 użyty jako
detektor N+1 (szpieg liczący każde leniwe pobranie razem ze stosem
wywołań). To zresztą, moim zdaniem, cenniejsza część Django 6.1 niż samo
FETCH_PEERSFETCH_RAISE da się użyć jako trwały gejt regresji
w testach.

Pełny raport z metodą, oboma wariantami konfiguracji cache i dowodem
równoważności wyniku: AUDYT-DJANGO-6.1-WYDAJNOSC.md na gałęzi
django-6.1. Wniosek o samym upgrade'zie: jest wydajnościowo
neutralny
— identyczne liczby zapytań na 15 scenariuszach, zero regresji.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XWWemKMkQMQmHZZPSmCdid

mpasternak and others added 5 commits August 7, 2026 13:04
…zamiast 7 wydziałów

`AutorAdmin.list_filter` miał goły string "aktualna_jednostka__wydzial",
z którego Django budowało `RelatedFieldListFilter`, a ten woła
`field.get_choices()`. Po Fazie B (#438) denorm `Jednostka.wydzial` jest
self-FK na `Jednostka`, więc `get_choices()` enumerowało CAŁĄ tabelę
jednostek: na kopii bazy produkcyjnej 504 opcje w dropdownie zamiast
7 jednostek-korzeni ("wydziałów"), plus 504 zapytania na każdy request
changelisty (każde `Jednostka.__str__` czyta `self.uczelnia`).

Faza B naprawiła to `WydzialFilter`-em, ale tylko dla `JednostkaAdmin` —
`AutorAdmin` został przeoczony. Dokładamy `WydzialAutoraFilter`: podklasę
`WydzialFilter`, która dziedziczy bez zmian `lookups()` (tylko korzenie,
zawężone do uczelni z requestu) i `has_output()` (bramka
`uzywaj_wydzialow`), a nadpisuje jedynie `queryset()` — bo tu zawężamy
`Autor`, więc do korzenia trzeba dojść przez `aktualna_jednostka__`
(`Q(aktualna_jednostka__wydzial_id=v) | Q(aktualna_jednostka_id=v)`).

Świadoma zmiana kontraktu URL: parametr filtra to teraz `?wydzial=<id>`
zamiast `?aktualna_jednostka__wydzial__id__exact=<id>`. Filtry admina to
ulotny stan UI, nie trwałe linki, więc to akceptujemy; stary querystring
degraduje się do 400 (`DisallowedModelAdminLookup`, zmierzone testem),
a nie do 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWWemKMkQMQmHZZPSmCdid
Dwa niezalezne defekty w `bpp/admin/filters.py`, oba wykryte pomiarem na
kopii bazy produkcyjnej (504 jednostki, 68 tys. autorow):

1. `JednostkaFilter.lookups()` mial `select_related("wydzial")`, ale
   `Jednostka.__str__` czyta DWA FK -- takze `self.uczelnia` (bramka
   `uzywaj_wydzialow`). Kazda pozycja listy kosztowala osobny SELECT:
   504 zapytania na KAZDE wejscie na changeliste autorow. Cacheops
   zamienial je na trafienia w Redis (`bpp.uczelnia` jest w regulach),
   wiec licznik zapytan SQL tego nie pokazywal -- ale 504 round-tripy
   do Redisa nadal kosztowaly ~200 ms na request.

2. `LogEntryFilterBase.lookups()` zawezal queryset przez
   `.only("pk", "username")`, a `BppUser.__str__` czyta jeszcze
   `last_name` i `first_name`. Kazde pole odroczone to osobny
   `refresh_from_db()` per uzytkownik, czyli DWA dodatkowe SELECT-y na
   wiersz -- "optymalizacja", ktora kosztowala zamiast oszczedzac. Na
   produkcji 156 zapytan na wejscie na changeliste wydawnictw ciaglych,
   i w przeciwienstwie do slownikow `bpp.bppuser` NIE jest cache'owany
   przez cacheops, wiec szly wprost do PostgreSQL.

Zmierzony efekt (kopia produkcji, produkcyjne reguly CACHEOPS):
changelist wyd. ciaglych 190 -> 34 zapytania, changelist autorow
620 -> 452 ms (przy tej samej liczbie zapytan SQL -- zniknely
round-tripy do Redisa).

Testy pilnuja sedna: obie listy powstaja DOKLADNIE jednym zapytaniem,
niezaleznie od liczby pozycji. Drugi test dodatkowo asertuje, ze imie
i nazwisko sa w etykiecie -- inaczej `only()` znow by je pominelo, a
`__str__` po cichu degradowalby do samego `username`.

Uporzadkowana tez kolejnosc importow w `test_filters.py` (plik nie
przechodzil `ruff check` juz przed ta zmiana; pre-commit sprawdza tylko
pliki zmieniane, wiec nikt tego nie zauwazyl).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWWemKMkQMQmHZZPSmCdid
`browse/jednostki.html` wolal `item.aktualna_jednostka.count` -- relacja
ODWROTNA (Autor -> Jednostka), wiec kazde uzycie to osobny `COUNT(*)`.
Szablon uzywal go 3-4 razy na wiersz (warunek `> 0`, sama liczba, dwa
warunki odmiany), a strona pokazuje domyslnie 150 jednostek. Na kopii
bazy produkcyjnej dawalo to 514 zapytan na JEDNO wejscie na indeks
jednostek -- najwiekszy pojedynczy koszt zapytan w calej czesci
publicznej.

Ani cacheops, ani fetch modes z Django 6.1 tego NIE lapaly: queryset
dotyczy `bpp.autor`, ktorego nie ma w regulach CACHEOPS, a `FETCH_PEERS`
obsluguje leniwe FK i pola odroczone -- nie `RelatedManager.count()`.
Zmierzone: z globalnym FETCH_PEERS bylo 514 -> 514 zapytan, czyli zero
zmiany. Lekarstwem jest adnotacja, nie tryb pobierania.

Zmierzony efekt (kopia produkcji, produkcyjne reguly CACHEOPS):
514 -> 3 zapytania, 137 -> 20 ms.

Adnotacja jest tu bezpieczna (nie zawyza liczb przez zdublowane wiersze),
bo -- jak dokumentuje komentarz w `Browser.get_queryset` -- zadna sciezka
filtrowania `JednostkiView` nie mnozy wierszy: filtr literki to
`istartswith` na wlasnej kolumnie, fulltext dla `Jednostka` to predykat
na jednokolumnowym tsvectorze bez JOIN-a, a `scope_jednostki_do_uczelni`
porownuje skalarny FK. Sprawdzone tez wyczerpujaco: dla WSZYSTKICH 504
jednostek z bazy produkcyjnej adnotacja dala te same liczby, co
`.count()` per wiersz (0 roznic, suma 60 711 autorow), a wyrenderowany
HTML jest identyczny bajt w bajt.

Dwa testy, bo zmiana ma dwa rozne ryzyka:
* `test_JednostkiView_liczba_autorow_jednym_zapytaniem` -- sedno, czyli
  brak zapytania per wiersz,
* `test_browse_jednostki_pokazuje_liczbe_autorow` -- literowka w nazwie
  adnotacji NIE wywalilaby wyjatku (Django renderuje nieistniejaca
  zmienna jako pusty lancuch), wiec strona po cichu przestalaby pokazywac
  liczby.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWWemKMkQMQmHZZPSmCdid
Django 6.1 wprowadzilo *fetch modes*. `FETCH_PEERS` sprawia, ze PIERWSZE
leniwe dotkniecie relacji (albo pola odroczonego) na obiekcie z querysetu
dociaga ja HURTEM dla calego rodzenstwa z tego samego pobrania
(`prefetch_related_objects` pod spodem) -- N+1 zamienia sie w 2 zapytania,
bez zgadywania z gory, ktore FK dotknie szablon.

Changelisty admina to najgestsze w BPP skupisko tego wzorca: `list_display`
i `__str__` modeli siegaja po FK, ktorych nikt nie zadeklarowal w
`list_select_related`. Ustawiamy tryb w `BaseBppAdminMixin.get_queryset`,
czyli w jednym miejscu dla 31 adminow.

Zmierzone na kopii bazy produkcyjnej (produkcyjne reguly CACHEOPS),
zapytania i mediana czasu na request:

  changelist jednostek        66 -> 17 zapytan, 185 -> 120 ms
  changelist wyd. zwartych   220 ->  40 zapytan, 227 -> 184 ms
  changelist wyd. ciaglych   190 ->  38 zapytan, 221 -> 190 ms

Dlaczego TU, a nie globalnie (podstawienie `DEFAULT_FETCH_MODE`):
`track_peers` trzyma `weakref` do kazdej instancji z pobrania, wiec koszt
ponosilby KAZDY queryset w aplikacji, a zysk jest skoncentrowany w
adminie. Samo `get_queryset` wystarcza, bo `QuerySet._clone()` przenosi
`_fetch_mode` -- tryb przezywa filtry, sortowanie i slicing dokladane
przez dalsze mixiny i przez sam `ChangeList`. Test pilnuje obu tych
wlasnosci osobno.

Semantyka sie NIE zmienia: `fetch_one` i `fetch_many` ida ta sama sciezka
managera (`_base_manager`) -- tryb nie zaczyna nagle odfiltrowywac
rekordow, co jest istotne przy soft-delete. Sprawdzone tez empirycznie na
danych produkcyjnych: 15 stron (publiczne, admin, API) zwrocilo wynik
identyczny bajt w bajt w obu trybach.

WYMAGA Django >= 6.1, dlatego ta gałąź celuje w `django-6.1`, a nie w `dev`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWWemKMkQMQmHZZPSmCdid
@mpasternak
mpasternak force-pushed the django-6.1-optimizations branch from 8ed909b to fdc1197 Compare August 7, 2026 11:38
@mpasternak mpasternak changed the title Optymalizacje wydajności: N+1 w adminie i na indeksie jednostek + FETCH_PEERS (Django 6.1) Włącz FETCH_PEERS (Django 6.1) w adminie Aug 7, 2026
@mpasternak
mpasternak merged commit ccb9756 into django-6.1 Aug 7, 2026
1 check passed
@mpasternak
mpasternak deleted the django-6.1-optimizations branch August 7, 2026 12:06
mpasternak added a commit that referenced this pull request Aug 7, 2026
* fix(admin): brakujace JOIN-y na listach jednostek i wydawnictw zwartych

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

* test(admin): bramka regresyjna na N+1 oparta o FETCH_RAISE (Django 6.1)

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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