Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
aaabd77
Add on_skipped_intervals_callback for catchup=False Dags
Tegh25 Jun 10, 2026
306b66a
Add unit tests for skipped intervals callback and listener
Tegh25 Jun 15, 2026
317e583
Add docs and newsfragment for skipped intervals callback and listener
Tegh25 Jun 15, 2026
6c7eec9
Fix callback types table, add skipped intervals callback to ignored keys
Tegh25 Jun 17, 2026
eadb847
Apply linting fixes after rebase
Tegh25 Jun 17, 2026
ac9667c
Use gap summary for skipped intervals callback and listener
Tegh25 Jun 19, 2026
1b58730
Type on_skipped_intervals_callback with SkippedIntervalsCallbackConte…
Tegh25 Jun 19, 2026
00f79c1
Update scheduler_job_runner.py comment with correct Dag capitalization
Tegh25 Jun 19, 2026
6bf5230
Update DagSkippedIntervalsCallbackRequest docstring, replace .in_() w…
Tegh25 Jun 19, 2026
f5bdcd7
Fix linting errors after rebase and regenerate schema
Tegh25 Jun 19, 2026
9dfc39f
Remove skipped_interval_count field from callback/listener payload
Tegh25 Jun 19, 2026
b204f67
Restore supervisor schema version 2026-06-16 for backward compatibility
Tegh25 Jun 19, 2026
0ac587a
Document SkippedIntervalsCallbackContext for Sphinx inventory
Tegh25 Jun 22, 2026
6c8368b
Skipped intervals listener hook moved outside of retry_db_transaction
Tegh25 Jun 30, 2026
73f05ea
Fix: version migration iterates through callback dict to remove new s…
Tegh25 Jun 30, 2026
1e305a1
Update task-sdk schema.json and ts-sdk supervisor.ts as per hooks
Tegh25 Jul 8, 2026
27ceffd
Update schema version references in Go sdk
Tegh25 Jul 11, 2026
b562ca1
Avoid false skipped-interval callbacks for consecutive trigger timeta…
Tegh25 Jul 21, 2026
77184b8
Remove stray blank line right after _execute_dag_skipped_intervals_ca…
Tegh25 Jul 21, 2026
2709f2d
Ruff formatting for callback requests imports on test scheduler job
Tegh25 Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions airflow-core/docs/administration-and-deployment/listeners.rst
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,23 @@ Beginning with Airflow 3, listeners are also notified whenever a state change is
:start-after: [START howto_listen_dagrun_failure_task]
:end-before: [END howto_listen_dagrun_failure_task]

Dag Skipped Interval Events
---------------------------

When a Dag with ``catchup=False`` skips one or more scheduled data intervals (for example after
a scheduler restart or when a paused Dag is re-enabled), the scheduler invokes the
``on_intervals_skipped`` listener hook with a
:class:`~airflow.timetables.base.SkippedIntervalsSummary`. This is the listener counterpart to the Dag-level
``on_skipped_intervals_callback``; listeners run synchronously in the scheduler, while the callback
is dispatched to the dag processor.

- ``on_intervals_skipped``

.. exampleinclude:: /../src/airflow/example_dags/plugins/event_listener.py
:language: python
:start-after: [START howto_listen_intervals_skipped]
:end-before: [END howto_listen_intervals_skipped]


TaskInstance State Change Events
--------------------------------
Expand Down Expand Up @@ -207,3 +224,5 @@ List of changes in the listener interfaces since 2.8.0 when they were introduced
+-----------------+--------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------+
| 3.2.0 | ``on_task_instance_skipped`` | New listener method added to the interface |
+-----------------+--------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------+
| 3.3.0 | ``on_intervals_skipped`` | New listener method added; fires when a Dag with ``catchup=False`` skips scheduled intervals |
+-----------------+--------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------+
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ Name Description
is not started to be executed because of a preceding branching
decision in the Dag or a trigger rule which causes execution
to skip so that the task execution is never scheduled.
``on_skipped_intervals_callback`` Invoked when a Dag with ``catchup=False`` skips one or more Dag
scheduled data intervals without creating Dag runs (for example
after a scheduler restart or when a paused Dag is re-enabled).
=========================================== ======================================================================= =================


Expand Down Expand Up @@ -95,6 +98,55 @@ For example, a timeout may be caused by a number of stalling tasks, but only one
Before Airflow 3.2.0, the rules above did not apply and the task instance passed to Dag callback was not related to Dag state, rather being selected as the latest task in the Dag
lexicographically.

Skipped Intervals Callback
^^^^^^^^^^^^^^^^^^^^^^^^^^

When a Dag is defined with ``catchup=False``, the scheduler may advance past missed scheduled
intervals without creating Dag runs for them. Set ``on_skipped_intervals_callback`` on the Dag
to be notified when that happens.

