Files
rippy/docker/worker/test_logbruecke.py
Hitonabi 87484d6863
Ampel / ampel (push) Successful in 28s
feat(worker): der externe Worker wird erwachsen - Log in Rippy, Anzeige, Slots
Commander 26.07.2026: "der externe Encoder Worker ist ein bisschen duenn - der
koennte noch viel mehr." Drei Punkte, alle am Tray.

1. LOG IN RIPPY STATT TXT-DATEI (ausdruecklich gewuenscht). Das Tray schrieb sein
   Log nach %LOCALAPPDATA% und oeffnete es im Editor - wer wissen wollte, warum
   der Worker nichts tut, musste sich an den PC setzen. Neue Bruecke
   (logbruecke.py) meldet die wichtigen Zeilen nach Rippy, Quelle "w:<name>",
   und die Logs-Seite hat jetzt Knoepfe je Quelle: "was macht mein PC" ist ein
   Klick. Der Filter konnte Quellen schon immer, es gab nur keinen Knopf.

   Durchgelassen wird WENIG und mit Grund: Die Job-Meldungen stehen laengst in
   Rippy (tasks.py schreibt sie selbst). Es fehlte, was DANEBEN passiert und den
   Worker unbrauchbar macht, ohne dass ein Job existiert - hochgefahren oder
   nicht, Verbindung zu Redis/Postgres, Abstuerze. Alles andere fliegt weg:
   Celery ist bei --loglevel=info gespraechig, die logs-Tabelle hat keine
   Aufraeumung, und ein zugemuelltes Log ist so unbrauchbar wie keins. Dazu eine
   Drossel (30 Zeilen/Minute), die MELDET, wieviel sie verschluckt hat.
   Zeilenformat woertlich aus dem laufenden Container abgenommen (Celery 5.4.0).

   Die lokale Datei bleibt - sie ist genau dann die einzige Auskunft, wenn Rippy
   nicht erreichbar ist.

2. DAS TRAY ZEIGT, WAS LAEUFT. Vorher stand dort "laeuft" oder "gestoppt" - auf
   einer Maschine, die stundenlang an einem Film rechnet, ist das keine Auskunft.
   Jetzt Titel, Prozent und Restzeit, geholt von Rippys /jobs. Bewusst dieselbe
   Quelle wie das Dashboard, damit im Tray nicht eine zweite, abweichende
   Schaetzung steht.

   Dazu: Windows schlaeft nicht mehr mitten im Encode ein
   (SetThreadExecutionState, ohne ES_DISPLAY_REQUIRED - der Bildschirm darf
   ausgehen). Die Sperre wird zurueckgenommen, sobald nichts laeuft, und auch bei
   einem harten Ende des Trays - sonst schlaeft der PC nie wieder ein und niemand
   weiss warum.

3. MEHRERE ENCODES GLEICHZEITIG. Der Worker lief fest mit --pool=solo und nahm
   genau EINEN Auftrag an. Der Installer fragt die Zahl jetzt (GUI: Feld neben
   dem Namen, mit der erkannten Kernzahl daneben), Vorbelegung ab 12 Kernen
   zwei, sonst einer: HandBrake nutzt schon alle Kerne, aber x265 skaliert nicht
   linear. Auf Windows gibt es keinen prefork-Pool (kein fork) - deshalb
   --pool=threads, was hier passt, weil die Arbeit ein Kind-Prozess ist und der
   Thread nur wartet.

GUI-Layout headless gerendert und angesehen (nichts ueberlappt, 16 Kerne
korrekt erkannt), beide .ps1 mit echtem PowerShell 5.1 geprueft, BOM und CRLF
erhalten, .exe neu gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:17:14 +02:00

128 lines
4.9 KiB
Python

