fix(testy): rozstrzyganie szablonu nie może wymagać bazy w teście bez django_db - #770
Merged
Conversation
Dwa testy z django_bpp/tests/test_auth_server.py
(test_szablon_logowania_nie_udaje_awarii,
test_szablony_authservera_maja_polskie_znaki) padaly w pelnym przebiegu
suity, a przechodzily w izolacji i na shardowanym CI. Winowajca nie jest
ani cached.Loader, ani DIRS, ani override_settings — tylko loader
dbtemplates.
BPP stawia dbtemplates.loader.Loader PRZED loaderami dyskowymi, zeby
dowolny szablon dalo sie nadpisac wierszem w django_template. Zanim ten
loader odpowie "nie mam, nastepny", pyta should_skip() -> known_names(),
a to przy zimnym cache robi SELECT name FROM django_template. Czyli
get_template("cokolwiek.html") potrafi siegnac po baze.
Cache known_names() to zmienna globalna PROCESU
(dbtemplates.utils.names._names), kasowana sygnalem przy kazdym zapisie
i usunieciu wiersza Template. Efekt: czy test bez django_db renderujacy
szablon przejdzie, zalezy wylacznie od tego, co wczesniej przelecialo na
tym samym workerze xdist. W izolacji te dwa testy poprzedzaly testy
@pytest.mark.django_db z tego samego pliku, ktore ocieplaly cache; na CI
podzial na 12 shardow inaczej rozkladal testy po procesach. Uruchomione
pojedynczo padaly od zawsze:
pytest "src/django_bpp/tests/test_auth_server.py::test_szablon_logowania_nie_udaje_awarii"
Poprawka jest w src/conftest.py i usuwa zaleznosc, nie objaw:
- known_names() przy zablokowanej przez pytest-django bazie zwraca pusty
zbior zamiast wybuchac. To nie obejscie, tylko fakt: test bez django_db
nie ma bazy, wiec nie ma tez zadnych nadpisan z bazy — loader ma oddac
sterowanie loaderom dyskowym. Lapiemy WYLACZNIE RuntimeError blokady
(po tresci komunikatu), kazdy inny leci dalej.
- cache nazw jest zerowany przed kazdym testem, zeby zaden nie dziedziczyl
po poprzedniku nazw z bazy wycofanej rollbackiem (druga, cichsza wersja
tego samego sprzezenia: nadpisanie z bazy moglo zostac pominiete).
Dzieki temu chronione sa wszystkie testy renderujace szablony bez
django_db, a nie tylko te dwa — mierzone sonda w pelnym przebiegu: na
samym gw0 bylo ich ~2350.
Closes #766
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Closes #766
Mechanizm
Winowajcą nie jest ani
cached.Loader, aniTEMPLATES[0]["DIRS"], ani żadneprzeciekające
override_settings. Silnik szablonów przez cały przebieg mapoprawne
dirs=['…/src/django_bpp/templates']. Problemem jest loaderdbtemplates.BPP stawia
dbtemplates.loader.Loaderprzed loaderami dyskowymi(
settings/local.py:108), żeby dowolny szablon dało się nadpisać wierszemw
django_template. Zanim ten loader odpowie „nie mam, następny", pytashould_skip()→known_names(), a to przy zimnym cache wykonujeSELECT name FROM django_template. Innymi słowy: w tej konfiguracjiget_template("cokolwiek.html")potrafi sięgnąć po bazę.Cache
known_names()to zmienna globalna procesu(
dbtemplates.utils.names._names), kasowana sygnałempost_save/post_deleteprzy każdym zapisie i usunięciu wiersza
Template(
dbtemplates/models.py:85-86). Skutek: czy test bezdjango_dbrenderujący szablon przejdzie, zależy wyłącznie od tego, co wcześniej
przeleciało na tym samym workerze xdist.
Prawdziwy wyjątek na dole traceback'u (przechwycony sondą w pełnym przebiegu)
to nie
TemplateDoesNotExist, tylko:czyli
SELECT "django_template"."name" … FROM "django_template"wykonanyz wnętrza
get_template()w teście, który bazy nie zamówił.Dlaczego to wyglądało na „tylko w pełnym przebiegu"
W izolacji pliku 18/18 przechodzi, bo
test_auth_server.pyzaczyna sięod testów
@pytest.mark.django_db(test_is_superuser_*), które ocieplającache nazw — dwa późniejsze testy bez
django_dbjuż nie muszą pytać bazy.Uruchomione pojedynczo padają od zawsze (0,6 s, bez żadnych zmian
w kodzie) — to jest deterministyczne repro, którego brakowało w zgłoszeniu:
CI tego nie łapało, bo przy 12 shardach te dwa testy trafiały do procesu,
w którym cache był ciepły (albo nie było w nim testu zapisującego wiersz
Template, który go kasuje). Sharding nie „maskuje błędu" przypadkiem —zmienia dokładnie tę zmienną, od której defekt zależy: skład testów
w jednym procesie.
-p no:randomlynic nie zmienia, bo to nie jest kwestia losowej kolejności,tylko tego, które testy dzielą proces.
Problem był znacznie szerszy niż te dwa testy: sonda wpięta w teardown każdego
testu (próbny
get_template("auth_server/login.html")) pokazała ~2350testów bez
django_dbna samym workerze gw0, które przy zimnym cachezachowałyby się identycznie. Przechodziły dotąd wyłącznie na szczęście.
Co zmieniono
src/conftest.py(obie połówki opisane w komentarzu przy kodzie):known_names()przy bazie zablokowanej przez pytest-django zwraca pustyzbiór zamiast wybuchać. To nie jest obejście, tylko stwierdzenie faktu:
test bez
django_dbnie ma bazy, więc nie ma też żadnych nadpisań z bazy —loader ma oddać sterowanie loaderom dyskowym. Łapiemy wyłącznie
RuntimeErrorblokady (rozpoznawany po treści komunikatu), każdy innyRuntimeErrorleci dalej.po poprzedniku nazw z bazy wycofanej rollbackiem. To druga, cichsza wersja
tego samego sprzężenia: ciepły, nieaktualny zbiór potrafi kazać loaderowi
iść do bazy po nazwę, której już nie ma — albo pominąć nadpisanie, które
w bieżącym teście naprawdę istnieje.
Produkcja nie jest ruszona: tam baza zawsze jest, a zachowanie loadera się nie
zmienia. Do dwóch testów z
test_auth_server.pycelowo nie dołożono@pytest.mark.django_db— sedno poprawki polega na tym, że nie powinny gopotrzebować, a marker naprawiłby tylko te dwa przypadki z ~2350.
Test regresji
src/bpp/tests/test_dbtemplates_bez_bazy.py(4 testy):test_szablon_z_dysku_rozstrzyga_sie_przy_zimnym_cache_i_bez_bazy— właściwystrażnik: jawnie wywołuje
invalidate_known_names()(czyli odtwarza stan pozapisie wiersza
Template) i renderuje szablon z dysku bezdjango_db.Deterministyczny, niezależny od kolejności testów.
test_z_baza_nadpisanie_szablonu_wierszem_w_bazie_dalej_dziala— druga stronamedalu: guard nie może uciszyć dbtemplates tam, gdzie baza JEST dostępna.
test_loader_dbtemplates_pyta_o_znane_nazwy— pilnujeDBTEMPLATES_SKIP_UNKNOWN_NAMES = True, bo to na tej fladze guard się opiera.test_cache_nazw_jest_zimny_na_starcie_testu— kontrakt fixture'u zerującego.Weryfikacja w obie strony (jawnie, na tym samym venvie i kontenerach)
Bez poprawki (
git stash push src/conftest.py):Z poprawką (ten sam zestaw + cały
test_auth_server.py):Plus deterministyczne repro pojedynczego testu: przed poprawką
1 failedw 0,63 s, po poprawce
1 passedw 0,53 s.Pełna suita
Baseline na czystym
dev(odtworzony lokalnie, świeże kontenery,-n 4 -m "not playwright" -p no:randomly -q):Po poprawce (
uv run pytest -n 4 -m "not playwright" -q, świeże kontenery,losowa kolejność):
Arytmetyka się zgadza: baseline to 9393 testy (2 failed + 9391 passed),
po poprawce 9397 = 9393 + 4 nowe testy regresji.
Newsfragment:
src/bpp/newsfragments/766.bugfix.rst.