diff --git a/HANDOFF-pomiar-wydajnosci-2026-08-07.md b/HANDOFF-pomiar-wydajnosci-2026-08-07.md new file mode 100644 index 000000000..6a6284eed --- /dev/null +++ b/HANDOFF-pomiar-wydajnosci-2026-08-07.md @@ -0,0 +1,302 @@ +# HANDOFF: pomiar czasów wydajności — ZAMKNIĘTY + +**Data zlecenia:** 2026-08-07 +**Data wykonania:** 2026-08-07 (MacBook Pro, cichy host) +**Gałąź:** `django-6.1` +**Status:** **ZROBIONE.** Czasy zmierzone, kryterium kontrolera spełnione. +Wynik niżej; oryginalna treść handoffu zostaje jako zapis procedury. + +--- + +## WYNIK + +Warunki: kopia bazy produkcyjnej (`db-backup-20260603-023000`, 68 355 +autorów, 491 jednostek), wariant `bench_prod` (23 produkcyjne reguły +`CACHEOPS`), 25 powtórzeń × dwa niezależne przebiegi na stronę, sekwencja +`po → przed → po → przed`, `FLUSHDB` przed każdym przebiegiem. Dev-owy +stack `docker compose` zatrzymany na czas pomiaru. + +### Kontroler — kryterium ważności SPEŁNIONE + +`admin: źródło (changelist)`, 16 zapytań we **wszystkich czterech** +przebiegach; mediany 284,2 / 281,7 / 284,6 / 281,4 ms. Rozrzut między +przebiegami **3,2 ms = 1,1 %** mediany, **zero flag `SZUM`**. (Na +`mac-mini` ten sam kontroler skakał 320 → 420 ms, +31 %.) + +Podłoga szumu wyznaczona dodatkowo z **dziesięciu** niezmienionych +scenariuszy: dla stron > 100 ms żaden nie odchylił się o więcej niż +**3,5 %**; na stronach kilkudziesięciomilisekundowych sięga 9 %. + +### Czasy, para `ee5e81a35` → `ccb9756ff` + +| scenariusz | zapytań | przed [ms] | po [ms] | różnica | +|---|---|---|---|---| +| publiczne: lista jednostek | 514→3 | 196,2 / 182,0 | 26,7 / 29,1 | **−161 ms (−85 %)** | +| admin: autor (changelist) | 27→27 | 788,4 / 698,7 | 237,2 / 240,3 | **−505 ms (−68 %)** | +| admin: jednostka (changelist) | 66→17 | 240,2 / 199,1 | 131,7 / 123,7 | **−92 ms (−42 %)** | +| admin: wyd. zwarte (changelist) | 220→36 | 246,6 / 249,8 | 210,8 / 208,0 | **−39 ms (−16 %)** | +| admin: wyd. ciągłe (changelist) | 190→34 | 233,4 / 233,1 | 219,5 / 207,4 | −20 ms (−8,5 %) | + +Wszystkie powyżej progu 3,5 %; `wyd. ciągłe` najbliżej i najmniej pewna. +Liczby zapytań odtworzyły się co do jednego względem tych z handoffu. + +### Ile z tego wymaga Django 6.1 — prawie nic + +Trzeci punkt pomiarowy (czubek `dev`, Django 5.2.16; zbiory migracji +`dev` i `django-6.1` są identyczne, więc obsłużyła go ta sama baza): + +| scenariusz | `ee5e81a35` | `dev` 5.2 | 6.1 + PEERS | `dev` + PR #738 (5.2) | +|---|---|---|---|---| +| publiczne: lista jednostek | 514 | **3** | 3 | 3 | +| admin: wyd. ciągłe | 190 | **34** | 34 | 34 | +| admin: wyd. zwarte | 220 | 70 | 36 | **35** | +| admin: jednostka | 66 | 66 | 17 | **16** | +| admin: źródło (kontroler) | 16 | 16 | 16 | 16 | + +Wnioski: + +1. Dwie wygrane (`lista jednostek`, `wyd. ciągłe`) były **już na 5.2** — + dają je poprawki z `dev`, `FETCH_PEERS` nie dokłada tam nic. +2. Spadek czasu `admin: autor` 788 → ~250 ms daje commit `5ffc83f50` + z `dev`; `FETCH_PEERS` dokłada już tylko ~250 → 238 ms. Czyli i ta + wygrana jest **w praktyce cała na 5.2**. +3. Pozostałe dwie (`jednostka`, `wyd. zwarte`) da się na 5.2 zrobić + jawnym `select_related` — i wychodzi **o jedno zapytanie lepiej** niż + `FETCH_PEERS`, bo ten musi dorzucić zapytanie hurtowe na relację. + Zrobione w **PR #738** (do `dev`). + +Czyli: **sam upgrade do 6.1 nie kupuje wydajności, a `FETCH_PEERS` daje +mniej, niż wyglądało.** Jego wartością zostaje bycie siatką bezpieczeństwa +na relacje, których nikt nie zadeklarował, oraz `FETCH_RAISE` jako +detektor N+1 (PR #736). + +### Znalezisko poboczne + +Na każdej changelistcie admina siedzi stałe **13 zapytań** aplikacji +`dynamic_admin_columns` (8 × `dynamic_columns_modeladmin` + 5 × +`dynamic_columns_modeladmincolumn`), w 5.2 i w 6.1 jednakowo. +`FETCH_PEERS` tego nie rusza. Przy changelistcie autorów to prawie połowa +z 27 zapytań — jedno miejsce, zysk na wszystkich changelistach. + +### PUŁAPKA, której nie było w oryginalnym handoffie + +`bench.py` ustawia porty kontenerów przez `os.environ.setdefault()`: + +```python +os.environ.setdefault("DJANGO_BPP_DB_PORT", "55432") +os.environ.setdefault("DJANGO_BPP_REDIS_PORT", "55379") +``` + +`setdefault` **nie nadpisuje** zmiennej już ustawionej. Jeśli profil +shella eksportuje `DJANGO_BPP_DB_PORT=5432` / `DJANGO_BPP_REDIS_PORT=6379` +(typowe przy dev-owym `docker compose`), benchmark **po cichu uderza +w dev-owe kontenery** zamiast w odtworzony dump. Bez błędu, bez +ostrzeżenia, z wiarygodnie wyglądającym wynikiem. To samo dotyczy +`manage.py migrate` uruchomionego z tym settings-modułem — domigruje +cudzą bazę. + +Kontrola: przed pomiarem wypisz `settings.DATABASES["default"]["PORT"]` — +ma być `55432`. Druga kontrola: liczby zapytań muszą odtworzyć `514`, +`190`, `220`, `66` (przed) i `3`, `34`, `36`, `17` (po); na dev-owej bazie +się nie odtworzą. Obejście: **eksportuj** porty zamiast polegać na +`setdefault`. + +--- + +## Co jest ZROBIONE (nie powtarzaj) + +Audyt Django 6.1 pod kątem wydajności + cztery naprawione defekty. +Pełny raport: `AUDYT-DJANGO-6.1-WYDAJNOSC.md` (nietrackowany, w korzeniu +worktree `bpp-django-6.1`). + +### Ustalenia, które są już pewne + +1. **Sam upgrade do Django 6.1 nie przyspiesza niczego.** Liczby zapytań + bit w bit identyczne jak na 5.2 we wszystkich 15 scenariuszach, w obu + wariantach konfiguracji cache. Zero regresji — i zero zysku. +2. Zysk wymaga jawnego opt-inu w *fetch modes* + (`QuerySet.fetch_mode(FETCH_PEERS)`). +3. Najcenniejsze w 6.1 jest `FETCH_RAISE`/własny `FetchMode` jako + **detektor N+1**. Tym mechanizmem znalazłem cztery defekty, których nie + widać w kodzie. + +### Wdrożone poprawki + +Na `dev` (Django 5.2, wypchnięte): + +| commit | co | +|---|---| +| `f509c7bdc` | filtr „Wydział" na `/admin/bpp/autor/` listował **504 jednostki zamiast 7** (bug UI, nie tylko koszt) | +| `5ffc83f50` | `JednostkaFilter` bez `select_related("uczelnia")`; `LogEntryFilterBase.only()` bez `last_name`/`first_name` | +| `6416e2ac0` | indeks jednostek: `.count()` na relacji odwrotnej 3–4× na wiersz → `annotate()` | + +Na `django-6.1`: + +| commit | co | +|---|---| +| `ccb9756ff` | PR #733 (squash): `FETCH_PEERS` w `BaseBppAdminMixin` — **wymaga 6.1** | + +### Zmierzone liczby zapytań (pewne, powtórzone w 5 przebiegach) + +Konfiguracja z produkcyjnymi regułami `CACHEOPS`: + +``` +scenariusz przed po +publiczne: lista jednostek 514 3 +admin: wyd. ciągłe (changelist) 190 34 +admin: wyd. zwarte (changelist) 220 36 +admin: jednostka (changelist) 66 17 +admin: autor (changelist) 27 27 (spadły round-tripy do Redisa, nie SQL) +admin: źródło (changelist) 16 16 ← KONTROLER, nietknięty +pozostałe 9 scenariuszy bez zmian +``` + +### Testy + +* `django-6.1` (po mergu #733): `1016 passed, 1 skipped` +* `dev` (Django 5.2): `1012 passed, 1 skipped` + (różnica = 4 testy `test_fetch_peers.py`, które żyją tylko na 6.1) + +Zakres: `src/bpp/tests/test_admin/` + `src/bpp/tests/test_views/`. + +--- + +## Co ZOSTAŁO do zrobienia + +**Jedno zadanie: zmierzyć czasy odpowiedzi na cichym hoście.** + +Dotychczasowe pomiary czasu są niewiarygodne i **nie wolno ich cytować jako +wyniku**. Dowód: scenariusz kontrolny `admin: źródło (changelist)` ma te +same 16 zapytań przed i po wszystkich zmianach (żadna go nie dotyka), a +zmierzony czas skakał **320 → 420 ms**. Skoro niezmieniona ścieżka kodu daje ++100 ms, to różnice czasowe pochodzą z obciążenia hosta, nie z kodu. Prawie +każdy pomiar dostawał flagę `SZUM` (odchylenie > 10% mediany). + +Liczby czasowe, które trafiły do wiadomości commitów i do opisu PR #733 +(np. „185 → 120 ms"), pochodzą ze spokojniejszego okna i są **rzędem +wielkości, nie pomiarem** — tak też je tam opisałem. Po czystym pomiarze +warto je zastąpić albo potwierdzić. + +### Kryterium sukcesu + +Dla każdego scenariusza podać medianę i odchylenie z >= 25 powtórzeń, przy +czym: + +* **kontroler `admin: źródło (changelist)` musi wypaść stabilnie** (rozrzut + < 10% mediany, brak flagi `SZUM`) — inaczej cały przebieg jest do + wyrzucenia, +* różnicę uznajemy za realną tylko wtedy, gdy jest **większa niż rozrzut + kontrolera**. + +--- + +## Warunki wstępne na drugim komputerze + +1. **Docker** (testcontainers + kontenery benchmarkowe). +2. **Cichy host** — żadnych równoległych suit testowych, `run-site` ani + innych sesji agenta. To jest cały powód tego handoffu. +3. **Dump bazy produkcyjnej.** To jedyna rzecz, której NIE ma w repo: + `db-backup-20260603-023000.tar.gz`, ~772 MB, leży na + `/Volumes/SSD/` na `mac-mini` (symlink z `~`). Trzeba go przekopiować, + np. przez udział SMB `mpasternak` albo `rsync`. Bez niego nie ma pomiaru + — świeża baza nie pokaże N+1 (ma pojedyncze wiersze zamiast 504 + jednostek i 68 tys. autorów). + Alternatywa: mniejszy `db-backup-20260428-093811.pg_dump` (~48 MB), + ale ma inny wolumen danych, więc liczby nie będą porównywalne z tymi + wyżej. + +--- + +## Procedura + +Cała mechanika jest opisana w `docs/deweloper/benchmark-orm.md` — poniżej +skrót właściwy dla tego zadania. + +```bash +git clone git@github.com:iplweb/bpp.git && cd bpp +git checkout django-6.1 +uv sync + +# 1. Kontenery (nietypowe porty, żeby nie kolidować z niczym) +docker run -d --name bpp-bench-pg -p 55432:5432 \ + -e POSTGRES_USER=bpp -e POSTGRES_PASSWORD=password -e POSTGRES_DB=bpp \ + --shm-size=1g iplweb/bpp_dbserver:psql-16.13 \ + -c shared_buffers=1GB -c work_mem=64MB -c maintenance_work_mem=512MB +docker run -d --name bpp-bench-redis -p 55379:6379 redis:7-alpine + +# 2. Baza (dump to format KATALOGOWY pg_dump -Fd w tarze, nie .sql!) +gzip -dc db-backup-20260603-023000.tar.gz | tar -xf - +PGPASSWORD=password pg_restore -h localhost -p 55432 -U bpp -d bpp \ + --no-owner --no-privileges --no-comments -j 6 db-backup-20260603-023000/ +DJANGO_SETTINGS_MODULE=django_bpp.settings.bench \ + uv run python src/manage.py migrate --noinput + +# 3. Pomiar PO zmianach (czubek django-6.1) +docker exec bpp-bench-redis redis-cli -n 7 FLUSHDB +DJANGO_SETTINGS_MODULE=django_bpp.settings.bench_prod \ + uv run python bench/bench_orm.py --pomiar --powtorzenia 25 > /tmp/po.txt + +# 4. Pomiar PRZED zmianami — commit-rodzic squasha #733. +# Harness trzeba przenieść, bo w ee5e81a35 jeszcze nie istniał: +git checkout ee5e81a35 +git checkout django-6.1 -- bench/ src/django_bpp/settings/bench.py \ + src/django_bpp/settings/bench_prod.py +docker exec bpp-bench-redis redis-cli -n 7 FLUSHDB +DJANGO_SETTINGS_MODULE=django_bpp.settings.bench_prod \ + uv run python bench/bench_orm.py --pomiar --powtorzenia 25 > /tmp/przed.txt +git checkout . && git checkout django-6.1 +``` + +**UWAGA na `ee5e81a35`:** to stan przed CAŁĄ pracą wydajnościową na linii +6.1, czyli bez `FETCH_PEERS` — ale też bez poprawek z `dev` (A/C/D/B), +bo #733 wszedł jako squash i wciągnął je razem ze sobą. Czyli para +`ee5e81a35` vs `ccb9756ff` mierzy **efekt łączny wszystkich czterech +poprawek + FETCH_PEERS**, co jest dokładnie tym, co chcemy pokazać. + +Jeśli chcesz rozbić wkład `FETCH_PEERS` osobno od poprawek A/C/D/B, użyj +`--tryb peers` na czubku `dev` (Django 5.2 nie ma fetch modes, więc to +trzeba zrobić na 6.1 — zob. „Ograniczenia" w dokumentacji harnessu). + +### Czyszczenie po pomiarze + +```bash +docker rm -f bpp-bench-pg bpp-bench-redis +``` + +--- + +## Pułapki, w które już wpadłem (nie powtarzaj) + +1. **`grep -c` bez mianownika kłamie.** `grep -c "^\[ \]"` zwrócił `0` + („brak zaległych migracji"), a komenda po prostu **wywaliła się + tracebackiem** i nie wypisała nic. Zawsze licz też mianownik. +2. **`cmd > log 2>&1; echo "exit=$?"` w potoku mierzy exit ostatniego + członu.** Dwa razy uwierzyłem w „exit 0", gdy `migrate` padał. + Zapisuj kod wyjścia JAWNIE do pliku z logiem. +3. **`pytest ... | tail -25` gubi podsumowanie**, bo logi zamykanych + kontenerów testcontainers wychodzą PO nim. Zapisuj pełny output do + pliku i grepuj. +4. **Cacheops pikluje instancje modeli.** Pomiar 6.1 czytający wpisy + zapisane przez 5.2 daje `RuntimeWarning` i nieważne wyniki. `FLUSHDB` + przy każdej zmianie wersji. +5. **`constance` odrzuca LocMemCache** (`ImproperlyConfigured`) — dlatego + `bench.py` trzyma `constance_cache` na Redisie. +6. **Nie wyrzucaj `easyaudit` z `INSTALLED_APPS`** — `bpp.0470` deklaruje + zależność migracji od `easyaudit.0001_initial`, więc graf migracji się + nie zbuduje. Wystarczy zgasić middleware i hooki sygnałów (tak robi + `bench.py`). +7. **Nie ubijaj kontenerów wzorcem na współdzielonym hoście.** Przy + sprzątaniu dwa razy trafiły mi na listę „sierot" CUDZE, żywe przebiegi + pytest — bo w trakcie detekcji startował kolejny Ryuk. Selekcjonuj po + nazwie/ID i wypisz listę PRZED usunięciem. + +--- + +## Stan repozytorium + +* `dev` — wypchnięte, `6416e2ac0` +* `django-6.1` — wypchnięte, `ccb9756ff` (+ ten handoff i `bench/`) +* PR #733 — **zmergowany** +* PR #731 (Django 6.1 → dev) — otwarty +* `AUDYT-DJANGO-6.1-WYDAJNOSC.md` — nietrackowany, tylko na `mac-mini`; + jeśli potrzebny na drugiej maszynie, skopiuj razem z dumpem diff --git a/bench/bench_atrybucja.py b/bench/bench_atrybucja.py new file mode 100644 index 000000000..4bc56837c --- /dev/null +++ b/bench/bench_atrybucja.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python +"""Rozbij leniwe pobrania JEDNEGO pola na konkretne miejsca w kodzie. + +``bench_orm.py --inwentarz`` pokazuje pierwsze miejsce dla danej pary +(model, pole). Gdy licznik nie zgadza się z liczbą wierszy na stronie +(np. 1058 pobrań ``Jednostka.uczelnia`` przy 504 jednostkach w bazie), +trzeba wiedzieć, ILE pobrań przychodzi z KTÓREJ ścieżki wywołania — bo +inaczej opowiadamy o mechanizmie, którego nie sprawdziliśmy. + +Ten skrypt liczy pobrania per *podpis stosu* zredukowany do ramek kodu +projektu — czyli „kto to wywołał", a nie tylko „gdzie pękło". + +Użycie:: + + uv run python bench_atrybucja.py [Model.pole] [etykieta scenariusza] + +Domyślnie ``Jednostka.uczelnia`` na ``admin: autor (changelist)``. Działa +zarówno dla relacji (``fetcher`` = deskryptor FK), jak i dla pól +odroczonych (``fetcher`` = ``DeferredAttribute``) — oba mają ``.field``. +""" + +from __future__ import annotations + +import collections +import os +import sys +import traceback + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_bpp.settings.bench") + +import django # noqa: E402 + +django.setup() + +from bench_orm import ( # noqa: E402 + KATALOG_PROJEKTU, + odkryj_scenariusze, + ustaw_tryb_globalnie, +) +from django.db.models.fetch_modes import FetchMode # noqa: E402 + +CEL = tuple((sys.argv[1] if len(sys.argv) > 1 else "Jednostka.uczelnia").split(".")) +SCENARIUSZ = sys.argv[2] if len(sys.argv) > 2 else "admin: autor (changelist)" + + +def podpis_stosu(pomin=3, ile_ramek=4): + """Do ``ile_ramek`` najgłębszych ramek kodu projektu, od najgłębszej.""" + ramki = [] + for ramka, linia in traceback.walk_stack(sys._getframe(pomin)): + plik = ramka.f_code.co_filename + if plik.startswith(KATALOG_PROJEKTU): + ramki.append( + f"{os.path.relpath(plik, KATALOG_PROJEKTU)}:{linia}" + f" ({ramka.f_code.co_name})" + ) + if len(ramki) >= ile_ramek: + break + return " ← ".join(ramki) or "(brak ramek projektu)" + + +class FetchAtrybucja(FetchMode): + track_peers = False + + def __init__(self): + self.per_stos = collections.Counter() + self.suma = 0 + + def fetch(self, fetcher, instance): + if (type(instance).__name__, fetcher.field.name) == CEL: + self.suma += 1 + self.per_stos[podpis_stosu()] += 1 + fetcher.fetch_one(instance) + + +def main(): + szpieg = FetchAtrybucja() + ustaw_tryb_globalnie(szpieg) + + for etykieta, fn in odkryj_scenariusze(): + if etykieta != SCENARIUSZ: + continue + odp = fn() + print(f"\n# {SCENARIUSZ} (HTTP {odp.status_code})") + print(f"# leniwych pobrań {CEL[0]}.{CEL[1]}: {szpieg.suma}\n") + for stos, ile in szpieg.per_stos.most_common(10): + print(f"{ile:>6}× {stos}") + return 0 + print(f"Nie znalazłem scenariusza {SCENARIUSZ!r}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bench/bench_licznik_wywolan.py b/bench/bench_licznik_wywolan.py new file mode 100644 index 000000000..bfaaea8a8 --- /dev/null +++ b/bench/bench_licznik_wywolan.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python +"""Policz, ile RAZY na jeden request admina powstaje ChangeList i lookupy filtra. + +Atrybucja (``bench_atrybucja.py``) pokazała 504 + 504 pobrań +``Jednostka.uczelnia``, czyli enumerację wszystkich jednostek DWA razy na +request — mimo istnienia ``PerRequestChangelistInstanceMixin``, który ma +memoizować ``ChangeList`` w obrębie żądania. + +Zamiast wnioskować ze stosów, liczymy wprost: + +* ile razy woła się ``JednostkaFilter.lookups`` i ile razy jego generator + faktycznie zostaje skonsumowany (to dwie różne rzeczy — ``lookups`` + zwraca generator expression, więc samo wywołanie nic nie odpytuje), +* ile razy ``get_changelist_instance`` trafia w cache, a ile razy pudłuje. +""" + +from __future__ import annotations + +import os +import sys + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_bpp.settings.bench") + +import django # noqa: E402 + +django.setup() + +from bench_orm import odkryj_scenariusze # noqa: E402 + +LICZNIKI = { + "lookups_wywolan": 0, + "lookups_skonsumowanych": 0, + "cl_pudel": 0, + "cl_trafien": 0, +} + + +def zainstaluj_liczniki(): + from bpp.admin.autor import AutorAdmin + from bpp.admin.filters import JednostkaFilter + from bpp.admin.xlsx_export.mixins import PerRequestChangelistInstanceMixin + + oryginalne_lookups = JednostkaFilter.lookups + + def lookups_z_licznikiem(self, request, model_admin): + LICZNIKI["lookups_wywolan"] += 1 + wynik = oryginalne_lookups(self, request, model_admin) + + def opakowany(): + LICZNIKI["lookups_skonsumowanych"] += 1 + yield from wynik + + return opakowany() + + JednostkaFilter.lookups = lookups_z_licznikiem + + oryginalne_gci = PerRequestChangelistInstanceMixin.get_changelist_instance + + def gci_z_licznikiem(self, request): + cache = getattr(request, "_bpp_changelist_instance_cache", None) + if cache is not None and id(self) in cache: + LICZNIKI["cl_trafien"] += 1 + else: + LICZNIKI["cl_pudel"] += 1 + return oryginalne_gci(self, request) + + PerRequestChangelistInstanceMixin.get_changelist_instance = gci_z_licznikiem + + return AutorAdmin + + +def main(): + zainstaluj_liczniki() + for etykieta, fn in odkryj_scenariusze(): + if etykieta != "admin: autor (changelist)": + continue + odp = fn() + print(f"\n# {etykieta} (HTTP {odp.status_code})\n") + for nazwa, ile in LICZNIKI.items(): + print(f" {nazwa:28} {ile}") + print( + "\n (504 jednostki w bazie × liczba skonsumowanych generatorów\n" + " = liczba pobrań Jednostka.uczelnia z filtra)" + ) + return 0 + print("Nie znalazłem scenariusza") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bench/bench_orm.py b/bench/bench_orm.py new file mode 100644 index 000000000..1291fe7ba --- /dev/null +++ b/bench/bench_orm.py @@ -0,0 +1,398 @@ +#!/usr/bin/env python +"""Benchmark ORM-a BPP: ile zapytań i ile czasu, Django 5.2 vs 6.1. + +Po co: Django 6.1 wprowadziło *fetch modes* +(``QuerySet.fetch_mode(FETCH_ONE | FETCH_PEERS | FETCH_RAISE)``). +``FETCH_PEERS`` sprawia, że pierwsze leniwe dotknięcie relacji na obiekcie +z querysetu dociąga ją **hurtem dla całego rodzeństwa** z tego samego +pobrania (``prefetch_related_objects`` pod spodem), czyli zamienia N+1 na +2 zapytania — bez deklarowania ``select_related`` z góry. + +Ten skrypt robi trzy rzeczy, w tej kolejności: + +1. ``--inwentarz`` (tylko 6.1) — podstawia *szpiegujący* ``FetchMode``, + który zachowuje się jak ``FETCH_ONE``, ale zapisuje każde leniwe + pobranie: model, pole, licznik i najbliższą ramkę kodu projektu. + To jest KROK PROFILOWANIA — inwentarz N+1 mierzony, nie zgadywany. +2. ``--pomiar`` — dla każdego scenariusza liczy zapytania (przez + ``CaptureQueriesContext``) i czas ściany, medianą z N przebiegów. +3. ``--tryb peers`` (tylko 6.1) — to samo, ale z globalnie podstawionym + ``FETCH_PEERS``, żeby zmierzyć SUFIT wygranej na całej aplikacji bez + dotykania 50 miejsc w kodzie. + +Uczciwość pomiaru (Gate 5 skilla python-performance): +* Ten sam Python (3.13), ta sama baza (kopia produkcji), ten sam kod + scenariuszy — jedyna różnica to wersja Django. +* ``CACHES["default"]`` = DummyCache, więc cache aplikacji nie schowa + pracy bazy ani po jednej, ani po drugiej stronie. +* Pierwszy przebieg każdego scenariusza jest ROZGRZEWKĄ i nie wchodzi + do statystyki — inaczej mierzylibyśmy zimny bufor PG i import modułów. +* Raportujemy medianę i rozrzut; przy rozrzucie > 10% mediany wynik jest + szumem i jest tak oznaczany. +""" + +from __future__ import annotations + +import argparse +import collections +import os +import statistics +import sys +import time +import traceback + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_bpp.settings.bench") + +import django # noqa: E402 + +django.setup() + +from django.db import connection, reset_queries # noqa: E402 +from django.test import Client # noqa: E402 +from django.test.utils import CaptureQueriesContext # noqa: E402 +from django.urls import NoReverseMatch, reverse # noqa: E402 + +WERSJA_DJANGO = django.get_version() +MA_FETCH_MODES = WERSJA_DJANGO >= "6.1" + +# Skrypty leżą w ``bench/``, a kod projektu w ``src/`` — o jeden poziom wyżej. +# Ta ścieżka służy do rozpoznawania, które ramki stosu należą do BPP +# (atrybucja leniwych pobrań), więc musi wskazywać na ``/src``. +KATALOG_PROJEKTU = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src" +) + + +# --------------------------------------------------------------------------- +# Szpieg leniwych pobrań (inwentarz N+1). Tylko Django >= 6.1. +# --------------------------------------------------------------------------- + + +def zbuduj_szpiega(): + """Zwróć ``FetchMode``, który liczy leniwe pobrania, ale ich nie blokuje. + + Dlaczego nie ``FETCH_RAISE``: on wywala się na PIERWSZYM leniwym + pobraniu, więc jeden przebieg pokazuje jedno miejsce i chowa resztę. + Szpieg przepuszcza pobranie dalej (``fetch_one``), więc jeden request + wypluwa CAŁY inwentarz naraz. + """ + from django.db.models.fetch_modes import FetchMode + + class FetchSzpieg(FetchMode): + # Bez ``track_peers`` — udajemy dokładnie zachowanie Django 5.2. + track_peers = False + + def __init__(self): + self.trafienia = collections.Counter() + self.miejsca = {} + + def fetch(self, fetcher, instance): + klucz = (type(instance).__name__, fetcher.field.name) + self.trafienia[klucz] += 1 + if klucz not in self.miejsca: + self.miejsca[klucz] = _najblizsza_ramka_projektu() + fetcher.fetch_one(instance) + + return FetchSzpieg() + + +def _najblizsza_ramka_projektu(): + """``plik:linia`` najgłębszej ramki należącej do kodu BPP. + + Leniwe pobranie wywołane z szablonu ma na stosie wyłącznie ramki + Django (``template/base.py`` itd.) — wtedy zwracamy najgłębszą ramkę + szablonową z nazwą szablonu, jeśli da się ją wyłuskać z locals. + """ + najlepsza = None + for ramka, linia in traceback.walk_stack(sys._getframe(2)): + plik = ramka.f_code.co_filename + if plik.startswith(KATALOG_PROJEKTU): + return f"{os.path.relpath(plik, KATALOG_PROJEKTU)}:{linia}" + if najlepsza is None and "django/template" in plik: + szablon = _nazwa_szablonu_z_ramki(ramka) + if szablon: + najlepsza = f"szablon {szablon}" + return najlepsza or "(tylko ramki Django)" + + +def _nazwa_szablonu_z_ramki(ramka): + for nazwa in ("self", "context"): + obj = ramka.f_locals.get(nazwa) + origin = getattr(obj, "origin", None) or getattr( + getattr(obj, "template", None), "origin", None + ) + nazwa_szablonu = getattr(origin, "template_name", None) + if nazwa_szablonu: + return nazwa_szablonu + return None + + +def ustaw_tryb_globalnie(tryb): + """Podstaw ``tryb`` jako domyślny ``FetchMode`` dla CAŁEJ aplikacji. + + Dwa miejsca, bo Django czyta domyślny tryb z dwóch niezależnych + źródeł: + + * ``query.DEFAULT_FETCH_MODE`` — czytane w ``QuerySet.__init__``, więc + dotyczy obiektów pochodzących z querysetów (czyli praktycznie + wszystkiego, co widzi widok), + * ``base.FETCH_ONE`` — domyślna wartość ``ModelState.fetch_mode`` dla + instancji powstałych POZA querysetem (``Model(...)``). + + Podstawienie ``FETCH_PEERS`` w drugim miejscu jest bezpieczne, bo + ``ModelState.peers`` ma klasowy default ``()``, a ``FetchPeers.fetch`` + przy pustym rodzeństwie degraduje do ``fetch_one``. + """ + from django.db.models import base as models_base + from django.db.models import query as models_query + + models_query.DEFAULT_FETCH_MODE = tryb + models_base.FETCH_ONE = tryb + + +# --------------------------------------------------------------------------- +# Scenariusze — prawdziwe requesty na prawdziwych danych. +# --------------------------------------------------------------------------- + + +def _url_bpp(nazwa, **kwargs): + """``reverse`` w przestrzeni ``bpp:``, albo ``None`` gdy trasy nie ma.""" + try: + return reverse(f"bpp:{nazwa}", kwargs=kwargs) + except NoReverseMatch: + return None + + +def _scenariusze_publiczne(klient): + """Publiczne strony przeglądania, na identyfikatorach WZIĘTYCH Z BAZY. + + Identyfikatory z palca dałyby 404 i mierzylibyśmy obsługę błędu. + Autora wybieramy tego z NAJWIĘKSZĄ liczbą publikacji — najostrzejszy + przypadek dla N+1 na podstronie autora. + """ + from django.db.models import Count + + from bpp.models import Autor, Jednostka, Rekord, Zrodlo + + autor = ( + Autor.objects.filter(pokazuj=True) + .annotate(ile=Count("wydawnictwo_ciagle")) + .order_by("-ile") + .first() + ) + jednostka = Jednostka.objects.filter(widoczna=True).first() + zrodlo = Zrodlo.objects.first() + rekord = Rekord.objects.exclude(rok=None).order_by("-rok").first() + rok = rekord.rok if rekord else 2024 + + pozycje = [ + ("publiczne: lista autorów", _url_bpp("browse_autorzy")), + ("publiczne: lista jednostek", _url_bpp("browse_jednostki")), + ("publiczne: lista źródeł", _url_bpp("browse_zrodla")), + ("publiczne: lista lat", _url_bpp("browse_lata")), + ("publiczne: rekordy z roku", _url_bpp("browse_rok", rok=rok)), + ] + if autor: + pozycje.append( + ("publiczne: autor (szczegóły)", _url_bpp("browse_autor", pk=autor.pk)) + ) + if jednostka: + pozycje.append( + ( + "publiczne: jednostka (szczegóły)", + _url_bpp("browse_jednostka", slug=jednostka.slug), + ) + ) + if zrodlo: + pozycje.append( + ( + "publiczne: źródło (szczegóły)", + _url_bpp("browse_zrodlo", slug=zrodlo.slug), + ) + ) + return [(e, u, klient) for e, u in pozycje] + + +def _klient_admina(): + """Zalogowany klient na DEDYKOWANYM koncie benchmarkowym. + + Świadomie NIE logujemy się na istniejące konto z dumpu produkcyjnego — + to prawdziwe dane osobowe, a benchmark nie ma powodu zostawiać śladu + w ``request.user`` cudzego konta. + """ + from django.contrib.auth import get_user_model + + Uzytkownik = get_user_model() + konto, _ = Uzytkownik.objects.get_or_create( + username="__bench_admin__", + defaults={"is_staff": True, "is_superuser": True, "is_active": True}, + ) + if not (konto.is_staff and konto.is_superuser): + konto.is_staff = konto.is_superuser = True + konto.save() + + klient = Client() + klient.force_login(konto) + return klient + + +def _scenariusze_z_nazw_tras(pary, klient): + """``(etykieta, nazwa_trasy)`` → pozycje scenariuszy, pomijając brakujące.""" + pozycje = [] + for etykieta, nazwa in pary: + try: + pozycje.append((etykieta, reverse(nazwa), klient)) + except NoReverseMatch: + continue + return pozycje + + +def odkryj_scenariusze(): + """Zbuduj listę ``(etykieta, callable)`` z danych, które SĄ w bazie.""" + anonim = Client() + admin = _klient_admina() + + pozycje = [ + *_scenariusze_publiczne(anonim), + # changelisty admina to klasyczne siedlisko N+1 (list_display po FK) + *_scenariusze_z_nazw_tras( + ( + ( + "admin: wyd. ciągłe (changelist)", + "admin:bpp_wydawnictwo_ciagle_changelist", + ), + ( + "admin: wyd. zwarte (changelist)", + "admin:bpp_wydawnictwo_zwarte_changelist", + ), + ("admin: autor (changelist)", "admin:bpp_autor_changelist"), + ("admin: źródło (changelist)", "admin:bpp_zrodlo_changelist"), + ("admin: jednostka (changelist)", "admin:bpp_jednostka_changelist"), + ), + admin, + ), + # REST API: serializery DRF chodzą po FK per obiekt + *_scenariusze_z_nazw_tras( + ( + ("api_v1: rekordy", "api_v1:rekord-list"), + ("api_v1: autorzy", "api_v1:autor-list"), + ("api_v1: źródła", "api_v1:zrodlo-list"), + ), + anonim, + ), + ] + + return [ + (etykieta, lambda u=url, k=klient: k.get(u)) + for etykieta, url, klient in pozycje + if url + ] + + +# --------------------------------------------------------------------------- +# Pomiar +# --------------------------------------------------------------------------- + + +def zmierz(fn, powtorzenia): + """Zwróć ``(mediana_ms, rozrzut_ms, zapytania, kod_http)``. + + Pierwszy przebieg to rozgrzewka — odrzucany. Bez tego mierzylibyśmy + zimne bufory PostgreSQL i leniwe importy Django, a nie pracę ORM-a. + """ + odpowiedz = fn() + kod = getattr(odpowiedz, "status_code", None) + + reset_queries() + with CaptureQueriesContext(connection) as ctx: + fn() + zapytania = len(ctx.captured_queries) + + czasy = [] + for _ in range(powtorzenia): + start = time.perf_counter() + fn() + czasy.append((time.perf_counter() - start) * 1000) + + mediana = statistics.median(czasy) + rozrzut = statistics.stdev(czasy) if len(czasy) > 1 else 0.0 + return mediana, rozrzut, zapytania, kod + + +def uruchom_pomiar(powtorzenia, tryb): + if tryb == "peers": + if not MA_FETCH_MODES: + sys.exit("--tryb peers wymaga Django >= 6.1") + from django.db.models import FETCH_PEERS + + ustaw_tryb_globalnie(FETCH_PEERS) + + scenariusze = odkryj_scenariusze() + print(f"\n# Django {WERSJA_DJANGO}, tryb={tryb}, powtórzeń={powtorzenia}\n") + print( + f"{'scenariusz':38} {'zapytań':>8} {'ms (mediana)':>13} {'±ms':>7} {'HTTP':>5}" + ) + print("-" * 76) + wyniki = {} + for etykieta, fn in scenariusze: + try: + mediana, rozrzut, zapytania, kod = zmierz(fn, powtorzenia) + except Exception as exc: # noqa: BLE001 - raportujemy i lecimy dalej + print(f"{etykieta:38} {'BŁĄD':>8} {type(exc).__name__}: {exc}"[:120]) + continue + flaga = " SZUM" if mediana and rozrzut > 0.10 * mediana else "" + print( + f"{etykieta:38} {zapytania:>8} {mediana:>13.1f} {rozrzut:>7.1f} " + f"{kod:>5}{flaga}" + ) + wyniki[etykieta] = {"zapytania": zapytania, "ms": mediana, "http": kod} + return wyniki + + +def uruchom_inwentarz(): + if not MA_FETCH_MODES: + sys.exit("--inwentarz wymaga Django >= 6.1 (fetch modes)") + + szpieg = zbuduj_szpiega() + ustaw_tryb_globalnie(szpieg) + + scenariusze = odkryj_scenariusze() + print(f"\n# INWENTARZ leniwych pobrań — Django {WERSJA_DJANGO}\n") + for etykieta, fn in scenariusze: + szpieg.trafienia.clear() + szpieg.miejsca.clear() + try: + odpowiedz = fn() + except Exception as exc: # noqa: BLE001 + print(f"\n## {etykieta}\n BŁĄD: {type(exc).__name__}: {exc}"[:200]) + continue + kod = getattr(odpowiedz, "status_code", "?") + suma = sum(szpieg.trafienia.values()) + print(f"\n## {etykieta} (HTTP {kod}) — leniwych pobrań: {suma}") + if not suma: + print( + " (brak — wszystko przez select_related/prefetch albo kolumny lokalne)" + ) + continue + for (model, pole), ile in szpieg.trafienia.most_common(12): + print(f" {ile:>6}× {model}.{pole:<28} @ {szpieg.miejsca[(model, pole)]}") + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--inwentarz", action="store_true", help="wykryj miejsca N+1 (6.1)") + p.add_argument("--pomiar", action="store_true", help="zmierz zapytania i czas") + p.add_argument("--tryb", choices=("one", "peers"), default="one") + p.add_argument("--powtorzenia", type=int, default=5) + args = p.parse_args() + + if not (args.inwentarz or args.pomiar): + p.error("podaj --inwentarz albo --pomiar") + if args.inwentarz: + uruchom_inwentarz() + if args.pomiar: + uruchom_pomiar(args.powtorzenia, args.tryb) + + +if __name__ == "__main__": + main() diff --git a/bench/bench_rownowaznosc.py b/bench/bench_rownowaznosc.py new file mode 100644 index 000000000..768f7832f --- /dev/null +++ b/bench/bench_rownowaznosc.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python +"""Sprawdź, czy ``FETCH_PEERS`` NIE zmienia tego, co strona zwraca. + +Sam spadek liczby zapytań nic nie znaczy, dopóki nie wiadomo, że wynik +jest ten sam — „szybciej, ale inaczej" to nie optymalizacja, to regresja. +Dlatego dla każdego scenariusza pobieramy odpowiedź DWA razy w tym samym +procesie: raz w ``FETCH_ONE`` (zachowanie Django 5.2), raz w +``FETCH_PEERS`` — i porównujemy bajt w bajt. + +Kolejność jest ustalona (ONE, potem PEERS) i sprawdzamy też ONE↔ONE, żeby +odsiać strony niedeterministyczne z natury (CSRF token, znacznik czasu, +losowa kolejność) — inaczej ich niestabilność wyglądałaby jak wina +``FETCH_PEERS``. +""" + +from __future__ import annotations + +import os +import re +import sys + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_bpp.settings.bench") + +import django # noqa: E402 + +django.setup() + +from bench_orm import odkryj_scenariusze, ustaw_tryb_globalnie # noqa: E402 +from django.db.models import FETCH_ONE, FETCH_PEERS # noqa: E402 + +# Elementy zmienne PER REQUEST, niezależne od trybu pobierania. Każdy z nich +# został wskazany empirycznie — porównaniem dwóch odpowiedzi w tym SAMYM +# trybie (FETCH_ONE↔FETCH_ONE), więc żaden nie maskuje różnicy, którą mógłby +# wprowadzić FETCH_PEERS. Bez tej normalizacji strony admina są nieporównywalne. +WZORCE_ZMIENNE = [ + # token CSRF: pole formularza, nagłówek XHR, querystring, literał JS + (re.compile(rb'name="csrfmiddlewaretoken" value="[^"]*"'), b"CSRF-POLE"), + (re.compile(rb'X-CSRFToken", "[^"]*"'), b"CSRF-XHR"), + (re.compile(rb"csrfmiddlewaretoken=[A-Za-z0-9]+"), b"CSRF-QS"), + (re.compile(rb"csrfmiddlewaretoken: '[^']*'"), b"CSRF-JS"), + # znacznik czasu generacji strony w stopce BPP + (re.compile(rb"wygenerowano dnia [^,]+,"), b"CZAS,"), +] + + +def znormalizuj(tresc): + for wzorzec, zamiennik in WZORCE_ZMIENNE: + tresc = wzorzec.sub(zamiennik, tresc) + return tresc + + +def pobierz(fn, tryb): + ustaw_tryb_globalnie(tryb) + odp = fn() + return getattr(odp, "status_code", None), znormalizuj(getattr(odp, "content", b"")) + + +def main(): + scenariusze = odkryj_scenariusze() + print( + f"\n# RÓWNOWAŻNOŚĆ wyniku: FETCH_ONE vs FETCH_PEERS (Django {django.get_version()})\n" + ) + print(f"{'scenariusz':38} {'HTTP':>5} {'bajtów':>9} wynik") + print("-" * 84) + + ilezle = 0 + for etykieta, fn in scenariusze: + try: + kod_a, tresc_a = pobierz(fn, FETCH_ONE) + kod_a2, tresc_a2 = pobierz(fn, FETCH_ONE) + kod_b, tresc_b = pobierz(fn, FETCH_PEERS) + except Exception as exc: # noqa: BLE001 - raportujemy i lecimy dalej + print( + f"{etykieta:38} {'—':>5} {'—':>9} BŁĄD {type(exc).__name__}: {exc}"[ + :150 + ] + ) + ilezle += 1 + continue + + stabilna = tresc_a == tresc_a2 + zgodna = tresc_a == tresc_b + + if not stabilna: + # Strona sama z siebie nie jest deterministyczna — porównanie + # z PEERS nic nie powie. Porównujemy wtedy tylko długość i kod. + werdykt = ( + "NIEDETERMINISTYCZNA (ONE≠ONE); " + f"dł. ONE={len(tresc_a)} PEERS={len(tresc_b)}, HTTP zgodny={kod_a == kod_b}" + ) + elif zgodna and kod_a == kod_b: + werdykt = "OK — identyczna" + else: + werdykt = ( + f"!! ROZJECHANA (HTTP {kod_a}→{kod_b}, {len(tresc_a)}→{len(tresc_b)} B)" + ) + ilezle += 1 + + print(f"{etykieta:38} {kod_a!s:>5} {len(tresc_a):>9} {werdykt}") + + print() + if ilezle: + print(f"UWAGA: {ilezle} scenariusz(y) NIE potwierdziło równoważności.") + return 1 + print("Wszystkie deterministyczne scenariusze: wynik identyczny w obu trybach.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/deweloper/benchmark-orm.md b/docs/deweloper/benchmark-orm.md new file mode 100644 index 000000000..b7d44a20d --- /dev/null +++ b/docs/deweloper/benchmark-orm.md @@ -0,0 +1,135 @@ +# Benchmark ORM-a na kopii bazy produkcyjnej (`bench/`) + +Harness do mierzenia liczby zapytań i czasu odpowiedzi na **prawdziwych +danych**, przez prawdziwe requesty (`django.test.Client`), a nie na +mikrobenchmarkach. Powstał przy audycie Django 6.1 (*fetch modes*) i służy +dwóm rzeczom: + +1. **wykrywaniu N+1** — mierzonemu, nie zgadywanemu, +2. **weryfikacji optymalizacji** — czy spadek liczby zapytań jest realny + i czy strona nadal zwraca to samo. + +## Warunki wstępne + +* działający Docker, +* dump bazy produkcyjnej (patrz „Baza" niżej), +* Django >= 6.1 dla `--inwentarz`, `bench_atrybucja.py` i + `bench_licznik_wywolan.py` (używają *fetch modes*). `--pomiar` działa też + na 5.2. + +## Baza + +Stawiamy **własny** kontener PostgreSQL na nietypowym porcie, żeby nie +kolidować z `docker compose up db redis` ani z `run-site`: + +```bash +docker run -d --name bpp-bench-pg -p 55432:5432 \ + -e POSTGRES_USER=bpp -e POSTGRES_PASSWORD=password -e POSTGRES_DB=bpp \ + --shm-size=1g iplweb/bpp_dbserver:psql-16.13 \ + -c shared_buffers=1GB -c work_mem=64MB -c maintenance_work_mem=512MB +docker run -d --name bpp-bench-redis -p 55379:6379 redis:7-alpine +``` + +Dump produkcyjny `db-backup-*.tar.gz` to **format katalogowy** +(`pg_dump -Fd`) zapakowany w tar — NIE `.sql` ani `.dump`, więc +`run-site --from-dump` go nie przyjmie. Rozpakuj i odtwórz przez +`pg_restore`: + +```bash +gzip -dc db-backup-20260603-023000.tar.gz | tar -xf - +PGPASSWORD=password pg_restore -h localhost -p 55432 -U bpp -d bpp \ + --no-owner --no-privileges --no-comments -j 6 db-backup-20260603-023000/ +``` + +Dwa błędy `nierozpoznany parametr konfiguracyjny "transaction_timeout"` są +normalne — `pg_restore` 18 emituje GUC z PG 17+, którego PG 16 nie zna. + +Potem doprowadź schemat do HEAD (dump bywa starszy niż gałąź): + +```bash +DJANGO_SETTINGS_MODULE=django_bpp.settings.bench \ + uv run python src/manage.py migrate --noinput +``` + +## Dwa warianty konfiguracji — używaj OBU + +| moduł settings | `CACHEOPS` | co mierzy | +|---|---|---| +| `django_bpp.settings.bench` | brak reguł (jak `local.py`) | pełną pracę bazy, bez maskowania | +| `django_bpp.settings.bench_prod` | reguły **wczytane z `production.py`** | to, co realnie zobaczy wdrożenie | + +To rozróżnienie jest krytyczne, a łatwo je przeoczyć: `local.py` **nie +definiuje `CACHEOPS` wcale**, a produkcja cache'uje `bpp.uczelnia`, +`bpp.jednostka`, `bpp.tytul` i pozostałe słowniki. Pomiar tylko na +`local.py` **zawyża** zysk — liczy jako zapytania do PostgreSQL coś, co na +produkcji jest trafieniem w Redisa. Przy audycie Django 6.1 różnica była +dramatyczna: changelist autorów pokazywał 1102 → 96 zapytań bez cacheops, +a z produkcyjnymi regułami 27 → 27, czyli **zysk zerowy**. + +`bench_prod.py` czyta reguły z `production.py` przez AST (bez wykonywania +modułu), żeby nie mierzyć własnej, rozjeżdżającej się kopii. + +## Użycie + +```bash +# inwentarz N+1 — co i gdzie dociąga się leniwie (Django >= 6.1) +uv run python bench/bench_orm.py --inwentarz + +# pomiar: liczba zapytań + czas, mediana z N przebiegów +DJANGO_SETTINGS_MODULE=django_bpp.settings.bench_prod \ + uv run python bench/bench_orm.py --pomiar --powtorzenia 25 + +# sufit wygranej z FETCH_PEERS włączonym globalnie (Django >= 6.1) +uv run python bench/bench_orm.py --pomiar --tryb peers --powtorzenia 25 + +# dowód, że FETCH_PEERS nie zmienia wyniku (porównanie bajt w bajt) +uv run python bench/bench_rownowaznosc.py + +# rozbicie pobrań JEDNEGO pola na miejsca wywołania +uv run python bench/bench_atrybucja.py Jednostka.uczelnia "admin: autor (changelist)" + +# licznik budów ChangeList / konsumpcji generatora filtra +uv run python bench/bench_licznik_wywolan.py +``` + +**Przy każdej zmianie wersji Django wyczyść cacheops**, bo pikluje +instancje modeli i 6.1 czytające wpisy zapisane przez 5.2 daje +`RuntimeWarning` oraz nieważny pomiar: + +```bash +docker exec bpp-bench-redis redis-cli -n 7 FLUSHDB +``` + +## Jak czytać wyniki (i jak nie dać się oszukać) + +**Liczby zapytań są dokładne i powtarzalne.** Na nich opieraj wnioski. + +**Czasy są wiarygodne tylko na cichym hoście.** Harness oznacza flagą +`SZUM` każdy pomiar, w którym odchylenie standardowe przekracza 10% +mediany — taki wynik jest szumem, nie pomiarem. Na maszynie, gdzie równolegle +biegną testy albo inne stacki, prawie wszystko dostanie tę flagę. + +**Scenariusz kontrolny.** `admin: źródło (changelist)` nie był dotykany +żadną z dotychczasowych optymalizacji i ma stale 16 zapytań. Jego rozrzut +między przebiegami to **zmierzona podłoga szumu hosta**: jeśli „poprawiony" +scenariusz zmienił się mniej niż kontrolny, różnica nie jest realna. Przy +audycie ten kontroler pokazał skok 320 → 420 ms na niezmienionej ścieżce +kodu — i dzięki temu od razu było widać, że cały przebieg jest do wyrzucenia. + +**Cacheops ukrywa N+1 przed licznikiem zapytań, ale nie przed zegarem.** +Podstrona jednostki miała 10 zapytań przed i po `FETCH_PEERS`, a czas spadł +77 → 54 ms: 109 pobrań `Autor.tytul` było trafieniami w Redisa, więc +licznik SQL ich nie widział. Jeśli liczba zapytań się nie zmienia, a czas +tak — szukaj round-tripów do cache'u. + +## Ograniczenia + +* pojedyncze żądania sekwencyjne, bez współbieżności — nie mierzy + zachowania pod obciążeniem ani rywalizacji o połączenia, +* `CACHES["default"]` to `DummyCache` w obu wariantach, więc własny cache + BPP (`PaginatorZeZliczeniemZCache`, `cache_publiczny`) nie maskuje pracy + bazy; na produkcji część stron jest dodatkowo cache'owana, co jeszcze + zmniejsza realny wpływ optymalizacji, +* `--tryb peers` podstawia `DEFAULT_FETCH_MODE` globalnie, czyli mierzy + SUFIT wygranej, a nie stan produkcyjny (tam `FETCH_PEERS` jest włączony + tylko w `BaseBppAdminMixin`). diff --git a/docs/deweloper/django-6.1-co-jeszcze.md b/docs/deweloper/django-6.1-co-jeszcze.md new file mode 100644 index 000000000..eb539fb73 --- /dev/null +++ b/docs/deweloper/django-6.1-co-jeszcze.md @@ -0,0 +1,180 @@ +# Django 6.1 — co jeszcze warto wziąć + +Dokument roboczy powstały przy migracji BPP z Django 5.2 LTS na 6.1 +(sierpień 2026). Zbiera to, czego migracja **nie** objęła, a co warto +rozważyć — razem z dowodami z kodu, szacunkiem kosztu i warunkami wyjścia. + +Nie jest to lista życzeń przepisana z release notes. Każda pozycja była +sprawdzona w tym repozytorium; tam, gdzie sprawdzenie wykluczyło temat, +jest to zapisane, żeby nie wracał w dyskusji. + +## Stan na wejściu + +| Zrobione | Gdzie | +|---|---| +| Migracja na Django 6.1, Python >= 3.12 | PR #731 | +| `FETCH_PEERS` w adminie (N+1 → 2 zapytania) | PR #733 | +| `delete_confirmation_max_display` | w trakcie | +| Gate regresyjny na N+1 przez `FETCH_RAISE` | w trakcie | + +## Sprawdzone i wykluczone — zero pracy + +Odhaczone, żeby nie wracały: + +- **Zmiana derywacji soli podpisanych ciasteczek.** Dotyczy wyłącznie + `set_signed_cookie`/`get_signed_cookie`, których w kodzie nie ma. + `cerif_export/oai/tokeny.py` używa gołego `signing.dumps`/`loads` + z własną solą — mechanizm nietknięty (`django/core/signing.py`, + `_cookie_signer_salt` / `_unsign_cookie`). Brak ryzyka na wdrożeniu, + brak potrzeby ustawiania `SIGNED_COOKIE_LEGACY_SALT_FALLBACK`. +- **PBKDF2 1 200 000 → 1 500 000 iteracji.** `PASSWORD_HASHERS` nie jest + nadpisane, więc wzmocnienie dziedziczy się za darmo. Hasła rehashują się + przy kolejnym logowaniu. +- **Deprecacja `ModelAdmin.list_select_related = True`.** Nie dotyczy — + 25 wystąpień, wszystkie są listami albo dictami (dialekt + `django-dynamic-admin-columns`), żadne nie jest `True`. +- **`GeneratedField` jako kolumny wirtualne.** Wymaga PostgreSQL 18, + produkcja stoi na 16. +- **`StringAgg(distinct=True)` na SQLite, nowości Oracle.** + Bezprzedmiotowe — baza to PostgreSQL. +- **Framework `Tasks` z Django.** BPP ma Celery + `django-liveops` + + `celery-singleton`; migracja nie ma uzasadnienia. + +## Kandydatury otwarte + +### 1. `MAILERS` — nie opcja, tylko termin + +**Dlaczego:** `EMAIL_BACKEND`, `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_USE_TLS` +i cała rodzina są **deprecated w 6.1 i znikają w Django 7.0**. Dodatkowo +`fail_silently` przepada **bez zamiennika** — Django nie oferuje niczego +w zamian dla obsługi wyjątków. + +**Stan w kodzie:** + +- `settings/base.py` — `EMAIL_URL` przez `django-environ` + (`env.email(...)` + `vars().update(EMAIL_CONFIG)`), +- `settings/production.py:177-183` — warunkowa podmiana `EMAIL_BACKEND` + na `djcelery_email.backends.CeleryEmailBackend` plus doklejenie + `djcelery_email` do `INSTALLED_APPS` w runtime, +- 4 użycia `fail_silently=True`: `pbn_api/client/publication_sync.py` + (496, 574, 624) i `bpp/models/abstract/pbn.py:64`. + +**Zysk poza samą zgodnością:** `MAILERS` ten kod **upraszcza**. Zamiast +warunkowej podmiany backendu w runtime wystarczy drugi alias +(`"async": {...}`) i jawne `send_mail(..., using="async")` tam, gdzie +faktycznie chcemy kolejkować. Znika magia w `production.py`. + +**Uwaga:** przepisanie `fail_silently=True` na jawny `try/except` jest +zgodne z regułą projektu zakazującą cichego łykania wyjątków — więc to +nie tylko koszt, ale i porządek. + +### 2. CSP — teraz w rdzeniu Django, więc znacznie taniej + +**Dlaczego:** BPP nie ma dziś **żadnego** Content-Security-Policy. Django +6.1 daje to w rdzeniu, bez dodatkowej paczki: +`django.middleware.csp.ContentSecurityPolicyMiddleware`, ustawienia +`SECURE_CSP` / `SECURE_CSP_REPORT_ONLY`, tag szablonowy `csp_nonce_attr`, +check systemowy `security.W027` pilnujący spójności konfiguracji. +Szablony admina i wbudowane **same niosą już nonce**. + +**Koszt — nie ukrywajmy go:** ok. **68 szablonów zawiera inline +`