phase2b: Auftraege ueberleben einen Neustart von MC2
- jobengine: jeder Auftrag laeuft als eigene systemd-Einheit mc2-job-<id> (systemd-run, RuntimeMaxSec als Zeitlimit); Akte, Protokoll und Exit-Code liegen unter <Datenordner>/mc2-jobs; MC2 nimmt laufende Auftraege beim Start wieder auf - Geheimnisse (HF_TOKEN) ueber eine nur fuer den Nutzer lesbare Umgebungsdatei, die der Auftrag beim Start liest und loescht; nichts davon in Befehlszeile oder Einheit - benannte Nacharbeiten (wartung:nach_update, modell:rolle) statt Closures, laufen auch nach einem Neustart - ohne systemd (PC, Tests) weiter als Kindprozess - deploy.sh wartet nur noch auf Update-Auftraege; Downloads laufen weiter - 14 neue Tests; systemd-Weg auf der Box echt geprueft (Neustart, Abbruch, Zeitlimit, Geheimnis nicht sichtbar) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
75611be9a9
commit
e1d7d499f8
+421
-150
@@ -1,80 +1,251 @@
|
||||
"""
|
||||
Mini-Job-System: Hintergrund-Prozesse mit Live-Log + Download-Fortschritt.
|
||||
Portiert aus Mission Control v1 (jobengine.py). In-Memory, ein Daemon-Thread je Job.
|
||||
Auftrags-System: Hintergrund-Aufträge (Updates, Modell-Downloads) mit Protokoll und Fortschritt.
|
||||
Ursprünglich aus Mission Control v1 portiert.
|
||||
|
||||
Seit 24.09.2026:
|
||||
- Jobs laufen in einer eigenen Prozessgruppe; „Abbrechen“ beendet die ganze Gruppe (vorher nur
|
||||
die bash-Hülle — sudo, apt und hf liefen weiter, während der Job schon „abgebrochen“ hieß).
|
||||
- Jede Job-Art hat ein Zeitlimit; ein hängendes apt hält den Wartungs-Riegel nicht mehr ewig.
|
||||
- `start_job_exklusiv` prüft und trägt unter EINER Sperre ein: zwei Klicks starten nicht mehr
|
||||
zwei Updates derselben Gruppe.
|
||||
- Beendete Jobs werden nach einem Tag bzw. ab 40 Stück aufgeräumt.
|
||||
Seit Phase 2 (24.09.2026) überleben Aufträge einen Neustart von MC2:
|
||||
- Jeder Auftrag läuft als eigene systemd-Einheit „mc2-job-<id>“ (systemd-run), nicht mehr als
|
||||
Kindprozess von MC2. Ein Deploy oder Absturz von MC2 würgt ihn nicht mehr ab.
|
||||
- Akte (<id>.json), Protokoll (<id>.log) und Exit-Code (<id>.exit) liegen unter JOBS_DIR.
|
||||
MC2 liest die Akten beim Start wieder ein (wiederaufnehmen()) und beobachtet weiter.
|
||||
- Nacharbeiten („Rolle setzen, wenn der Download fertig ist“) sind benannt (@nacharbeit) und
|
||||
stehen mit ihren Daten in der Akte — sie laufen auch, wenn MC2 zwischendurch neu gestartet hat.
|
||||
- Geheimnisse (z. B. HF_TOKEN) gehen über eine Umgebungsdatei (nur für den Nutzer lesbar), die
|
||||
der Auftrag beim Start liest und löscht — nicht über die Befehlszeile oder die Einheit.
|
||||
- Ohne systemd (Entwicklungsrechner, Tests: MC_JOBS_ART=prozess) läuft der Auftrag als
|
||||
Kindprozess; dann überlebt er einen Neustart nicht.
|
||||
|
||||
Noch offen (Phase 2): Jobs überleben keinen Neustart von MC2, sie gehören in einen eigenen Worker.
|
||||
Weiterhin: „Abbrechen“ beendet den ganzen Auftrag samt Kindern, jede Auftragsart hat ein
|
||||
Zeitlimit, start_job_exklusiv prüft und trägt unter EINER Sperre ein, beendete Aufträge
|
||||
verschwinden nach einem Tag bzw. ab 40 Stück.
|
||||
|
||||
Nur MC2 selbst beobachtet Aufträge (wiederaufnehmen() im Lebenszyklus der App); andere Prozesse
|
||||
starten keine.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
JOBS: dict[str, dict] = {}
|
||||
_PROCS: dict[str, subprocess.Popen] = {}
|
||||
_LOG_CAP = 400
|
||||
_LOCK = threading.Lock()
|
||||
BEHALTEN_MAX = 40 # so viele beendete Jobs bleiben sichtbar
|
||||
BEHALTEN_S = 24 * 3600 # beendete Jobs verschwinden nach einem Tag
|
||||
_LOG_CAP = 400
|
||||
_LOG_LESEN_MAX = 256_000 # so viel vom Protokollende wird für die Anzeige gelesen
|
||||
BEHALTEN_MAX = 40 # so viele beendete Aufträge bleiben sichtbar
|
||||
BEHALTEN_S = 24 * 3600 # beendete Aufträge verschwinden nach einem Tag
|
||||
STANDARD_ZEITLIMIT_S = 3 * 3600
|
||||
_ENDE = ("done", "failed", "canceled")
|
||||
_TAKT_S = 1.0 # wie oft ein Beobachter nach dem Exit-Code schaut
|
||||
_EINHEIT_PRUEFEN_S = 5.0 # wie oft er zusätzlich fragt, ob die Einheit noch lebt
|
||||
|
||||
# Nur im Speicher (nicht in der Akte): Fortschritt eines Downloads.
|
||||
_LAUFZEIT = {"progress", "done_bytes", "rate_bps", "eta_s"}
|
||||
# Nicht an die Oberfläche: Verwaltung des Auftrags.
|
||||
_INTERN = {"art", "nacharbeit", "nacharbeit_daten", "nacharbeit_erledigt", "fortschritt", "zeitlimit_s"}
|
||||
# Nicht in die Umgebung des Auftrags: gehört zur Einheit von MC2 oder ist in bash schreibgeschützt.
|
||||
_UMGEBUNG_OHNE = {"INVOCATION_ID", "JOURNAL_STREAM", "SYSTEMD_EXEC_PID", "MANAGERPID", "NOTIFY_SOCKET",
|
||||
"LISTEN_PID", "LISTEN_FDS", "LISTEN_FDNAMES", "WATCHDOG_PID", "WATCHDOG_USEC", "MAINPID",
|
||||
"_", "SHLVL", "PWD", "OLDPWD", "SHELLOPTS", "BASHOPTS", "BASH_VERSINFO", "EUID", "UID",
|
||||
"PPID", "GROUPS"}
|
||||
_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
# Die Hülle um den eigentlichen Befehl: Umgebung lesen (und die Datei löschen), Ausgabe ins
|
||||
# Protokoll, Exit-Code atomar ablegen. $1 = Pfadpräfix der Akte, danach der Befehl.
|
||||
_HUELLE = ('akte="$1"; shift; '
|
||||
'if [ -f "$akte.env" ]; then . "$akte.env"; rm -f "$akte.env"; fi; '
|
||||
'exec >>"$akte.log" 2>&1 </dev/null; '
|
||||
'"$@"; code=$?; echo "$code" > "$akte.exit.tmp" && mv -f "$akte.exit.tmp" "$akte.exit"')
|
||||
|
||||
_NACHARBEITEN: dict[str, Callable[..., None]] = {}
|
||||
_geladen = False
|
||||
|
||||
|
||||
def _append_log(job: dict, line: str) -> None:
|
||||
job["log"].append(line)
|
||||
if len(job["log"]) > _LOG_CAP:
|
||||
del job["log"][0]
|
||||
def nacharbeit(name: str) -> Callable[[Callable[..., None]], Callable[..., None]]:
|
||||
"""Eine Nacharbeit unter festem Namen anmelden. Sie bekommt die nacharbeit_daten des Auftrags
|
||||
als Schlüsselwort-Argumente und läuft nur, wenn der Auftrag erfolgreich war."""
|
||||
def anmelden(fn: Callable[..., None]) -> Callable[..., None]:
|
||||
_NACHARBEITEN[name] = fn
|
||||
return fn
|
||||
return anmelden
|
||||
|
||||
|
||||
def _pump_output(job: dict, stream) -> None:
|
||||
"""Liest byteweise; `\\r` (tqdm/hf-Fortschritt) überschreibt die letzte Zeile."""
|
||||
buf = b""
|
||||
overwrite = False
|
||||
pending_cr = False
|
||||
# --- Ablage ------------------------------------------------------------------------
|
||||
|
||||
def commit():
|
||||
line = buf.decode("utf-8", "replace")
|
||||
if overwrite and job["log"]:
|
||||
job["log"][-1] = line
|
||||
else:
|
||||
_append_log(job, line)
|
||||
def _jobs_dir() -> Path:
|
||||
return Path(os.environ.get("MC_JOBS_DIR", str(einstellungen().daten_dir / "mc2-jobs")))
|
||||
|
||||
while True:
|
||||
ch = stream.read(1)
|
||||
if not ch:
|
||||
break
|
||||
if pending_cr:
|
||||
pending_cr = False
|
||||
if ch == b"\n":
|
||||
commit(); overwrite = False; buf = b""
|
||||
continue
|
||||
commit(); overwrite = True; buf = b""
|
||||
if ch == b"\r":
|
||||
pending_cr = True
|
||||
elif ch == b"\n":
|
||||
commit(); overwrite = False; buf = b""
|
||||
else:
|
||||
buf += ch
|
||||
if pending_cr:
|
||||
commit(); overwrite = True; buf = b""
|
||||
if buf:
|
||||
commit()
|
||||
|
||||
def _pfad(job_id: str, endung: str) -> Path:
|
||||
return _jobs_dir() / f"{job_id}{endung}"
|
||||
|
||||
|
||||
def _speichern(job: dict) -> None:
|
||||
"""Akte ohne Laufzeitfelder atomar schreiben."""
|
||||
daten = {k: v for k, v in job.items() if k not in _LAUFZEIT}
|
||||
try:
|
||||
_jobs_dir().mkdir(parents=True, exist_ok=True)
|
||||
tmp = _pfad(job["id"], ".json.tmp")
|
||||
tmp.write_text(json.dumps(daten, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(_pfad(job["id"], ".json"))
|
||||
except OSError:
|
||||
log.warning("jobengine: Akte %s nicht schreibbar", job.get("id"), exc_info=True)
|
||||
|
||||
|
||||
def _protokoll(job_id: str, zeile: str) -> None:
|
||||
"""Eine Zeile von MC2 ins Protokoll des Auftrags schreiben."""
|
||||
try:
|
||||
_jobs_dir().mkdir(parents=True, exist_ok=True)
|
||||
with _pfad(job_id, ".log").open("a", encoding="utf-8", newline="\n") as f:
|
||||
f.write(zeile.rstrip("\n") + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def zeilen_aus(text: str) -> list[str]:
|
||||
"""Protokolltext in Anzeigezeilen: `\\r` (Fortschrittsbalken von hf/tqdm/apt) überschreibt die Zeile."""
|
||||
zeilen = []
|
||||
for roh in text.split("\n"):
|
||||
if "\r" in roh:
|
||||
teile = [t for t in roh.split("\r") if t]
|
||||
roh = teile[-1] if teile else ""
|
||||
zeilen.append(roh)
|
||||
if zeilen and zeilen[-1] == "":
|
||||
zeilen.pop()
|
||||
return zeilen[-_LOG_CAP:]
|
||||
|
||||
|
||||
def _protokoll_lesen(job_id: str) -> list[str]:
|
||||
try:
|
||||
with _pfad(job_id, ".log").open("rb") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
groesse = f.tell()
|
||||
f.seek(max(0, groesse - _LOG_LESEN_MAX))
|
||||
roh = f.read()
|
||||
except OSError:
|
||||
return []
|
||||
text = roh.decode("utf-8", "replace")
|
||||
if groesse > _LOG_LESEN_MAX:
|
||||
text = text.split("\n", 1)[-1] # angeschnittene erste Zeile verwerfen
|
||||
return zeilen_aus(text)
|
||||
|
||||
|
||||
def _exit_code(job_id: str) -> int | None:
|
||||
try:
|
||||
return int(_pfad(job_id, ".exit").read_text(encoding="utf-8").strip())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _exit_ablegen(job_id: str, code: int) -> None:
|
||||
try:
|
||||
tmp = _pfad(job_id, ".exit.tmp")
|
||||
tmp.write_text(str(code), encoding="utf-8")
|
||||
tmp.replace(_pfad(job_id, ".exit"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _umgebungsdatei(job_id: str, env: dict | None) -> None:
|
||||
"""Umgebung des Auftrags (MC2-Umgebung + env) als nur für den Nutzer lesbare Datei."""
|
||||
werte = {**os.environ, **(env or {})}
|
||||
zeilen = [f"export {k}={shlex.quote(str(v))}" for k, v in werte.items()
|
||||
if _NAME.match(k) and k not in _UMGEBUNG_OHNE]
|
||||
fd = os.open(_pfad(job_id, ".env"), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write("\n".join(zeilen) + "\n")
|
||||
|
||||
|
||||
# --- Ausführung ----------------------------------------------------------------------
|
||||
|
||||
def _art() -> str:
|
||||
"""„systemd“, wenn Aufträge als eigene Einheiten laufen können, sonst „prozess“."""
|
||||
gewuenscht = os.environ.get("MC_JOBS_ART", "").strip().lower()
|
||||
if gewuenscht in ("systemd", "prozess"):
|
||||
return gewuenscht
|
||||
if os.name != "posix" or not shutil.which("systemd-run"):
|
||||
return "prozess"
|
||||
if os.geteuid() != 0 and not os.environ.get("XDG_RUNTIME_DIR"):
|
||||
return "prozess"
|
||||
return "systemd"
|
||||
|
||||
|
||||
def _systemd(programm: str, *args: str) -> list[str]:
|
||||
"""systemctl/systemd-run für den richtigen Manager: als root der System-, sonst der Nutzer-Manager."""
|
||||
als_root = os.name == "posix" and os.geteuid() == 0
|
||||
return [programm, *(() if als_root else ("--user",)), *args]
|
||||
|
||||
|
||||
def _einheit(job_id: str) -> str:
|
||||
return f"mc2-job-{job_id}"
|
||||
|
||||
|
||||
def _einheit_lebt(job_id: str) -> bool:
|
||||
try:
|
||||
r = subprocess.run(_systemd("systemctl", "is-active", _einheit(job_id)),
|
||||
capture_output=True, text=True, timeout=10)
|
||||
except Exception:
|
||||
return True # Im Zweifel weiter beobachten statt einen laufenden Auftrag totzusagen.
|
||||
return r.stdout.strip() in ("active", "activating", "deactivating", "reloading")
|
||||
|
||||
|
||||
def _starte_systemd(job: dict, args: list[str], env: dict | None) -> None:
|
||||
job_id = job["id"]
|
||||
_umgebungsdatei(job_id, env)
|
||||
befehl = _systemd("systemd-run", "--unit", _einheit(job_id), "--collect", "--quiet",
|
||||
"--working-directory", os.getcwd(),
|
||||
"--property", f"RuntimeMaxSec={int(job['zeitlimit_s'])}",
|
||||
"--", "/bin/bash", "-c", _HUELLE, "mc2-job", str(_jobs_dir() / job_id), *args)
|
||||
r = subprocess.run(befehl, capture_output=True, text=True, timeout=30)
|
||||
if r.returncode != 0:
|
||||
_pfad(job_id, ".env").unlink(missing_ok=True)
|
||||
raise RuntimeError((r.stderr or r.stdout or "systemd-run scheiterte").strip()[:300])
|
||||
threading.Thread(target=_beobachten, args=(job_id,), daemon=True).start()
|
||||
|
||||
|
||||
def _starte_prozess(job: dict, args: list[str], env: dict | None) -> None:
|
||||
"""Rückfall ohne systemd: Kindprozess, Ausgabe ins Protokoll; ein Warte-Thread schließt ab."""
|
||||
job_id = job["id"]
|
||||
with _pfad(job_id, ".log").open("ab") as protokoll:
|
||||
proc = subprocess.Popen(list(args), stdout=protokoll, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
|
||||
env={**os.environ, **(env or {})}, start_new_session=(os.name == "posix"))
|
||||
_PROCS[job_id] = proc
|
||||
grenze = int(job["zeitlimit_s"])
|
||||
|
||||
def zu_lang() -> None:
|
||||
job["zeitlimit"] = True
|
||||
_protokoll(job_id, f"[mc] Zeitlimit ({grenze // 60} min) überschritten, Auftrag wird beendet.")
|
||||
_beende_gruppe(proc)
|
||||
|
||||
wecker = threading.Timer(grenze, zu_lang)
|
||||
wecker.daemon = True
|
||||
wecker.start()
|
||||
|
||||
def warten() -> None:
|
||||
code = proc.wait()
|
||||
wecker.cancel()
|
||||
_exit_ablegen(job_id, code)
|
||||
_abschliessen(job, code)
|
||||
|
||||
threading.Thread(target=warten, daemon=True).start()
|
||||
|
||||
|
||||
def _beende_gruppe(proc: subprocess.Popen) -> None:
|
||||
"""Den Job-Prozess samt Kindern beenden (posix: ganze Prozessgruppe, sonst nur den Prozess)."""
|
||||
"""Den Auftragsprozess samt Kindern beenden (posix: ganze Prozessgruppe, sonst nur den Prozess)."""
|
||||
try:
|
||||
if os.name == "posix":
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
@@ -84,71 +255,97 @@ def _beende_gruppe(proc: subprocess.Popen) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _run_job(job_id: str, args: list[str], env: dict | None = None, zeitlimit_s: int | None = None):
|
||||
"""Job-Prozess starten und mitschreiben.
|
||||
|
||||
v3-Umbau P1 (28.08.2026): Auf der Box läuft sudo passwortlos (`NOPASSWD: ALL`), der Job
|
||||
braucht keine stdin-Pipe. DEVNULL sorgt dafür, dass ein Job, der wider Erwarten nach einem
|
||||
Passwort fragt, sofort scheitert statt still zu hängen."""
|
||||
job = JOBS[job_id]
|
||||
job["state"] = "running"
|
||||
wecker: threading.Timer | None = None
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
list(args), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.DEVNULL,
|
||||
bufsize=0,
|
||||
env={**os.environ, **(env or {})},
|
||||
start_new_session=(os.name == "posix"),
|
||||
)
|
||||
_PROCS[job_id] = proc
|
||||
grenze = zeitlimit_s or STANDARD_ZEITLIMIT_S
|
||||
|
||||
def _zu_lang() -> None:
|
||||
job["zeitlimit"] = True
|
||||
_append_log(job, f"[mc] Zeitlimit ({grenze // 60} min) überschritten, Job wird beendet.")
|
||||
_beende_gruppe(proc)
|
||||
|
||||
wecker = threading.Timer(grenze, _zu_lang)
|
||||
wecker.daemon = True
|
||||
wecker.start()
|
||||
|
||||
_pump_output(job, proc.stdout)
|
||||
proc.wait()
|
||||
job["returncode"] = proc.returncode
|
||||
def _abschliessen(job: dict, code: int | None) -> None:
|
||||
"""Endzustand festhalten (genau einmal) und, wenn erfolgreich, die Nacharbeit ausführen."""
|
||||
with _LOCK:
|
||||
if job["state"] in _ENDE:
|
||||
return
|
||||
job["returncode"] = code
|
||||
job["finished_at"] = time.time()
|
||||
if job.get("canceled"):
|
||||
job["state"] = "canceled"
|
||||
elif job.get("zeitlimit"):
|
||||
elif job.get("zeitlimit") or (code is None and time.time() - job["started_at"] >= job["zeitlimit_s"] - 5):
|
||||
job.update(state="failed", zeitlimit=True, error="Zeitlimit überschritten")
|
||||
elif code is None:
|
||||
job["state"] = "failed"
|
||||
job["error"] = "Zeitlimit überschritten"
|
||||
job.setdefault("error", "Der Auftrag endete ohne Rückmeldung.")
|
||||
else:
|
||||
job["state"] = "done" if proc.returncode == 0 else "failed"
|
||||
except Exception as exc:
|
||||
_append_log(job, f"[mc] Fehler: {exc}")
|
||||
job["state"] = "failed"
|
||||
job["error"] = str(exc)
|
||||
job["returncode"] = -1
|
||||
finally:
|
||||
if wecker is not None:
|
||||
wecker.cancel()
|
||||
_PROCS.pop(job_id, None)
|
||||
job["finished_at"] = time.time()
|
||||
cb = job.pop("_on_done", None)
|
||||
if cb and job["state"] == "done":
|
||||
try:
|
||||
cb()
|
||||
except Exception as exc:
|
||||
_append_log(job, f"[mc] Nachbearbeitung-Fehler: {exc}")
|
||||
job["state"] = "done" if code == 0 else "failed"
|
||||
_PROCS.pop(job["id"], None)
|
||||
_speichern(job)
|
||||
if job["state"] == "done":
|
||||
_nacharbeit_ausfuehren(job)
|
||||
|
||||
|
||||
def _nacharbeit_ausfuehren(job: dict) -> None:
|
||||
name = job.get("nacharbeit")
|
||||
if not name or job.get("nacharbeit_erledigt"):
|
||||
return
|
||||
job["nacharbeit_erledigt"] = True
|
||||
_speichern(job)
|
||||
try:
|
||||
fn = _NACHARBEITEN.get(name)
|
||||
if fn is None:
|
||||
raise RuntimeError(f"Nacharbeit „{name}“ ist in dieser Instanz unbekannt")
|
||||
fn(**(job.get("nacharbeit_daten") or {}))
|
||||
except Exception as exc:
|
||||
_protokoll(job["id"], f"[mc] Nachbearbeitung-Fehler: {exc}")
|
||||
log.warning("jobengine: Nacharbeit %s von %s gescheitert", name, job["id"], exc_info=True)
|
||||
|
||||
|
||||
def _beobachten(job_id: str) -> None:
|
||||
"""Wartet auf den Exit-Code einer systemd-Einheit. Endet sie ohne einen (Abbruch, Zeitlimit,
|
||||
Absturz), schließt er den Auftrag trotzdem ab."""
|
||||
naechste_pruefung = time.time() + _EINHEIT_PRUEFEN_S
|
||||
while True:
|
||||
job = JOBS.get(job_id)
|
||||
if job is None or job["state"] in _ENDE:
|
||||
return
|
||||
if (code := _exit_code(job_id)) is not None:
|
||||
_abschliessen(job, code)
|
||||
return
|
||||
if time.time() >= naechste_pruefung:
|
||||
if not _einheit_lebt(job_id):
|
||||
time.sleep(0.5) # der Exit-Code kann gerade erst geschrieben werden
|
||||
_abschliessen(job, _exit_code(job_id))
|
||||
return
|
||||
naechste_pruefung = time.time() + _EINHEIT_PRUEFEN_S
|
||||
time.sleep(_TAKT_S)
|
||||
|
||||
|
||||
def _starten(job: dict, args: list[str], env: dict | None) -> None:
|
||||
job["state"] = "running"
|
||||
_speichern(job)
|
||||
try:
|
||||
if job["art"] == "systemd":
|
||||
_starte_systemd(job, args, env)
|
||||
else:
|
||||
_starte_prozess(job, args, env)
|
||||
except Exception as exc:
|
||||
_protokoll(job["id"], f"[mc] Fehler: {exc}")
|
||||
job["error"] = str(exc)
|
||||
_abschliessen(job, -1)
|
||||
|
||||
|
||||
# --- Fortschritt (Downloads) ------------------------------------------------------------
|
||||
|
||||
def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> None:
|
||||
"""Fortschritt in % aus wachsenden *.incomplete-Dateien (hf schreibt sie)."""
|
||||
if not total_bytes or total_bytes <= 0:
|
||||
return
|
||||
job = JOBS.get(job_id)
|
||||
if job is not None:
|
||||
job["progress"] = 0
|
||||
job["total_bytes"] = total_bytes
|
||||
if job is None or not total_bytes or total_bytes <= 0:
|
||||
return
|
||||
job["fortschritt"] = {"ordner": local_dir, "gesamt": total_bytes}
|
||||
job["total_bytes"] = total_bytes
|
||||
job["progress"] = 0
|
||||
_speichern(job)
|
||||
_fortschritt_beobachten(job_id)
|
||||
|
||||
|
||||
def _fortschritt_beobachten(job_id: str) -> None:
|
||||
ziel = (JOBS.get(job_id) or {}).get("fortschritt") or {}
|
||||
local_dir, total_bytes = ziel.get("ordner"), ziel.get("gesamt")
|
||||
if not local_dir or not total_bytes:
|
||||
return
|
||||
|
||||
def _watch():
|
||||
pat = os.path.join(local_dir, ".cache", "huggingface", "download", "**", "*.incomplete")
|
||||
@@ -183,76 +380,150 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
|
||||
threading.Thread(target=_watch, daemon=True).start()
|
||||
|
||||
|
||||
# --- Verwaltung -------------------------------------------------------------------
|
||||
|
||||
def _laden() -> None:
|
||||
"""Akten von der Platte einlesen (einmal je Prozess; unter _LOCK aufrufen)."""
|
||||
global _geladen
|
||||
if _geladen:
|
||||
return
|
||||
_geladen = True
|
||||
for datei in sorted(_jobs_dir().glob("*.json")):
|
||||
try:
|
||||
akte = json.loads(datei.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if isinstance(akte, dict) and akte.get("id") and akte["id"] not in JOBS:
|
||||
JOBS[akte["id"]] = akte
|
||||
|
||||
|
||||
def _aufraeumen() -> None:
|
||||
"""Alte beendete Jobs entfernen (unter _LOCK aufrufen)."""
|
||||
"""Alte beendete Aufträge samt Dateien entfernen (unter _LOCK aufrufen)."""
|
||||
jetzt = time.time()
|
||||
fertig = sorted((j for j in JOBS.values() if j["state"] in _ENDE),
|
||||
key=lambda j: j.get("finished_at") or 0, reverse=True)
|
||||
for i, j in enumerate(fertig):
|
||||
if i >= BEHALTEN_MAX or jetzt - (j.get("finished_at") or jetzt) > BEHALTEN_S:
|
||||
JOBS.pop(j["id"], None)
|
||||
for endung in (".json", ".log", ".exit", ".env", ".exit.tmp", ".json.tmp"):
|
||||
_pfad(j["id"], endung).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _eintragen(args: list[str], label: str, on_done, group: str | None) -> str:
|
||||
def _eintragen(args: list[str], label: str, group: str | None, zeitlimit_s: int | None,
|
||||
nacharbeit_name: str | None, nacharbeit_daten: dict | None) -> dict:
|
||||
"""Neue Akte anlegen (unter _LOCK aufrufen)."""
|
||||
if nacharbeit_name and nacharbeit_name not in _NACHARBEITEN:
|
||||
raise ValueError(f"Unbekannte Nacharbeit: {nacharbeit_name}")
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
JOBS[job_id] = {
|
||||
"id": job_id, "label": label, "state": "queued", "group": group,
|
||||
"log": ["$ " + " ".join(shlex.quote(a) for a in args)],
|
||||
job = {
|
||||
"id": job_id, "label": label, "state": "queued", "group": group, "art": _art(),
|
||||
"returncode": None, "started_at": time.time(), "finished_at": None,
|
||||
"zeitlimit_s": int(zeitlimit_s or STANDARD_ZEITLIMIT_S),
|
||||
"nacharbeit": nacharbeit_name, "nacharbeit_daten": nacharbeit_daten or {},
|
||||
}
|
||||
if on_done:
|
||||
JOBS[job_id]["_on_done"] = on_done
|
||||
return job_id
|
||||
JOBS[job_id] = job
|
||||
_protokoll(job_id, "$ " + " ".join(shlex.quote(a) for a in args))
|
||||
_speichern(job)
|
||||
return job
|
||||
|
||||
|
||||
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None,
|
||||
group: str | None = None, zeitlimit_s: int | None = None) -> str:
|
||||
with _LOCK:
|
||||
_aufraeumen()
|
||||
job_id = _eintragen(list(args), label, on_done, group)
|
||||
threading.Thread(target=_run_job, args=(job_id, list(args), env, zeitlimit_s), daemon=True).start()
|
||||
return job_id
|
||||
|
||||
|
||||
def start_job_exklusiv(group: str, args: list[str], label: str, env: dict | None = None, on_done=None,
|
||||
zeitlimit_s: int | None = None) -> tuple[str | None, dict | None]:
|
||||
"""Wie start_job, aber nur, wenn in der Gruppe nichts läuft — Prüfen und Eintragen unter einer
|
||||
Sperre. Rückgabe: (neue Job-ID, None) oder (None, der schon laufende Job)."""
|
||||
with _LOCK:
|
||||
if (laeuft := active_in_group(group)) is not None:
|
||||
return None, laeuft
|
||||
_aufraeumen()
|
||||
job_id = _eintragen(list(args), label, on_done, group)
|
||||
threading.Thread(target=_run_job, args=(job_id, list(args), env, zeitlimit_s), daemon=True).start()
|
||||
return job_id, None
|
||||
|
||||
|
||||
def active_in_group(group: str) -> dict | None:
|
||||
"""Erster laufender/wartender Job einer Gruppe (z.B. 'maintenance'), sonst None.
|
||||
Basis für den Wartungs-Riegel: nur EIN System-Update gleichzeitig."""
|
||||
def _aktiv_in(group: str) -> dict | None:
|
||||
for j in list(JOBS.values()):
|
||||
if j.get("group") == group and j.get("state") in ("running", "queued"):
|
||||
return j
|
||||
return None
|
||||
|
||||
|
||||
def start_job(args: list[str], label: str, env: dict | None = None, group: str | None = None,
|
||||
zeitlimit_s: int | None = None, nacharbeit: str | None = None,
|
||||
nacharbeit_daten: dict | None = None) -> str:
|
||||
with _LOCK:
|
||||
_laden()
|
||||
_aufraeumen()
|
||||
job = _eintragen(list(args), label, group, zeitlimit_s, nacharbeit, nacharbeit_daten)
|
||||
_starten(job, list(args), env)
|
||||
return job["id"]
|
||||
|
||||
|
||||
def start_job_exklusiv(group: str, args: list[str], label: str, env: dict | None = None,
|
||||
zeitlimit_s: int | None = None, nacharbeit: str | None = None,
|
||||
nacharbeit_daten: dict | None = None) -> tuple[str | None, dict | None]:
|
||||
"""Wie start_job, aber nur, wenn in der Gruppe nichts läuft — Prüfen und Eintragen unter einer
|
||||
Sperre. Rückgabe: (neue Auftrags-ID, None) oder (None, der schon laufende Auftrag)."""
|
||||
with _LOCK:
|
||||
_laden()
|
||||
if (laeuft := _aktiv_in(group)) is not None:
|
||||
return None, laeuft
|
||||
_aufraeumen()
|
||||
job = _eintragen(list(args), label, group, zeitlimit_s, nacharbeit, nacharbeit_daten)
|
||||
_starten(job, list(args), env)
|
||||
return job["id"], None
|
||||
|
||||
|
||||
def active_in_group(group: str) -> dict | None:
|
||||
"""Erster laufender/wartender Auftrag einer Gruppe (z. B. 'maintenance'), sonst None.
|
||||
Basis für den Wartungs-Riegel: nur EIN System-Update gleichzeitig."""
|
||||
with _LOCK:
|
||||
_laden()
|
||||
return _aktiv_in(group)
|
||||
|
||||
|
||||
def wiederaufnehmen() -> int:
|
||||
"""Beim Start von MC2: Akten einlesen und laufende Aufträge weiter beobachten.
|
||||
Rückgabe: Zahl der wieder aufgenommenen Aufträge."""
|
||||
with _LOCK:
|
||||
_laden()
|
||||
offen = [j for j in JOBS.values() if j["state"] in ("queued", "running")]
|
||||
nacharbeit_offen = [j for j in JOBS.values() if j["state"] == "done" and j.get("nacharbeit")
|
||||
and not j.get("nacharbeit_erledigt")]
|
||||
for job in offen:
|
||||
if job.get("art") == "systemd":
|
||||
threading.Thread(target=_beobachten, args=(job["id"],), daemon=True).start()
|
||||
_fortschritt_beobachten(job["id"])
|
||||
elif (code := _exit_code(job["id"])) is not None:
|
||||
_abschliessen(job, code)
|
||||
else:
|
||||
# Kindprozess des vorigen MC2: sein Warte-Thread ist mit ihm gegangen.
|
||||
_protokoll(job["id"], "[mc] MC2 wurde neu gestartet; der Auftrag lief als Kindprozess mit.")
|
||||
job["error"] = "MC2 wurde während des Auftrags neu gestartet."
|
||||
_abschliessen(job, None)
|
||||
for job in nacharbeit_offen:
|
||||
_nacharbeit_ausfuehren(job)
|
||||
if offen:
|
||||
log.info("jobengine: %d laufende Aufträge wieder aufgenommen", len(offen))
|
||||
return len(offen)
|
||||
|
||||
|
||||
def cancel_job(job_id: str) -> bool:
|
||||
job = JOBS.get(job_id)
|
||||
if not job or job["state"] in _ENDE:
|
||||
return False
|
||||
job["canceled"] = True
|
||||
_append_log(job, "[mc] Abbruch angefordert…")
|
||||
proc = _PROCS.get(job_id)
|
||||
if proc is not None:
|
||||
with _LOCK:
|
||||
_laden()
|
||||
job = JOBS.get(job_id)
|
||||
if not job or job["state"] in _ENDE:
|
||||
return False
|
||||
job["canceled"] = True
|
||||
_speichern(job)
|
||||
_protokoll(job_id, "[mc] Abbruch angefordert…")
|
||||
if job["state"] == "queued":
|
||||
_abschliessen(job, None)
|
||||
elif job.get("art") == "systemd":
|
||||
try:
|
||||
subprocess.run(_systemd("systemctl", "stop", "--no-block", _einheit(job_id)),
|
||||
capture_output=True, timeout=15)
|
||||
except Exception:
|
||||
log.warning("jobengine: Abbruch von %s gescheitert", job_id, exc_info=True)
|
||||
elif (proc := _PROCS.get(job_id)) is not None:
|
||||
_beende_gruppe(proc)
|
||||
else:
|
||||
job["state"] = "canceled"
|
||||
job["finished_at"] = time.time()
|
||||
_abschliessen(job, None)
|
||||
return True
|
||||
|
||||
|
||||
def public_jobs() -> list[dict]:
|
||||
"""Jobs ohne interne Felder (_on_done) für die API."""
|
||||
def public_jobs(mit_log: bool = True) -> list[dict]:
|
||||
"""Aufträge ohne interne Felder für die API; das Protokoll kommt aus der Datei (Ende)."""
|
||||
with _LOCK:
|
||||
_laden()
|
||||
_aufraeumen()
|
||||
return [{k: v for k, v in j.items() if not k.startswith("_")} for j in JOBS.values()]
|
||||
jobs = [{k: v for k, v in j.items() if k not in _INTERN} for j in JOBS.values()]
|
||||
for j in jobs:
|
||||
j["log"] = _protokoll_lesen(j["id"]) if mit_log else []
|
||||
return sorted(jobs, key=lambda j: j.get("started_at") or 0)
|
||||
|
||||
Reference in New Issue
Block a user