Files
rippy/docker/worker/zombies.py
T
Hitonabi 19f3dc5330
Ampel / ampel (push) Successful in 30s
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 <noreply@anthropic.com>
2026-07-26 16:16:05 +02:00

165 lines
7.1 KiB
Python

"""Erkennt Job-Leichen: Jobs, die behaupten zu laufen, an denen aber niemand arbeitet.
Befund 25.07.2026 (Akira-UHD): Nach einem Absturz stand ein Job auf
`transcoding` bei 96 %, obwohl weder ein Prozess lief noch etwas in den
Celery-Queues stand. Folge für den Nutzer: kein Fortschritt, kein Download —
und der Knopf „Neu komprimieren" fehlte, weil `_kann_neu_komprimieren`
(api/main.py) `status == "failed"` verlangt. Der Job war damit unerreichbar,
obwohl die Rohdateien vollständig dalagen.
## Die Leitregel: ohne vollständige Auskunft wird NICHTS angetastet
Ein falsch als Leiche markierter Job ist teurer als eine stehengebliebene
Leiche. Deshalb drei Sicherungen:
1. **Nur beim Worker-Start.** Da ist die Aussage eindeutig: „als ich hochkam,
lief nichts". Ein periodischer Lauf könnte einen Job erwischen, der legitim
in der Warteschlange wartet, weil alle Arbeitsplätze belegt sind — der ist
für `active()`/`reserved()` unsichtbar. Bewusst nicht gebaut.
2. **Gnadenfrist.** Nach einem Neustart stellt Celery unbestätigte Aufgaben
erneut zu. Erst abwarten, dann urteilen.
3. **Vollzähligkeit.** Es wird nur geurteilt, wenn mindestens so viele
Celery-Knoten antworten, wie laut Herzschlag online sind. Antwortet ein
beschäftigter Remote-Worker nicht rechtzeitig, wäre sein laufender Job
sonst eine falsche Leiche.
Die Kernfunktionen sind bewusst rein (kein Postgres, kein Redis), damit die
Ampel sie ohne Infrastruktur prüfen kann.
"""
# Zustände, die behaupten: hier arbeitet gerade jemand.
#
# ⚠️ „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.
GNADENFRIST_SEKUNDEN = 120
# Wie lange auf Antworten der Celery-Knoten gewartet wird. Großzügig, weil ein
# Worker mitten in einem 4K-Encode träge antwortet.
INSPEKT_TIMEOUT_SEKUNDEN = 10
def belegte_job_ids(inspektionen, kandidaten) -> set:
"""Welche der `kandidaten`-IDs kommen in irgendeiner Celery-Aufgabe vor?
Bewusst als Textsuche über die ganze Inspektions-Antwort: `job_id` steht
bei `rip_disc` an ZWEITER, bei `transcode_files` an ERSTER Stelle, und
Celery liefert `args` je nach Version als Liste oder als Text. Eine
Positions-Auswertung wäre an beidem zerbrechlich. Eine Job-ID ist eine
36-stellige UUID — Fehltreffer sind praktisch ausgeschlossen.
`inspektionen` ist die Liste der Antworten (active/reserved/scheduled);
`None`-Einträge werden übersprungen.
"""
vorhandene = [i for i in (inspektionen or []) if i]
if not vorhandene:
return set()
text = repr(vorhandene)
return {jid for jid in kandidaten if jid and jid in text}
def antwortende_knoten(inspektionen) -> set:
"""Namen aller Celery-Knoten, die auf die Inspektion geantwortet haben."""
knoten = set()
for antwort in (inspektionen or []):
if antwort:
knoten.update(antwort.keys())
return knoten
def auskunft_vollstaendig(anzahl_antworten: int, anzahl_bekannt: int) -> bool:
"""Darf aus dieser Auskunft überhaupt ein Urteil folgen?
Nein, wenn niemand geantwortet hat (dann wissen wir nichts), und nein, wenn
weniger Knoten antworten als laut Herzschlag online sind (dann fehlt uns
genau der Knoten, der den Job vielleicht gerade bearbeitet).
"""
if anzahl_antworten < 1:
return False
return anzahl_antworten >= anzahl_bekannt
def finde_zombies(offene_jobs, belegte) -> list:
"""Jobs aus `offene_jobs`, zu denen keine Celery-Aufgabe existiert."""
return [job for job in offene_jobs if job.get("id") not in belegte]
def fehlertext(job) -> str:
"""Ehrlicher Klartext für die Job-Karte — was war, und was jetzt hilft."""
zuletzt = job.get("status") or "unbekannt"
return (
f"Abgebrochen: Der Vorgang lief nicht mehr (zuletzt gemeldet: {zuletzt}). "
"Beim Start des Workers war dazu weder ein Prozess noch eine Aufgabe in "
"der Warteschlange zu finden — meistens ein Absturz oder ein Neustart "
"mitten im Lauf. Die Rohdateien wurden NICHT gelöscht: mit "
"'Neu komprimieren' läuft die Kompression erneut, ohne die Disc noch "
"einmal zu rippen."
)
def raeume_zombies_auf(celery_app, db) -> dict:
"""Sucht Leichen und setzt sie ehrlich auf `failed`. Wirft nie.
Rückgabe (auch für den Log): was geprüft und was getan wurde.
"""
bericht = {"geprueft": 0, "aufgeraeumt": [], "uebersprungen": ""}
try:
offene = db.list_jobs_mit_status(ARBEITS_STATI)
bericht["geprueft"] = len(offene)
if not offene:
return bericht
inspektor = celery_app.control.inspect(timeout=INSPEKT_TIMEOUT_SEKUNDEN)
inspektionen = [inspektor.active(), inspektor.reserved(), inspektor.scheduled()]
knoten = antwortende_knoten(inspektionen)
bekannt = db.zaehle_online_worker()
if not auskunft_vollstaendig(len(knoten), bekannt):
bericht["uebersprungen"] = (
f"unvollständige Auskunft ({len(knoten)} von {bekannt} Worker "
"haben geantwortet) — es wird nichts als Leiche gewertet"
)
db.add_log(
"info", "worker",
f"Zombie-Erkennung übersprungen: {bericht['uebersprungen']}",
)
return bericht
belegte = belegte_job_ids(inspektionen, [j.get("id") for j in offene])
for job in finde_zombies(offene, belegte):
db.update_job(
job["id"],
status="failed",
error=fehlertext(job),
finished_at=db.utcnow(),
)
db.add_log(
"warning", "worker",
f"Job {job['id']} ({job.get('title') or 'ohne Titel'}) stand auf "
f"'{job.get('status')}', es arbeitet aber niemand daran — "
"ehrlich auf 'failed' gesetzt. Rohdateien bleiben liegen.",
)
bericht["aufgeraeumt"].append(job["id"])
except Exception as e: # Erkennung darf den Worker-Start NIEMALS verhindern
bericht["uebersprungen"] = f"Fehler: {e}"
try:
db.add_log("warning", "worker", f"Zombie-Erkennung fehlgeschlagen: {e}")
except Exception:
pass
return bericht