From 19f3dc5330efcd3ae6ee50bfc6c5466f54c23d9c Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sun, 26 Jul 2026 16:16:05 +0200 Subject: [PATCH] fix(zombies): "running" fehlte - ein abgestuerzter RIP wurde NIE gefunden Der Commander: "Ausserdem ist gerade mitten im Rip das Laufwerk ausgegangen... glaube ich zumindest." Gemessen war es etwas anderes, und der Fund ist groesser als der Vorfall. WAS MESSBAR WAR: Job 2182d525 status=running progress=12 (Rip gestartet 14:00:24) makemkvcon laeuft NICHT (per /proc geprueft, PID gegengeprueft) Rohdatei 5.167.382.528 Bytes, waechst in 10 s nicht Laufwerk Status 4 (Disc drin), /dev/sr0 + /dev/sg1 da Worker-Start 14:07:02 <- mein `docker compose up -d --build` Das Laufwerk ist also NICHT ausgegangen. Der Rip wurde von MEINEM Deploy getoetet: `up -d --build` baut den worker-Container neu, und der laufende Rip stirbt mit ihm. Genau davor warnt der SAVEPOINT seit v3.18 - die Warnung half nichts, weil sie niemand liest und nichts sie prueft. DER EIGENTLICHE FUND: Die Zombie-Erkennung lief um 14:09:04 und meldete `{'geprueft': 0, 'aufgeraeumt': []}` - obwohl der tote Job direkt vor ihr lag. Ursache: ARBEITS_STATI = ("ripping", "transcoding", "canceling") # zombies.py db.update_job(job_id, status="running", ...) # tasks.py - der Rip Der Rip setzt "running", gesucht wurde "ripping". Dieser Wert steht ausschliesslich in Celerys Task-META und NIE in einer Job-Zeile (nachgeprueft: kein einziger Schreiber im ganzen Baum). Die Zombie-Erkennung aus v3.14 wurde gebaut, um genau einen abgestuerzten Rip zu finden - und hat ihn nie gesehen. Besonders tueckisch: `geprueft: 0` sah bei jedem Worker-Start wie "nachgesehen, alles gesund" aus, waehrend sie nach einem Status suchte, den es nicht gibt. Deshalb blieb der Job auf "processing 12 %" stehen - mit einer Restzeit-Schaetzung von 1 h 30 min obendrauf, die es fuer einen toten Prozess nicht geben duerfte. GEBAUT: * "running" in ARBEITS_STATI. * Ein Test, der das Auseinanderlaufen MECHANISCH verhindert: Er liest tasks.py, sammelt jeden Status, den der Worker per db.update_job in eine Job-Zeile schreibt, und verlangt, dass jeder davon entweder ein Arbeitsstatus oder ein Endzustand ist. Ein Kommentar haette das nicht verhindert. * GET /health/arbeit + eine Sperre in deploy.sh: Laeuft ein Job, bricht der Deploy ab (uebersteuerbar mit RIPPY_TROTZDEM=1 - dann ist es eine Entscheidung und kein Versehen). Ein Satz Code gegen eine verlorene Stunde. * Server-Status zeigt jetzt die eingehaengten FREIGABEN mit freiem Platz, nicht nur die Container-Platte (Commander-Wunsch). Genau dort liegen die Rohdaten, und bei externem Encoden muessen sie dort liegen - wer wissen wollte, ob noch Platz fuer eine Disc ist, sah die falsche Zahl. Co-Authored-By: Claude Opus 5 --- deploy.sh | 22 ++++++++++ docker/api/main.py | 27 ++++++++++++ docker/ui/src/pages/Dashboard.tsx | 71 +++++++++++++++++++++++++++++-- docker/worker/test_zombies.py | 51 ++++++++++++++++++++++ docker/worker/zombies.py | 17 +++++++- 5 files changed, 183 insertions(+), 5 deletions(-) diff --git a/deploy.sh b/deploy.sh index 68027e0..dd923eb 100644 --- a/deploy.sh +++ b/deploy.sh @@ -36,6 +36,28 @@ ENV_SRC="${2:-$HOME/rippy/.env}" CLONE="${3:-$HOME/notfall-rippy}" DIENST="$4" +# ⚠️ NICHT DEPLOYEN, WÄHREND EIN JOB LÄUFT (Vorfall 26.07.2026, selbst verursacht) +# +# `docker compose up -d --build` baut den worker-Container neu — und tötet damit +# einen laufenden Rip. Genau das ist passiert: mitten in einem Blu-ray-Rip, bei +# 12 %, nach 5,1 GB. Die Warnung stand im SAVEPOINT und half nichts, weil sie +# niemand las und nichts sie prüfte. Jetzt prüft es das Skript. +# +# Übersteuern mit RIPPY_TROTZDEM=1 — dann ist es eine Entscheidung und kein +# Versehen. +if [ -z "${RIPPY_TROTZDEM:-}" ]; then + ANTWORT="$(curl -s -m 5 http://localhost:8000/health/arbeit 2>/dev/null || true)" + case "$ANTWORT" in + *'"arbeit":true'*|*'"arbeit": true'*) + echo "ABBRUCH: Auf dieser Maschine läuft gerade ein Job." >&2 + echo " Ein Rebuild des worker-Containers würde ihn töten." >&2 + echo "$ANTWORT" | tr ',' '\n' | grep -E '"(status|titel|progress)"' >&2 || true + echo " Warten, oder bewusst überstimmen: RIPPY_TROTZDEM=1 ./deploy.sh" >&2 + exit 1 + ;; + esac +fi + if [ ! -d "$CLONE/.git" ]; then git clone --branch main "$REPO_URL" "$CLONE" fi diff --git a/docker/api/main.py b/docker/api/main.py index c03a762..e600380 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -308,6 +308,33 @@ async def health_check(): return {"status": "ok", "service": "api"} +@app.get("/health/arbeit") +async def health_arbeit(): + """Läuft gerade ein Job? — die Frage VOR einem Deploy. + + ⚠️ Warum es das gibt (26.07.2026, selbst verursacht): Ein + `docker compose up -d --build` baut den worker-Container neu und tötet damit + einen laufenden Rip. Genau das ist passiert — mitten in einem Blu-ray-Rip, + bei 12 %, nach 5,1 GB. Im SAVEPOINT stand die Warnung „nicht deployen, + während ein Rip läuft" schon; sie half nichts, weil niemand sie las und + nichts sie prüfte. + + Jetzt fragt `deploy.sh` hier nach und bricht ab. Ein Satz Code gegen eine + verlorene Stunde. + """ + def sammle(): + laufend = [ + {"id": z["id"], "status": z["status"], "progress": z.get("progress") or 0, + "titel": z.get("title") or ""} + for z in db.list_jobs() + if z.get("status") in ("pending", "running", "ripping", + "transcoding", "canceling") + ] + return {"arbeit": bool(laufend), "jobs": laufend} + + return await asyncio.to_thread(sammle) + + @app.get("/health/vorraete") async def health_vorraete(): """Laufen die Hintergrund-Schleifen wirklich? (Diagnose, kein UI-Endpunkt) diff --git a/docker/ui/src/pages/Dashboard.tsx b/docker/ui/src/pages/Dashboard.tsx index b931d5d..cec2f8f 100644 --- a/docker/ui/src/pages/Dashboard.tsx +++ b/docker/ui/src/pages/Dashboard.tsx @@ -62,6 +62,21 @@ interface LaufwerkLive { disc?: { title?: string, year?: number | null, disc_type?: string } } +// Ein Ablageziel aus GET /storage-targets — Verzeichnisse unter /app/media +// inklusive der eingehängten Netzwerk-Freigaben, mit freiem Platz. +// +// Commander-Befund 26.07.2026: Der Server-Status zeigte nur „Media +// (/app/media)" — also die Container-Platte. Die eingehängte NAS-Freigabe mit +// ihren 2,1 TB fehlte, obwohl genau dort die Rohdaten und (bei externem +// Encoden) auch die Ablage liegen müssen. Wer wissen will, ob noch Platz für +// eine Disc ist, sah die falsche Zahl. +interface AblageZiel { + name: string + path: string + is_mount: boolean + free_gb: number | null +} + function posterUrl(meta?: JobMeta | null): string | null { const p = meta?.poster_path if (!p) return null @@ -90,6 +105,7 @@ export default function Dashboard() { const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all') const [workersLive, setWorkersLive] = useState([]) const [laufwerke, setLaufwerke] = useState([]) + const [ablagen, setAblagen] = useState([]) const { toast } = useToast() /* @@ -145,7 +161,7 @@ export default function Dashboard() { useEffect(() => { const loadData = async () => { try { - const [jobsData, sysData, capsData, devData] = await Promise.all([ + const [jobsData, sysData, capsData, devData, ablData] = await Promise.all([ fetchJobs(), api.get('/system/info').then(r => r.data).catch(() => null), // Für die ECHTE Online-Zahl: /capabilities kennt den Celery-Ping, @@ -156,11 +172,15 @@ export default function Dashboard() { // Die Laufwerke: Ohne sie behauptete die Server-Status-Karte // „keine Disc in Arbeit", während oben die erkannte Disc stand. api.get('/devices').then(r => r.data || []).catch(() => []), + // Die Ablageziele inkl. eingehängter Freigaben — ohne sie zeigte der + // Server-Status nur die Container-Platte (Commander-Befund). + api.get('/storage-targets').then(r => Array.isArray(r.data) ? r.data : []).catch(() => []), ]) setJobs(jobsData) if (sysData) setSystemInfo(sysData) setWorkersLive(capsData) setLaufwerke(devData) + setAblagen(ablData) } finally { setLoading(false) } @@ -213,6 +233,10 @@ export default function Dashboard() { * weiß nicht, welcher er glauben soll. */ const discImLaufwerk = laufwerke.find(l => l.disc?.title) + // Nur die echten Netzwerk-Freigaben, nicht die normalen Unterordner von + // /app/media (movies/series/music liegen auf der Container-Platte und sind + // dort schon mitgezählt). + const freigaben = ablagen.filter(a => a.is_mount) const phaseText = aktiverJob ? (aktiverJob.status === 'transcoding' ? 'Kompression läuft (HandBrake)' @@ -609,11 +633,14 @@ export default function Dashboard() { )} - {/* 3. Platz — war schon immer echt */} + {/* 3. Platz — Container-Platte UND die eingehängten Freigaben. + Vorher stand hier nur die Container-Platte; wer wissen + wollte, ob die NAS noch Platz für eine Disc hat, sah die + falsche Zahl (Commander-Befund 26.07.2026). */}
- {mainPlatz?.name || 'Ablage'}: + Container-Platte: 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}> {freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'} @@ -627,13 +654,49 @@ export default function Dashboard() { style={{ width: `${Math.max(2, belegtPercent)}%` }} />
- {freiGb > 0 && freiGb < 60 && ( + {freiGb > 0 && freiGb < 60 && freigaben.length === 0 && (

Eine Blu-ray braucht roh ~40 GB, eine 4K-UHD bis 100 GB — das reicht nicht mehr für jede Disc.

)}
+ + {/* Die eingehängten Freigaben. Sie sind der Ort, an dem die + Rohdaten liegen und — bei externem Encoden — liegen MÜSSEN. */} +
+
+ + Freigaben: + + 0 ? 'text-slate-200' : 'text-slate-400'}> + {freigaben.length > 0 + ? `${freigaben.length} eingehängt` + : 'keine eingehängt'} + +
+ {freigaben.length === 0 ? ( +

+ Ohne Freigabe kann ein externer Encoder nichts tun — er sieht die + Container-Platte nicht. Einhängen: Einstellungen → Speicherziele. +

+ ) : ( +
+ {freigaben.map(f => ( +

+ + {f.name} + + + {f.free_gb != null ? `${f.free_gb} GB frei` : 'Platz unbekannt'} + +

+ ))} +
+ )} +
diff --git a/docker/worker/test_zombies.py b/docker/worker/test_zombies.py index 801f797..6aa9d4e 100644 --- a/docker/worker/test_zombies.py +++ b/docker/worker/test_zombies.py @@ -188,3 +188,54 @@ def test_fehler_reisst_den_worker_start_nicht_mit(): db = KaputteDb([]) bericht = zombies.raeume_zombies_auf(FakeCelery(FakeInspektor()), db) assert "Postgres weg" in bericht["uebersprungen"] + + +# --- Die Luecke, die die Erkennung nutzlos machte (Befund 26.07.2026) -------- + + +def test_running_gilt_als_arbeitsstatus(): + """DER Fehler: Die Erkennung suchte "ripping", der Rip setzt aber "running". + Ergebnis: Ein abgestuerzter RIP wurde NIE gefunden - und die Meldung + `{'geprueft': 0}` sah bei jedem Worker-Start wie Gesundheit aus.""" + assert "running" in zombies.ARBEITS_STATI + assert "transcoding" in zombies.ARBEITS_STATI + assert "canceling" in zombies.ARBEITS_STATI + + +def test_abgestuerzter_rip_wird_gefunden(): + """Der konkrete Fall vom 26.07.2026: Job 2182d525 stand auf running/12 %, + kein makemkvcon lief, die Rohdatei wuchs nicht mehr - und die Erkennung + pruefte null Jobs.""" + offene = [{"id": "2182d525", "status": "running", "title": "Akira"}] + assert zombies.finde_zombies(offene, set()) == offene + + +def test_arbeitsstati_deckt_ab_was_der_worker_wirklich_schreibt(): + """Mechanische Sperre gegen genau dieses Auseinanderlaufen. + + Liest tasks.py und sammelt jeden Status, den der Worker per db.update_job in + eine Job-ZEILE schreibt. Jeder davon muss entweder ein Arbeitsstatus sein + oder ein Endzustand - sonst gibt es wieder einen Zustand, den niemand + aufraeumt. Ein Kommentar haette das nicht verhindert; dieser Test schon. + """ + import os + import re + + pfad = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tasks.py") + quelle = open(pfad, encoding="utf-8").read() + + # Nur die Aufrufe, die wirklich die Job-Zeile aendern. + geschrieben = set() + for aufruf in re.finditer(r"db\.update_job\((?:[^()]|\([^()]*\))*\)", quelle): + for treffer in re.finditer(r'status\s*=\s*"([a-z]+)"', aufruf.group(0)): + geschrieben.add(treffer.group(1)) + + endzustaende = {"completed", "failed"} + unbeaufsichtigt = geschrieben - set(zombies.ARBEITS_STATI) - endzustaende + assert not unbeaufsichtigt, ( + f"Diese Job-Status schreibt der Worker, aber niemand raeumt sie auf: " + f"{sorted(unbeaufsichtigt)}. Entweder in ARBEITS_STATI aufnehmen oder " + f"als Endzustand behandeln." + ) + # Gegenprobe, dass der Test wirklich etwas gesehen hat + assert "running" in geschrieben diff --git a/docker/worker/zombies.py b/docker/worker/zombies.py index a02e8ab..e65780e 100644 --- a/docker/worker/zombies.py +++ b/docker/worker/zombies.py @@ -28,7 +28,22 @@ Ampel sie ohne Infrastruktur prüfen kann. """ # Zustände, die behaupten: hier arbeitet gerade jemand. -ARBEITS_STATI = ("ripping", "transcoding", "canceling") +# +# ⚠️ „running" FEHLTE bis zum 26.07.2026 — und das war der ganze Witz: Die +# Zombie-Erkennung aus v3.14 wurde gebaut, um genau einen abgestürzten RIP zu +# finden, und hat ihn nie gesehen. Der Rip setzt `status="running"` +# (tasks.py, db.update_job), gesucht wurde aber „ripping". Dieser Wert steht +# ausschließlich in Celerys Task-Meta und NIE in einer Job-Zeile. +# +# Die Folge war besonders tückisch: Bei jedem Worker-Start meldete die Erkennung +# `{'geprueft': 0, ...}` — und das sah wie „nachgesehen, alles gesund" aus, +# während sie in Wahrheit nach einem Status suchte, den es nicht gibt. Gefunden +# am 26.07.2026, als ein Rip mitten im Lauf abbrach und der Job danach dauerhaft +# auf „processing 12 %" stand, mit einer Restzeit-Schätzung obendrauf. +# +# „ripping" bleibt bewusst drin: Es schadet nicht und deckt eine etwaige +# Bestandsinstallation ab, in der es doch gesetzt wurde. +ARBEITS_STATI = ("running", "ripping", "transcoding", "canceling") # Wartezeit nach dem Worker-Start, bevor geurteilt wird. Deckt die # Wiederzustellung unbestätigter Aufgaben durch Celery ab.