diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 296d4cae2..12b3b5b9a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -367,7 +367,15 @@ jobs: # 0-indexed (kept that way so the job names / required checks # don't change). Map group = shard + 1. GROUP=$((SHARD_ID + 1)) + # GAŁĄŹ OBSERWACYJNA — NIE MERGOWAĆ DO dev. + # BPP_LEAK_GUARD_STRICT=1 zamienia wykrycie wycieku scommitowanych + # danych w twardy błąd testu, zamiast cichego TRUNCATE + raportu na + # końcu sesji. Na dev to zablokowałoby merge'e (przyczyna wycieku + # nieznana), więc siedzi na osobnej gałęzi — żeby ZOBACZYĆ pełny + # obraz: przy którym teście następuje wykrycie, czyje są wiersze + # i jaki jest wtedy stan izolacji połączenia. docker compose $COMPOSE_FILES run --rm \ + -e BPP_LEAK_GUARD_STRICT=1 \ test-runner uv run pytest \ -n auto \ --splits "$NUM_SHARDS" --group "$GROUP" \ diff --git a/src/bpp/tests/test_leak_guard_tracer.py b/src/bpp/tests/test_leak_guard_tracer.py new file mode 100644 index 000000000..6fe048e1a --- /dev/null +++ b/src/bpp/tests/test_leak_guard_tracer.py @@ -0,0 +1,59 @@ +"""Guard dla guarda: tracer połączeń MUSI widzieć zapis z innego wątku. + +Bez tego testu tracer mógłby cicho nie działać (monkey-patch nie założony, +zdarzenia czyszczone w złym momencie) i milczenie na CI czytalibyśmy jako +„zapisów spoza głównego wątku nie ma" zamiast „nie mierzymy". +""" + +import threading + +import pytest + + +def _src_conftest(request): + """Zwraca INSTANCJĘ modułu ``src/conftest.py`` załadowaną przez pytest. + + Wszystkie prostsze drogi zawodzą pod shardowaniem xdista na CI: + + - gołe ``import conftest`` rozstrzyga się na pierwszy ``conftest.py`` na + ``sys.path`` — bywa to conftest aplikacyjny bez tracera; + - świeży ``importlib`` daje OSOBNĄ instancję z własnym ``_LEAK_GUARD``, + a tracer dopisuje zdarzenia do instancji załadowanej przez pytest; + - filtr po ``__file__`` w ``sys.modules`` jest kruchy — na CI ścieżka + bywa WZGLĘDNA (``src/conftest.py``), więc ``endswith("/src/conftest.py")`` + nie łapie. + + Deterministycznie: pytest rejestruje KAŻDY ``conftest.py`` jako plugin. + Bierzemy ten z zarejestrowanych, który ma ``_zainstaluj_tracer_polaczen`` + — czyli dokładnie rootdir-owy ``src/conftest.py``, tę samą instancję, + której używa runtime. + """ + kandydaci = [ + p + for p in request.config.pluginmanager.get_plugins() + if hasattr(p, "_zainstaluj_tracer_polaczen") + ] + assert kandydaci, "nie znaleziono zarejestrowanego src/conftest.py z tracerem" + return kandydaci[0] + + +@pytest.mark.django_db +def test_tracer_widzi_polaczenie_z_innego_watku(request): + conftest = _src_conftest(request) + + conftest._zainstaluj_tracer_polaczen() + conftest._LEAK_GUARD["zdarzenia"] = [] + + def w_watku(): + from django.db import connection + + with connection.cursor() as cur: + cur.execute("SELECT 1") + connection.close() + + t = threading.Thread(target=w_watku) + t.start() + t.join(10) + + zdarzenia = conftest._LEAK_GUARD["zdarzenia"] + assert any("CONNECT w wątku" in z for z in zdarzenia), zdarzenia diff --git a/src/conftest.py b/src/conftest.py index 620ed9118..3d8f79ab5 100644 --- a/src/conftest.py +++ b/src/conftest.py @@ -298,7 +298,79 @@ def _zainstaluj_testowy_progress_liveops(): # capture pytest-a) — inaczej print z fixture'a jest łykany i NIEwidoczny # w logach CI (właśnie po to jest ta diagnostyka). "raporty": [], + # Zdarzenia na połączeniu w trakcie BIEŻĄCEGO testu (czyszczone na setupie). + # Patrz _zainstaluj_tracer_polaczen. + "zdarzenia": [], } + + +# ============================================================================= +# TRACER POŁĄCZEŃ — odpowiada na pytanie „CZEMU to wycieka". +# +# Ustalone pomiarem: wyciekające testy to zwykłe ``django_db`` (tx=False), a +# wyciekłe wiersze to ICH WŁASNE dane (próbki etykiet: „Nowak", „Kat.", +# „brygadier"). Osobne połączenie sondy je widzi, więc są SCOMMITOWANE — +# sesja PG na READ COMMITTED nie zobaczy cudzych danych niescommitowanych. +# +# Rollback pytest-django jest niemal na pewno sprawny. Zostają dwa sposoby, +# żeby zapis ominął transakcję testu — i tracer rozróżnia je wprost: +# +# A. ZAPIS Z INNEGO WĄTKU. ``connections`` jest thread-local, więc wątek +# spoza głównego dostaje WŁASNE połączenie, w autocommit, poza atomic +# blokiem. Tracer notuje każde ``connect()`` spoza MainThread + stos. +# +# B. PODMIANA POŁĄCZENIA W TRAKCIE TESTU. Gdy coś zamknie połączenie, +# Django otworzy nowe — atomic block przepada, dalsze zapisy lecą +# w autocommit, a rollback na teardownie działa na nowym obiekcie, +# który nie ma czego cofać. UWAGA: mierzone wcześniej +# ``closed_in_tx=False`` tego NIE wyklucza — flaga siedzi na obiekcie +# połączenia, a po podmianie nowy obiekt ma ją czystą. Tracer notuje +# ``close()`` wołane wewnątrz atomic bloku + stos. +# +# Włączany przez BPP_LEAK_GUARD_TRACE=1 lub BPP_LEAK_GUARD_STRICT=1 (gałąź +# obserwacyjna). Domyślnie NIEAKTYWNY — monkey-patch na warstwie połączeń +# nie ma prawa działać w zwykłym przebiegu. +# ============================================================================= + + +def _zainstaluj_tracer_polaczen(): + import threading + import traceback + + from django.db.backends.base.base import BaseDatabaseWrapper + + if getattr(BaseDatabaseWrapper, "_bpp_tracer", False): + return + BaseDatabaseWrapper._bpp_tracer = True + + def _stos(): + # Pomijamy ramki samego tracera; 7 ostatnich wystarcza, żeby zobaczyć + # KTO zawołał, a nie zalewa logu CI. + return " <- ".join( + f"{f.filename.rsplit('/', 1)[-1]}:{f.lineno}:{f.name}" + for f in traceback.extract_stack()[-9:-2] + ) + + orig_connect = BaseDatabaseWrapper.connect + orig_close = BaseDatabaseWrapper.close + + def connect(self): + watek = threading.current_thread() + if watek is not threading.main_thread(): + _LEAK_GUARD["zdarzenia"].append(f"CONNECT w wątku {watek.name}: {_stos()}") + return orig_connect(self) + + def close(self): + if getattr(self, "in_atomic_block", False): + _LEAK_GUARD["zdarzenia"].append(f"CLOSE w atomic bloku: {_stos()}") + return orig_close(self) + + BaseDatabaseWrapper.connect = connect + BaseDatabaseWrapper.close = close + + +if os.environ.get("BPP_LEAK_GUARD_TRACE") or os.environ.get("BPP_LEAK_GUARD_STRICT"): + _zainstaluj_tracer_polaczen() _LEAK_GUARD_TABLES = ( "bpp_autor", "bpp_jednostka", @@ -469,6 +541,7 @@ def _neutralizuj_wyciekle_dane(request): f"{', '.join(wyciekle)}. Najprawdopodobniejszy sprawca (poprzedni " f"test DB na tym workerze): {_LEAK_GUARD['poprzedni']}" ) + _LEAK_GUARD["zdarzenia"] = [] _LEAK_GUARD["poprzedni_przed"] = _LEAK_GUARD["poprzedni"] _LEAK_GUARD["poprzedni"] = request.node.nodeid @@ -571,6 +644,11 @@ def _stan_izolacji(item): f"needs_rollback={getattr(connection, 'needs_rollback', '?')} " f"autocommit={autocommit} " f"poprzedni={_LEAK_GUARD['poprzedni_przed']}" + + ( + " || ZDARZENIA NA POŁĄCZENIU: " + " ;; ".join(_LEAK_GUARD["zdarzenia"]) + if _LEAK_GUARD["zdarzenia"] + else " || zdarzenia na połączeniu: BRAK" + ) )