diff --git a/airflow/jobs/base_job.py b/airflow/jobs/base_job.py
index a424e1dc8bfee..9e5f7e8bb81f9 100644
--- a/airflow/jobs/base_job.py
+++ b/airflow/jobs/base_job.py
@@ -23,7 +23,8 @@
from sqlalchemy import Column, Index, Integer, String, and_, or_
from sqlalchemy.exc import OperationalError
-from sqlalchemy.orm.session import make_transient
+from sqlalchemy.orm.session import make_transient, Session
+from typing import Optional
from airflow import configuration as conf
from airflow import executors, models
@@ -71,25 +72,54 @@ class BaseJob(Base, LoggingMixin):
Index('idx_job_state_heartbeat', state, latest_heartbeat),
)
+ heartrate = conf.getfloat('scheduler', 'JOB_HEARTBEAT_SEC')
+
def __init__(
self,
executor=executors.get_default_executor(),
- heartrate=conf.getfloat('scheduler', 'JOB_HEARTBEAT_SEC'),
+ heartrate=None,
*args, **kwargs):
self.hostname = get_hostname()
self.executor = executor
self.executor_class = executor.__class__.__name__
self.start_date = timezone.utcnow()
self.latest_heartbeat = timezone.utcnow()
- self.heartrate = heartrate
+ if heartrate is not None:
+ self.heartrate = heartrate
self.unixname = getpass.getuser()
self.max_tis_per_query = conf.getint('scheduler', 'max_tis_per_query')
super().__init__(*args, **kwargs)
- def is_alive(self):
+ @classmethod
+ @provide_session
+ def most_recent_job(cls, session: Session) -> Optional['BaseJob']:
+ """
+ Return the most recent job of this type, if any, based on last
+ heartbeat received.
+
+ This method should be called on a subclass (i.e. on SchedulerJob) to
+ return jobs of that type.
+
+ :param session: Database session
+ :rtype: BaseJob or None
+ """
+ return session.query(cls).order_by(cls.latest_heartbeat.desc()).limit(1).first()
+
+ def is_alive(self, grace_multiplier=2.1):
+ """
+ Is this job currently alive.
+
+ We define alive as in a state of RUNNING, and having sent a heartbeat
+ within a multiple of the heartrate (default of 2.1)
+
+ :param grace_multiplier: multiplier of heartrate to require heart beat
+ within
+ :type grace_multiplier: number
+ :rtype: boolean
+ """
return (
- (timezone.utcnow() - self.latest_heartbeat).seconds <
- (conf.getint('scheduler', 'JOB_HEARTBEAT_SEC') * 2.1)
+ self.state == State.RUNNING and
+ (timezone.utcnow() - self.latest_heartbeat).seconds < self.heartrate * grace_multiplier
)
@provide_session
diff --git a/airflow/jobs/scheduler_job.py b/airflow/jobs/scheduler_job.py
index ccce9f3381660..6b8ef69a15836 100644
--- a/airflow/jobs/scheduler_job.py
+++ b/airflow/jobs/scheduler_job.py
@@ -293,6 +293,7 @@ class SchedulerJob(BaseJob):
__mapper_args__ = {
'polymorphic_identity': 'SchedulerJob'
}
+ heartrate = conf.getint('scheduler', 'SCHEDULER_HEARTBEAT_SEC')
def __init__(
self,
@@ -336,7 +337,6 @@ def __init__(
self.do_pickle = do_pickle
super().__init__(*args, **kwargs)
- self.heartrate = conf.getint('scheduler', 'SCHEDULER_HEARTBEAT_SEC')
self.max_threads = conf.getint('scheduler', 'max_threads')
if log:
@@ -362,6 +362,27 @@ def _exit_gracefully(self, signum, frame):
self.processor_agent.end()
sys.exit(os.EX_OK)
+ def is_alive(self, grace_multiplier=None):
+ """
+ Is this SchedulerJob alive?
+
+ We define alive as in a state of running and a heartbeat within the
+ threshold defined in the ``scheduler_health_check_threshold`` config
+ setting.
+
+ ``grace_multiplier`` is accepted for compatibility with the parent class.
+
+ :rtype: boolean
+ """
+ if grace_multiplier is not None:
+ # Accept the same behaviour as superclass
+ return super().is_alive(grace_multiplier=grace_multiplier)
+ scheduler_health_check_threshold = conf.getint('scheduler', 'scheduler_health_check_threshold')
+ return (
+ self.state == State.RUNNING and
+ (timezone.utcnow() - self.latest_heartbeat).seconds < scheduler_health_check_threshold
+ )
+
@provide_session
def manage_slas(self, dag, session=None):
"""
diff --git a/airflow/macros/__init__.py b/airflow/macros/__init__.py
index 4b533d10b7466..27205a22c8d2d 100644
--- a/airflow/macros/__init__.py
+++ b/airflow/macros/__init__.py
@@ -66,6 +66,23 @@ def ds_format(ds, input_format, output_format):
return datetime.strptime(ds, input_format).strftime(output_format)
+def datetime_diff_for_humans(dt, since=None):
+ """
+ Return a human-readable/approximate difference between two datetimes, or
+ one and now.
+
+ :param dt: The datetime to display the diff for
+ :type dt: datetime
+ :param since: When to display the date from. If ``None`` then the diff is
+ between ``dt`` and now.
+ :type since: None or datetime
+ :rtype: str
+ """
+ import pendulum
+
+ return pendulum.instance(dt).diff_for_humans(since)
+
+
def _integrate_plugins():
"""Integrate plugins to the context"""
import sys
diff --git a/airflow/www/templates/appbuilder/baselayout.html b/airflow/www/templates/appbuilder/baselayout.html
index 9cf6145cb8053..0b5b82df63afb 100644
--- a/airflow/www/templates/appbuilder/baselayout.html
+++ b/airflow/www/templates/appbuilder/baselayout.html
@@ -46,6 +46,16 @@
{% block messages %}
{% include 'appbuilder/flash.html' %}
+ {% if scheduler_job is defined and (not scheduler_job or not scheduler_job.is_alive()) %}
+
+
The scheduler does not appear to be running.
+ {% if scheduler_job %}
+ Last heartbeat was received .
+ {% endif %}
+
+
The DAGs list may not update, and new tasks will not be scheduled.