Ampel / ampel (push) Failing after 48s
WAS: Der Ablauf ist aus tasks.py heraus (ablauf.py, ohne Celery), die
LocalQueue wird bedient, ein Laeufer arbeitet Auftraege im selben Prozess
ab, und Rippy meldet sich mit gemessenen Faehigkeiten selbst als Arbeiter.
WARUM: "Rippy fuer Windows soll standalone funktionieren" (Commander). Bis
hierher konnte die Windows-App alles ANZEIGEN und nichts TUN — ein Rip waere
eingereiht worden und fuer immer liegengeblieben, weil niemand ihn holt.
DIE TRENNUNG: rip_disc hing an GENAU DREI Celery-Stellen in 234 Zeilen —
self.update_state, _transcode_queue, transcode_files.apply_async. Alle drei
sind Fragen der ZUSTELLUNG, nicht des Ablaufs. Sie sind jetzt Rueckrufe:
tasks.py reicht die Celery-Fassung herein, standalone.py die lokale. OHNE
Rueckruf komprimiert derselbe Prozess weiter — genau das, was ein
Ein-Prozess-Rippy braucht. Der Ablauf selbst ist Zeile fuer Zeile derselbe;
der Docker-Betrieb merkt vom Umbau nichts (Task-Namen, Argumente, Queues
unveraendert).
EINE ZUSTELL-STELLE statt drei: celery_client.abschicken() bedient alle
Auftragsarten. Vorher rief jede Stelle send_task selbst auf — der
Standalone-Betrieb haette an drei Stellen umgebogen werden muessen, beim
naechsten Auftragstyp an einer vierten.
WEITERER BLOCKER GEFUNDEN: ablauf.py holte detect_disc_type fest aus dem
LINUX-Treiber, in einem try/except. Unter Windows waere es damit IMMER None
gewesen und Rippen "hart verriegelt" — Rippy haette alles angezeigt und
nichts gerippt, ohne dass irgendwo ein Fehler stuende. Jetzt fragt es den
Treiber-Port.
WERKZEUGE: ripping.py und caps.py suchten nur im PATH. Auf dem Commander-PC
gemessen, vorher/nachher:
vorher check_makemkv_installed() -> False (obwohl installiert)
erkenne_encoder() -> nur CPU
nachher MakeMKV 1.18.4 C:\Program Files (x86)\MakeMKV\makemkvcon64.exe
HandBrake 1.11.2 ueber die API geholt, in 2,7 s
Encoder cpu-x264, cpu-x265, cpu-av1, VCE, VCE-AV1
107 Presets, Ryzen 7 9700X, 16 Kerne, avx512f
VCE ist die Hardwarebeschleunigung der Radeon — die hat Rippy auf diesem
Rechner vorher nie gesehen, weil es HandBrake gar nicht fand.
NEUE ROUTEN: GET /system/werkzeuge (was liegt wo, in welcher Fassung, gibt
es Neueres) und POST /system/werkzeuge/{name}/holen. HandBrake kommt
vollautomatisch von GitHub. MakeMKV wird NICHT mitgeliefert — Rippy laedt
die offizielle Datei und startet sie (Black-Box-Trennung, KONZEPT.md § 6).
makemkv.com antwortete beim Bauen mit HTTP 525; das wird im Klartext
gemeldet, und eine selbst geholte Datei bleibt moeglich.
HERZSCHLAG: /capabilities las die workers-Tabelle, die bisher nur der
Celery-Herzschlag fuellte. Im Standalone-Betrieb stand dort "0 Worker" und
die Encoder-Auswahl im UI blieb LEER — auf einem Rechner, der alles kann.
Jetzt meldet sich der Prozess selbst, mit dem, was caps.py MISST.
GEMESSEN, aus der fertigen EXE (29,0 MB):
bereit nach 1 s, keine Fehler im Log
Werkzeuge: beide gefunden, mit Version und Pfad
Worker: 1 (TobisNicerPC), 5 Encoder, 107 Presets
Die ganze Kette ist als Test festgehalten (test_kette.py): zustellen ->
einreihen -> Laeufer -> ablauf -> Job endet in einem EHRLICHEN Zustand.
Ohne Laufwerk geprueft, und das ist der wichtigere Fall: Ein Rip auf ein
totes Geraet muss zuegig scheitern, nicht auf "pending" haengenbleiben.
GEMESSEN: ruff sauber, 512 Tests gruen + 15 uebersprungen (vorher 489).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
138 lines
5.4 KiB
Python
138 lines
5.4 KiB
Python
"""Die ganze Kette im Standalone-Betrieb — von der Zustellung bis zum Job-Ende.
|
|
|
|
## Was hier bewiesen wird
|
|
|
|
celery_client.abschicken(...) die API stellt zu
|
|
-> standalone.zustellung uebersetzt in einen Auftrag
|
|
-> lokal.einreihen Auftragstabelle mit Lease
|
|
-> Laeufer.einmal() holt ihn und arbeitet ihn ab
|
|
-> ablauf.rippen() der echte Ablauf, ohne Celery
|
|
-> db.update_job() der Job endet in einem ehrlichen Zustand
|
|
|
|
Das ist der Weg, der unter Windows bis V2-4 gar nicht existierte: Dort haette
|
|
die API einen Rip eingereiht, und er waere fuer immer liegengeblieben, weil
|
|
niemand ihn holt.
|
|
|
|
## Warum ohne Laufwerk getestet wird
|
|
|
|
Es gibt hier keins (es haengt an der VM). Das ist kein Mangel, sondern der
|
|
wichtigere Fall: Ein Rip auf ein Geraet, das nicht antwortet, muss **ehrlich
|
|
scheitern** — mit einem Zustand und einer Begruendung, die im UI ankommen.
|
|
Was NICHT passieren darf: dass der Job auf „pending" stehenbleibt und
|
|
niemand erfaehrt, warum nichts geschieht.
|
|
"""
|
|
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from rippy import standalone, store
|
|
from rippy.queue import laeufer as laeufer_modul
|
|
from rippy.queue import lokal
|
|
|
|
|
|
@pytest.fixture
|
|
def umgebung(tmp_path):
|
|
vorher = store.zustand_sichern()
|
|
store.verbinden(f"sqlite:///{(tmp_path / 'kette.db').as_posix()}")
|
|
store.init_db()
|
|
yield store
|
|
store.engine_holen().dispose()
|
|
store.zustand_wiederherstellen(vorher)
|
|
|
|
|
|
class FakeBus:
|
|
def __init__(self):
|
|
self.gesendet = []
|
|
|
|
def senden(self, typ, daten=None, entitaet=None, entitaet_id=None):
|
|
self.gesendet.append((typ, entitaet_id, daten or {}))
|
|
|
|
|
|
def test_die_ganze_kette_bis_zum_ehrlichen_fehlschlag(umgebung):
|
|
"""Ein Rip auf ein Geraet, das es nicht gibt.
|
|
|
|
Erwartet wird KEIN Erfolg — erwartet wird, dass der Job in einem
|
|
ENDZUSTAND landet und die Begruendung im Log steht. Ein Job, der auf
|
|
„pending" haengenbleibt, waere der schlimmere Ausgang: Der Nutzer saehe
|
|
einen Balken, der sich nie bewegt, und nirgends stuende warum.
|
|
"""
|
|
umgebung.insert_job("job-kette", "/dev/gibt-es-nicht", disc_type=None)
|
|
|
|
bus = FakeBus()
|
|
zustellen = standalone.zustellung_bauen(bus)
|
|
zustellen("worker.tasks.rip_disc", ["/dev/gibt-es-nicht", "job-kette", None])
|
|
|
|
assert len(lokal.offene_auftraege()) == 1, "Der Auftrag wurde nicht eingereiht"
|
|
|
|
lauf = laeufer_modul.Laeufer(standalone.ausfuehren, "test-knoten",
|
|
kann={"art=rip"}, bus=bus, store=umgebung)
|
|
assert lauf.einmal() is True, "Der Laeufer hat den Auftrag nicht geholt"
|
|
|
|
job = umgebung.get_job("job-kette")
|
|
assert job["status"] in ("failed", "completed"), (
|
|
f"Der Job steht auf {job['status']!r} — er muss in einem Endzustand "
|
|
"landen, sonst wartet der Nutzer auf etwas, das nie passiert.")
|
|
assert job["status"] == "failed"
|
|
assert job["error"], "Ein Fehlschlag ohne Begruendung ist im UI wertlos"
|
|
|
|
meldungen = " ".join(z["message"] for z in umgebung.list_logs(limit=50))
|
|
assert "job-kette" in meldungen, "Im Log steht nichts ueber diesen Job"
|
|
|
|
assert lokal.offene_auftraege() == [], "Der Auftrag blieb in der Queue liegen"
|
|
|
|
|
|
def test_der_laeufer_bleibt_danach_arbeitsfaehig(umgebung):
|
|
"""Nach einem Fehlschlag muss der naechste Auftrag trotzdem laufen —
|
|
sonst legt ein einziger kaputter Rip die ganze Installation lahm."""
|
|
umgebung.insert_job("job-a", "/dev/gibt-es-nicht")
|
|
umgebung.insert_job("job-b", "/dev/gibt-es-auch-nicht")
|
|
zustellen = standalone.zustellung_bauen()
|
|
zustellen("worker.tasks.rip_disc", ["/dev/gibt-es-nicht", "job-a", None])
|
|
zustellen("worker.tasks.rip_disc", ["/dev/gibt-es-auch-nicht", "job-b", None])
|
|
|
|
lauf = laeufer_modul.Laeufer(standalone.ausfuehren, "test-knoten",
|
|
kann={"art=rip"}, store=umgebung)
|
|
assert lauf.einmal() is True
|
|
assert lauf.einmal() is True
|
|
assert umgebung.get_job("job-a")["status"] == "failed"
|
|
assert umgebung.get_job("job-b")["status"] == "failed"
|
|
|
|
|
|
def test_die_api_stellt_ueber_dieselbe_stelle_zu(umgebung, monkeypatch):
|
|
"""`celery_client.start_rip` ist der Weg, den POST /jobs geht. Er MUSS
|
|
im Standalone-Betrieb in der lokalen Queue landen — sonst reiht die API
|
|
an einen Broker ein, den es nicht gibt, und der Job verschwindet."""
|
|
import sys
|
|
|
|
from rippy.daemon import _api_pfad
|
|
|
|
if _api_pfad() not in sys.path:
|
|
sys.path.insert(0, _api_pfad())
|
|
import celery_client
|
|
|
|
umgebung.insert_job("job-api", "/dev/sr0")
|
|
monkeypatch.setattr(celery_client, "_zusteller", standalone.zustellung_bauen())
|
|
|
|
celery_client.start_rip("/dev/sr0", "job-api", "/app/media/x")
|
|
|
|
offen = lokal.offene_auftraege()
|
|
assert len(offen) == 1
|
|
assert offen[0]["job_id"] == "job-api"
|
|
assert offen[0]["art"] == "rip"
|
|
|
|
|
|
def test_ein_rip_haengt_nicht_ewig(umgebung):
|
|
"""Zeitgrenze als Zusage: Ein Auftrag auf ein totes Geraet muss ZUEGIG
|
|
scheitern. Haengt er, merkt es im Betrieb niemand — der Job steht auf
|
|
„laeuft", und der Laeufer nimmt keinen weiteren an."""
|
|
umgebung.insert_job("job-zeit", "/dev/nichts")
|
|
standalone.zustellung_bauen()("worker.tasks.rip_disc", ["/dev/nichts", "job-zeit", None])
|
|
|
|
lauf = laeufer_modul.Laeufer(standalone.ausfuehren, "k", kann={"art=rip"},
|
|
store=umgebung)
|
|
start = time.monotonic()
|
|
lauf.einmal()
|
|
dauer = time.monotonic() - start
|
|
assert dauer < 30, f"Der Fehlschlag brauchte {dauer:.1f}s — das ist zu lang"
|