From b838cf4723d99757994338bd26d59b18d9b9ed79 Mon Sep 17 00:00:00 2001 From: migatu Date: Sun, 9 Aug 2026 11:59:00 +0200 Subject: [PATCH] =?UTF-8?q?fix(dane):=20bazy=20zastane=20na=20udziale=20zo?= =?UTF-8?q?staj=C4=85=20w=20u=C5=BCyciu=20po=20przej=C5=9Bciu=20na=20rejes?= =?UTF-8?q?tr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bez tego wdrożenie DAN-27 WYŁĄCZYŁOBY WYSZUKIWANIE. Dotąd bazy działały domyślnie (wyłączało się je jawnie przez DISABLED_BASES). Po przejściu na rejestr plik bez wpisu w stanie dostaje `ready`, czyli NIE w użyciu — a stan po wdrożeniu jest pusty. Efekt: żadna baza nie jest aktywna i program nagle niczego nie znajduje. Ta cicha zmiana zachowania byłaby gorsza od awarii, bo wygląda jak pusta baza, a nie jak zepsuty deploy — i szukałoby się jej w warstwie danych albo w indeksie. Teraz brak PLIKU stanu oznacza pierwsze uruchomienie i bazy zastane są przyjmowane jako aktywne. Pusty słownik przy ISTNIEJĄCYM pliku to co innego: ktoś świadomie wszystko odstawił, więc nie wskrzeszamy — osobny test tego pilnuje. Rozróżnienie jest celowe i też pod testem: `ready` dotyczy plików WGRANYCH przez ekran (te ktoś musi świadomie włączyć), a nie zastanych przy przejściu na rejestr. Inaczej nowa baza wchodziłaby do wyników sama, bez niczyjej decyzji. Przyjęcie działa też na udziale tylko do odczytu: zapis stanu wtedy nie przechodzi, więc powtórzy się przy każdym uruchomieniu — zachowanie to samo, koszt żaden. Dwa testy opisujące STARE zachowanie zostały poprawione, bo to one były błędne. Co-Authored-By: Claude Opus 5 --- services/data/app/files.py | 28 +++++++++++++- services/data/tests/test_files.py | 62 ++++++++++++++++++++++++++++--- 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/services/data/app/files.py b/services/data/app/files.py index 5500c8c..1b97450 100644 --- a/services/data/app/files.py +++ b/services/data/app/files.py @@ -202,6 +202,30 @@ def _scan(root: Path) -> list[Path]: if p.is_file() and not p.name.startswith((".", "~$"))] +def _adopt_existing(root: Path) -> dict: + """Pierwsze uruchomienie: bazy zastane na udziale są OD RAZU w użyciu. + + Bez tego wdrożenie DAN-27 wyłączyłoby wyszukiwanie. Dotąd bazy działały + domyślnie (wyłączało się je jawnie przez DISABLED_BASES); po przejściu na + rejestr plik bez wpisu dostaje `ready`, czyli NIE w użyciu — więc pusty stan + po wdrożeniu oznaczałby, że program nagle niczego nie znajduje. Ta cicha + zmiana zachowania byłaby gorsza od awarii, bo wygląda jak pusta baza. + + Rozróżnienie jest celowe: `ready` dotyczy plików WGRANYCH przez ekran (te + ktoś musi świadomie włączyć), a nie zastanych przy przejściu na rejestr. + + Zapis stanu może się nie udać (udział read-only) — wtedy trudno, przy każdym + uruchomieniu przyjmiemy je na nowo. Zachowanie jest to samo, koszt żaden.""" + files = {str(p.relative_to(root)): {"status": ACTIVE, "adopted_at": _now()} + for p in _scan(root)} + data = {"files": files, "rules": {**DEFAULT_RULES}} + try: + _write_state(root, data) + except OSError: + pass + return data + + def registry(root: Path | str, *, for_admin: bool = False) -> list[dict]: """Pliki na udziale wraz ze stanem. `for_admin` odsłania kwarantannę i powody. @@ -209,7 +233,9 @@ def registry(root: Path | str, *, for_admin: bool = False) -> list[dict]: dochodziły do przeglądarki i były tylko ukrywane stylem, wystarczyłby podgląd źródła strony, żeby poznać reguły walidacji.""" root = Path(root) - data = _read_state(root) + # Brak PLIKU stanu = pierwsze uruchomienie. Pusty słownik przy istniejącym + # pliku to co innego: ktoś świadomie wszystko odstawił, więc nie wskrzeszamy. + data = _read_state(root) if state_path(root).exists() else _adopt_existing(root) out: list[dict] = [] for p in _scan(root): rel = str(p.relative_to(root)) diff --git a/services/data/tests/test_files.py b/services/data/tests/test_files.py index e36f421..ce3af87 100644 --- a/services/data/tests/test_files.py +++ b/services/data/tests/test_files.py @@ -32,19 +32,24 @@ def _xlsx(path, rows=3, header=("id", "opis")): # ── rejestr i stany ────────────────────────────────────────────────────── -def test_new_file_is_visible_but_not_in_use(root): +def test_a_file_dropped_on_the_share_is_adopted_as_active(root): + """Baza położona na udziale poza aplikacją (np. przez NFS) ma działać — + tak było przed DAN-27 i tak ma zostać. Plik WGRANY EKRANEM to inna sprawa: + ten wymaga świadomego włączenia (patrz test niżej).""" _xlsx(root / "baza.xlsx") entry = files.registry(root)[0] - assert entry["status"] == files.READY - assert entry["in_use"] is False, "nowy plik nie może sam wejść do wyszukiwania" + assert entry["status"] == files.ACTIVE + assert entry["in_use"] is True def test_only_active_files_reach_the_search(root): _xlsx(root / "a.xlsx") _xlsx(root / "b.xlsx") - assert files.usable_paths(root) == [] - files.set_status(root, "a.xlsx", files.ACTIVE) + files.registry(root) # przyjęcie zastanych + files.set_status(root, "b.xlsx", files.READY) # świadome odstawienie assert [pathlib.Path(p).name for p in files.usable_paths(root)] == ["a.xlsx"] + files.set_status(root, "b.xlsx", files.ACTIVE) + assert len(files.usable_paths(root)) == 2 def test_state_survives_a_restart(root): @@ -160,3 +165,50 @@ def test_delete_removes_the_file_and_its_entry(root): assert not (root / "a.xlsx").exists() assert files.registry(root, for_admin=True) == [] assert files.usable_paths(root) == [] + + +# ── przejście na rejestr nie może wyłączyć wyszukiwania ───────────────── + +def test_bases_already_on_the_share_stay_in_use_after_the_switch(root): + """Dotąd bazy działały domyślnie (wyłączało się je przez DISABLED_BASES). + Po przejściu na rejestr pusty stan oznaczałby, że program nagle niczego nie + znajduje — cicha zmiana zachowania gorsza od awarii, bo wygląda jak pusta baza.""" + _xlsx(root / "main_base.xlsx") + _xlsx(root / "zodiac_pl.xlsx") + assert len(files.usable_paths(root)) == 2, "zastane bazy wypadły z wyszukiwania" + assert all(e["in_use"] for e in files.registry(root)) + + +def test_adoption_happens_once_and_respects_later_decisions(root): + """Po przyjęciu stan jest zapisany, więc świadome odstawienie bazy ZOSTAJE — + kolejny odczyt nie może jej wskrzesić.""" + _xlsx(root / "a.xlsx") + _xlsx(root / "b.xlsx") + files.registry(root) # przyjęcie + files.set_status(root, "a.xlsx", files.READY) # świadome odstawienie + assert [pathlib.Path(p).name for p in files.usable_paths(root)] == ["b.xlsx"] + files.set_status(root, "b.xlsx", files.READY) # odstawiamy wszystko + assert files.usable_paths(root) == [], "pusty wybór został wskrzeszony" + + +def test_uploaded_files_still_need_an_explicit_switch_on(root): + """Przyjęcie dotyczy TYLKO baz zastanych. Plik wgrany ekranem ktoś musi + świadomie włączyć — inaczej nowa baza wchodziłaby do wyników sama.""" + _xlsx(root / "zastana.xlsx") + files.registry(root) + out = files.store_upload(root, "nowa.xlsx", _xlsx(root / "tmp.xlsx").read_bytes()) + assert out["accepted"] is True + names = [pathlib.Path(p).name for p in files.usable_paths(root)] + assert "nowa.xlsx" not in names, "wgrana baza weszła do wyników bez decyzji" + + +def test_adoption_survives_a_read_only_share(root, monkeypatch): + """Na udziale tylko do odczytu stanu nie da się zapisać — zachowanie ma + zostać to samo, tylko przyjęcie powtórzy się przy każdym uruchomieniu.""" + _xlsx(root / "a.xlsx") + + def boom(*a, **kw): + raise OSError("read-only file system") + + monkeypatch.setattr(files, "_write_state", boom) + assert len(files.usable_paths(root)) == 1