fix(api): os.path.isdir hing im Kernel und toetete die Schleife - harte Zeitgrenze
Ampel / ampel (push) Successful in 28s
Ampel / ampel (push) Successful in 28s
Ursache gefunden, nicht geraten. Der neue Diagnose-Endpunkt sagte `rohdaten.alter_sekunden: null` - die Funktion war NIE EINMAL fertig geworden, und kein Fehler war gemeldet. Der Blick auf die Threads des API-Prozesses zeigte zwei im Zustand **D** (uninterruptible sleep, im Kernel blockiert): tid=1600034 name=uvicorn state=D tid=1600037 name=uvicorn state=D Der Mechanismus: Die Schleife startete ihren ersten Durchlauf, waehrend Rippy beim Container-Start die CIFS-Freigabe neu einhaengte. Ihr `os.path.isdir` blieb im Kernel stecken, `asyncio.to_thread` kam nie zurueck, die Schleife erreichte ihr `sleep` nie - und war damit fuer immer tot. Sichtbar war nur, dass can_retry dauerhaft false blieb. Ein Timeout um den Aufruf haette nichts geholfen: Ein im Kernel haengender Thread laesst sich aus Python nicht abbrechen, jeder Versuch haette einen weiteren Thread verbrannt, bis der Pool leer ist. Ein Kind-PROZESS laesst sich abbrechen. Geprueft wird jetzt mit `timeout 4 ls -d <pfad>` - dasselbe Werkzeug, das mounts.ist_erreichbar seit dem 24.07.2026 fuer genau dieses Problem benutzt (dort fuer den toten NAS-Mount). Laeuft es in die Zeitgrenze, gilt das Verzeichnis als "nicht da": Ein Ort, den man nicht in Sekunden ansehen kann, ist fuer einen Rip ohnehin unbrauchbar. os.listdir bleibt fuer /app/media selbst - das ist ein lokales Verzeichnis, die Freigaben sind Unterordner davon. Und os.path.isdir bleibt fuer den Rueckfall auf /app/temp/raw: ein Docker-Volume, dort kann nichts haengen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+20
-8
@@ -6,6 +6,7 @@ from typing import List, Optional, Dict
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import posixpath
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -344,13 +345,16 @@ async def root():
|
|||||||
def _rohdaten_suchen(job_id: str, work_dir: str) -> list:
|
def _rohdaten_suchen(job_id: str, work_dir: str) -> list:
|
||||||
"""Wo liegen die Roh-MKVs dieses Jobs? (Details in rohdaten.py)
|
"""Wo liegen die Roh-MKVs dieses Jobs? (Details in rohdaten.py)
|
||||||
|
|
||||||
Kann LANGSAM sein: Ein schlafendes NAS lässt `os.path.isdir` bis zum
|
Geprüft wird mit `rohdaten.verzeichnis_da` und NICHT mit os.path.isdir:
|
||||||
CIFS-Timeout hängen — auf dieser VM gemessene 10 Sekunden, während der Mount
|
Letzteres hing am 26.07.2026 unbegrenzt im Kernel, während Rippy die
|
||||||
nach einem Container-Neustart hochkam. Deshalb nur dort direkt aufrufen, wo
|
CIFS-Freigabe neu einhängte — und nahm die Hintergrund-Schleife dauerhaft
|
||||||
ein Mensch auf genau diese Antwort wartet (/rohdaten, retry-transcode).
|
mit. Ausführliche Begründung dort.
|
||||||
Für die Job-Liste gibt es den Vorrat unten.
|
|
||||||
|
Bleibt trotzdem der langsame Weg (mehrere Kind-Prozesse, im schlechtesten
|
||||||
|
Fall je vier Sekunden). Für die Job-Liste, die das Dashboard alle vier
|
||||||
|
Sekunden abfragt, gibt es deshalb den Vorrat unten.
|
||||||
"""
|
"""
|
||||||
return rohdaten.suche(job_id, work_dir, os.listdir, os.path.isdir)
|
return rohdaten.suche(job_id, work_dir, os.listdir, rohdaten.verzeichnis_da)
|
||||||
|
|
||||||
|
|
||||||
# Vorrat für die Job-Liste. Dasselbe Muster wie beim Celery-Ping in
|
# Vorrat für die Job-Liste. Dasselbe Muster wie beim Celery-Ping in
|
||||||
@@ -410,7 +414,9 @@ def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool:
|
|||||||
vorrat = _ROHDATEN["treffer"]
|
vorrat = _ROHDATEN["treffer"]
|
||||||
if job["id"] in vorrat:
|
if job["id"] in vorrat:
|
||||||
return bool(vorrat[job["id"]])
|
return bool(vorrat[job["id"]])
|
||||||
return os.path.isdir(os.path.join(rohdaten.RAW_STANDARD, job["id"]))
|
# Nur der lokale Ort: /app/temp ist ein Docker-Volume, os.path.isdir kann
|
||||||
|
# dort nicht hängen (im Gegensatz zu allem unter /app/media).
|
||||||
|
return os.path.isdir(posixpath.join(rohdaten.RAW_STANDARD, job["id"]))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/jobs", response_model=List[Job])
|
@app.get("/jobs", response_model=List[Job])
|
||||||
@@ -443,7 +449,13 @@ async def get_jobs():
|
|||||||
|
|
||||||
|
|
||||||
def _rohdaten_groesse(pfade: list) -> tuple:
|
def _rohdaten_groesse(pfade: list) -> tuple:
|
||||||
"""(Bytes, Dateizahl) der Roh-MKVs (Details in rohdaten.py)."""
|
"""(Bytes, Dateizahl) der Roh-MKVs (Details in rohdaten.py).
|
||||||
|
|
||||||
|
Hier bleibt os.* stehen: Diese Funktion wird nur aufgerufen, NACHDEM
|
||||||
|
`verzeichnis_da` den Pfad innerhalb von Sekunden bestätigt hat — der Mount
|
||||||
|
antwortet also. Und sie läuft nur, wenn ein Mensch auf die Zahl wartet
|
||||||
|
(/rohdaten, Löschen), nie in der Hintergrund-Schleife.
|
||||||
|
"""
|
||||||
return rohdaten.groesse(pfade, os.listdir, os.path.isfile, os.path.getsize)
|
return rohdaten.groesse(pfade, os.listdir, os.path.isfile, os.path.getsize)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+50
-2
@@ -39,11 +39,15 @@ Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import posixpath
|
import posixpath
|
||||||
|
import subprocess
|
||||||
|
|
||||||
# Container-Standard für Roh-Rips (RAW_DIR im Worker).
|
# Container-Standard für Roh-Rips (RAW_DIR im Worker).
|
||||||
RAW_STANDARD = "/app/temp/raw"
|
RAW_STANDARD = "/app/temp/raw"
|
||||||
MEDIA_ROOT = "/app/media"
|
MEDIA_ROOT = "/app/media"
|
||||||
|
|
||||||
|
# Harte Obergrenze für EINE Verzeichnis-Prüfung. Siehe verzeichnis_da().
|
||||||
|
PRUEF_TIMEOUT_SEKUNDEN = 4
|
||||||
|
|
||||||
|
|
||||||
def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
|
def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
|
||||||
"""Alle Orte, an denen die Roh-MKVs dieses Jobs liegen KÖNNTEN (pure).
|
"""Alle Orte, an denen die Roh-MKVs dieses Jobs liegen KÖNNTEN (pure).
|
||||||
@@ -76,12 +80,56 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
|
|||||||
return eindeutig
|
return eindeutig
|
||||||
|
|
||||||
|
|
||||||
|
def verzeichnis_da(pfad: str, laufen=None) -> bool:
|
||||||
|
"""Gibt es dieses Verzeichnis? — mit HARTER Zeitgrenze.
|
||||||
|
|
||||||
|
## Warum nicht os.path.isdir
|
||||||
|
|
||||||
|
Weil es an einem Netz-Mount unbegrenzt hängen kann, und zwar im Kernel
|
||||||
|
(Prozess-Zustand D, „uninterruptible sleep"). Genau das ist am 26.07.2026
|
||||||
|
passiert: Die Hintergrund-Schleife startete ihren ersten Durchlauf, während
|
||||||
|
Rippy die CIFS-Freigabe nach einem Container-Neustart neu einhängte. Ihr
|
||||||
|
`os.path.isdir` blieb stecken, `asyncio.to_thread` kam nie zurück, die
|
||||||
|
Schleife erreichte ihr `sleep` nie — und war damit für immer tot. Sichtbar
|
||||||
|
war nur, dass `can_retry` dauerhaft `false` blieb; zwei Threads standen im
|
||||||
|
Zustand D.
|
||||||
|
|
||||||
|
Ein Timeout um den Aufruf hätte nichts geholfen: Ein im Kernel hängender
|
||||||
|
Thread lässt sich aus Python nicht abbrechen, jeder Versuch hätte einen
|
||||||
|
weiteren Thread verbrannt, bis der Pool leer ist.
|
||||||
|
|
||||||
|
Ein Kind-PROZESS lässt sich abbrechen. Deshalb `timeout N ls -d <pfad>` —
|
||||||
|
dasselbe Werkzeug, das `mounts.ist_erreichbar` seit dem 24.07.2026 für
|
||||||
|
genau dieses Problem benutzt (dort für den toten NAS-Mount). Läuft es in
|
||||||
|
die Zeitgrenze, gilt das Verzeichnis als „nicht da": Ein Ort, den man nicht
|
||||||
|
innerhalb von Sekunden ansehen kann, ist für einen Rip ohnehin unbrauchbar.
|
||||||
|
|
||||||
|
`laufen` ist einspritzbar, damit das ohne echte Prozesse testbar bleibt.
|
||||||
|
"""
|
||||||
|
if not pfad:
|
||||||
|
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:
|
||||||
"""Die Orte, an denen wirklich etwas liegt.
|
"""Die Orte, an denen wirklich etwas liegt.
|
||||||
|
|
||||||
`listdir` und `isdir` werden übergeben statt importiert — so ist die Suche
|
`listdir` und `isdir` werden übergeben statt importiert — so ist die Suche
|
||||||
ohne Dateisystem prüfbar, und ein toter CIFS-Mount kann hier keinen
|
ohne Dateisystem prüfbar. Für `isdir` gehört `verzeichnis_da` eingesetzt und
|
||||||
Import-Zyklus verursachen.
|
NICHT os.path.isdir: Die Kandidaten liegen unter /app/media, und dort kann
|
||||||
|
ein Netz-Mount unbegrenzt hängen (Begründung bei verzeichnis_da).
|
||||||
|
|
||||||
|
`listdir` darf os.listdir bleiben: Gelistet wird nur /app/media selbst, und
|
||||||
|
das ist ein lokales Verzeichnis — die Freigaben sind Unterordner davon.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
unterordner = sorted(listdir(MEDIA_ROOT))
|
unterordner = sorted(listdir(MEDIA_ROOT))
|
||||||
|
|||||||
@@ -109,3 +109,73 @@ def test_groesse_ueberspringt_unlesbares():
|
|||||||
bytes_gesamt, anzahl = rohdaten.groesse(
|
bytes_gesamt, anzahl = rohdaten.groesse(
|
||||||
["/x"], lambda p: ["a.mkv"], lambda p: True, getsize_kaputt)
|
["/x"], lambda p: ["a.mkv"], lambda p: True, getsize_kaputt)
|
||||||
assert (bytes_gesamt, anzahl) == (0, 0)
|
assert (bytes_gesamt, anzahl) == (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Die harte Zeitgrenze: ein haengender Mount darf nichts toeten -----------
|
||||||
|
#
|
||||||
|
# Am 26.07.2026 hing os.path.isdir an der CIFS-Freigabe im
|
||||||
|
# KERNEL (Prozess-Zustand D). asyncio.to_thread kam nie zurueck, die
|
||||||
|
# Hintergrund-Schleife erreichte ihr sleep nie und war dauerhaft tot.
|
||||||
|
|
||||||
|
|
||||||
|
class _Lauf:
|
||||||
|
"""Merkt sich das Kommando und liefert einen gesetzten Rueckgabewert."""
|
||||||
|
|
||||||
|
def __init__(self, rc=0, wirf=None):
|
||||||
|
self.rc, self.wirf, self.cmd = rc, wirf, None
|
||||||
|
|
||||||
|
def __call__(self, cmd, **kwargs):
|
||||||
|
self.cmd = cmd
|
||||||
|
if self.wirf:
|
||||||
|
raise self.wirf
|
||||||
|
|
||||||
|
class E:
|
||||||
|
returncode = self.rc
|
||||||
|
return E()
|
||||||
|
|
||||||
|
|
||||||
|
def test_verzeichnis_da_nutzt_timeout_und_ls():
|
||||||
|
"""Ein Kind-PROZESS laesst sich abbrechen, ein im Kernel haengender Thread
|
||||||
|
nicht. Deshalb genau dieses Kommando - wie mounts.ist_erreichbar."""
|
||||||
|
lauf = _Lauf(rc=0)
|
||||||
|
assert rohdaten.verzeichnis_da("/app/media/rippy/x", lauf) is True
|
||||||
|
assert lauf.cmd[0] == "timeout"
|
||||||
|
assert lauf.cmd[1] == str(rohdaten.PRUEF_TIMEOUT_SEKUNDEN)
|
||||||
|
assert lauf.cmd[2:] == ["ls", "-d", "/app/media/rippy/x"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_verzeichnis_da_nicht_vorhanden():
|
||||||
|
assert rohdaten.verzeichnis_da("/gibt/es/nicht", _Lauf(rc=2)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_verzeichnis_in_der_zeitgrenze_gilt_als_nicht_da():
|
||||||
|
"""`timeout` beendet ls mit 124. Ein Ort, den man nicht in Sekunden ansehen
|
||||||
|
kann, ist fuer einen Rip ohnehin unbrauchbar."""
|
||||||
|
assert rohdaten.verzeichnis_da("/app/media/totes-nas/x", _Lauf(rc=124)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_verzeichnis_da_ueberlebt_kaputte_umgebung():
|
||||||
|
import subprocess as sp
|
||||||
|
|
||||||
|
assert rohdaten.verzeichnis_da("/x", _Lauf(wirf=OSError("kein timeout"))) is False
|
||||||
|
assert rohdaten.verzeichnis_da(
|
||||||
|
"/x", _Lauf(wirf=sp.TimeoutExpired("ls", 6))) is False
|
||||||
|
assert rohdaten.verzeichnis_da("", _Lauf(rc=0)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_suche_mit_der_zeitgrenze_findet_den_echten_fall():
|
||||||
|
"""Zusammenspiel: os.listdir fuer /app/media (lokal, sicher),
|
||||||
|
verzeichnis_da fuer die Kandidaten (koennen im Netz liegen)."""
|
||||||
|
def laufen(cmd, **kwargs):
|
||||||
|
pfad = cmd[-1]
|
||||||
|
|
||||||
|
class E:
|
||||||
|
returncode = 0 if pfad == f"/app/media/rippy/{JOB}" else 1
|
||||||
|
return E()
|
||||||
|
|
||||||
|
gefunden = rohdaten.suche(
|
||||||
|
JOB, "",
|
||||||
|
listdir=lambda p: ["bluray", "movies", "rippy"],
|
||||||
|
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
|
||||||
|
)
|
||||||
|
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
||||||
|
|||||||
Reference in New Issue
Block a user