This is an automated email from the ASF dual-hosted git repository.
kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 8699a93e659 Bound job loading for component health checks (#76751)
8699a93e659 is described below
commit 8699a93e659038ae7726446a87d4857d75aa534f
Author: Vlad Vladov <[email protected]>
AuthorDate: Tue Nov 10 18:43:11 2026 +0200
Bound job loading for component health checks (#76751)
Uncooperative shutdowns leave unfinished jobs indefinitely. Hydrating
every abandoned row makes health probes increasingly expensive.
MySQL cannot semijoin an IN over a UNION ALL, so it ran the combined
health query as a dependent subquery per job row and the cost still grew
with job history. Two plain indexed queries stay flat.
The alive rule now lives in one SQL helper next to Job._is_alive, so the
health query and the jobs API filter cannot drift apart.
Amp-Thread-ID:
https://ampcode.com/threads/T-01a1c4c7-1193-77df-941c-935af7861fbe
---
.../src/airflow/api/common/airflow_health.py | 34 ++++++---
.../airflow/api_fastapi/common/parameters/misc.py | 30 ++------
airflow-core/src/airflow/jobs/job.py | 26 ++++++-
.../tests/unit/api/common/test_airflow_health.py | 80 +++++++++++++++-------
airflow-core/tests/unit/jobs/test_base_job.py | 29 ++++++++
5 files changed, 141 insertions(+), 58 deletions(-)
diff --git a/airflow-core/src/airflow/api/common/airflow_health.py
b/airflow-core/src/airflow/api/common/airflow_health.py
index 728fba5a2a0..7b05a03f959 100644
--- a/airflow-core/src/airflow/api/common/airflow_health.py
+++ b/airflow-core/src/airflow/api/common/airflow_health.py
@@ -17,11 +17,13 @@
from __future__ import annotations
import logging
+from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING, Any
from sqlalchemy import select
+from airflow._shared.timezones import timezone
from airflow.configuration import conf
from airflow.dag_processing.bundles.manager import
get_configured_bundle_team_names
from airflow.jobs.dag_processor_job_runner import DagProcessorJobRunner
@@ -53,16 +55,28 @@ class DetailedHealthStatus(str, Enum):
@provide_session
def get_jobs_health(job_runner_class, *, session: Session = NEW_SESSION) ->
list[Job]:
- """Return unfinished jobs for the runner class, ordered by latest
heartbeat."""
- return list(
- session.scalars(
- select(Job)
- .where(
- Job.job_type == job_runner_class.job_type,
- Job.end_date.is_(None),
- )
- .order_by(Job.latest_heartbeat.desc())
- )
+ """
+ Return live unfinished jobs and the latest unfinished job, ordered by
heartbeat.
+
+ Keep the latest job even when dead so health responses retain its
heartbeat.
+ """
+ job_type = job_runner_class.job_type
+ unfinished = Job.end_date.is_(None)
+ # Two separate queries rather than one ``id IN (... UNION ALL ...)``:
MySQL cannot semijoin that
+ # form and runs it as a dependent subquery per ``job`` row, which scales
with job history.
+ live_jobs = session.scalars(
+ select(Job).where(Job.job_type == job_type, unfinished,
Job.is_alive_clause([job_type]))
+ )
+ latest_job = session.scalar(
+ select(Job).where(Job.job_type == job_type,
unfinished).order_by(Job.latest_heartbeat.desc()).limit(1)
+ )
+ jobs = {job.id: job for job in live_jobs}
+ if latest_job is not None:
+ jobs.setdefault(latest_job.id, latest_job)
+ return sorted(
+ jobs.values(),
+ key=lambda job: job.latest_heartbeat or
datetime.min.replace(tzinfo=timezone.utc),
+ reverse=True,
)
diff --git a/airflow-core/src/airflow/api_fastapi/common/parameters/misc.py
b/airflow-core/src/airflow/api_fastapi/common/parameters/misc.py
index e25cd99cbf3..b6130a77f47 100644
--- a/airflow-core/src/airflow/api_fastapi/common/parameters/misc.py
+++ b/airflow-core/src/airflow/api_fastapi/common/parameters/misc.py
@@ -88,30 +88,14 @@ class _IsAliveFilter(BaseParam[bool]):
if self.value is None and self.skip_none:
return select
- from datetime import timedelta
-
- from sqlalchemy import and_, case, or_
-
- from airflow._shared.timezones import timezone
- from airflow.jobs.job import Job, JobState, health_check_threshold
-
- now = timezone.utcnow()
- # Only the concrete core job types are persisted here and their
thresholds come from config
- # rather than the per-row heartrate, so the cutoff is a bound
timestamp and the predicate stays
- # portable. Kept in sync with Job._is_alive: alive means RUNNING with
a heartbeat newer than the
- # job type's health-check threshold. The case wraps the result so it
is always True/False (never
- # NULL), keeping the negated is_alive=False query exact.
- fresh_per_type = or_(
- *(
- and_(
- Job.job_type == job_type,
- Job.latest_heartbeat > now -
timedelta(seconds=health_check_threshold(job_type, 0)),
- )
- for job_type in ("SchedulerJob", "TriggererJob",
"DagProcessorJob")
- )
- )
+ from sqlalchemy import case
+
+ from airflow.jobs.job import Job
+
+ # The case wraps the result so it is always True/False (never NULL),
keeping the negated
+ # is_alive=False query exact.
alive = case(
- (and_(Job.state == JobState.RUNNING,
Job.latest_heartbeat.is_not(None), fresh_per_type), True),
+ (Job.is_alive_clause(("SchedulerJob", "TriggererJob",
"DagProcessorJob")), True),
else_=False,
)
return select.where(alive if self.value else ~alive)
diff --git a/airflow-core/src/airflow/jobs/job.py
b/airflow-core/src/airflow/jobs/job.py
index f08b2fd1cb8..43862eb5a2b 100644
--- a/airflow-core/src/airflow/jobs/job.py
+++ b/airflow-core/src/airflow/jobs/job.py
@@ -18,14 +18,14 @@
from __future__ import annotations
from collections.abc import Callable, Iterable
-from datetime import datetime
+from datetime import datetime, timedelta
from enum import Enum
from functools import cached_property, lru_cache
from time import sleep
from typing import TYPE_CHECKING, NoReturn
from uuid import UUID
-from sqlalchemy import Index, Integer, String, Uuid, case, select
+from sqlalchemy import Index, Integer, String, Uuid, and_, case, or_, select
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Mapped, backref, foreign, mapped_column,
relationship
from sqlalchemy.orm.session import make_transient
@@ -59,6 +59,7 @@ class JobState(str, Enum):
if TYPE_CHECKING:
from sqlalchemy.orm.session import Session
+ from sqlalchemy.sql.elements import ColumnElement
def _resolve_dagrun_model():
@@ -334,6 +335,27 @@ class Job(Base, LoggingMixin):
and (timezone.utcnow() - latest_heartbeat).total_seconds() <
health_check_threshold_value
)
+ @staticmethod
+ def is_alive_clause(job_types: Iterable[str]) -> ColumnElement[bool]:
+ """
+ Return the SQL form of :meth:`_is_alive` for the given job types.
+
+ Only the core job types are supported: their thresholds come from
config rather than the per-row
+ heartrate, so the cutoff is a bound timestamp and the predicate stays
portable. Keep in sync
+ with :meth:`_is_alive`.
+ """
+ now = timezone.utcnow()
+ fresh_per_type = or_(
+ *(
+ and_(
+ Job.job_type == job_type,
+ Job.latest_heartbeat > now -
timedelta(seconds=health_check_threshold(job_type, 0)),
+ )
+ for job_type in job_types
+ )
+ )
+ return and_(Job.state == JobState.RUNNING,
Job.latest_heartbeat.is_not(None), fresh_per_type)
+
@provide_session
def most_recent_job(job_type: str, *, session: Session = NEW_SESSION) -> Job |
None:
diff --git a/airflow-core/tests/unit/api/common/test_airflow_health.py
b/airflow-core/tests/unit/api/common/test_airflow_health.py
index 9affe9faab6..649ad465c5c 100644
--- a/airflow-core/tests/unit/api/common/test_airflow_health.py
+++ b/airflow-core/tests/unit/api/common/test_airflow_health.py
@@ -20,6 +20,7 @@ from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
import pytest
+import time_machine
from airflow._shared.timezones import timezone
from airflow.api.common.airflow_health import (
@@ -31,7 +32,8 @@ from airflow.api.common.airflow_health import (
get_airflow_health,
get_jobs_health,
)
-from airflow.jobs.job import Job, JobState
+from airflow.jobs.dag_processor_job_runner import DagProcessorJobRunner
+from airflow.jobs.job import Job, JobState, health_check_threshold
from airflow.jobs.scheduler_job_runner import SchedulerJobRunner
from airflow.jobs.triggerer_job_runner import TriggererJobRunner
from airflow.utils.session import provide_session
@@ -83,13 +85,14 @@ def _create_job(
runner_class,
*,
hostname: str,
- heartbeat: datetime,
+ heartbeat: datetime | None,
state: JobState = JobState.RUNNING,
end_date: datetime | None = None,
team_names: list[str] | None = None,
bundle_names: list[str] | None = None,
) -> Job:
job = Job(
+ job_type=runner_class.job_type,
state=state,
latest_heartbeat=heartbeat,
hostname=hostname,
@@ -97,10 +100,6 @@ def _create_job(
team_names=team_names or [],
bundle_names=bundle_names,
)
- if runner_class is TriggererJobRunner:
- runner_class(job=job, capacity=1)
- else:
- runner_class(job=job)
session.add(job)
return job
@@ -482,39 +481,71 @@ class TestAirflowHealthFromDb:
@pytest.fixture(autouse=True)
def cleanup_jobs(self):
clear_db_jobs()
+ health_check_threshold.cache_clear()
yield
clear_db_jobs()
+ health_check_threshold.cache_clear()
+ @pytest.mark.parametrize(
+ ("runner_class", "threshold"),
+ [(SchedulerJobRunner, 17), (TriggererJobRunner, 41.5),
(DagProcessorJobRunner, 73)],
+ )
+ @pytest.mark.parametrize("latest_failed", [False, True])
+ @time_machine.travel("2026-11-09T09:00:00Z", tick=False)
+ @conf_vars(
+ {
+ ("scheduler", "scheduler_health_check_threshold"): "17",
+ ("triggerer", "triggerer_health_check_threshold"): "41.5",
+ ("dag_processor", "health_check_threshold"): "73",
+ }
+ )
@provide_session
- def
test_get_jobs_health_returns_unfinished_jobs_ordered_by_heartbeat(self, *,
session):
- older = timezone.utcnow() - timedelta(minutes=5)
- newer = timezone.utcnow()
- older_job = _create_job(session, SchedulerJobRunner, hostname="older",
heartbeat=older)
- newer_job = _create_job(session, SchedulerJobRunner, hostname="newer",
heartbeat=newer)
+ def test_get_jobs_health_returns_live_jobs_and_latest_unfinished(
+ self, runner_class, threshold, latest_failed, *, session
+ ):
+ now = timezone.utcnow()
+ cutoff = now - timedelta(seconds=threshold)
+ _create_job(session, runner_class, hostname="live-newer",
heartbeat=now - timedelta(seconds=1))
+ _create_job(
+ session, runner_class, hostname="live-older", heartbeat=cutoff +
timedelta(microseconds=1)
+ )
+ _create_job(session, runner_class, hostname="at-threshold",
heartbeat=cutoff)
+ _create_job(session, runner_class, hostname="stale", heartbeat=cutoff
- timedelta(microseconds=1))
_create_job(
session,
- SchedulerJobRunner,
+ runner_class,
hostname="ended",
- heartbeat=newer,
- end_date=newer,
+ heartbeat=now + timedelta(seconds=1),
+ end_date=now,
)
- unfinished_failed = _create_job(
+ _create_job(
session,
- SchedulerJobRunner,
+ runner_class,
hostname="failed-unfinished",
- heartbeat=older - timedelta(minutes=1),
+ heartbeat=now if latest_failed else now - timedelta(seconds=2),
state=JobState.FAILED,
)
- _create_job(session, TriggererJobRunner, hostname="triggerer",
heartbeat=newer)
+ other_runner = SchedulerJobRunner if runner_class is
TriggererJobRunner else TriggererJobRunner
+ _create_job(session, other_runner, hostname="other-component",
heartbeat=now)
+ session.commit()
+
+ jobs = get_jobs_health(runner_class, session=session)
+
+ expected = ["live-newer", "live-older"]
+ if latest_failed:
+ expected.insert(0, "failed-unfinished")
+ assert [job.hostname for job in jobs] == expected
+
+ @provide_session
+ def test_get_jobs_health_keeps_one_job_with_null_heartbeat(self, *,
session):
+ _create_job(session, SchedulerJobRunner, hostname="no-heartbeat-1",
heartbeat=None)
+ _create_job(session, SchedulerJobRunner, hostname="no-heartbeat-2",
heartbeat=None)
session.commit()
jobs = get_jobs_health(SchedulerJobRunner, session=session)
- assert [job.hostname for job in jobs] == [
- newer_job.hostname,
- older_job.hostname,
- unfinished_failed.hostname,
- ]
+ assert len(jobs) == 1
+ assert jobs[0].latest_heartbeat is None
def test_get_airflow_health_no_jobs(self):
health_status = get_airflow_health()
@@ -590,6 +621,9 @@ class TestAirflowHealthFromDb:
_create_job(session, SchedulerJobRunner, hostname="stale-3",
heartbeat=third)
session.commit()
+ jobs = get_jobs_health(SchedulerJobRunner, session=session)
+ assert [job.hostname for job in jobs] == ["stale-1"]
+
health_status = get_airflow_health()
assert health_status["scheduler"]["status"] == HealthStatus.UNHEALTHY
diff --git a/airflow-core/tests/unit/jobs/test_base_job.py
b/airflow-core/tests/unit/jobs/test_base_job.py
index eda85fa270b..8443f30985d 100644
--- a/airflow-core/tests/unit/jobs/test_base_job.py
+++ b/airflow-core/tests/unit/jobs/test_base_job.py
@@ -33,6 +33,7 @@ from airflow.utils.session import create_session
from airflow.utils.state import State
from tests_common.test_utils.config import conf_vars
+from tests_common.test_utils.db import clear_db_jobs
from unit.listeners import lifecycle_listener
from unit.utils.test_helpers import MockJobRunner, SchedulerJobRunner,
TriggererJobRunner
@@ -214,6 +215,34 @@ class TestJob:
job.latest_heartbeat = timezone.utcnow() -
datetime.timedelta(seconds=10)
assert job.is_alive() is False, "Completed jobs even with recent
heartbeat should not be alive"
+ def test_is_alive_clause_matches_is_alive(self, session):
+ clear_db_jobs()
+ core_types = ("SchedulerJob", "TriggererJob", "DagProcessorJob")
+ now = timezone.utcnow()
+ for job_type in core_types:
+ threshold = health_check_threshold(job_type, 0)
+ for state in (State.RUNNING, State.SUCCESS, State.FAILED,
State.RESTARTING):
+ for age in (None, 0, threshold - 1, threshold + 1, 86400):
+ session.add(
+ Job(
+ job_type=job_type,
+ state=state,
+ hostname=f"{job_type}|{state}|{age}",
+ latest_heartbeat=None if age is None else now -
datetime.timedelta(seconds=age),
+ )
+ )
+ session.commit()
+
+ jobs = session.scalars(select(Job)).all()
+ alive_in_sql = {
+ job.hostname for job in
session.scalars(select(Job).where(Job.is_alive_clause(core_types)))
+ }
+
+ assert alive_in_sql == {job.hostname for job in jobs if job.is_alive()}
+ assert alive_in_sql, "the scenarios must include live jobs"
+ assert len(alive_in_sql) < len(jobs)
+ clear_db_jobs()
+
@patch("airflow.jobs.job.create_session")
def test_heartbeat_failed(self, mock_create_session, caplog):
when = timezone.utcnow() - datetime.timedelta(seconds=60)