fix(mounts): Wache, die die Freigabe nach einem Rebuild von selbst zurueckholt
Ampel / ampel (push) Successful in 28s
Ampel / ampel (push) Successful in 28s
Dreimal in Folge reproduziert, jetzt bei JEDEM Deploy: Nach `docker compose up -d --build` ist die CIFS-Freigabe tot. `mount` meldet Rueckgabewert 0, /proc/mounts zeigt genau eine korrekt aussehende Schicht, die Erreichbarkeits-Probe antwortet direkt nach dem Mount sogar - und Sekunden spaeter laeuft jeder Zugriff in die Zeitgrenze. Derselbe Ablauf ein bis zwei Minuten spaeter stellt sie zuverlaessig her (POST /storage-mounts/rippy/repair, mehrfach belegt). 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 - und der Commander haette es jedes Mal von Hand richten muessen. Wenn die Heilung bekannt und billig ist, gehoert sie automatisiert, auch ohne die Ursache zu kennen. Die Wache sieht jede Minute nach und verbindet stumme Freigaben neu. Zwei Dinge sind dabei wichtiger als die Heilung selbst: 1. NIE waehrend ein Job laeuft. Neu verbinden heisst `umount -l`; mitten in einem Rip oder Encode waere das ein Datenverlust. Die Wache steht still, solange irgendein Job nicht durch ist - auch bei einem wartenden, der jeden Moment anlaufen kann (db.hat_arbeit). 2. Gemeldet wird nur der UEBERGANG. Ist das NAS ausgeschaltet, waere ein Log je Minute ein Wasserfall. Der Zustand steht in /health/vorraete, damit man sehen kann, dass die Wache lebt - dieselbe Lehre wie heute Nachmittag beim stillen except. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -210,6 +210,24 @@ def delete_worker(name: str) -> None:
|
|||||||
conn.execute(workers.delete().where(workers.c.name == name))
|
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:
|
def has_active_job(device: str) -> bool:
|
||||||
"""True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz)."""
|
"""True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz)."""
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ async def startup_event():
|
|||||||
# schlafendes NAS lässt os.path.isdir bis zum CIFS-Timeout hängen (hier
|
# schlafendes NAS lässt os.path.isdir bis zum CIFS-Timeout hängen (hier
|
||||||
# 10 s gemessen) — und /jobs wird alle 4 Sekunden abgefragt.
|
# 10 s gemessen) — und /jobs wird alle 4 Sekunden abgefragt.
|
||||||
asyncio.create_task(_rohdaten_schleife())
|
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,
|
# 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),
|
else round(jetzt - _ROHDATEN["stand"], 1),
|
||||||
"intervall": ROHDATEN_INTERVALL_SEKUNDEN,
|
"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
|
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():
|
async def _rohdaten_schleife():
|
||||||
"""Hält den Rohdaten-Vorrat frisch. Darf nie sterben.
|
"""Hält den Rohdaten-Vorrat frisch. Darf nie sterben.
|
||||||
|
|
||||||
|
|||||||
@@ -157,3 +157,92 @@ def test_worker_setup_routen_die_gebraucht_werden_sind_da():
|
|||||||
for pfad in ("/worker-setup/paket", "/worker-setup/windows",
|
for pfad in ("/worker-setup/paket", "/worker-setup/windows",
|
||||||
"/worker-setup/windows-exe"):
|
"/worker-setup/windows-exe"):
|
||||||
assert pfad in routen, f"Route {pfad} fehlt — Worker-Installation kaputt"
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user