diff --git a/docker/api/db.py b/docker/api/db.py index 1407722..0e6f86e 100644 --- a/docker/api/db.py +++ b/docker/api/db.py @@ -210,6 +210,24 @@ def delete_worker(name: str) -> None: conn.execute(workers.delete().where(workers.c.name == name)) +def hat_arbeit() -> bool: + """True, wenn IRGENDEIN Job noch nicht durch ist (egal auf welchem Gerät). + + Gebraucht von der Mount-Wache: Eine Freigabe neu zu verbinden bedeutet ein + `umount -l` — mitten in einem laufenden Rip oder Encode wäre das ein + Datenverlust. `pending` zählt bewusst mit: so ein Job kann jeden Moment + anlaufen. + """ + with engine.connect() as conn: + zeile = conn.execute( + select(jobs.c.id) + .where(jobs.c.status.in_( + ("pending", "running", "ripping", "transcoding", "canceling"))) + .limit(1) + ).first() + return zeile is not None + + def has_active_job(device: str) -> bool: """True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz).""" with engine.connect() as conn: diff --git a/docker/api/main.py b/docker/api/main.py index 7265625..731f0b0 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -73,6 +73,9 @@ async def startup_event(): # schlafendes NAS lässt os.path.isdir bis zum CIFS-Timeout hängen (hier # 10 s gemessen) — und /jobs wird alle 4 Sekunden abgefragt. asyncio.create_task(_rohdaten_schleife()) + # Freigaben bewachen: Nach einem Rebuild ist die CIFS-Freigabe reproduzierbar + # tot, und der zweite Anlauf hilft (Begründung bei _mount_schleife). + asyncio.create_task(_mount_schleife()) # Auto-Pre-Scan-Ergebnisse je Laufwerk: das Dashboard zeigt damit sofort, @@ -330,6 +333,10 @@ async def health_vorraete(): else round(jetzt - _ROHDATEN["stand"], 1), "intervall": ROHDATEN_INTERVALL_SEKUNDEN, }, + "mounts": { + "stand": _MOUNT_STAND, + "intervall": MOUNT_WACHE_INTERVALL_SEKUNDEN, + }, } @@ -365,6 +372,71 @@ _ROHDATEN = {"treffer": {}, "stand": None} ROHDATEN_INTERVALL_SEKUNDEN = 30 +# --- Mount-Wache ------------------------------------------------------------ +# +# ⚠️ Warum es die braucht (26.07.2026, DREIMAL in Folge reproduziert): Nach +# `docker compose up -d --build` ist die CIFS-Freigabe tot. `mount` meldet +# Rückgabewert 0, /proc/mounts zeigt genau eine korrekt aussehende Schicht, die +# Erreichbarkeits-Probe antwortet direkt nach dem Mount sogar — und Sekunden +# später läuft jeder Zugriff in die Zeitgrenze. Derselbe Ablauf ein zweites Mal, +# ein bis zwei Minuten später, stellt sie zuverlässig her. +# +# Die Ursache liegt am NAS und ist nicht gefunden. Aber die Wirkung ist teuer: +# Nach JEDEM Update war jeder Rip auf die NAS kaputt, ohne dass irgendwo etwas +# davon zu sehen war. Wenn die Heilung bekannt und billig ist, gehört sie +# automatisiert — auch ohne die Ursache zu kennen. Genau das tut diese Schleife. +MOUNT_WACHE_INTERVALL_SEKUNDEN = 60 +_MOUNT_STAND = {} + + +async def _mount_schleife(): + """Sieht nach, ob die Freigaben antworten, und verbindet sie sonst neu.""" + while True: + # Erst warten: Der Start hat gerade selbst gemountet (alle_remounten), + # und die zweite Chance soll die sein, die laut Messung funktioniert. + await asyncio.sleep(MOUNT_WACHE_INTERVALL_SEKUNDEN) + try: + await asyncio.to_thread(_mounts_nachsehen) + except Exception as e: # darf nie sterben + print(f"Mount-Wache fehlgeschlagen: {type(e).__name__}: {e}") + + +def _mounts_nachsehen() -> None: + """Unerreichbare Freigaben neu verbinden — NIE während ein Job läuft. + + Neu verbinden heißt `umount -l`; mitten in einem Rip oder Encode wäre das + ein Datenverlust. Deshalb steht die Wache still, solange irgendein Job nicht + durch ist — auch bei einem wartenden, der jeden Moment anlaufen kann. + """ + eintraege = db.list_mounts() + if not eintraege or db.hat_arbeit(): + return + for eintrag in eintraege: + name = eintrag["name"] + erreichbar = mount_verwaltung.ist_erreichbar(name) + vorher = _MOUNT_STAND.get(name) + _MOUNT_STAND[name] = erreichbar + if erreichbar: + if vorher is False: + db.add_log("success", "mounts", f"{name}: antwortet wieder") + continue + # Nur beim ÜBERGANG meckern, nicht jede Minute: Ist das NAS + # ausgeschaltet, wäre das sonst ein Log-Wasserfall. + if vorher is not False: + db.add_log("warning", "mounts", + f"{name}: antwortet nicht — wird neu verbunden") + try: + mount_verwaltung.reparieren( + name, eintrag["typ"], eintrag["quelle"], + eintrag.get("optionen") or "", eintrag.get("username") or "", + eintrag.get("passwort") or "", + ) + _MOUNT_STAND[name] = True + db.add_log("success", "mounts", f"{name}: neu verbunden") + except Exception as e: + db.add_log("warning", "mounts", f"{name}: Neuverbinden fehlgeschlagen — {e}") + + async def _rohdaten_schleife(): """Hält den Rohdaten-Vorrat frisch. Darf nie sterben. diff --git a/docker/api/test_api_smoke.py b/docker/api/test_api_smoke.py index 3ce19ec..cbace9e 100644 --- a/docker/api/test_api_smoke.py +++ b/docker/api/test_api_smoke.py @@ -157,3 +157,92 @@ def test_worker_setup_routen_die_gebraucht_werden_sind_da(): for pfad in ("/worker-setup/paket", "/worker-setup/windows", "/worker-setup/windows-exe"): assert pfad in routen, f"Route {pfad} fehlt — Worker-Installation kaputt" + +# --- Mount-Wache: heilt, was nach jedem Rebuild kaputt ist ------------------- +# +# Hier statt in test_mounts_helpers.py, weil diese Tests `main` brauchen und +# main.py haengt an fcntl (Linux). Dieses Modul ueberspringt sich unter Windows +# selbst — es laeuft also genau da, wo auch die Ampel laeuft. + + +def test_wache_ruehrt_nichts_an_solange_ein_job_laeuft(monkeypatch): + """Neu verbinden heisst `umount -l`. Mitten in einem Rip oder Encode waere + das ein Datenverlust - die Wache muss dann stillstehen.""" + import main + + monkeypatch.setattr(main.db, "list_mounts", lambda: [ + {"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"}]) + monkeypatch.setattr(main.db, "hat_arbeit", lambda: True) + monkeypatch.setattr(main.mount_verwaltung, "ist_erreichbar", + lambda name: (_ for _ in ()).throw(AssertionError("nicht anfassen!"))) + + main._mounts_nachsehen() # darf einfach nichts tun + + +def test_wache_verbindet_eine_stumme_freigabe_neu(monkeypatch): + import main + + repariert, gelogged = [], [] + monkeypatch.setattr(main.db, "list_mounts", lambda: [ + {"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"}]) + monkeypatch.setattr(main.db, "hat_arbeit", lambda: False) + monkeypatch.setattr(main.db, "add_log", + lambda lvl, src, msg: gelogged.append((lvl, msg))) + monkeypatch.setattr(main.mount_verwaltung, "ist_erreichbar", lambda name: False) + monkeypatch.setattr(main.mount_verwaltung, "reparieren", + lambda *a, **k: repariert.append(a[0])) + main._MOUNT_STAND.clear() + + main._mounts_nachsehen() + + assert repariert == ["rippy"] + assert any("neu verbunden" in m for _, m in gelogged) + + +def test_wache_meckert_nicht_jede_minute(monkeypatch): + """Ist das NAS ausgeschaltet, waere ein Log je Minute ein Wasserfall. + Gemeldet wird nur der UEBERGANG.""" + import main + + gelogged = [] + monkeypatch.setattr(main.db, "list_mounts", lambda: [ + {"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"}]) + monkeypatch.setattr(main.db, "hat_arbeit", lambda: False) + monkeypatch.setattr(main.db, "add_log", + lambda lvl, src, msg: gelogged.append(msg)) + monkeypatch.setattr(main.mount_verwaltung, "ist_erreichbar", lambda name: False) + + def reparieren_scheitert(*a, **k): + raise RuntimeError("NAS aus") + + monkeypatch.setattr(main.mount_verwaltung, "reparieren", reparieren_scheitert) + main._MOUNT_STAND.clear() + + for _ in range(5): + main._mounts_nachsehen() + + # "antwortet nicht" genau EINMAL (der Uebergang), die Fehlschlaege sind + # jeweils eigene Meldungen - aber kein wiederholtes "antwortet nicht". + assert len([m for m in gelogged if "antwortet nicht" in m]) == 1 + + +def test_wache_meldet_wenn_es_wieder_geht(monkeypatch): + import main + + gelogged = [] + zustand = {"da": False} + monkeypatch.setattr(main.db, "list_mounts", lambda: [ + {"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"}]) + monkeypatch.setattr(main.db, "hat_arbeit", lambda: False) + monkeypatch.setattr(main.db, "add_log", + lambda lvl, src, msg: gelogged.append(msg)) + monkeypatch.setattr(main.mount_verwaltung, "ist_erreichbar", + lambda name: zustand["da"]) + monkeypatch.setattr(main.mount_verwaltung, "reparieren", + lambda *a, **k: None) + main._MOUNT_STAND.clear() + main._MOUNT_STAND["rippy"] = False + + zustand["da"] = True + main._mounts_nachsehen() + assert any("antwortet wieder" in m for m in gelogged)