Gemessen nach dem letzten Deploy: Der ERSTE Zugriff auf die CIFS-Freigabe stallt
nach einem Container-Neustart mehrere Sekunden (die SMB-Sitzung wird neu
aufgebaut), danach antwortet sie in 0,01 s - zehn von zehn Versuchen, NAS per
Ping bei 0,47 ms. Die Freigabe ist also gesund, nur der erste Griff ist teuer.
In diesem Fenster lief die Pruefung in ihre Zeitgrenze, der Vorrat notierte
"keine Rohdaten", und der Knopf "Neu komprimieren" verschwand - obwohl 74 GB
dalagen. Der Nutzer haette daraus geschlossen, seine Daten seien weg. Genau
diese Sorte Fehlschluss ("aus einem Zustandswert auf einen Mechanismus") hat das
Projekt schon zweimal bezahlt.
Deshalb drei Antworten statt zwei: "da", "weg", "unklar". 124 ist der
Rueckgabewert von `timeout`, wenn es das Kind abgeschossen hat - das heisst
NICHT angesehen. Bleibt eine Pruefung unklar und der Vorrat hatte vorher einen
Treffer, wird die letzte bekannte Antwort gehalten statt Abwesenheit behauptet.
Wer eine Ja/Nein-Antwort braucht (verzeichnis_da), bekommt im Zweifel weiter
Nein - das ist die richtige Richtung fuer eine einzelne Abfrage.
Live davor geprueft: Die Schleife LEBT jetzt (alter_sekunden 30 -> 21 -> 12 -> 3
ueber 100 s) und heilt sich selbst - sobald die Freigabe antwortete, stand
mit_treffer=1 und can_retry=true.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+14
-2
@@ -381,13 +381,25 @@ async def _rohdaten_schleife():
|
|||||||
|
|
||||||
|
|
||||||
def _rohdaten_vorrat_auffrischen() -> None:
|
def _rohdaten_vorrat_auffrischen() -> None:
|
||||||
"""Für jeden fehlgeschlagenen Job nachsehen, wo seine Rohdaten liegen."""
|
"""Für jeden fehlgeschlagenen Job nachsehen, wo seine Rohdaten liegen.
|
||||||
|
|
||||||
|
„Konnte nicht nachsehen" behält die letzte bekannte Antwort: Nach einem
|
||||||
|
Container-Neustart stallt der erste Zugriff auf die CIFS-Freigabe mehrere
|
||||||
|
Sekunden. Ohne diese Regel verschwände in dem Fenster der Knopf
|
||||||
|
„Neu komprimieren", und der Nutzer schlösse daraus, seine 74 GB seien weg.
|
||||||
|
"""
|
||||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
||||||
|
alt = _ROHDATEN["treffer"]
|
||||||
treffer = {}
|
treffer = {}
|
||||||
for zeile in db.list_jobs():
|
for zeile in db.list_jobs():
|
||||||
if zeile.get("status") != "failed":
|
if zeile.get("status") != "failed":
|
||||||
continue
|
continue
|
||||||
treffer[zeile["id"]] = _rohdaten_suchen(zeile["id"], work_dir)
|
job_id = zeile["id"]
|
||||||
|
ergebnis = rohdaten.suche_mit_status(job_id, work_dir, os.listdir)
|
||||||
|
if not ergebnis["pfade"] and ergebnis["unklar"] and alt.get(job_id):
|
||||||
|
treffer[job_id] = alt[job_id] # letzte bekannte Antwort halten
|
||||||
|
else:
|
||||||
|
treffer[job_id] = ergebnis["pfade"]
|
||||||
_ROHDATEN["treffer"] = treffer
|
_ROHDATEN["treffer"] = treffer
|
||||||
_ROHDATEN["stand"] = time.monotonic()
|
_ROHDATEN["stand"] = time.monotonic()
|
||||||
|
|
||||||
|
|||||||
+60
-12
@@ -80,6 +80,38 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
|
|||||||
return eindeutig
|
return eindeutig
|
||||||
|
|
||||||
|
|
||||||
|
def pruefen(pfad: str, laufen=None) -> str:
|
||||||
|
"""Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze.
|
||||||
|
|
||||||
|
Drei Antworten statt zwei, weil „ich konnte nicht nachsehen" etwas anderes
|
||||||
|
ist als „es ist nicht da". Gemessen am 26.07.2026: Nach einem
|
||||||
|
Container-Neustart stallt der ERSTE Zugriff auf die CIFS-Freigabe mehrere
|
||||||
|
Sekunden (die SMB-Sitzung wird neu aufgebaut), danach antwortet sie in
|
||||||
|
0,01 s — zehn von zehn Versuchen. Ohne die Unterscheidung verschwindet in
|
||||||
|
diesem Fenster der Knopf „Neu komprimieren", und der Nutzer schließt daraus,
|
||||||
|
seine 74 GB seien weg. Genau diese Sorte Fehlschluss hat das Projekt schon
|
||||||
|
zweimal bezahlt.
|
||||||
|
|
||||||
|
Begründung der Technik siehe verzeichnis_da.
|
||||||
|
"""
|
||||||
|
if not pfad:
|
||||||
|
return "weg"
|
||||||
|
starten = laufen or subprocess.run
|
||||||
|
try:
|
||||||
|
ergebnis = starten(
|
||||||
|
["timeout", str(PRUEF_TIMEOUT_SEKUNDEN), "ls", "-d", pfad],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=PRUEF_TIMEOUT_SEKUNDEN + 2,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return "unklar"
|
||||||
|
if ergebnis.returncode == 0:
|
||||||
|
return "da"
|
||||||
|
# 124 ist der Rückgabewert von `timeout`, wenn es das Kind abgeschossen hat
|
||||||
|
# (dokumentiert in coreutils). Das heißt: nicht angesehen, nicht „weg".
|
||||||
|
return "unklar" if ergebnis.returncode == 124 else "weg"
|
||||||
|
|
||||||
|
|
||||||
def verzeichnis_da(pfad: str, laufen=None) -> bool:
|
def verzeichnis_da(pfad: str, laufen=None) -> bool:
|
||||||
"""Gibt es dieses Verzeichnis? — mit HARTER Zeitgrenze.
|
"""Gibt es dieses Verzeichnis? — mit HARTER Zeitgrenze.
|
||||||
|
|
||||||
@@ -105,19 +137,12 @@ def verzeichnis_da(pfad: str, laufen=None) -> bool:
|
|||||||
innerhalb von Sekunden ansehen kann, ist für einen Rip ohnehin unbrauchbar.
|
innerhalb von Sekunden ansehen kann, ist für einen Rip ohnehin unbrauchbar.
|
||||||
|
|
||||||
`laufen` ist einspritzbar, damit das ohne echte Prozesse testbar bleibt.
|
`laufen` ist einspritzbar, damit das ohne echte Prozesse testbar bleibt.
|
||||||
|
|
||||||
|
Für den Fall „konnte nicht nachsehen" gibt es `pruefen()` mit drei
|
||||||
|
Antworten. Hier gilt nur „da" als ja — wer eine Ja/Nein-Antwort braucht,
|
||||||
|
soll im Zweifel Nein bekommen.
|
||||||
"""
|
"""
|
||||||
if not pfad:
|
return pruefen(pfad, laufen) == "da"
|
||||||
return False
|
|
||||||
starten = laufen or subprocess.run
|
|
||||||
try:
|
|
||||||
ergebnis = starten(
|
|
||||||
["timeout", str(PRUEF_TIMEOUT_SEKUNDEN), "ls", "-d", pfad],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=PRUEF_TIMEOUT_SEKUNDEN + 2,
|
|
||||||
)
|
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
|
||||||
return False
|
|
||||||
return ergebnis.returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
|
def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
|
||||||
@@ -147,6 +172,29 @@ def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
|
|||||||
return gefunden
|
return gefunden
|
||||||
|
|
||||||
|
|
||||||
|
def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None) -> dict:
|
||||||
|
"""Wie suche(), aber sagt auch, ob etwas UNGEPRÜFT geblieben ist.
|
||||||
|
|
||||||
|
Rückgabe: {"pfade": [...], "unklar": bool}. `unklar` heißt: Mindestens ein
|
||||||
|
Ort hat nicht geantwortet — ein leeres `pfade` ist dann kein Beweis für
|
||||||
|
„nichts da". Der Aufrufer soll in diesem Fall seine letzte bekannte Antwort
|
||||||
|
behalten, statt Abwesenheit zu behaupten (siehe pruefen()).
|
||||||
|
"""
|
||||||
|
pruefe = pruefer or pruefen
|
||||||
|
try:
|
||||||
|
unterordner = sorted(listdir(MEDIA_ROOT))
|
||||||
|
except OSError:
|
||||||
|
unterordner = []
|
||||||
|
gefunden, unklar = [], False
|
||||||
|
for ort in kandidaten(job_id, work_dir, unterordner):
|
||||||
|
antwort = pruefe(ort)
|
||||||
|
if antwort == "da":
|
||||||
|
gefunden.append(ort)
|
||||||
|
elif antwort == "unklar":
|
||||||
|
unklar = True
|
||||||
|
return {"pfade": gefunden, "unklar": unklar}
|
||||||
|
|
||||||
|
|
||||||
def groesse(pfade: list, listdir, isfile, getsize) -> tuple:
|
def groesse(pfade: list, listdir, isfile, getsize) -> tuple:
|
||||||
"""(Bytes, Dateizahl) der Roh-Dateien — flach, nicht rekursiv.
|
"""(Bytes, Dateizahl) der Roh-Dateien — flach, nicht rekursiv.
|
||||||
|
|
||||||
|
|||||||
@@ -179,3 +179,54 @@ def test_suche_mit_der_zeitgrenze_findet_den_echten_fall():
|
|||||||
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
|
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
|
||||||
)
|
)
|
||||||
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Drei Antworten: "konnte nicht nachsehen" ist nicht "ist weg" ------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_pruefen_unterscheidet_drei_faelle():
|
||||||
|
assert rohdaten.pruefen("/x", _Lauf(rc=0)) == "da"
|
||||||
|
assert rohdaten.pruefen("/x", _Lauf(rc=2)) == "weg"
|
||||||
|
# 124 = `timeout` hat das Kind abgeschossen (coreutils) -> nicht angesehen
|
||||||
|
assert rohdaten.pruefen("/x", _Lauf(rc=124)) == "unklar"
|
||||||
|
assert rohdaten.pruefen("/x", _Lauf(wirf=OSError("kein timeout"))) == "unklar"
|
||||||
|
assert rohdaten.pruefen("", _Lauf(rc=0)) == "weg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verzeichnis_da_bleibt_streng():
|
||||||
|
"""Wer Ja/Nein braucht, bekommt im Zweifel Nein."""
|
||||||
|
assert rohdaten.verzeichnis_da("/x", _Lauf(rc=0)) is True
|
||||||
|
assert rohdaten.verzeichnis_da("/x", _Lauf(rc=124)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_suche_mit_status_meldet_ungepruefte_orte():
|
||||||
|
"""Der echte Fall: Die Freigabe antwortet nicht, der lokale Ort ist leer.
|
||||||
|
Ein leeres Ergebnis darf dann NICHT als "nichts da" gelten."""
|
||||||
|
def pruefe(pfad):
|
||||||
|
return "unklar" if pfad.startswith("/app/media/rippy/") else "weg"
|
||||||
|
|
||||||
|
e = rohdaten.suche_mit_status(
|
||||||
|
JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe)
|
||||||
|
assert e == {"pfade": [], "unklar": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_suche_mit_status_ohne_zweifel():
|
||||||
|
def pruefe(pfad):
|
||||||
|
return "da" if pfad == f"/app/media/rippy/{JOB}" else "weg"
|
||||||
|
|
||||||
|
e = rohdaten.suche_mit_status(
|
||||||
|
JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe)
|
||||||
|
assert e == {"pfade": [f"/app/media/rippy/{JOB}"], "unklar": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_suche_mit_status_findet_trotz_unklarem_anderen_ort():
|
||||||
|
"""Ein Treffer bleibt ein Treffer, auch wenn ein anderer Ort schweigt."""
|
||||||
|
def pruefe(pfad):
|
||||||
|
if pfad == f"/app/media/rippy/{JOB}":
|
||||||
|
return "da"
|
||||||
|
return "unklar" if "totes-nas" in pfad else "weg"
|
||||||
|
|
||||||
|
e = rohdaten.suche_mit_status(
|
||||||
|
JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe)
|
||||||
|
assert e["pfade"] == [f"/app/media/rippy/{JOB}"]
|
||||||
|
assert e["unklar"] is True
|
||||||
|
|||||||
Reference in New Issue
Block a user