Comment thread
ferruzzi marked this conversation as resolved.
The callback is invoked once per scheduler decision that creates a new scheduled Dag run while
intervals were skipped between the previous automated run and the new run. No Dag runs are
created for the skipped intervals.

The callback runs in the dag processor (like other Dag-level callbacks), not in the scheduler.
The context mapping contains:

* ``dag`` -- the Dag object
* ``reason`` -- always ``"skipped_intervals"``
* ``skipped_range`` -- a :class:`~airflow.timetables.base.DataInterval` from the
previous automated run's ``data_interval_end`` to the new run's ``data_interval_start``

To enumerate every skipped interval, call
:meth:`~airflow.serialization.definitions.dag.SerializedDAG.iter_dagrun_infos_between`
on ``context["dag"]`` with ``skipped_range.start`` and ``skipped_range.end``.

Example:

.. code-block:: python

from airflow.sdk import DAG
from airflow.providers.standard.operators.empty import EmptyOperator


def log_skipped_intervals(context):
skipped_range = context["skipped_range"]
print(f"Gap: {skipped_range.start} -> {skipped_range.end}")
for info in context["dag"].iter_dagrun_infos_between(skipped_range.start, skipped_range.end):
if info.data_interval is None:
continue
interval = info.data_interval
print(f"Skipped interval: {interval.start} -> {interval.end}")


with DAG(
dag_id="example_skipped_intervals_callback",
schedule="@daily",
catchup=False,
on_skipped_intervals_callback=log_skipped_intervals,
):
EmptyOperator(task_id="task")


Examples
--------
Expand Down
3 changes: 3 additions & 0 deletions airflow-core/docs/core-concepts/dag-run.rst
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ If you set ``catchup=True`` in the Dag, the scheduler will kick off a Dag Run fo
If your Dag is not written to handle its catchup (i.e., not limited to the interval, but instead to ``Now`` for instance.),
then you will want to turn catchup off, which is the default setting or can be done explicitly by setting ``catchup=False`` in the Dag definition, if the default config has been changed for your Airflow environment.

When intervals are skipped this way, Airflow does not create Dag runs for the missed periods.
To observe those skips, set ``on_skipped_intervals_callback`` on the Dag (see :doc:`Callbacks </administration-and-deployment/logging-monitoring/callbacks>`) or implement the ``on_intervals_skipped`` listener hook (see :doc:`Listeners </administration-and-deployment/listeners>`).

.. code-block:: python