"""Tests der Log-Brücke — reine Funktionen, keine Datenbank, keine Uhr.
Die Beispielzeilen sind WÖRTLICH aus dem laufenden Worker-Container abgenommen
(26.07.2026, Celery 5.4.0) — AGENTS Regel D. Erfundene Formate hätten hier
keinen Wert: Genau am geratenen Format scheitert so eine Brücke.
"""
import logbruecke
# Aus `docker compose logs worker`, unverändert.
ECHT_CONNECTED = "[2026-07-26 12:04:18,060: INFO/MainProcess] Connected to redis://redis:6379/0"
ECHT_MINGLE = "[2026-07-26 12:04:18,063: INFO/MainProcess] mingle: searching for neighbors"
ECHT_ALLEIN = "[2026-07-26 12:04:19,071: INFO/MainProcess] mingle: all alone"
ECHT_READY = "[2026-07-26 12:04:19,082: INFO/MainProcess] celery@ced31798928d ready."
ECHT_WARNUNG = (
"[2026-07-26 12:06:19,094: WARNING/MainProcess] Zombie-Erkennung: "
"{'geprueft': 0, 'aufgeraeumt': [], 'uebersprungen': ''}"
)
ECHT_BANNER = " -------------- celery@ced31798928d v5.4.0 (opalescent)"
def test_ready_ist_die_meldung_auf_die_man_wartet():
"""Wenn der Worker nichts tut, ist „ready." die Zeile, die man sehen will —
deshalb als Erfolg, nicht als Info im Grundrauschen."""
assert logbruecke.einordnen(ECHT_READY) == ("success", "celery@ced31798928d ready.")
def test_verbindung_kommt_durch():
level, text = logbruecke.einordnen(ECHT_CONNECTED)
assert level == "info"
assert "redis" in text
def test_warnungen_und_fehler_kommen_immer_durch():
level, text = logbruecke.einordnen(ECHT_WARNUNG)
assert level == "warning"
assert "Zombie-Erkennung" in text
assert logbruecke.einordnen(
"[2026-07-26 12:00:00,000: ERROR/MainProcess] kaputt")[0] == "error"
assert logbruecke.einordnen(
"[2026-07-26 12:00:00,000: CRITICAL/MainProcess] ganz kaputt")[0] == "error"
def test_grundrauschen_wird_verworfen():
"""Celery ist bei --loglevel=info gesprächig, und die logs-Tabelle hat keine
automatische Aufräumung. Ein zugemülltes Log ist so unbrauchbar wie keins."""
assert logbruecke.einordnen(ECHT_MINGLE) is None
assert logbruecke.einordnen(ECHT_ALLEIN) is None
assert logbruecke.einordnen(ECHT_BANNER) is None
assert logbruecke.einordnen("") is None
assert logbruecke.einordnen(None) is None
assert logbruecke.einordnen(
"[2026-07-26 12:00:00,000: DEBUG/MainProcess] kleinteiliges Zeug") is None
def test_absturz_ohne_celery_klammer_kommt_durch():
"""Ein Traceback trägt kein Celery-Präfix — und ist das Wichtigste, was
passieren kann."""
assert logbruecke.einordnen("Traceback (most recent call last):")[0] == "error"
assert logbruecke.einordnen(
"ConnectionRefusedError: [Errno 111] Connection refused")[0] == "error"
def test_quelle_wird_auf_32_zeichen_gekappt():
"""`logs.source` ist String(32). Ohne Kappen scheitert das Insert STILL bei
einem langen Worker-Namen — und dann fehlt genau das Log, das man sucht."""
assert logbruecke.quelle_fuer("tobisnicerpc") == "w:tobisnicerpc"
lang = logbruecke.quelle_fuer("x" * 100)
assert len(lang) == 32
assert lang.startswith("w:")
assert logbruecke.quelle_fuer("") == "w:worker"
assert logbruecke.quelle_fuer(None) == "w:worker"
# --- Drosselung -------------------------------------------------------------
def _bruecke(uhr):
geschrieben = []
b = logbruecke.Bruecke(
"testpc",
schreiber=lambda level, quelle, text: geschrieben.append((level, text)),
jetzt=lambda: uhr[0],
)
return b, geschrieben
def test_drosselung_haelt_bei_30_zeilen_die_minute():
uhr = [0.0]
b, geschrieben = _bruecke(uhr)
for i in range(40):
b.zeile(f"[2026-07-26 12:00:00,000: ERROR/MainProcess] Fehler {i}")
assert len(geschrieben) == logbruecke.MAX_ZEILEN_JE_MINUTE
def test_naechste_minute_meldet_wieviel_verschluckt_wurde():
"""Stilles Verschlucken wäre genau der Fehler, den diese Sitzung schon
einmal eine Stunde gekostet hat."""
uhr = [0.0]
b, geschrieben = _bruecke(uhr)
for i in range(40):
b.zeile(f"[2026-07-26 12:00:00,000: ERROR/MainProcess] Fehler {i}")
uhr[0] = 61.0
b.zeile("[2026-07-26 12:01:01,000: ERROR/MainProcess] naechster Fehler")
unterdrueckt = [t for lvl, t in geschrieben if "unterdrückt" in t]
assert len(unterdrueckt) == 1
assert "10 weitere" in unterdrueckt[0]
def test_kaputter_schreiber_bringt_den_worker_nicht_um():
"""Rippy nicht erreichbar → die lokale Log-Datei bleibt der Rückfall. Der
Worker muss weiterarbeiten."""
def kaputt(level, quelle, text):
raise RuntimeError("Datenbank weg")
b = logbruecke.Bruecke("testpc", schreiber=kaputt, jetzt=lambda: 0.0)
assert b.zeile("[2026-07-26 12:00:00,000: ERROR/MainProcess] Fehler") is False
def test_verworfene_zeilen_zaehlen_nicht_gegen_die_drossel():
uhr = [0.0]
b, geschrieben = _bruecke(uhr)
for _ in range(100):
b.zeile(ECHT_MINGLE)
b.zeile(ECHT_READY)
assert geschrieben == [("success", "celery@ced31798928d ready.")]