2554c2633b
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>
171 lines
7.1 KiB
Python
171 lines
7.1 KiB
Python
"""Wo liegen die Roh-MKVs eines Jobs? — Suchen statt annehmen.
|
||
|
||
## Der Fund, der dieses Modul nötig gemacht hat (26.07.2026, live gemessen)
|
||
|
||
Job `95afdc89` stand auf `failed`, und im Ablageziel lagen **79,6 GB** intakter
|
||
Rohschnitt (`/app/media/rippy/95afdc89-…/title_t00.mkv`). Der SAVEPOINT v3.16
|
||
schrieb dazu: „→ ‚Neu komprimieren' genügt, kein Neu-Rip". Die Gegenprobe an der
|
||
laufenden Instanz sagt: **`can_retry` war `false`** — den Knopf gab es gar nicht.
|
||
|
||
Ursache: `_kann_neu_komprimieren` suchte an genau zwei Orten — im
|
||
Container-Standard `/app/temp/raw/<id>` und unter dem AKTUELLEN Wert der
|
||
Einstellung `workDir`. Der Rip war aber mit einer Wahl *für diesen einen Rip*
|
||
auf die NAS gelegt worden (das gibt es seit v3.15 im Rip-Dialog), und die
|
||
Einstellung selbst stand auf leer. Damit zeigte nichts mehr auf die Datei:
|
||
|
||
workDir (Einstellung) = "" → geprüft wurde nur /app/temp/raw
|
||
tatsächlicher Ort = /app/media/rippy/<id>
|
||
Ergebnis → 75 GB unsichtbar, Neu-Rip scheinbar unvermeidlich
|
||
|
||
Das ist derselbe Fehler, der dieses Projekt schon mehrfach gekostet hat: aus
|
||
einem Zustandswert (der heutigen Einstellung) auf einen Mechanismus (wohin
|
||
damals gerippt wurde) geschlossen, statt nachzusehen.
|
||
|
||
## Warum gesucht und nicht gespeichert wird
|
||
|
||
Den Ort in die Job-Zeile zu schreiben wäre sauberer — aber die jobs-Tabelle
|
||
bräuchte eine neue Spalte, und `create_all` legt nur fehlende TABELLEN an, keine
|
||
fehlenden Spalten. Eine Migration für einen Suchraum von einer Handvoll
|
||
Verzeichnissen ist das falsche Werkzeug, und Bestandsjobs (genau der Fall hier)
|
||
hätten den Wert ohnehin nicht.
|
||
|
||
Der Suchraum ist nämlich klein und geschlossen: `_arbeitsverzeichnis()` im Worker
|
||
lässt ausschließlich den Container-Standard oder einen Pfad UNTER `/app/media`
|
||
zu. Es genügt also, `/app/temp/raw/<id>` und `<jedes Speicherziel>/<id>`
|
||
anzusehen — die oberste Ebene von `/app/media`, ohne Rekursion.
|
||
|
||
Verwechslungsgefahr gibt es dabei nicht: Roh-Verzeichnisse heißen exakt wie die
|
||
Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`.
|
||
"""
|
||
|
||
import posixpath
|
||
import subprocess
|
||
|
||
# Container-Standard für Roh-Rips (RAW_DIR im Worker).
|
||
RAW_STANDARD = "/app/temp/raw"
|
||
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:
|
||
"""Alle Orte, an denen die Roh-MKVs dieses Jobs liegen KÖNNTEN (pure).
|
||
|
||
`media_unterordner` sind die Namen der obersten Ebene unter /app/media
|
||
(Ablageziele inkl. eingehängter Freigaben) — die Liste kommt vom Aufrufer,
|
||
damit diese Funktion ohne Dateisystem testbar bleibt.
|
||
|
||
Reihenfolge: Container-Standard, dann die eingestellte Wahl, dann alle
|
||
Ablageziele. Doppelte fliegen raus, die Reihenfolge bleibt stabil.
|
||
|
||
posixpath, nicht os.path: Das sind Container-Pfade. os.path.join baut unter
|
||
Windows Backslashes daraus, und dann greift keine Prüfung mehr — dieselbe
|
||
Falle wie bei `_zielbasis()` (v3.14) und `_mountpoint()` (26.07.2026).
|
||
"""
|
||
if not job_id:
|
||
return []
|
||
orte = [posixpath.join(RAW_STANDARD, job_id)]
|
||
wahl = (work_dir or "").strip().rstrip("/")
|
||
if wahl and (wahl == MEDIA_ROOT or wahl.startswith(MEDIA_ROOT + "/")):
|
||
orte.append(posixpath.join(wahl, job_id))
|
||
for name in media_unterordner or []:
|
||
if name:
|
||
orte.append(posixpath.join(MEDIA_ROOT, name, job_id))
|
||
gesehen, eindeutig = set(), []
|
||
for ort in orte:
|
||
if ort not in gesehen:
|
||
gesehen.add(ort)
|
||
eindeutig.append(ort)
|
||
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:
|
||
"""Die Orte, an denen wirklich etwas liegt.
|
||
|
||
`listdir` und `isdir` werden übergeben statt importiert — so ist die Suche
|
||
ohne Dateisystem prüfbar. Für `isdir` gehört `verzeichnis_da` eingesetzt und
|
||
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:
|
||
unterordner = sorted(listdir(MEDIA_ROOT))
|
||
except OSError:
|
||
unterordner = []
|
||
gefunden = []
|
||
for ort in kandidaten(job_id, work_dir, unterordner):
|
||
try:
|
||
if isdir(ort):
|
||
gefunden.append(ort)
|
||
except OSError:
|
||
# Toter Mount → als „nicht da" werten. Ein Fehlschlag hier darf die
|
||
# Job-Liste nicht mitnehmen (Befund 24.07. bei /storage-targets).
|
||
continue
|
||
return gefunden
|
||
|
||
|
||
def groesse(pfade: list, listdir, isfile, getsize) -> tuple:
|
||
"""(Bytes, Dateizahl) der Roh-Dateien — flach, nicht rekursiv.
|
||
|
||
Flach genügt: MakeMKV legt die Titel als `title_tNN.mkv` direkt in das
|
||
Job-Verzeichnis, Unterordner entstehen dort nicht.
|
||
"""
|
||
bytes_gesamt, dateien = 0, 0
|
||
for pfad in pfade or []:
|
||
try:
|
||
namen = listdir(pfad)
|
||
except OSError:
|
||
continue
|
||
for name in namen:
|
||
voll = posixpath.join(pfad, name)
|
||
try:
|
||
if isfile(voll):
|
||
bytes_gesamt += getsize(voll)
|
||
dateien += 1
|
||
except OSError:
|
||
continue
|
||
return bytes_gesamt, dateien
|