"""
Expand Down
8 changes: 8 additions & 0 deletions airflow-core/docs/howto/listener-plugin.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Using this plugin, following events can be listened:
* Dag run is in running state.
* Dag run is in success state.
* Dag run is in failure state.
* scheduler skips scheduled intervals for a Dag with ``catchup=False``
* on start before event like Airflow job, scheduler
* before stop for event like Airflow job, scheduler

Expand Down Expand Up @@ -91,5 +92,12 @@ This example listens when the Dag run is change to failed state

Similarly, code to listen after dag_run success and during running state can be implemented.

This example listens when the scheduler skips scheduled intervals for a Dag with ``catchup=False``

.. exampleinclude:: /../src/airflow/example_dags/plugins/event_listener.py
:language: python
:start-after: [START howto_listen_intervals_skipped]
:end-before: [END howto_listen_intervals_skipped]

The listener plugin files required to add the listener implementation is added as part of the
Airflow plugin into ``$AIRFLOW_HOME/plugins/`` folder and loaded during Airflow startup.
1 change: 1 addition & 0 deletions airflow-core/newsfragments/68359.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add ``on_skipped_intervals_callback`` for Dags with ``catchup=False`` and the ``on_intervals_skipped`` listener hook, so operators are notified when the scheduler advances past missed scheduled intervals without creating Dag runs.
40 changes: 39 additions & 1 deletion airflow-core/src/airflow/callbacks/callback_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from __future__ import annotations

from collections.abc import Mapping
from datetime import datetime
from typing import TYPE_CHECKING, Annotated, Any, Literal, cast

import structlog
Expand All @@ -26,7 +27,9 @@
from sqlalchemy.orm.attributes import set_committed_value
from sqlalchemy.orm.exc import DetachedInstanceError

from airflow._shared.timezones import timezone
from airflow.api_fastapi.execution_api.datamodels import taskinstance as ti_datamodel # noqa: TC001
from airflow.timetables.base import DataInterval, SkippedIntervalsSummary
from airflow.utils.state import TaskInstanceState

if TYPE_CHECKING:
Expand Down Expand Up @@ -174,8 +177,43 @@ class DagCallbackRequest(BaseCallbackRequest):
type: Literal["DagCallbackRequest"] = "DagCallbackRequest"


class DagSkippedIntervalsCallbackRequest(BaseCallbackRequest):
"""Store skipped intervals callback data for execution by the Dag processor."""

dag_id: str
skipped_range: tuple[datetime, datetime]
"""Envelope from the previous automated run's data_interval_end to the new run's start."""
type: Literal["DagSkippedIntervalsCallbackRequest"] = "DagSkippedIntervalsCallbackRequest"

@classmethod
def from_summary(
cls,
*,
filepath: str,
bundle_name: str,
bundle_version: str | None,
dag_id: str,
summary: SkippedIntervalsSummary,
) -> Self:
return cls(
filepath=filepath,
bundle_name=bundle_name,
bundle_version=bundle_version,
dag_id=dag_id,
skipped_range=(summary.skipped_range.start, summary.skipped_range.end),
)

def to_summary(self) -> SkippedIntervalsSummary:
return SkippedIntervalsSummary(
skipped_range=DataInterval(
start=timezone.coerce_datetime(self.skipped_range[0]),
end=timezone.coerce_datetime(self.skipped_range[1]),
),
)


CallbackRequest = Annotated[
DagCallbackRequest | TaskCallbackRequest | EmailRequest,
DagCallbackRequest | DagSkippedIntervalsCallbackRequest | TaskCallbackRequest | EmailRequest,
Field(discriminator="type"),
]

Expand Down
31 changes: 30 additions & 1 deletion airflow-core/src/airflow/dag_processing/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from airflow.callbacks.callback_requests import (
CallbackRequest,
DagCallbackRequest,
DagSkippedIntervalsCallbackRequest,
EmailRequest,
TaskCallbackRequest,
)
Expand Down Expand Up @@ -104,7 +105,7 @@
from airflow.api_fastapi.execution_api.app import InProcessExecutionAPI
from airflow.sdk.api.client import Client
from airflow.sdk.bases.operator import BaseOperator
from airflow.sdk.definitions.context import Context
from airflow.sdk.definitions.context import Context, SkippedIntervalsCallbackContext
from airflow.sdk.definitions.dag import DAG
from airflow.sdk.definitions.mappedoperator import MappedOperator
from airflow.typing_compat import Self
Expand Down Expand Up @@ -344,6 +345,8 @@ def _execute_callbacks(
_execute_task_callbacks(dagbag, request, log)
elif isinstance(request, DagCallbackRequest):
_execute_dag_callbacks(dagbag, request, log)
elif isinstance(request, DagSkippedIntervalsCallbackRequest):
_execute_dag_skipped_intervals_callback(dagbag, request, log)
elif isinstance(request, EmailRequest):
_execute_email_callbacks(dagbag, request, log)

Expand Down Expand Up @@ -405,6 +408,32 @@ def _execute_dag_callbacks(dagbag: DagBag, request: DagCallbackRequest, log: Fil
)


def _execute_dag_skipped_intervals_callback(
dagbag: DagBag, request: DagSkippedIntervalsCallbackRequest, log: FilteringBoundLogger
) -> None:
dag, _ = _get_dag_with_task(dagbag, request.dag_id)
callbacks = dag.on_skipped_intervals_callback
if not callbacks:
log.warning("Skipped intervals callback requested, but dag didn't have any", dag_id=request.dag_id)
return

callbacks = callbacks if isinstance(callbacks, list) else [callbacks]
summary = request.to_summary()
context: SkippedIntervalsCallbackContext = {
"dag": dag,
"reason": "skipped_intervals",
"skipped_range": summary.skipped_range,
}

for callback in callbacks:
log.info("Executing on_skipped_intervals_callback", dag_id=request.dag_id)
try:
callback(context)
except Exception:
log.exception("Callback failed", dag_id=request.dag_id)
stats.incr("dag.callback_exceptions", tags={"dag_id": request.dag_id})


def _execute_task_callbacks(dagbag: DagBag, request: TaskCallbackRequest, log: FilteringBoundLogger) -> None:
if not request.is_failure_callback:
log.warning(
Expand Down
20 changes: 20 additions & 0 deletions airflow-core/src/airflow/example_dags/plugins/event_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,23 @@ def on_dag_run_running(dag_run: DagRun, msg: str):


# [END howto_listen_dagrun_running_task]


# [START howto_listen_intervals_skipped]
@hookimpl
def on_intervals_skipped(dag_id: str, summary):
"""
Called when a Dag with catchup=False skips one or more scheduled intervals.

``summary`` is a :class:`~airflow.timetables.base.SkippedIntervalsSummary` with
``skipped_range``. Call
:meth:`~airflow.serialization.definitions.dag.SerializedDAG.iter_dagrun_infos_between`
with ``skipped_range.start`` and ``skipped_range.end`` if the full list of skipped
intervals is required.
"""
print(
f"Dag {dag_id} skipped intervals in range: {summary.skipped_range.start} -> {summary.skipped_range.end}"
)


# [END howto_listen_intervals_skipped]
Loading
Loading