#!/usr/bin/env python3 """Alt-Paare und verwaiste Live-Läufe finden. Standard: Probelauf. python scripts/laeufe_reparieren.py python scripts/laeufe_reparieren.py --apply """ from __future__ import annotations import argparse import json import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from grotti import jobs, store # noqa: E402 from grotti.config import load # noqa: E402 from grotti.db import connect, prepare # noqa: E402 def find_pairs(conn): return conn.execute( """ SELECT a.id AS pred_id, b.id AS succ_id, a.printer_host, a.filename, a.state AS pred_state, b.state AS succ_state, a.duration_s AS pred_dur, b.duration_s AS succ_dur, a.ended_at, b.started_at, a.deducted AS pred_deducted, b.deducted AS succ_deducted, a.chargen_nr AS pred_chargen, b.chargen_nr AS succ_chargen FROM runs a JOIN runs b ON a.printer_host = b.printer_host AND a.filename = b.filename AND a.id <> b.id AND b.started_at BETWEEN a.ended_at - interval '30 seconds' AND a.ended_at + interval '5 seconds' WHERE a.state IN ('cancelled', 'interrupted', 'error') AND a.ended_at IS NOT NULL AND a.moonraker_job_id NOT LIKE 'live:%%' AND b.moonraker_job_id NOT LIKE 'live:%%' AND COALESCE(b.duration_s, 0) > 60 AND COALESCE(b.duration_s, 0) >= COALESCE(a.duration_s, 0) - 30 ORDER BY a.ended_at DESC """ ).fetchall() def find_orphan_live(conn): return conn.execute( """ SELECT id, printer_host, filename, state, started_at, ended_at, duration_s, chargen_nr FROM runs WHERE moonraker_job_id LIKE 'live:%%' AND state IN ('interrupted', 'completed', 'cancelled') ORDER BY COALESCE(ended_at, started_at) DESC LIMIT 100 """ ).fetchall() def main(): parser = argparse.ArgumentParser(description="Läufe nach Filament-Resume reparieren") parser.add_argument("--apply", action="store_true", help="Wirklich zusammenlegen") args = parser.parse_args() cfg = load() prepare(cfg["database_url"]) with connect(cfg["database_url"]) as conn: pairs = find_pairs(conn) orphans = find_orphan_live(conn) print("=== Verdachtspaare (Beweis: Dauer fortgesetzt, Lücke ≤ 5 s) ===") for row in pairs: print(json.dumps({k: str(v) for k, v in dict(row).items()}, ensure_ascii=False)) print("%d Paare" % len(pairs)) print("=== Verwaiste live:-Läufe ===") for row in orphans: print(json.dumps({k: str(v) for k, v in dict(row).items()}, ensure_ascii=False)) print("%d orphan" % len(orphans)) if not args.apply: print("Probelauf. Mit --apply werden Paare verbunden.") conn.rollback() return merged = 0 for row in pairs: try: jobs.manual_merge_runs( conn, row["pred_id"], row["succ_id"], confirm=True ) merged += 1 print("verbunden", row["pred_id"], "+", row["succ_id"]) except Exception as exc: print("Fehler", row["pred_id"], row["succ_id"], exc) conn.rollback() # Orphans nur an History derselben Datei hängen (kein Host-Fallback). # adopt_live_run nimmt geschlossene live: nur ≤ 2 h — ältere bleiben liegen. cleaned = 0 skipped = 0 for row in orphans: fname = (row.get("filename") or "").strip() if not fname: skipped += 1 continue hist = conn.execute( """ SELECT * FROM runs WHERE printer_host = %s AND filename = %s AND moonraker_job_id NOT LIKE 'live:%%' AND state IN ('in_progress', 'printing', 'paused') ORDER BY id DESC LIMIT 1 """, (row["printer_host"], fname), ).fetchone() if not hist: skipped += 1 continue store.adopt_live_run(conn, row["printer_host"], fname, hist["id"]) still = conn.execute( "SELECT 1 FROM runs WHERE id = %s", (row["id"],) ).fetchone() if still: skipped += 1 else: cleaned += 1 conn.commit() print( "verbunden=%d orphan_adopted=%d orphan_skipped=%d" % (merged, cleaned, skipped) ) if __name__ == "__main__": main()