Story 9
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Crash recovery and idempotent processing guarantees.
|
||||
|
||||
On startup: re-queues any video stuck in PROCESSING past the lock timeout.
|
||||
During processing: saves per-video checkpoint JSON to scratch for resume.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CHECKPOINT_DIR = "checkpoints"
|
||||
|
||||
|
||||
class CrashRecovery:
|
||||
"""Handle crash recovery, checkpointing, and idempotent re-queueing."""
|
||||
|
||||
def __init__(self, db_connector, config):
|
||||
self._db = db_connector
|
||||
scratch = config.get("storage.scratch_path", "/scratch")
|
||||
self._checkpoint_dir = Path(scratch) / _CHECKPOINT_DIR
|
||||
mon = config.get_section("monitoring")
|
||||
cr = mon.get("crash_recovery", {})
|
||||
self._lock_timeout_minutes: int = int(cr.get("lock_timeout_minutes", 5))
|
||||
self._auto_requeue: bool = bool(cr.get("auto_requeue", True))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Startup recovery
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def recover_on_startup(self) -> int:
|
||||
"""
|
||||
Re-queue videos stuck in PROCESSING past the lock timeout.
|
||||
|
||||
Returns the count of videos re-queued.
|
||||
"""
|
||||
if not self._auto_requeue:
|
||||
logger.info("auto_requeue disabled; skipping crash recovery")
|
||||
return 0
|
||||
|
||||
try:
|
||||
result = self._db.execute(
|
||||
"""UPDATE videos
|
||||
SET status = 'PENDING', updated_at = NOW()
|
||||
WHERE status = 'PROCESSING'
|
||||
AND updated_at < NOW() - INTERVAL %s MINUTE""",
|
||||
(self._lock_timeout_minutes,),
|
||||
)
|
||||
count = result if isinstance(result, int) else 0
|
||||
if count:
|
||||
logger.warning("Crash recovery: re-queued %d stuck PROCESSING videos", count)
|
||||
else:
|
||||
logger.info("Crash recovery: no stuck videos found")
|
||||
return count
|
||||
except Exception as exc:
|
||||
logger.error("Crash recovery query failed: %s", exc)
|
||||
return 0
|
||||
|
||||
def list_stuck_videos(self) -> List[Dict]:
|
||||
"""Return videos currently stuck past the lock timeout (read-only)."""
|
||||
try:
|
||||
rows = self._db.fetchall(
|
||||
"""SELECT id, file_path, updated_at
|
||||
FROM videos
|
||||
WHERE status = 'PROCESSING'
|
||||
AND updated_at < NOW() - INTERVAL %s MINUTE""",
|
||||
(self._lock_timeout_minutes,),
|
||||
)
|
||||
return rows or []
|
||||
except Exception as exc:
|
||||
logger.error("Failed to list stuck videos: %s", exc)
|
||||
return []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Per-video checkpointing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def save_checkpoint(self, video_id: int, state: str, progress: Dict) -> None:
|
||||
"""Persist a checkpoint JSON for the given video."""
|
||||
self._checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
||||
checkpoint = {
|
||||
"video_id": video_id,
|
||||
"state": state,
|
||||
"progress": progress,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
path = self._checkpoint_dir / f"{video_id}.json"
|
||||
try:
|
||||
path.write_text(json.dumps(checkpoint, indent=2), encoding="utf-8")
|
||||
logger.debug("Saved checkpoint for video %d (state=%s)", video_id, state)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to save checkpoint for video %d: %s", video_id, exc)
|
||||
|
||||
def load_checkpoint(self, video_id: int) -> Optional[Dict]:
|
||||
"""Return the stored checkpoint for video_id, or None if absent."""
|
||||
path = self._checkpoint_dir / f"{video_id}.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to read checkpoint for video %d: %s", video_id, exc)
|
||||
return None
|
||||
|
||||
def delete_checkpoint(self, video_id: int) -> None:
|
||||
"""Remove the checkpoint file after successful processing."""
|
||||
path = self._checkpoint_dir / f"{video_id}.json"
|
||||
if path.exists():
|
||||
try:
|
||||
path.unlink()
|
||||
logger.debug("Deleted checkpoint for video %d", video_id)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to delete checkpoint for video %d: %s", video_id, exc)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Idempotency guard
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def is_already_completed(self, video_id: int) -> bool:
|
||||
"""
|
||||
Return True if this video already has a COMPLETED status.
|
||||
|
||||
Prevents duplicate processing if a job is accidentally re-submitted.
|
||||
"""
|
||||
try:
|
||||
rows = self._db.fetchall(
|
||||
"SELECT status FROM videos WHERE id = %s",
|
||||
(video_id,),
|
||||
)
|
||||
return bool(rows and rows[0].get("status") == "COMPLETED")
|
||||
except Exception as exc:
|
||||
logger.warning("Idempotency check failed for video %d: %s", video_id, exc)
|
||||
return False
|
||||
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
Confidence distribution drift detection.
|
||||
|
||||
Compares recent video confidence scores against a stored baseline and fires
|
||||
alerts when the distribution shifts beyond configured thresholds.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DriftDetector:
|
||||
"""Detect and alert on confidence distribution drift."""
|
||||
|
||||
def __init__(self, db_connector, config, alert_fn: Optional[Callable[[str], None]] = None):
|
||||
self._db = db_connector
|
||||
mon = config.get_section("monitoring")
|
||||
alerts = mon.get("alerts", {})
|
||||
self._drift_threshold: float = float(alerts.get("confidence_drift_threshold", 0.10))
|
||||
self._review_max_size: int = int(alerts.get("review_queue_max_size", 1000))
|
||||
self._review_max_age_h: int = int(alerts.get("review_queue_max_age_hours", 24))
|
||||
self._throughput_min: float = float(alerts.get("throughput_min_videos_per_hour", 20))
|
||||
self._throughput_window_h: int = int(alerts.get("throughput_min_duration_hours", 1))
|
||||
self._error_rate_threshold: float = float(alerts.get("error_rate_threshold", 0.05))
|
||||
self._error_rate_window_h: int = int(alerts.get("error_rate_window_hours", 1))
|
||||
self._alert_fn = alert_fn or _log_alert
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run_all_checks(self) -> Dict[str, bool]:
|
||||
"""
|
||||
Run all alert checks and return a dict of {check_name: triggered}.
|
||||
|
||||
Fires the alert function for each triggered condition.
|
||||
"""
|
||||
results = {}
|
||||
results["confidence_drift"] = self.check_confidence_drift()
|
||||
results["review_queue_growth"] = self.check_review_queue_growth()
|
||||
results["low_throughput"] = self.check_low_throughput()
|
||||
results["high_error_rate"] = self.check_error_rate()
|
||||
return results
|
||||
|
||||
def check_confidence_drift(self) -> bool:
|
||||
"""
|
||||
Compare p(C > 0.5) in recent processing logs to the stored baseline.
|
||||
|
||||
Returns True if drift exceeds the threshold.
|
||||
"""
|
||||
current = self._fetch_recent_confidences(hours=24 * 7)
|
||||
baseline = self._fetch_baseline_confidences()
|
||||
if not current or not baseline:
|
||||
logger.debug("Not enough data for drift check")
|
||||
return False
|
||||
|
||||
triggered = detect_drift(current, baseline, self._drift_threshold)
|
||||
if triggered:
|
||||
current_high = _high_conf_fraction(current)
|
||||
baseline_high = _high_conf_fraction(baseline)
|
||||
shift = abs(current_high - baseline_high)
|
||||
self._alert_fn(
|
||||
f"Confidence drift detected: {shift:.1%} shift in p(C>0.5) "
|
||||
f"(current={current_high:.1%}, baseline={baseline_high:.1%}, "
|
||||
f"threshold={self._drift_threshold:.1%})"
|
||||
)
|
||||
return triggered
|
||||
|
||||
def check_review_queue_growth(self) -> bool:
|
||||
"""Alert if the unannotated review queue has been above max_size for max_age hours."""
|
||||
try:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=self._review_max_age_h)
|
||||
rows = self._db.fetchall(
|
||||
"""SELECT COUNT(*) AS cnt
|
||||
FROM review_queue
|
||||
WHERE annotated = FALSE
|
||||
AND created_at < %s""",
|
||||
(cutoff.replace(tzinfo=None),),
|
||||
)
|
||||
count = int((rows or [{"cnt": 0}])[0]["cnt"])
|
||||
if count > self._review_max_size:
|
||||
self._alert_fn(
|
||||
f"Review queue growth alert: {count} unannotated items older than "
|
||||
f"{self._review_max_age_h}h (threshold={self._review_max_size})"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.warning("Review queue growth check failed: %s", exc)
|
||||
return False
|
||||
|
||||
def check_low_throughput(self) -> bool:
|
||||
"""Alert if throughput dropped below the minimum for the last window hours."""
|
||||
try:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=self._throughput_window_h)
|
||||
rows = self._db.fetchall(
|
||||
"""SELECT COUNT(*) AS cnt
|
||||
FROM videos
|
||||
WHERE status = 'COMPLETED'
|
||||
AND last_processed_time >= %s""",
|
||||
(cutoff.replace(tzinfo=None),),
|
||||
)
|
||||
count = int((rows or [{"cnt": 0}])[0]["cnt"])
|
||||
videos_per_hour = count / max(self._throughput_window_h, 1)
|
||||
if videos_per_hour < self._throughput_min:
|
||||
self._alert_fn(
|
||||
f"Low throughput alert: {videos_per_hour:.1f} videos/hour "
|
||||
f"over last {self._throughput_window_h}h "
|
||||
f"(minimum={self._throughput_min})"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.warning("Throughput check failed: %s", exc)
|
||||
return False
|
||||
|
||||
def check_error_rate(self) -> bool:
|
||||
"""Alert if the fraction of ERROR/UNSCANNABLE videos exceeds the threshold."""
|
||||
try:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=self._error_rate_window_h)
|
||||
rows = self._db.fetchall(
|
||||
"""SELECT status, COUNT(*) AS cnt
|
||||
FROM videos
|
||||
WHERE last_processed_time >= %s
|
||||
AND status IN ('COMPLETED', 'ERROR', 'UNSCANNABLE')
|
||||
GROUP BY status""",
|
||||
(cutoff.replace(tzinfo=None),),
|
||||
)
|
||||
counts: Dict[str, int] = {r["status"]: int(r["cnt"]) for r in (rows or [])}
|
||||
total = sum(counts.values())
|
||||
if total == 0:
|
||||
return False
|
||||
errors = counts.get("ERROR", 0) + counts.get("UNSCANNABLE", 0)
|
||||
rate = errors / total
|
||||
if rate > self._error_rate_threshold:
|
||||
self._alert_fn(
|
||||
f"High error rate alert: {rate:.1%} over last {self._error_rate_window_h}h "
|
||||
f"({errors}/{total} videos, threshold={self._error_rate_threshold:.1%})"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.warning("Error rate check failed: %s", exc)
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DB helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _fetch_recent_confidences(self, hours: int = 168) -> List[float]:
|
||||
"""Return confidence scores from the last `hours` hours."""
|
||||
try:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
rows = self._db.fetchall(
|
||||
"""SELECT confidence_score
|
||||
FROM processing_logs
|
||||
WHERE processed_at >= %s
|
||||
AND confidence_score IS NOT NULL""",
|
||||
(cutoff.replace(tzinfo=None),),
|
||||
)
|
||||
return [float(r["confidence_score"]) for r in (rows or [])]
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch recent confidences: %s", exc)
|
||||
return []
|
||||
|
||||
def _fetch_baseline_confidences(self) -> List[float]:
|
||||
"""Return the baseline confidence distribution stored in the DB."""
|
||||
try:
|
||||
rows = self._db.fetchall(
|
||||
"""SELECT confidence_score
|
||||
FROM processing_logs
|
||||
WHERE confidence_score IS NOT NULL
|
||||
ORDER BY processed_at ASC
|
||||
LIMIT 10000"""
|
||||
)
|
||||
return [float(r["confidence_score"]) for r in (rows or [])]
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch baseline confidences: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level pure functions (testable without DB)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def detect_drift(
|
||||
current_confidences: List[float],
|
||||
baseline_confidences: List[float],
|
||||
threshold: float = 0.10,
|
||||
) -> bool:
|
||||
"""
|
||||
Return True if |p(C>0.5)_current - p(C>0.5)_baseline| > threshold.
|
||||
|
||||
Both lists must be non-empty.
|
||||
"""
|
||||
if not current_confidences or not baseline_confidences:
|
||||
return False
|
||||
current_high = _high_conf_fraction(current_confidences)
|
||||
baseline_high = _high_conf_fraction(baseline_confidences)
|
||||
shift = abs(current_high - baseline_high)
|
||||
if shift > threshold:
|
||||
logger.warning(
|
||||
"Drift detected: shift=%.2f%% (current=%.2f%% baseline=%.2f%%)",
|
||||
shift * 100, current_high * 100, baseline_high * 100,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _high_conf_fraction(confidences: List[float]) -> float:
|
||||
"""Return the fraction of scores > 0.5."""
|
||||
return sum(1 for c in confidences if c > 0.5) / len(confidences)
|
||||
|
||||
|
||||
def _log_alert(message: str):
|
||||
logger.warning("ALERT: %s", message)
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Worker health check HTTP endpoint.
|
||||
|
||||
Exposes GET /health returning a JSON status document.
|
||||
Designed to be started in a background thread alongside the main worker.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_START_TIME = time.monotonic()
|
||||
|
||||
|
||||
class HealthStatus:
|
||||
"""Mutable singleton updated by the worker as it processes videos."""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._data: Dict[str, Any] = {
|
||||
"status": "starting",
|
||||
"gpu_available": False,
|
||||
"gpu_memory_used_gb": 0.0,
|
||||
"queue_depth": 0,
|
||||
"videos_processed_today": 0,
|
||||
"last_error": None,
|
||||
}
|
||||
|
||||
def update(self, **kwargs):
|
||||
with self._lock:
|
||||
self._data.update(kwargs)
|
||||
|
||||
def snapshot(self) -> Dict[str, Any]:
|
||||
with self._lock:
|
||||
data = dict(self._data)
|
||||
data["uptime_seconds"] = round(time.monotonic() - _START_TIME, 1)
|
||||
return data
|
||||
|
||||
|
||||
# Module-level singleton shared between the HTTP handler and the worker
|
||||
health = HealthStatus()
|
||||
|
||||
|
||||
class _HealthHandler(BaseHTTPRequestHandler):
|
||||
"""Minimal HTTP handler — only responds to GET /health."""
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
if self.path.rstrip("/") == "/health":
|
||||
body = json.dumps(health.snapshot(), default=str).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, fmt, *args): # silence default access log
|
||||
logger.debug("health_check: " + fmt, *args)
|
||||
|
||||
|
||||
def start_health_server(port: int = 8080) -> threading.Thread:
|
||||
"""
|
||||
Start the health HTTP server in a daemon thread.
|
||||
|
||||
Returns the thread so callers can join if needed.
|
||||
"""
|
||||
server = HTTPServer(("0.0.0.0", port), _HealthHandler)
|
||||
|
||||
def _serve():
|
||||
logger.info("Health check server listening on port %d", port)
|
||||
server.serve_forever()
|
||||
|
||||
t = threading.Thread(target=_serve, daemon=True, name="health-server")
|
||||
t.start()
|
||||
return t
|
||||
|
||||
|
||||
def refresh_health_from_system(db_connector=None, scratch_path: str = "/scratch"):
|
||||
"""
|
||||
Update the health singleton with current GPU, queue, and disk state.
|
||||
|
||||
Call this periodically from the worker main loop.
|
||||
"""
|
||||
updates: Dict[str, Any] = {"status": "healthy"}
|
||||
|
||||
# GPU state
|
||||
try:
|
||||
import pynvml
|
||||
pynvml.nvmlInit()
|
||||
handle = pynvml.nvmlDeviceGetHandleByIndex(0)
|
||||
mem = pynvml.nvmlDeviceGetMemoryInfo(handle)
|
||||
updates["gpu_available"] = True
|
||||
updates["gpu_memory_used_gb"] = round(mem.used / 1024**3, 2)
|
||||
pynvml.nvmlShutdown()
|
||||
except Exception:
|
||||
updates["gpu_available"] = _torch_gpu_available()
|
||||
|
||||
# Queue depth from DB
|
||||
if db_connector is not None:
|
||||
try:
|
||||
rows = db_connector.fetchall(
|
||||
"SELECT COUNT(*) AS cnt FROM videos WHERE status IN ('PENDING','PROCESSING')"
|
||||
)
|
||||
updates["queue_depth"] = int((rows or [{"cnt": 0}])[0]["cnt"])
|
||||
|
||||
today = datetime.now(timezone.utc).date().isoformat()
|
||||
rows2 = db_connector.fetchall(
|
||||
"SELECT COUNT(*) AS cnt FROM videos "
|
||||
"WHERE status = 'COMPLETED' AND DATE(last_processed_time) = %s",
|
||||
(today,),
|
||||
)
|
||||
updates["videos_processed_today"] = int((rows2 or [{"cnt": 0}])[0]["cnt"])
|
||||
except Exception as exc:
|
||||
logger.debug("Health DB query failed: %s", exc)
|
||||
|
||||
health.update(**updates)
|
||||
|
||||
|
||||
def _torch_gpu_available() -> bool:
|
||||
try:
|
||||
import torch
|
||||
return torch.cuda.is_available()
|
||||
except ImportError:
|
||||
return False
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
Prometheus metrics definitions for VideoDetect.
|
||||
|
||||
All metric objects are module-level singletons — import and call them directly.
|
||||
Call start_metrics_server() once at worker startup to expose /metrics.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Populated on first _ensure_prometheus() call; None if library unavailable.
|
||||
_prometheus_available: Optional[bool] = None
|
||||
_server_started = False
|
||||
_server_lock = threading.Lock()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lazy metric definitions — avoid import errors when prometheus_client absent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ensure_prometheus():
|
||||
global _prometheus_available
|
||||
if _prometheus_available is not None:
|
||||
return _prometheus_available
|
||||
try:
|
||||
import prometheus_client # noqa: F401
|
||||
_prometheus_available = True
|
||||
except ImportError:
|
||||
logger.warning("prometheus_client not installed; metrics disabled")
|
||||
_prometheus_available = False
|
||||
return _prometheus_available
|
||||
|
||||
|
||||
class _NoOpMetric:
|
||||
"""Drop-in replacement used when prometheus_client is unavailable."""
|
||||
def labels(self, **_):
|
||||
return self
|
||||
def inc(self, *_, **__):
|
||||
pass
|
||||
def set(self, *_, **__):
|
||||
pass
|
||||
def observe(self, *_, **__):
|
||||
pass
|
||||
|
||||
|
||||
def _counter(name, doc, labelnames=()):
|
||||
if not _ensure_prometheus():
|
||||
return _NoOpMetric()
|
||||
from prometheus_client import Counter
|
||||
return Counter(name, doc, list(labelnames))
|
||||
|
||||
|
||||
def _gauge(name, doc, labelnames=()):
|
||||
if not _ensure_prometheus():
|
||||
return _NoOpMetric()
|
||||
from prometheus_client import Gauge
|
||||
return Gauge(name, doc, list(labelnames))
|
||||
|
||||
|
||||
def _histogram(name, doc, labelnames=(), buckets=None):
|
||||
if not _ensure_prometheus():
|
||||
return _NoOpMetric()
|
||||
from prometheus_client import Histogram
|
||||
kwargs = {"labelnames": list(labelnames)}
|
||||
if buckets is not None:
|
||||
kwargs["buckets"] = buckets
|
||||
return Histogram(name, doc, **kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Counters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
videos_processed_total = _counter(
|
||||
"videos_processed_total",
|
||||
"Total videos processed",
|
||||
["routing_decision"],
|
||||
)
|
||||
|
||||
videos_processed_by_model_total = _counter(
|
||||
"videos_processed_by_model_total",
|
||||
"Total videos processed per model version",
|
||||
["model_version"],
|
||||
)
|
||||
|
||||
frames_extracted_total = _counter(
|
||||
"frames_extracted_total",
|
||||
"Total frames extracted across all videos",
|
||||
)
|
||||
|
||||
faces_detected_total = _counter(
|
||||
"faces_detected_total",
|
||||
"Total face crops detected",
|
||||
)
|
||||
|
||||
inference_errors_total = _counter(
|
||||
"inference_errors_total",
|
||||
"Total inference errors by type",
|
||||
["error_type"],
|
||||
)
|
||||
|
||||
retry_attempts_total = _counter(
|
||||
"retry_attempts_total",
|
||||
"Total retry attempts by pipeline step",
|
||||
["step"],
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gauges
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
gpu_utilization_percent = _gauge(
|
||||
"gpu_utilization_percent",
|
||||
"GPU utilization as a percentage",
|
||||
["gpu_id"],
|
||||
)
|
||||
|
||||
gpu_memory_used_bytes = _gauge(
|
||||
"gpu_memory_used_bytes",
|
||||
"GPU memory currently allocated in bytes",
|
||||
["gpu_id"],
|
||||
)
|
||||
|
||||
gpu_memory_free_bytes = _gauge(
|
||||
"gpu_memory_free_bytes",
|
||||
"GPU memory currently free in bytes",
|
||||
["gpu_id"],
|
||||
)
|
||||
|
||||
queue_depth_pending = _gauge(
|
||||
"queue_depth_pending",
|
||||
"Number of videos in PENDING state",
|
||||
)
|
||||
|
||||
queue_depth_processing = _gauge(
|
||||
"queue_depth_processing",
|
||||
"Number of videos currently in PROCESSING state",
|
||||
)
|
||||
|
||||
queue_depth_review = _gauge(
|
||||
"queue_depth_review",
|
||||
"Number of unannotated videos in the review queue",
|
||||
)
|
||||
|
||||
scratch_usage_bytes = _gauge(
|
||||
"scratch_usage_bytes",
|
||||
"Scratch space used in bytes",
|
||||
)
|
||||
|
||||
scratch_usage_percent = _gauge(
|
||||
"scratch_usage_percent",
|
||||
"Scratch space used as a percentage of total",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Histograms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CONFIDENCE_BUCKETS = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
|
||||
|
||||
video_processing_duration_seconds = _histogram(
|
||||
"video_processing_duration_seconds",
|
||||
"End-to-end processing time per video in seconds",
|
||||
["routing_decision"],
|
||||
)
|
||||
|
||||
confidence_score_distribution = _histogram(
|
||||
"confidence_score_distribution",
|
||||
"Distribution of video-level confidence scores",
|
||||
buckets=_CONFIDENCE_BUCKETS,
|
||||
)
|
||||
|
||||
frame_count_per_video = _histogram(
|
||||
"frame_count_per_video",
|
||||
"Number of frames sampled per video",
|
||||
buckets=[1, 2, 5, 10, 20, 50, 100, 200, 500],
|
||||
)
|
||||
|
||||
face_count_per_video = _histogram(
|
||||
"face_count_per_video",
|
||||
"Number of face crops detected per video",
|
||||
buckets=[0, 1, 2, 5, 10, 20, 50, 100],
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server startup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def start_metrics_server(port: int = 9090):
|
||||
"""Start the Prometheus HTTP metrics endpoint (idempotent)."""
|
||||
global _server_started
|
||||
with _server_lock:
|
||||
if _server_started:
|
||||
return
|
||||
if not _ensure_prometheus():
|
||||
logger.warning("Cannot start metrics server: prometheus_client unavailable")
|
||||
return
|
||||
try:
|
||||
from prometheus_client import start_http_server
|
||||
start_http_server(port)
|
||||
_server_started = True
|
||||
logger.info("Prometheus metrics server started on port %d", port)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to start metrics server: %s", exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Convenience update functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def update_queue_depths(db_connector):
|
||||
"""Query the DB and refresh all queue-depth gauges."""
|
||||
try:
|
||||
rows = db_connector.fetchall(
|
||||
"""SELECT status, COUNT(*) AS cnt
|
||||
FROM videos
|
||||
WHERE status IN ('PENDING', 'PROCESSING')
|
||||
GROUP BY status"""
|
||||
)
|
||||
counts = {r["status"]: int(r["cnt"]) for r in (rows or [])}
|
||||
queue_depth_pending.set(counts.get("PENDING", 0))
|
||||
queue_depth_processing.set(counts.get("PROCESSING", 0))
|
||||
|
||||
review_rows = db_connector.fetchall(
|
||||
"SELECT COUNT(*) AS cnt FROM review_queue WHERE annotated = FALSE"
|
||||
)
|
||||
queue_depth_review.set(int((review_rows or [{"cnt": 0}])[0]["cnt"]))
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to update queue depth metrics: %s", exc)
|
||||
|
||||
|
||||
def update_gpu_metrics():
|
||||
"""Poll nvidia-smi (via pynvml) and refresh GPU gauges."""
|
||||
try:
|
||||
import pynvml
|
||||
pynvml.nvmlInit()
|
||||
count = pynvml.nvmlDeviceGetCount()
|
||||
for i in range(count):
|
||||
handle = pynvml.nvmlDeviceGetHandleByIndex(i)
|
||||
util = pynvml.nvmlDeviceGetUtilizationRates(handle)
|
||||
mem = pynvml.nvmlDeviceGetMemoryInfo(handle)
|
||||
gpu_id = str(i)
|
||||
gpu_utilization_percent.labels(gpu_id=gpu_id).set(util.gpu)
|
||||
gpu_memory_used_bytes.labels(gpu_id=gpu_id).set(mem.used)
|
||||
gpu_memory_free_bytes.labels(gpu_id=gpu_id).set(mem.free)
|
||||
pynvml.nvmlShutdown()
|
||||
except Exception as exc:
|
||||
logger.debug("GPU metrics unavailable: %s", exc)
|
||||
|
||||
|
||||
def update_scratch_metrics(scratch_path: str = "/scratch"):
|
||||
"""Measure scratch disk usage and refresh gauges."""
|
||||
try:
|
||||
import shutil
|
||||
total, used, free = shutil.disk_usage(scratch_path)
|
||||
scratch_usage_bytes.set(used)
|
||||
scratch_usage_percent.set(round(used / max(total, 1) * 100, 2))
|
||||
except Exception as exc:
|
||||
logger.debug("Scratch metrics unavailable: %s", exc)
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Retry decorator with exponential backoff for transient failures.
|
||||
|
||||
Usage:
|
||||
@retry(max_attempts=3, step="extract")
|
||||
def extract_frames(...): ...
|
||||
|
||||
Non-retryable error types are re-raised immediately without consuming attempts.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import time
|
||||
from typing import Callable, Optional, Tuple, Type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Error class names that should never be retried
|
||||
_NON_RETRYABLE_NAMES = frozenset({
|
||||
"CodecUnsupportedError",
|
||||
"FileCorruptError",
|
||||
"InvalidPathError",
|
||||
"FileNotFoundError",
|
||||
"PermissionError",
|
||||
"IsADirectoryError",
|
||||
})
|
||||
|
||||
|
||||
class RetryExhaustedError(Exception):
|
||||
"""Raised when all retry attempts have been exhausted."""
|
||||
|
||||
|
||||
def retry(
|
||||
max_attempts: int = 3,
|
||||
initial_delay: float = 1.0,
|
||||
backoff_factor: float = 2.0,
|
||||
exceptions: Tuple[Type[Exception], ...] = (Exception,),
|
||||
step: str = "unknown",
|
||||
):
|
||||
"""
|
||||
Decorator: retry ``func`` up to ``max_attempts`` times on retryable exceptions.
|
||||
|
||||
Non-retryable exceptions (see _NON_RETRYABLE_NAMES) propagate immediately.
|
||||
Each retry waits ``initial_delay * backoff_factor ** attempt`` seconds.
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
last_exc: Optional[Exception] = None
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except exceptions as exc:
|
||||
if _is_non_retryable(exc):
|
||||
logger.error(
|
||||
"Non-retryable error in step=%s (attempt %d/%d): %s: %s",
|
||||
step, attempt + 1, max_attempts,
|
||||
type(exc).__name__, exc,
|
||||
)
|
||||
raise
|
||||
|
||||
last_exc = exc
|
||||
_increment_retry_counter(step)
|
||||
|
||||
if attempt < max_attempts - 1:
|
||||
delay = initial_delay * (backoff_factor ** attempt)
|
||||
logger.warning(
|
||||
"Transient error in step=%s (attempt %d/%d), "
|
||||
"retrying in %.1fs: %s: %s",
|
||||
step, attempt + 1, max_attempts, delay,
|
||||
type(exc).__name__, exc,
|
||||
)
|
||||
time.sleep(delay)
|
||||
else:
|
||||
logger.error(
|
||||
"All %d attempts exhausted in step=%s: %s: %s",
|
||||
max_attempts, step, type(exc).__name__, exc,
|
||||
)
|
||||
|
||||
raise RetryExhaustedError(
|
||||
f"step={step} failed after {max_attempts} attempts"
|
||||
) from last_exc
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def retry_from_config(config, step: str = "unknown"):
|
||||
"""Build a ``@retry`` decorator from config.yaml monitoring.retry settings."""
|
||||
mon = config.get_section("monitoring")
|
||||
cfg = mon.get("retry", {})
|
||||
return retry(
|
||||
max_attempts=int(cfg.get("max_attempts", 3)),
|
||||
initial_delay=float(cfg.get("initial_delay", 1.0)),
|
||||
backoff_factor=float(cfg.get("backoff_factor", 2.0)),
|
||||
step=step,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _is_non_retryable(exc: Exception) -> bool:
|
||||
return type(exc).__name__ in _NON_RETRYABLE_NAMES
|
||||
|
||||
|
||||
def _increment_retry_counter(step: str):
|
||||
try:
|
||||
from metrics import retry_attempts_total
|
||||
retry_attempts_total.labels(step=step).inc()
|
||||
except Exception:
|
||||
pass # metrics not available; don't break the retry logic
|
||||
Reference in New Issue
Block a user