Ampel / ampel (push) Successful in 1m32s
Commander: „Über den kompletten vorgang steht dort ‚Restzeit wird gemessen'
aber messung wird nicht abgeschlossen. Das heißt man hat kein ETA"
Zwei Ursachen, beide unabhaengig, beide toedlich fuer sich allein.
## 1. Die Messreihe lag nur in Redis
`eta.py` schrieb sie ausschliesslich in den Cache — mit der Begruendung im
Modul-Kopf: „Der Cache (Redis) ist schon da". Im Container stimmt das. Auf
einem Windows-PC gibt es kein Redis: `cache_get` gab bei JEDEM Aufruf None
zurueck, `beobachtung_hinzufuegen` legte also jedes Mal eine frische Reihe mit
EINEM Punkt an — und `restzeit_sekunden` braucht `MINDEST_PUNKTE = 2`.
Jetzt wird in beide Ablagen geschrieben: in den Cache, wo es einen gibt (er
ueberlebt einen API-Neustart), und in ein Woerterbuch im Prozess. Das ist ein
paar Zahlen gross, gilt nur fuer die Dauer eines Jobs, und im eigenstaendigen
Betrieb gibt es ohnehin nur diesen einen Prozess. Alte Reihen werden nach
einem Tag weggeraeumt.
## 2. Der Ereignisstrom rechnete die Restzeit gar nicht
Die Rechnung stand nur in `/jobs`. Der SSE-Schnappschuss baute seine Jobs mit
dem nackten `_job_row_to_model` — also ohne Restzeit. **Seit der Umstellung
auf den Ereignisstrom (V2-3) liest die Oberflaeche aber genau diesen
Schnappschuss und nicht mehr `/jobs`.** Die Restzeit wurde also brav berechnet
und niemandem gezeigt.
Beides jetzt in `jobs_fuer_ui()` — dieselbe Lehre wie bei
`laufwerke_mit_disc` heute frueh: Eine Auskunft in zwei Fassungen ist eine
Fassung zu viel.
Nebenbei: Unlesbare Einstellungen duerfen die Jobliste nicht umwerfen. Seit
sie auch den Schnappschuss baut, haengt daran die ganze Oberflaeche — zwei
Snapshot-Tests wurden davon prompt rot.
## Beweis
Job in der Datenbank, Fortschritt 10 % -> 25 % ueber 130 s:
nach 1. Messpunkt : eta_text='' (richtig, eine Messung reicht nicht)
nach 2. Messpunkt : 674 s, „noch ca. 11 min"
877 Tests gruen, ruff sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
174 lines
7.1 KiB
Python
174 lines
7.1 KiB
Python
"""Restzeit-Schätzung für laufende Jobs — aus dem gemessenen Fortschritt.
|
||
|
||
Commander-Anforderung 26.07.2026: *„Der Server Status muss dringend überarbeitet
|
||
werden, das Dashboard soll ja quasi alles auf einen Blick zeigen"* — dazu eine
|
||
**ETA** im Dashboard und beim externen Worker.
|
||
|
||
## Warum hier und nicht im Browser
|
||
|
||
Der naheliegende Weg wäre, den Fortschritt im UI mitzuschreiben. Drei Gründe
|
||
dagegen: Ein Seitenwechsel setzt die Messreihe zurück; zwei offene Browser
|
||
zeigten verschiedene Zahlen; und die Kompression läuft womöglich auf einer
|
||
ANDEREN Maschine (genau dort wollte der Commander die ETA sehen). Die Reihe
|
||
gehört also dorthin, wo der Fortschritt ankommt.
|
||
|
||
## Warum nicht einfach „vergangene Zeit / Prozent"
|
||
|
||
Weil ein Job zwei völlig verschiedene Phasen hat: Der Rip dauert rund eine
|
||
Stunde, die Kompression Stunden bis Tage (auf der Rippy-VM gemessene 28-55 h je
|
||
4K-Film). Aus dem Gesamt-Mittel entstünde bei jedem Phasenwechsel eine
|
||
haarsträubende Zahl. Deshalb wird die Messreihe bei JEDEM Statuswechsel
|
||
verworfen und die Rate nur innerhalb der laufenden Phase bestimmt.
|
||
|
||
## Ehrlichkeit vor Zahl
|
||
|
||
Lieber „wird noch geschätzt" als eine erfundene Minute:
|
||
|
||
- Unter MINDEST_PUNKTE Messwerten gibt es keine Schätzung.
|
||
- Die Reihe muss MINDEST_SPANNE_SEKUNDEN abdecken UND MINDEST_FORTSCHRITT
|
||
Prozentpunkte gestiegen sein — bei 4K bewegt sich eine halbe Stunde lang
|
||
nichts, daraus ließe sich sonst „fertig in 3 Minuten" ableiten.
|
||
- Nur die letzten FENSTER Punkte zählen: HandBrake wird bei komplexen Szenen
|
||
langsamer, die frühen Werte lügen dann.
|
||
- Über OBERGRENZE_SEKUNDEN wird nicht mehr aufs Detail gerechnet, sondern
|
||
„mehr als 2 Tage" gesagt. Eine Zahl wie „51:23 h" wirkt genau, ist es aber
|
||
nicht.
|
||
"""
|
||
|
||
MINDEST_PUNKTE = 2
|
||
MINDEST_SPANNE_SEKUNDEN = 60
|
||
MINDEST_FORTSCHRITT = 1
|
||
FENSTER = 10
|
||
OBERGRENZE_SEKUNDEN = 48 * 3600
|
||
|
||
# Status, für die eine Restzeit überhaupt Sinn hat.
|
||
LAUFENDE_STATUS = ("running", "processing", "ripping", "transcoding")
|
||
|
||
|
||
def beobachtung_hinzufuegen(reihe, status: str, progress: int, jetzt: float) -> dict:
|
||
"""Neuen Messpunkt anfügen (pure Funktion) → die aktualisierte Reihe.
|
||
|
||
`reihe` ist {"status": str, "punkte": [[zeit, prozent], ...]} oder None.
|
||
Bei Statuswechsel beginnt die Reihe neu — siehe Modul-Doku (Rip und
|
||
Kompression haben nichts miteinander zu tun).
|
||
|
||
Ein unveränderter Fortschritt wird NICHT als neuer Punkt angefügt, aber der
|
||
letzte Punkt behält seine ursprüngliche Zeit. Das ist wichtig: Ein Stillstand
|
||
verlängert damit automatisch die gemessene Spanne und macht die Schätzung
|
||
langsamer — genau richtig, denn er heißt ja, dass es langsam vorangeht.
|
||
"""
|
||
alt = reihe or {}
|
||
punkte = list(alt.get("punkte") or []) if alt.get("status") == status else []
|
||
if not punkte or punkte[-1][1] != progress:
|
||
punkte.append([jetzt, progress])
|
||
return {"status": status, "punkte": punkte[-FENSTER:]}
|
||
|
||
|
||
def restzeit_sekunden(reihe, jetzt: float) -> int:
|
||
"""Geschätzte Restzeit in Sekunden — oder -1 für „noch keine Aussage".
|
||
|
||
-1 statt None, damit der Wert unverändert durch JSON und das
|
||
Antwort-Modell passt (dasselbe Muster wie get_progress_from_line im
|
||
Worker, wo -1 „keine Angabe" heißt).
|
||
"""
|
||
punkte = (reihe or {}).get("punkte") or []
|
||
if len(punkte) < MINDEST_PUNKTE:
|
||
return -1
|
||
erste_zeit, erster_prozent = punkte[0]
|
||
letzte_zeit, letzter_prozent = punkte[-1]
|
||
# Die Spanne bis JETZT, nicht bis zum letzten Punkt: sonst zeigt ein
|
||
# stehender Job dauerhaft die Rate von vor drei Stunden.
|
||
spanne = max(jetzt, letzte_zeit) - erste_zeit
|
||
gewachsen = letzter_prozent - erster_prozent
|
||
if spanne < MINDEST_SPANNE_SEKUNDEN or gewachsen < MINDEST_FORTSCHRITT:
|
||
return -1
|
||
rest_prozent = 100 - letzter_prozent
|
||
if rest_prozent <= 0:
|
||
return 0
|
||
pro_prozent = spanne / gewachsen
|
||
return int(rest_prozent * pro_prozent)
|
||
|
||
|
||
def formatiere_restzeit(sekunden: int) -> str:
|
||
"""Restzeit als Text fürs UI. Leer, wenn es keine Aussage gibt."""
|
||
if sekunden is None or sekunden < 0:
|
||
return ""
|
||
if sekunden > OBERGRENZE_SEKUNDEN:
|
||
return "mehr als 2 Tage"
|
||
if sekunden < 60:
|
||
return "unter einer Minute"
|
||
minuten = sekunden // 60
|
||
if minuten < 60:
|
||
return f"noch ca. {minuten} min"
|
||
stunden, rest_minuten = divmod(minuten, 60)
|
||
if stunden < 24:
|
||
return f"noch ca. {stunden} h {rest_minuten:02d} min"
|
||
tage, rest_stunden = divmod(stunden, 24)
|
||
return f"noch ca. {tage} Tag{'e' if tage > 1 else ''} {rest_stunden} h"
|
||
|
||
|
||
def schluessel(job_id: str) -> str:
|
||
return f"eta:{job_id}"
|
||
|
||
|
||
#: Messreihen im eigenen Prozess — der Rückfall, wenn es kein Redis gibt.
|
||
#:
|
||
#: ## Der Befund des Commanders (29.08.2026)
|
||
#:
|
||
#: > „Über den kompletten vorgang steht dort ‚Restzeit wird gemessen' aber
|
||
#: > messung wird nicht abgeschlossen. Das heißt man hat kein ETA"
|
||
#:
|
||
#: Die Messreihe lag ausschließlich im Cache, und der Modul-Kopf sagte dazu:
|
||
#: „Der Cache (Redis) ist schon da". Im Container stimmt das. Auf einem
|
||
#: Windows-PC gibt es kein Redis — `cache_get` gab bei JEDEM Aufruf `None`
|
||
#: zurück, `beobachtung_hinzufuegen` legte also jedes Mal eine frische Reihe
|
||
#: mit EINEM Punkt an, und `restzeit_sekunden` braucht `MINDEST_PUNKTE = 2`.
|
||
#: Ergebnis: über den ganzen Rip hinweg „noch keine Aussage".
|
||
#:
|
||
#: Ein Wörterbuch im Prozess reicht hier vollkommen: Die Reihe ist ein paar
|
||
#: Zahlen, sie gilt nur für die Dauer eines Jobs, und im eigenständigen
|
||
#: Betrieb gibt es ohnehin nur diesen einen Prozess. Redis bleibt der bessere
|
||
#: Ort, wo es eins gibt — es überlebt einen API-Neustart.
|
||
_REIHEN: dict = {}
|
||
|
||
#: Wie lange eine Reihe im Prozess aufgehoben wird (wie `expire` im Cache).
|
||
REIHE_HALTBAR_SEKUNDEN = 86400
|
||
|
||
|
||
def _aufraeumen(jetzt: float) -> None:
|
||
"""Alte Reihen wegwerfen — sonst waechst das Woerterbuch unbegrenzt."""
|
||
for k, (stand, _) in list(_REIHEN.items()):
|
||
if jetzt - stand > REIHE_HALTBAR_SEKUNDEN:
|
||
_REIHEN.pop(k, None)
|
||
|
||
|
||
def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
|
||
jetzt: float, cache_get, cache_set) -> dict:
|
||
"""Messreihe fortschreiben und die Restzeit zurückgeben.
|
||
|
||
Gespeichert wird in BEIDEN Ablagen: im Cache, wo es einen gibt (er
|
||
überlebt einen API-Neustart), und im Prozess, damit die Schätzung auch
|
||
ohne Redis zustande kommt. Siehe `_REIHEN`.
|
||
"""
|
||
if status not in LAUFENDE_STATUS or progress <= 0:
|
||
return {"sekunden": -1, "text": ""}
|
||
k = schluessel(job_id)
|
||
try:
|
||
reihe = cache_get(k)
|
||
except Exception:
|
||
reihe = None
|
||
if not reihe:
|
||
# Kein Cache (oder leer) — dann der eigene Vorrat.
|
||
eintrag = _REIHEN.get(k)
|
||
reihe = eintrag[1] if eintrag else None
|
||
reihe = beobachtung_hinzufuegen(reihe, status, progress, jetzt)
|
||
try:
|
||
# Eine Reihe ohne Fortschritt ist nach einem Tag wertlos.
|
||
cache_set(k, reihe, expire=REIHE_HALTBAR_SEKUNDEN)
|
||
except Exception:
|
||
pass
|
||
_REIHEN[k] = (jetzt, reihe)
|
||
_aufraeumen(jetzt)
|
||
sekunden = restzeit_sekunden(reihe, jetzt)
|
||
return {"sekunden": sekunden, "text": formatiere_restzeit(sekunden)}
|