211 lines
7.8 KiB
Python
Executable File
211 lines
7.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
DLQ Manager — Lê, classifica, reporta e marca falhas do wiki ingest.
|
|
|
|
Uso:
|
|
python3 dlq_manager.py --report # reporta falhas não reportadas
|
|
python3 dlq_manager.py --status # status resumido da DLQ
|
|
python3 dlq_manager.py --json # saída JSON completa
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
from typing import List, Dict, Optional
|
|
from dataclasses import dataclass, asdict, field
|
|
from collections import Counter
|
|
|
|
# ─── Config ────────────────────────────────────────────────────────────────
|
|
DLQ_PATH = os.path.expanduser("~/.hermes/wiki_ingest_failures.json")
|
|
REPORT_LOG = os.path.expanduser("~/.hermes/cron/output/dlq_reports.jsonl")
|
|
REPORT_DIR = os.path.expanduser("~/.hermes/cron/output/quality_report")
|
|
MAX_REPORT_HISTORY = 100 # entradas no JSONL
|
|
|
|
# ─── Data Model ─────────────────────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class DLQEntry:
|
|
file: str
|
|
error: str
|
|
timestamp: str
|
|
failure_class: str = "unknown"
|
|
reported: bool = False
|
|
retry_count: int = 0
|
|
last_retry: Optional[str] = None
|
|
error_hash: str = "" # hash do erro para deduplicação
|
|
|
|
# ─── File I/O ─────────────────────────────────────────────────────────────
|
|
|
|
def load_dlq() -> List[DLQEntry]:
|
|
if not os.path.exists(DLQ_PATH):
|
|
return []
|
|
try:
|
|
with open(DLQ_PATH, "r") as f:
|
|
data = json.load(f)
|
|
if isinstance(data, list):
|
|
return [DLQEntry(**item) for item in data]
|
|
elif isinstance(data, dict) and "failures" in data:
|
|
return [DLQEntry(**item) for item in data["failures"]]
|
|
return []
|
|
except Exception as e:
|
|
print(f"[DLQ-ERROR] Falha ao carregar: {e}", file=sys.stderr)
|
|
return []
|
|
|
|
def save_dlq(entries: List[DLQEntry]):
|
|
tmp = DLQ_PATH + ".tmp"
|
|
with open(tmp, "w") as f:
|
|
json.dump([asdict(e) for e in entries], f, indent=2)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.replace(tmp, DLQ_PATH)
|
|
|
|
# ─── Classification ─────────────────────────────────────────────────────────
|
|
|
|
def classify_error(error_msg: str) -> str:
|
|
e = error_msg.lower()
|
|
transient = ["timeout", "connection", "temporarily", "rate limit", "503", "502", "504",
|
|
"too many requests", "unavailable", "cannot", "refused", "reset"]
|
|
permanent = ["404", "not found", "invalid format", "parse error", "file not found",
|
|
"deleted", "permission denied", "encode", "utf-8", "json", "schema"]
|
|
for p in transient:
|
|
if p in e:
|
|
return "transient"
|
|
for p in permanent:
|
|
if p in e:
|
|
return "permanent"
|
|
return "unknown"
|
|
|
|
def compute_error_hash(file: str, error: str) -> str:
|
|
"""Gera um hash simples para deduplicação de erros similares."""
|
|
import hashlib
|
|
return hashlib.md5(f"{file}:{error[:80]}".encode()).hexdigest()[:8]
|
|
|
|
# ─── Reporting ─────────────────────────────────────────────────────────────
|
|
|
|
def build_report(entries: List[DLQEntry]) -> Dict:
|
|
unreported = [e for e in entries if not e.reported]
|
|
total = len(entries)
|
|
|
|
if not unreported:
|
|
return {"status": "ok", "unreported_count": 0, "total": total, "report": ""}
|
|
|
|
# Classifica
|
|
for e in unreported:
|
|
if e.failure_class == "unknown":
|
|
e.failure_class = classify_error(e.error)
|
|
|
|
by_class = Counter(e.failure_class for e in unreported)
|
|
by_error_short = Counter(str(e.error)[:70] for e in unreported)
|
|
by_file = Counter(os.path.basename(e.file) for e in unreported)
|
|
|
|
lines = [
|
|
f"🚨 [DLQ-ALERT] {len(unreported)} nova(s) falha(s) no ingest",
|
|
f" Total acumulado na DLQ: {total}",
|
|
"",
|
|
"Por classe:",
|
|
]
|
|
emoji = {"transient": "⏳", "permanent": "💀", "unknown": "❓"}
|
|
for cls, count in by_class.most_common():
|
|
lines.append(f" {emoji.get(cls, '❓')} {cls}: {count}")
|
|
|
|
lines.append("")
|
|
lines.append("Top erros:")
|
|
for err, count in by_error_short.most_common(5):
|
|
lines.append(f" • ({count}x) {err}")
|
|
|
|
lines.append("")
|
|
lines.append("Arquivos:")
|
|
for fname, count in by_file.most_common(10):
|
|
lines.append(f" • {fname} ({count}x)")
|
|
|
|
report_text = "\n".join(lines)
|
|
|
|
return {
|
|
"status": "alert",
|
|
"unreported_count": len(unreported),
|
|
"total": total,
|
|
"by_class": dict(by_class),
|
|
"top_errors": dict(by_error_short.most_common(5)),
|
|
"report": report_text,
|
|
}
|
|
|
|
def save_report(report: Dict):
|
|
os.makedirs(REPORT_DIR, exist_ok=True)
|
|
timestamp = datetime.now().isoformat()
|
|
|
|
# JSONL
|
|
with open(REPORT_LOG, "a") as f:
|
|
f.write(json.dumps({"timestamp": timestamp, **report}, ensure_ascii=False) + "\n")
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
|
|
def mark_reported(entries: List[DLQEntry]):
|
|
for e in entries:
|
|
e.reported = True
|
|
|
|
def get_status_summary(entries: List[DLQEntry]) -> Dict:
|
|
total = len(entries)
|
|
unreported = len([e for e in entries if not e.reported])
|
|
by_class = Counter(e.failure_class for e in entries)
|
|
recent = [e for e in entries if datetime.now() - datetime.fromisoformat(e.timestamp.replace("Z", "+00:00")) < timedelta(hours=24)]
|
|
|
|
return {
|
|
"total": total,
|
|
"unreported": unreported,
|
|
"by_class": dict(by_class),
|
|
"last_24h": len(recent),
|
|
"oldest": entries[0].timestamp if entries else None,
|
|
}
|
|
|
|
# ─── Main ───────────────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
import argparse
|
|
p = argparse.ArgumentParser(description="DLQ Manager — Auto-report de falhas")
|
|
p.add_argument("--report", action="store_true", help="Gerar relatório das não-reportadas")
|
|
p.add_argument("--status", action="store_true", help="Status resumido")
|
|
p.add_argument("--json", action="store_true", help="Saída JSON")
|
|
p.add_argument("--silent-if-ok", action="store_true", help="Silencioso se DLQ ok")
|
|
args = p.parse_args()
|
|
|
|
entries = load_dlq()
|
|
|
|
if args.status:
|
|
summary = get_status_summary(entries)
|
|
if args.json:
|
|
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
|
else:
|
|
print(f"DLQ status: {summary['total']} total, {summary['unreported']} não-reportadas")
|
|
for cls, count in summary.get("by_class", {}).items():
|
|
print(f" {cls}: {count}")
|
|
return
|
|
|
|
report = build_report(entries)
|
|
|
|
if report["status"] == "ok":
|
|
msg = "[DLQ-OK] Nenhuma falha nova desde último check."
|
|
if not args.silent_if_ok:
|
|
print(msg)
|
|
if args.json:
|
|
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
return
|
|
|
|
# Tem novas falhas
|
|
if args.json:
|
|
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
else:
|
|
print(report["report"])
|
|
|
|
# Salva e marca como reportadas
|
|
save_report(report)
|
|
mark_reported(entries)
|
|
save_dlq(entries)
|
|
|
|
# Exit code 1 para cron trigger
|
|
sys.exit(1)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|