- failed runs старше 24 часов пропускаются как историческая ошибка - предотвращает массовый перезапуск DAG-ов от 2024 года - параметр max_failed_age настраивается в config.yaml
191 lines
6.7 KiB
Python
191 lines
6.7 KiB
Python
"""DAG run analyzer - classifies runs and detects issues."""
|
|
|
|
import dataclasses
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Callable
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class DagIssue:
|
|
"""Represents a detected problem with a DAG run."""
|
|
|
|
dag_id: str
|
|
dag_run_id: str
|
|
issue_type: str # "failed" or "long_running"
|
|
state: str # airflow state string
|
|
start_time: str # ISO 8601 UTC
|
|
duration_seconds: float
|
|
error_info: str = ""
|
|
|
|
def to_dict(self) -> dict:
|
|
return dataclasses.asdict(self)
|
|
|
|
|
|
class DagAnalyzer:
|
|
"""Analyzes DAG runs and produces a list of issues."""
|
|
|
|
def __init__(self, long_running_threshold: int, max_failed_age: int = 86400):
|
|
self._threshold = long_running_threshold
|
|
self._max_failed_age = max_failed_age # ignore failed runs older than this (seconds)
|
|
logger.debug(
|
|
"DagAnalyzer initialized: long_running_threshold=%ds (%.1f min), max_failed_age=%ds (%.1f hours)",
|
|
long_running_threshold, long_running_threshold / 60,
|
|
max_failed_age, max_failed_age / 3600,
|
|
)
|
|
|
|
def analyze_dag_runs(
|
|
self,
|
|
dag_id: str,
|
|
runs: list[dict],
|
|
task_instances_fn: Callable[[str, str], list[dict]],
|
|
) -> list[DagIssue]:
|
|
"""Analyze DAG runs for a single DAG.
|
|
|
|
Args:
|
|
dag_id: DAG identifier.
|
|
runs: List of DAG run dicts from Airflow API.
|
|
task_instances_fn: Callable(dag_id, dag_run_id) -> list of task instances.
|
|
Called lazily only for failed runs to extract error details.
|
|
|
|
Returns:
|
|
List of DagIssue objects for problematic runs.
|
|
"""
|
|
issues = []
|
|
now = datetime.now(timezone.utc)
|
|
logger.debug("Analyzing %d runs for DAG '%s' (now=%s)", len(runs), dag_id, now.isoformat())
|
|
|
|
for run in runs:
|
|
run_id = run.get("dag_run_id", "")
|
|
state = run.get("state", "")
|
|
start_date_str = run.get("start_date") or run.get("execution_date", "")
|
|
|
|
if not start_date_str:
|
|
logger.warning("DAG run %s/%s has no start_date, skipping", dag_id, run_id)
|
|
continue
|
|
|
|
start_date = self._parse_datetime(start_date_str)
|
|
if start_date is None:
|
|
logger.warning(
|
|
"Cannot parse start_date '%s' for %s/%s",
|
|
start_date_str, dag_id, run_id,
|
|
)
|
|
continue
|
|
|
|
duration = (now - start_date).total_seconds()
|
|
|
|
logger.debug(
|
|
" Run %s/%s: state=%s, start=%s, duration=%.0fs (%.1f min), threshold=%ds",
|
|
dag_id, run_id, state, start_date_str,
|
|
duration, duration / 60, self._threshold,
|
|
)
|
|
|
|
if state == "failed":
|
|
# Skip old failed runs — only alert on recent failures
|
|
if duration > self._max_failed_age:
|
|
logger.debug(
|
|
" → SKIP: failed run too old (%.0fs > %ds max_failed_age)",
|
|
duration, self._max_failed_age,
|
|
)
|
|
continue
|
|
|
|
error_info = self._extract_error(dag_id, run_id, task_instances_fn)
|
|
issue = DagIssue(
|
|
dag_id=dag_id,
|
|
dag_run_id=run_id,
|
|
issue_type="failed",
|
|
state=state,
|
|
start_time=start_date.isoformat(),
|
|
duration_seconds=round(duration, 1),
|
|
error_info=error_info,
|
|
)
|
|
issues.append(issue)
|
|
logger.debug(" → ISSUE DETECTED: %s (error: %s)", issue.issue_type, error_info or "n/a")
|
|
|
|
elif state == "running" and duration > self._threshold:
|
|
issue = DagIssue(
|
|
dag_id=dag_id,
|
|
dag_run_id=run_id,
|
|
issue_type="long_running",
|
|
state=state,
|
|
start_time=start_date.isoformat(),
|
|
duration_seconds=round(duration, 1),
|
|
)
|
|
issues.append(issue)
|
|
logger.debug(
|
|
" → ISSUE DETECTED: long_running (%.0fs > %ds threshold)",
|
|
duration, self._threshold,
|
|
)
|
|
|
|
elif state == "running":
|
|
logger.debug(" → OK: running within threshold (%.0fs <= %ds)", duration, self._threshold)
|
|
|
|
else:
|
|
logger.debug(" → SKIP: state=%s (not actionable)", state)
|
|
|
|
logger.debug("Analysis complete for DAG '%s': %d issues found", dag_id, len(issues))
|
|
return issues
|
|
|
|
def _extract_error(
|
|
self,
|
|
dag_id: str,
|
|
dag_run_id: str,
|
|
task_instances_fn: Callable[[str, str], list[dict]],
|
|
) -> str:
|
|
"""Extract error info from failed task instances."""
|
|
logger.debug("Extracting error info for %s/%s", dag_id, dag_run_id)
|
|
try:
|
|
tasks = task_instances_fn(dag_id, dag_run_id)
|
|
except Exception as e:
|
|
logger.warning("Failed to fetch task instances for %s/%s: %s", dag_id, dag_run_id, e)
|
|
return ""
|
|
|
|
failed_tasks = [t for t in tasks if t.get("state") == "failed"]
|
|
logger.debug(
|
|
"Task instances for %s/%s: total=%d, failed=%d",
|
|
dag_id, dag_run_id, len(tasks), len(failed_tasks),
|
|
)
|
|
|
|
if not failed_tasks:
|
|
return ""
|
|
|
|
# Return info about the first failed task
|
|
task = failed_tasks[0]
|
|
task_id = task.get("task_id", "unknown")
|
|
# Try to get the error message from different possible fields
|
|
error = (
|
|
task.get("rendered_fields", {}).get("error", "")
|
|
or task.get("note", "")
|
|
or ""
|
|
)
|
|
# Truncate long error messages
|
|
if len(error) > 500:
|
|
error = error[:500] + "..."
|
|
|
|
result = f"Task '{task_id}' failed" + (f": {error}" if error else "")
|
|
logger.debug("Error extracted for %s/%s: %s", dag_id, dag_run_id, result)
|
|
return result
|
|
|
|
@staticmethod
|
|
def _parse_datetime(dt_string: str) -> datetime | None:
|
|
"""Parse ISO 8601 datetime string from Airflow API.
|
|
|
|
Handles both 'Z' suffix and '+00:00' timezone offset.
|
|
"""
|
|
if not dt_string:
|
|
return None
|
|
|
|
# Normalize 'Z' to '+00:00' for fromisoformat
|
|
cleaned = dt_string.replace("Z", "+00:00")
|
|
|
|
try:
|
|
dt = datetime.fromisoformat(cleaned)
|
|
# Ensure timezone-aware (UTC)
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
return dt
|
|
except ValueError:
|
|
return None
|