From f05fea137ebb61cba1ab28af7b1fa23acce0ef3e Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:31:23 -0500 Subject: [PATCH 01/29] Add on_skipped_intervals_callback for catchup=False Dags When catchup=False and the scheduler jumps past missed intervals, there was no way to observe which data intervals were skipped. Add a Dag-level on_skipped_intervals_callback (context-based, like on_success_callback) and an AIP-61 listener hook on_intervals_skipped. The scheduler detects gaps at scheduled run creation and fires the listener in-process; user callbacks are dispatched to the Dag File Processor after commit. --- .../airflow/callbacks/callback_requests.py | 12 +- .../src/airflow/dag_processing/processor.py | 35 ++++++ .../src/airflow/jobs/scheduler_job_runner.py | 111 +++++++++++++++++- .../src/airflow/listeners/listener.py | 4 +- .../src/airflow/listeners/spec/dag.py | 31 +++++ .../airflow/serialization/definitions/dag.py | 1 + .../src/airflow/serialization/schema.json | 1 + .../serialization/serialized_objects.py | 5 +- .../in_container/run_schema_defaults_check.py | 1 + task-sdk/src/airflow/sdk/definitions/dag.py | 9 ++ .../sdk/execution_time/schema/schema.json | 91 ++++++++++++++ .../schema/versions/__init__.py | 2 + .../schema/versions/v2026_10_30.py | 12 ++ 13 files changed, 306 insertions(+), 9 deletions(-) create mode 100644 airflow-core/src/airflow/listeners/spec/dag.py diff --git a/airflow-core/src/airflow/callbacks/callback_requests.py b/airflow-core/src/airflow/callbacks/callback_requests.py index 6609dc30c6d9d..a9cbd7602a716 100644 --- a/airflow-core/src/airflow/callbacks/callback_requests.py +++ b/airflow-core/src/airflow/callbacks/callback_requests.py @@ -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 @@ -174,8 +175,17 @@ class DagCallbackRequest(BaseCallbackRequest): type: Literal["DagCallbackRequest"] = "DagCallbackRequest" +class DagSkippedIntervalsCallbackRequest(BaseCallbackRequest): + """A Class with information about the skipped intervals DAG callback to be executed.""" + + dag_id: str + skipped_intervals: list[tuple[datetime, datetime]] + """List of (start, end) pairs for intervals skipped due to catchup=False.""" + type: Literal["DagSkippedIntervalsCallbackRequest"] = "DagSkippedIntervalsCallbackRequest" + + CallbackRequest = Annotated[ - DagCallbackRequest | TaskCallbackRequest | EmailRequest, + DagCallbackRequest | DagSkippedIntervalsCallbackRequest | TaskCallbackRequest | EmailRequest, Field(discriminator="type"), ] diff --git a/airflow-core/src/airflow/dag_processing/processor.py b/airflow-core/src/airflow/dag_processing/processor.py index e6f3f422a0abf..373c3d6ddf560 100644 --- a/airflow-core/src/airflow/dag_processing/processor.py +++ b/airflow-core/src/airflow/dag_processing/processor.py @@ -33,6 +33,7 @@ from airflow.callbacks.callback_requests import ( CallbackRequest, DagCallbackRequest, + DagSkippedIntervalsCallbackRequest, EmailRequest, TaskCallbackRequest, ) @@ -360,6 +361,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) except Exception: @@ -432,6 +435,38 @@ def _execute_dag_callbacks(dagbag: DagBag, request: DagCallbackRequest, log: Fil ) +def _execute_dag_skipped_intervals_callback( + dagbag: DagBag, request: DagSkippedIntervalsCallbackRequest, log: FilteringBoundLogger +) -> None: + from airflow._shared.timezones import timezone + from airflow.timetables.base import DataInterval + + 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] + skipped_intervals = [ + DataInterval(start=timezone.coerce_datetime(start), end=timezone.coerce_datetime(end)) + for start, end in request.skipped_intervals + ] + context: Context = { # type: ignore[typeddict-unknown-key] + "dag": dag, + "reason": "skipped_intervals", + "skipped_intervals": skipped_intervals, + } + + 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( diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py b/airflow-core/src/airflow/jobs/scheduler_job_runner.py index 40e1fa976ffeb..e2f7f06dc63c6 100644 --- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py +++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py @@ -66,6 +66,7 @@ from airflow.assets.evaluation import AssetEvaluator from airflow.callbacks.callback_requests import ( DagCallbackRequest, + DagSkippedIntervalsCallbackRequest, EmailRequest, TaskCallbackRequest, ) @@ -77,6 +78,7 @@ from airflow.executors.workloads.types import TaskInstanceUuid from airflow.jobs.base_job_runner import BaseJobRunner from airflow.jobs.job import Job, JobState, perform_heartbeat +from airflow.listeners.listener import get_listener_manager from airflow.models import Deadline, Log from airflow.models.asset import ( AssetActive, @@ -123,7 +125,7 @@ from airflow.serialization.definitions.assets import SerializedAssetUniqueKey from airflow.serialization.definitions.notset import NOTSET from airflow.ti_deps.dependencies_states import ACTIVE_STATES, EXECUTION_STATES -from airflow.timetables.base import Timetable, compute_rollup_fingerprint +from airflow.timetables.base import DataInterval, Timetable, compute_rollup_fingerprint from airflow.triggers.base import TriggerEvent from airflow.utils.event_scheduler import EventScheduler from airflow.utils.helpers import prune_dict @@ -2177,9 +2179,10 @@ def _do_scheduling(self, session: Session) -> int: :return: Number of TIs enqueued in this iteration """ # Put a check in place to make sure we don't commit unexpectedly + skip_callback_requests: list[DagSkippedIntervalsCallbackRequest] = [] with prohibit_commit(session) as guard: if self._scheduler_use_job_schedule: - self._create_dagruns_for_dags(guard, session) + skip_callback_requests = self._create_dagruns_for_dags(guard, session) self._start_queued_dagruns(session) guard.commit() @@ -2212,6 +2215,10 @@ def _do_scheduling(self, session: Session) -> int: else: self.log.error("DAG '%s' not found in serialized_dag table", dag_run.dag_id) + # Dispatch skipped-intervals callbacks after commit so DatabaseCallbackSink can write to DB. + for skip_req in skip_callback_requests: + self.executor.send_callback(skip_req) + with prohibit_commit(session) as guard: # Without this, the session has an invalid view of the DB session.expunge_all() @@ -2639,7 +2646,9 @@ def _create_dagruns_for_partitioned_asset_dags(self, session: Session) -> set[st return partition_dag_ids @retry_db_transaction - def _create_dagruns_for_dags(self, guard: CommitProhibitorGuard, session: Session) -> None: + def _create_dagruns_for_dags( + self, guard: CommitProhibitorGuard, session: Session + ) -> list[DagSkippedIntervalsCallbackRequest]: """Find Dag Models needing DagRuns and Create Dag Runs with retries in case of OperationalError.""" partition_dag_ids: set[str] = self._create_dagruns_for_partitioned_asset_dags(session) @@ -2653,7 +2662,7 @@ def _create_dagruns_for_dags(self, guard: CommitProhibitorGuard, session: Sessio # filter asset partition triggered Dags if d.dag_id not in partition_dag_ids } - self._create_dag_runs(non_asset_dags, session) + skip_callback_requests = self._create_dag_runs(non_asset_dags, session) if asset_triggered_dags: self._create_dag_runs_asset_triggered( dag_models=[d for d in asset_triggered_dags if d.dag_id not in partition_dag_ids], @@ -2664,6 +2673,8 @@ def _create_dagruns_for_dags(self, guard: CommitProhibitorGuard, session: Sessio guard.commit() # END: create dagruns + return skip_callback_requests + @provide_session def _mark_backfills_complete(self, *, session: Session = NEW_SESSION) -> None: """Mark completed backfills as completed.""" @@ -2695,8 +2706,66 @@ def _mark_backfills_complete(self, *, session: Session = NEW_SESSION) -> None: for b in backfills: b.completed_at = now - def _create_dag_runs(self, dag_models: Collection[DagModel], session: Session) -> None: - """Create a DAG run and update the dag_model to control if/when the next DAGRun should be created.""" + def _collect_skipped_intervals( + self, + serdag: SerializedDAG, + new_data_interval: DataInterval, + session: Session, + ) -> list[tuple[datetime, datetime]]: + """ + Return a list of (start, end) tuples for intervals skipped due to catchup=False. + + Computes the intervals that would have been scheduled between the previous + automated DagRun's data_interval_end and the new run's data_interval_start, + had catchup been True. Returns an empty list when there is no gap or when + no previous run exists. + """ + if serdag.catchup: + return [] + listener_has_impls = bool( + get_listener_manager().hook.on_intervals_skipped.get_hookimpls() # type: ignore[attr-defined] + ) + if not serdag.has_on_skipped_intervals_callback and not listener_has_impls: + return [] + + prev_run = session.scalar( + select(DagRun) + .where( + DagRun.dag_id == serdag.dag_id, + DagRun.run_type.in_([DagRunType.SCHEDULED]), + DagRun.data_interval_end.is_not(None), + DagRun.data_interval_end <= new_data_interval.start, + ) + .order_by(DagRun.data_interval_end.desc()) + .limit(1) + ) + if prev_run is None or prev_run.data_interval_end is None: + return [] + + prev_end = prev_run.data_interval_end + new_start = new_data_interval.start + if prev_end >= new_start: + return [] + + skipped: list[tuple[datetime, datetime]] = [] + for info in serdag.iter_dagrun_infos_between(prev_end, new_start): + if info.data_interval is None: + continue + if info.data_interval.start >= new_start: + continue + skipped.append((info.data_interval.start, info.data_interval.end)) + + return skipped + + def _create_dag_runs( + self, dag_models: Collection[DagModel], session: Session + ) -> list[DagSkippedIntervalsCallbackRequest]: + """ + Create a DAG run and update the dag_model to control if/when the next DAGRun should be created. + + Returns a list of skipped-intervals callback requests to dispatch after commit. + """ + skip_callback_requests: list[DagSkippedIntervalsCallbackRequest] = [] # Bulk Fetch DagRuns with dag_id and logical_date same # as DagModel.dag_id and DagModel.next_dagrun # This list is used to verify if the DagRun already exist so that we don't attempt to create @@ -2835,6 +2904,34 @@ def _create_dag_runs(self, dag_models: Collection[DagModel], session: Session) - records=consumed_asset_records, dag_id=dag_model.dag_id, session=session ) + if data_interval is not None: + skipped = self._collect_skipped_intervals( + serdag=serdag, + new_data_interval=data_interval, + session=session, + ) + if skipped: + try: + get_listener_manager().hook.on_intervals_skipped( # type: ignore[attr-defined] + dag_id=serdag.dag_id, + skipped_intervals=[DataInterval(start=s, end=e) for s, e in skipped], + ) + except Exception: + self.log.exception( + "Error notifying listener of skipped intervals", + dag_id=serdag.dag_id, + ) + if serdag.has_on_skipped_intervals_callback: + skip_callback_requests.append( + DagSkippedIntervalsCallbackRequest( + filepath=dag_model.relative_fileloc or "", + bundle_name=dag_model.bundle_name, + bundle_version=created_run.bundle_version, + dag_id=serdag.dag_id, + skipped_intervals=cast("list[tuple[datetime, datetime]]", skipped), + ) + ) + # Exceptions like ValueError, ParamValidationError, etc. are raised by # DagModel.create_dagrun() when dag is misconfigured. The scheduler should not # crash due to misconfigured dags. We should log any exception encountered @@ -2849,6 +2946,8 @@ def _create_dag_runs(self, dag_models: Collection[DagModel], session: Session) - # TODO[HA]: Should we do a session.flush() so we don't have to keep lots of state/object in # memory for larger dags? or expunge_all() + return skip_callback_requests + def _collect_gated_asset_events( self, *, dag: SerializedDAG, session: Session ) -> tuple[Sequence[AssetDagRunQueue], list[AssetEvent]] | None: diff --git a/airflow-core/src/airflow/listeners/listener.py b/airflow-core/src/airflow/listeners/listener.py index 06be7a1b9b908..798312a0a6c4a 100644 --- a/airflow-core/src/airflow/listeners/listener.py +++ b/airflow-core/src/airflow/listeners/listener.py @@ -21,7 +21,7 @@ from airflow._shared.listeners.listener import ListenerManager from airflow._shared.listeners.spec import lifecycle, taskinstance -from airflow.listeners.spec import asset, dagrun, importerrors +from airflow.listeners.spec import asset, dag, dagrun, importerrors from airflow.plugins_manager import integrate_listener_plugins @@ -32,6 +32,7 @@ def get_listener_manager() -> ListenerManager: Registers the following listeners: - lifecycle: on_starting, before_stopping + - dag: on_intervals_skipped - dagrun: on_dag_run_running, on_dag_run_success, on_dag_run_failed - taskinstance: on_task_instance_running, on_task_instance_success, etc. - asset: on_asset_created, on_asset_changed, etc. @@ -40,6 +41,7 @@ def get_listener_manager() -> ListenerManager: _listener_manager = ListenerManager() _listener_manager.add_hookspecs(lifecycle) + _listener_manager.add_hookspecs(dag) _listener_manager.add_hookspecs(dagrun) _listener_manager.add_hookspecs(taskinstance) _listener_manager.add_hookspecs(asset) diff --git a/airflow-core/src/airflow/listeners/spec/dag.py b/airflow-core/src/airflow/listeners/spec/dag.py new file mode 100644 index 0000000000000..8def650d0c728 --- /dev/null +++ b/airflow-core/src/airflow/listeners/spec/dag.py @@ -0,0 +1,31 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pluggy import HookspecMarker + +if TYPE_CHECKING: + from airflow.timetables.base import DataInterval + +hookspec = HookspecMarker("airflow") + + +@hookspec +def on_intervals_skipped(dag_id: str, skipped_intervals: list[DataInterval]): + """Execute when a DAG with catchup=False skips one or more scheduled intervals.""" diff --git a/airflow-core/src/airflow/serialization/definitions/dag.py b/airflow-core/src/airflow/serialization/definitions/dag.py index c529bf95adea9..4774891f306fe 100644 --- a/airflow-core/src/airflow/serialization/definitions/dag.py +++ b/airflow-core/src/airflow/serialization/definitions/dag.py @@ -127,6 +127,7 @@ class SerializedDAG: fail_fast: bool = False has_on_failure_callback: bool = False has_on_success_callback: bool = False + has_on_skipped_intervals_callback: bool = False is_paused_upon_creation: bool | None = None max_active_runs: int = 16 max_active_tasks: int = 16 diff --git a/airflow-core/src/airflow/serialization/schema.json b/airflow-core/src/airflow/serialization/schema.json index ed2251ce3dac7..51b8414ab5818 100644 --- a/airflow-core/src/airflow/serialization/schema.json +++ b/airflow-core/src/airflow/serialization/schema.json @@ -237,6 +237,7 @@ "is_paused_upon_creation": { "type": "boolean" }, "has_on_success_callback": { "type": "boolean", "default": false }, "has_on_failure_callback": { "type": "boolean", "default": false }, + "has_on_skipped_intervals_callback": { "type": "boolean", "default": false }, "render_template_as_native_obj": { "type": "boolean", "default": false }, "tags": { "type": "array" }, "task_group": {"anyOf": [ diff --git a/airflow-core/src/airflow/serialization/serialized_objects.py b/airflow-core/src/airflow/serialization/serialized_objects.py index f5fd8c118b9b3..f1aaed78db653 100644 --- a/airflow-core/src/airflow/serialization/serialized_objects.py +++ b/airflow-core/src/airflow/serialization/serialized_objects.py @@ -140,7 +140,9 @@ _CALLBACK_TYPES = ("execute", "failure", "success", "retry", "skipped") _OPERATOR_CALLBACK_FIELDS = frozenset(f"on_{x}_callback" for x in _CALLBACK_TYPES) _HAS_CALLBACK_FIELDS = frozenset(f"has_on_{x}_callback" for x in _CALLBACK_TYPES) -_DAG_CALLBACK_FIELDS = frozenset({"has_on_success_callback", "has_on_failure_callback"}) +_DAG_CALLBACK_FIELDS = frozenset( + {"has_on_success_callback", "has_on_failure_callback", "has_on_skipped_intervals_callback"} +) _OPERATOR_TIMEDELTA_FIELDS = frozenset({"retry_delay", "execution_timeout", "max_retry_delay"}) # Fields whose value must never be serialized: the object has no serializer, so it would # fall back to str(obj) and leak a non-deterministic memory address (a new DagVersion every @@ -2376,6 +2378,7 @@ class LazyDeserializedDAG(pydantic.BaseModel): "dag_display_name", "has_on_success_callback", "has_on_failure_callback", + "has_on_skipped_intervals_callback", "tags", # Attr properties that are nullable, or have a default that loads from config "description", diff --git a/scripts/in_container/run_schema_defaults_check.py b/scripts/in_container/run_schema_defaults_check.py index 7afcbd9ce546f..cc29526cb2983 100755 --- a/scripts/in_container/run_schema_defaults_check.py +++ b/scripts/in_container/run_schema_defaults_check.py @@ -230,6 +230,7 @@ def compare_dag_defaults() -> list[str]: computed_properties = { "has_on_success_callback", "has_on_failure_callback", + "has_on_skipped_intervals_callback", } if field_name not in computed_properties: errors.append( diff --git a/task-sdk/src/airflow/sdk/definitions/dag.py b/task-sdk/src/airflow/sdk/definitions/dag.py index 0594ac8065885..a521c99ddf4fe 100644 --- a/task-sdk/src/airflow/sdk/definitions/dag.py +++ b/task-sdk/src/airflow/sdk/definitions/dag.py @@ -534,6 +534,7 @@ def __rich_repr__(self): ) on_success_callback: None | DagStateChangeCallback | list[DagStateChangeCallback] = None on_failure_callback: None | DagStateChangeCallback | list[DagStateChangeCallback] = None + on_skipped_intervals_callback: None | DagStateChangeCallback | list[DagStateChangeCallback] = None doc_md: str | None = attrs.field(default=None, converter=_convert_doc_md) params: ParamsDict = attrs.field( # mypy doesn't really like passing the Converter object @@ -575,6 +576,7 @@ def __rich_repr__(self): has_on_success_callback: bool = attrs.field(init=False) has_on_failure_callback: bool = attrs.field(init=False) + has_on_skipped_intervals_callback: bool = attrs.field(init=False) disable_bundle_versioning: bool = attrs.field( factory=_config_bool_factory("dag_processor", "disable_bundle_versioning") ) @@ -725,6 +727,10 @@ def _has_on_success_callback(self) -> bool: def _has_on_failure_callback(self) -> bool: return self.on_failure_callback is not None + @has_on_skipped_intervals_callback.default + def _has_on_skipped_intervals_callback(self) -> bool: + return self.on_skipped_intervals_callback is not None + @sla_miss_callback.validator def _validate_sla_miss_callback(self, _, value): if value is not None: @@ -1635,11 +1641,13 @@ def _run_inline_trigger(trigger, task_sdk_ti): "sla_miss_callback", "on_success_callback", "on_failure_callback", + "on_skipped_intervals_callback", "template_undefined", "jinja_environment_kwargs", # has_on_*_callback are only stored if the value is True, as the default is False "has_on_success_callback", "has_on_failure_callback", + "has_on_skipped_intervals_callback", "auto_register", "schedule", } @@ -1667,6 +1675,7 @@ def dag( catchup: bool = ..., on_success_callback: None | DagStateChangeCallback | list[DagStateChangeCallback] = None, on_failure_callback: None | DagStateChangeCallback | list[DagStateChangeCallback] = None, + on_skipped_intervals_callback: None | DagStateChangeCallback | list[DagStateChangeCallback] = None, deadline: list[DeadlineAlert] | DeadlineAlert | None = None, doc_md: str | None = None, params: ParamsDict | dict[str, Any] | None = None, diff --git a/task-sdk/src/airflow/sdk/execution_time/schema/schema.json b/task-sdk/src/airflow/sdk/execution_time/schema/schema.json index 77888c5adfdea..9552fdad991aa 100644 --- a/task-sdk/src/airflow/sdk/execution_time/schema/schema.json +++ b/task-sdk/src/airflow/sdk/execution_time/schema/schema.json @@ -938,6 +938,7 @@ "discriminator": { "mapping": { "DagCallbackRequest": "#/$defs/DagCallbackRequest", + "DagSkippedIntervalsCallbackRequest": "#/$defs/DagSkippedIntervalsCallbackRequest", "EmailRequest": "#/$defs/EmailRequest", "TaskCallbackRequest": "#/$defs/TaskCallbackRequest" }, @@ -947,6 +948,9 @@ { "$ref": "#/$defs/DagCallbackRequest" }, + { + "$ref": "#/$defs/DagSkippedIntervalsCallbackRequest" + }, { "$ref": "#/$defs/TaskCallbackRequest" }, @@ -1507,6 +1511,93 @@ "title": "DagRunType", "type": "string" }, + "DagSkippedIntervalsCallbackRequest": { + "description": "A Class with information about the skipped intervals DAG callback to be executed.", + "properties": { + "filepath": { + "title": "Filepath", + "type": "string" + }, + "bundle_name": { + "title": "Bundle Name", + "type": "string" + }, + "bundle_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Bundle Version" + }, + "version_data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Version Data" + }, + "msg": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Msg" + }, + "dag_id": { + "title": "Dag Id", + "type": "string" + }, + "skipped_intervals": { + "items": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "format": "date-time", + "type": "string" + }, + { + "format": "date-time", + "type": "string" + } + ], + "type": "array" + }, + "title": "Skipped Intervals", + "type": "array" + }, + "type": { + "const": "DagSkippedIntervalsCallbackRequest", + "default": "DagSkippedIntervalsCallbackRequest", + "title": "Type", + "type": "string" + } + }, + "required": [ + "filepath", + "bundle_name", + "bundle_version", + "dag_id", + "skipped_intervals" + ], + "title": "DagSkippedIntervalsCallbackRequest", + "type": "object" + }, "DagSourceCode": { "description": "Raw source code and its language identifier for a DAG definition.", "properties": { diff --git a/task-sdk/src/airflow/sdk/execution_time/schema/versions/__init__.py b/task-sdk/src/airflow/sdk/execution_time/schema/versions/__init__.py index 0f35e9f0e6f2b..b0de197b44f26 100644 --- a/task-sdk/src/airflow/sdk/execution_time/schema/versions/__init__.py +++ b/task-sdk/src/airflow/sdk/execution_time/schema/versions/__init__.py @@ -40,6 +40,7 @@ def get_bundle() -> VersionBundle: from airflow.sdk.execution_time.schema.versions.v2026_10_30 import ( AddArgBindingsToSupervisorTIRunContext, AddDagDefinitionsToDagFileParsingResult, + AddDagSkippedIntervalsCallbackRequest, AddFirstTaskRescheduleStartDateToSupervisorTIRunContext, AddLoopRuntime, AddRegionCoordinatesToSupervisorTaskInstance, @@ -56,6 +57,7 @@ def get_bundle() -> VersionBundle: AddDagDefinitionsToDagFileParsingResult, AddRegionCoordinatesToSupervisorTaskInstance, AddFirstTaskRescheduleStartDateToSupervisorTIRunContext, + AddDagSkippedIntervalsCallbackRequest, ), Version("2026-06-16"), ) diff --git a/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py b/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py index d1efcf0b8c380..f30c846f16bb8 100644 --- a/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py +++ b/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py @@ -28,6 +28,7 @@ from cadwyn import VersionChange, schema +from airflow.callbacks.callback_requests import DagSkippedIntervalsCallbackRequest # noqa: SDK002 from airflow.dag_processing.processor import DagFileParsingResult # noqa: SDK002 from airflow.sdk.api.datamodels._generated import PreviousTIResponse, TaskInstance, TIRunContext from airflow.sdk.execution_time.comms import ( @@ -111,3 +112,14 @@ class AddFirstTaskRescheduleStartDateToSupervisorTIRunContext(VersionChange): instructions_to_migrate_to_previous_version = ( schema(TIRunContext).field("first_task_reschedule_start_date").didnt_exist, ) + + +class AddDagSkippedIntervalsCallbackRequest(VersionChange): + """Introduce ``DagSkippedIntervalsCallbackRequest`` in the ``CallbackRequest`` union.""" + + description = __doc__ + + instructions_to_migrate_to_previous_version = ( + schema(DagSkippedIntervalsCallbackRequest).field("dag_id").didnt_exist, + schema(DagSkippedIntervalsCallbackRequest).field("skipped_intervals").didnt_exist, + ) From d90707d273539a349066ba3c4a499a175109732b Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:03:09 -0500 Subject: [PATCH 02/29] Add unit tests for skipped intervals callback and listener Scheduler (test_scheduler_job.py) _collect_skipped_intervals: empty when catchup=True, no callback/listener, no prior run, no gap, returns 3 intervals when a gap exists _create_dag_runs: returns DagSkippedIntervalsCallbackRequest with correct payload _do_scheduling: dispatches request via executor.send_callback Dag processor (test_processor.py) TestExecuteCallbacks::test_execute_callbacks_routes_skipped_intervals_request: routing in executecallbacks TestExecuteDagSkippedIntervalsCallback: callback execution, context (dag, reason, skipped_intervals), multiple callbacks, missing callback warning, missing DAG error Listener (test_skipped_intervals_listener.py + skipped_intervals_listener.py) on_intervals_skipped fired when the scheduler creates a Dag run across a gap (listener-only, no Dag callback) Serialization and callback requests test_dag_serialization.py: has_on_skipped_intervals_callback round-trip (mirrors success/failure callback tests) test_callback_requests.py: DagSkippedIntervalsCallbackRequest JSON round-trip and CallbackRequest union validation All 15 new tests pass locally. --- .../unit/callbacks/test_callback_requests.py | 40 ++++ .../unit/dag_processing/test_processor.py | 142 ++++++++++++ .../tests/unit/jobs/test_scheduler_job.py | 211 ++++++++++++++++++ .../listeners/skipped_intervals_listener.py | 37 +++ .../test_skipped_intervals_listener.py | 98 ++++++++ .../serialization/test_dag_serialization.py | 31 +++ 6 files changed, 559 insertions(+) create mode 100644 airflow-core/tests/unit/listeners/skipped_intervals_listener.py create mode 100644 airflow-core/tests/unit/listeners/test_skipped_intervals_listener.py diff --git a/airflow-core/tests/unit/callbacks/test_callback_requests.py b/airflow-core/tests/unit/callbacks/test_callback_requests.py index 6a82a22fce2d1..3bfad6f04f728 100644 --- a/airflow-core/tests/unit/callbacks/test_callback_requests.py +++ b/airflow-core/tests/unit/callbacks/test_callback_requests.py @@ -32,6 +32,7 @@ CallbackRequest, DagCallbackRequest, DagRunContext, + DagSkippedIntervalsCallbackRequest, EmailRequest, TaskCallbackRequest, ) @@ -409,6 +410,45 @@ def test_dag_callback_request_serialization_with_context(self): assert result.context_from_server.last_ti.task_id == "test_task" +class TestDagSkippedIntervalsCallbackRequest: + def test_to_json_roundtrip(self): + interval_start = timezone.datetime(2024, 1, 1) + interval_end = timezone.datetime(2024, 1, 2) + request = DagSkippedIntervalsCallbackRequest( + filepath="test.py", + dag_id="test_dag", + bundle_name="testing", + bundle_version="v1", + skipped_intervals=[(interval_start, interval_end)], + ) + + result = DagSkippedIntervalsCallbackRequest.from_json(request.to_json()) + + assert result == request + assert result.skipped_intervals == [(interval_start, interval_end)] + + def test_callback_request_union_with_skipped_intervals(self): + interval_start = timezone.datetime(2024, 1, 1) + interval_end = timezone.datetime(2024, 1, 2) + request_data = { + "type": "DagSkippedIntervalsCallbackRequest", + "filepath": "test.py", + "dag_id": "test_dag", + "bundle_name": "testing", + "bundle_version": "v1", + "skipped_intervals": [ + [interval_start.isoformat(), interval_end.isoformat()], + ], + } + + adapter = TypeAdapter(CallbackRequest) + callback_request = adapter.validate_python(request_data) + + assert isinstance(callback_request, DagSkippedIntervalsCallbackRequest) + assert callback_request.dag_id == "test_dag" + assert len(callback_request.skipped_intervals) == 1 + + class TestEmailRequest: def test_email_notification_request_serialization(self): """Test EmailRequest can be serialized and used in CallbackRequest union.""" diff --git a/airflow-core/tests/unit/dag_processing/test_processor.py b/airflow-core/tests/unit/dag_processing/test_processor.py index 629cb6b1c49b1..e39c8e376cd40 100644 --- a/airflow-core/tests/unit/dag_processing/test_processor.py +++ b/airflow-core/tests/unit/dag_processing/test_processor.py @@ -48,6 +48,7 @@ CallbackRequest, DagCallbackRequest, DagRunContext, + DagSkippedIntervalsCallbackRequest, EmailRequest, TaskCallbackRequest, ) @@ -62,6 +63,7 @@ ToManager, _execute_callbacks, _execute_dag_callbacks, + _execute_dag_skipped_intervals_callback, _execute_email_callbacks, _execute_task_callbacks, _parse_file, @@ -980,6 +982,28 @@ def make_request(dag_id): assert log.exception.call_args.kwargs["dag_id"] == "removed_dag" assert "context_from_server" not in log.exception.call_args.kwargs["request"] + def test_execute_callbacks_routes_skipped_intervals_request(self): + callbacks = [ + DagSkippedIntervalsCallbackRequest( + filepath="test.py", + dag_id="test_dag", + bundle_name="testing", + bundle_version="some_commit_hash", + skipped_intervals=[(timezone.utcnow(), timezone.utcnow())], + ) + ] + log = MagicMock(spec=FilteringBoundLogger) + dagbag = MagicMock(spec=DagBag) + + with ( + patch("airflow.dag_processing.processor.BundleVersionLock") as mock_lock, + patch("airflow.dag_processing.processor._execute_dag_skipped_intervals_callback") as mock_execute, + ): + _execute_callbacks(dagbag, callbacks, log) + + mock_lock.assert_called_once_with(bundle_name="testing", bundle_version="some_commit_hash") + mock_execute.assert_called_once_with(dagbag, callbacks[0], log) + class TestExecuteDagCallbacks: """Test the _execute_dag_callbacks function with context_from_server""" @@ -1556,6 +1580,124 @@ def fake_collect_dags(self, *args, **kwargs): assert operation_response in caplog.text +class TestExecuteDagSkippedIntervalsCallback: + def test_execute_skipped_intervals_callback(self, spy_agency): + context_received = None + + def on_skipped_intervals(context): + nonlocal context_received + context_received = context + + with DAG(dag_id="test_dag", on_skipped_intervals_callback=on_skipped_intervals) as dag: + BaseOperator(task_id="test_task") + + def fake_collect_dags(self, *args, **kwargs): + self.dags[dag.dag_id] = dag + + spy_agency.spy_on(DagBag.collect_dags, call_fake=fake_collect_dags, owner=DagBag) + + dagbag = DagBag() + dagbag.collect_dags() + + interval_start = timezone.datetime(2024, 1, 1) + interval_end = timezone.datetime(2024, 1, 2) + request = DagSkippedIntervalsCallbackRequest( + filepath="test.py", + dag_id="test_dag", + bundle_name="testing", + bundle_version=None, + skipped_intervals=[(interval_start, interval_end)], + ) + + log = structlog.get_logger() + _execute_dag_skipped_intervals_callback(dagbag, request, log) + + assert context_received is not None + assert context_received["dag"] is dag + assert context_received["reason"] == "skipped_intervals" + assert len(context_received["skipped_intervals"]) == 1 + skipped = context_received["skipped_intervals"][0] + assert skipped.start == interval_start + assert skipped.end == interval_end + + def test_execute_skipped_intervals_callback_multiple_callbacks(self, spy_agency): + call_count = 0 + + def on_skipped_1(context): + nonlocal call_count + call_count += 1 + + def on_skipped_2(context): + nonlocal call_count + call_count += 1 + + with DAG( + dag_id="test_dag", + on_skipped_intervals_callback=[on_skipped_1, on_skipped_2], + ) as dag: + BaseOperator(task_id="test_task") + + def fake_collect_dags(self, *args, **kwargs): + self.dags[dag.dag_id] = dag + + spy_agency.spy_on(DagBag.collect_dags, call_fake=fake_collect_dags, owner=DagBag) + + dagbag = DagBag() + dagbag.collect_dags() + + request = DagSkippedIntervalsCallbackRequest( + filepath="test.py", + dag_id="test_dag", + bundle_name="testing", + bundle_version=None, + skipped_intervals=[(timezone.utcnow(), timezone.utcnow())], + ) + + _execute_dag_skipped_intervals_callback(dagbag, request, structlog.get_logger()) + assert call_count == 2 + + def test_execute_skipped_intervals_callback_no_callback_defined(self, spy_agency): + with DAG(dag_id="test_dag") as dag: + BaseOperator(task_id="test_task") + + def fake_collect_dags(self, *args, **kwargs): + self.dags[dag.dag_id] = dag + + spy_agency.spy_on(DagBag.collect_dags, call_fake=fake_collect_dags, owner=DagBag) + + dagbag = DagBag() + dagbag.collect_dags() + + request = DagSkippedIntervalsCallbackRequest( + filepath="test.py", + dag_id="test_dag", + bundle_name="testing", + bundle_version=None, + skipped_intervals=[(timezone.utcnow(), timezone.utcnow())], + ) + + log = MagicMock(spec=FilteringBoundLogger) + _execute_dag_skipped_intervals_callback(dagbag, request, log) + + log.warning.assert_called_once_with( + "Skipped intervals callback requested, but dag didn't have any", + dag_id="test_dag", + ) + + def test_execute_skipped_intervals_callback_missing_dag(self): + dagbag = DagBag() + request = DagSkippedIntervalsCallbackRequest( + filepath="test.py", + dag_id="missing_dag", + bundle_name="testing", + bundle_version=None, + skipped_intervals=[(timezone.utcnow(), timezone.utcnow())], + ) + + with pytest.raises(ValueError, match="DAG 'missing_dag' not found in DagBag"): + _execute_dag_skipped_intervals_callback(dagbag, request, structlog.get_logger()) + + class TestExecuteTaskCallbacks: """Test the _execute_task_callbacks function""" diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py b/airflow-core/tests/unit/jobs/test_scheduler_job.py index 7118d08edc61f..1eeba6a049dc8 100644 --- a/airflow-core/tests/unit/jobs/test_scheduler_job.py +++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py @@ -54,6 +54,7 @@ DagCallbackRequest, DagRunContext, EmailRequest, + DagSkippedIntervalsCallbackRequest, TaskCallbackRequest, ) from airflow.callbacks.database_callback_sink import DatabaseCallbackSink @@ -12060,6 +12061,216 @@ def test_do_scheduling_multi_team_schedules_task_instances(self, dag_maker, sess session.refresh(ti) assert ti.state != State.NONE + def test_collect_skipped_intervals_returns_empty_when_catchup_true(self, session, dag_maker): + with dag_maker( + schedule=timedelta(days=1), + start_date=DEFAULT_DATE, + catchup=True, + on_skipped_intervals_callback=lambda ctx: None, + session=session, + ): + EmptyOperator(task_id="dummy") + + serdag = dag_maker.serialized_dag + scheduler_job = Job() + self.job_runner = SchedulerJobRunner(job=scheduler_job, executors=[self.null_exec]) + new_interval = DataInterval( + start=DEFAULT_DATE + timedelta(days=4), + end=DEFAULT_DATE + timedelta(days=5), + ) + assert self.job_runner._collect_skipped_intervals(serdag, new_interval, session) == [] + + def test_collect_skipped_intervals_returns_empty_without_callback_or_listener(self, session, dag_maker): + with dag_maker( + schedule=timedelta(days=1), + start_date=DEFAULT_DATE, + catchup=False, + session=session, + ): + EmptyOperator(task_id="dummy") + + prev_start = DEFAULT_DATE + prev_end = DEFAULT_DATE + timedelta(days=1) + dag_maker.create_dagrun( + run_type=DagRunType.SCHEDULED, + logical_date=prev_start, + data_interval=(prev_start, prev_end), + state=State.SUCCESS, + ) + + serdag = dag_maker.serialized_dag + new_interval = DataInterval( + start=DEFAULT_DATE + timedelta(days=4), + end=DEFAULT_DATE + timedelta(days=5), + ) + scheduler_job = Job() + self.job_runner = SchedulerJobRunner(job=scheduler_job, executors=[self.null_exec]) + assert self.job_runner._collect_skipped_intervals(serdag, new_interval, session) == [] + + def test_collect_skipped_intervals_returns_gap_intervals(self, session, dag_maker): + with dag_maker( + dag_id="test_collect_skipped_intervals", + schedule=timedelta(days=1), + start_date=DEFAULT_DATE, + catchup=False, + on_skipped_intervals_callback=lambda ctx: None, + session=session, + ): + EmptyOperator(task_id="dummy") + + prev_start = DEFAULT_DATE + prev_end = DEFAULT_DATE + timedelta(days=1) + dag_maker.create_dagrun( + run_type=DagRunType.SCHEDULED, + logical_date=prev_start, + data_interval=(prev_start, prev_end), + state=State.SUCCESS, + ) + + new_start = DEFAULT_DATE + timedelta(days=4) + new_end = DEFAULT_DATE + timedelta(days=5) + serdag = dag_maker.serialized_dag + scheduler_job = Job() + self.job_runner = SchedulerJobRunner(job=scheduler_job, executors=[self.null_exec]) + + skipped = self.job_runner._collect_skipped_intervals( + serdag, + DataInterval(start=new_start, end=new_end), + session, + ) + + assert len(skipped) == 3 + assert skipped[0] == (prev_end, prev_end + timedelta(days=1)) + assert skipped[-1] == (new_start - timedelta(days=1), new_start) + + def test_collect_skipped_intervals_returns_empty_without_previous_run(self, session, dag_maker): + with dag_maker( + schedule=timedelta(days=1), + start_date=DEFAULT_DATE, + catchup=False, + on_skipped_intervals_callback=lambda ctx: None, + session=session, + ): + EmptyOperator(task_id="dummy") + + serdag = dag_maker.serialized_dag + scheduler_job = Job() + self.job_runner = SchedulerJobRunner(job=scheduler_job, executors=[self.null_exec]) + new_interval = DataInterval( + start=DEFAULT_DATE + timedelta(days=1), + end=DEFAULT_DATE + timedelta(days=2), + ) + assert self.job_runner._collect_skipped_intervals(serdag, new_interval, session) == [] + + def test_collect_skipped_intervals_returns_empty_when_no_gap(self, session, dag_maker): + with dag_maker( + schedule=timedelta(days=1), + start_date=DEFAULT_DATE, + catchup=False, + on_skipped_intervals_callback=lambda ctx: None, + session=session, + ): + EmptyOperator(task_id="dummy") + + prev_start = DEFAULT_DATE + prev_end = DEFAULT_DATE + timedelta(days=1) + dag_maker.create_dagrun( + run_type=DagRunType.SCHEDULED, + logical_date=prev_start, + data_interval=(prev_start, prev_end), + state=State.SUCCESS, + ) + + serdag = dag_maker.serialized_dag + scheduler_job = Job() + self.job_runner = SchedulerJobRunner(job=scheduler_job, executors=[self.null_exec]) + new_interval = DataInterval(start=prev_end, end=prev_end + timedelta(days=1)) + assert self.job_runner._collect_skipped_intervals(serdag, new_interval, session) == [] + + def test_create_dag_runs_returns_skipped_intervals_callback_request(self, session, dag_maker): + with dag_maker( + dag_id="test_create_dag_runs_skipped_intervals_callback", + schedule=timedelta(days=1), + start_date=DEFAULT_DATE, + catchup=False, + on_skipped_intervals_callback=lambda ctx: None, + session=session, + ) as dag: + EmptyOperator(task_id="dummy") + + prev_start = DEFAULT_DATE + prev_end = DEFAULT_DATE + timedelta(days=1) + dag_maker.create_dagrun( + run_type=DagRunType.SCHEDULED, + logical_date=prev_start, + data_interval=(prev_start, prev_end), + state=State.SUCCESS, + ) + + new_start = DEFAULT_DATE + timedelta(days=4) + new_end = DEFAULT_DATE + timedelta(days=5) + dag_model = dag_maker.dag_model + dag_model.next_dagrun = new_start + dag_model.next_dagrun_data_interval = DataInterval(start=new_start, end=new_end) + dag_model.next_dagrun_create_after = new_end + session.merge(dag_model) + session.commit() + + scheduler_job = Job() + mock_exec = MockExecutor(do_update=False) + self.job_runner = SchedulerJobRunner(job=scheduler_job, executors=[mock_exec]) + + skip_requests = self.job_runner._create_dag_runs([dag_model], session) + session.commit() + + assert len(skip_requests) == 1 + request = skip_requests[0] + assert isinstance(request, DagSkippedIntervalsCallbackRequest) + assert request.dag_id == dag.dag_id + assert request.filepath == dag_model.relative_fileloc + assert request.bundle_name == dag_model.bundle_name + assert len(request.skipped_intervals) == 3 + assert request.skipped_intervals[0] == (prev_end, prev_end + timedelta(days=1)) + + def test_do_scheduling_dispatches_skipped_intervals_callback(self, session, dag_maker): + with dag_maker( + dag_id="test_do_scheduling_skipped_intervals_callback", + schedule=timedelta(days=1), + start_date=DEFAULT_DATE, + catchup=False, + on_skipped_intervals_callback=lambda ctx: None, + session=session, + ): + EmptyOperator(task_id="dummy") + + prev_start = DEFAULT_DATE + prev_end = DEFAULT_DATE + timedelta(days=1) + dag_maker.create_dagrun( + run_type=DagRunType.SCHEDULED, + logical_date=prev_start, + data_interval=(prev_start, prev_end), + state=State.SUCCESS, + ) + + new_start = DEFAULT_DATE + timedelta(days=4) + new_end = DEFAULT_DATE + timedelta(days=5) + dag_model = dag_maker.dag_model + dag_model.next_dagrun = new_start + dag_model.next_dagrun_data_interval = DataInterval(start=new_start, end=new_end) + dag_model.next_dagrun_create_after = new_end + session.merge(dag_model) + session.commit() + + scheduler_job = Job() + mock_exec = MockExecutor(do_update=False) + self.job_runner = SchedulerJobRunner(job=scheduler_job, executors=[mock_exec]) + + with mock.patch.object(mock_exec, "send_callback") as mock_send_callback: + self.job_runner._do_scheduling(session) + + mock_send_callback.assert_called_once() + request = mock_send_callback.call_args.args[0] + assert isinstance(request, DagSkippedIntervalsCallbackRequest) @pytest.mark.need_serialized_dag def test_schedule_dag_run_with_upstream_skip(dag_maker, session): diff --git a/airflow-core/tests/unit/listeners/skipped_intervals_listener.py b/airflow-core/tests/unit/listeners/skipped_intervals_listener.py new file mode 100644 index 0000000000000..1d492bf1b9530 --- /dev/null +++ b/airflow-core/tests/unit/listeners/skipped_intervals_listener.py @@ -0,0 +1,37 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from typing import TYPE_CHECKING + +from airflow.listeners import hookimpl + +if TYPE_CHECKING: + from airflow.timetables.base import DataInterval + +events: list[tuple[str, list[DataInterval]]] = [] + + +@hookimpl +def on_intervals_skipped(dag_id: str, skipped_intervals: list[DataInterval]): + events.append((dag_id, list(skipped_intervals))) + + +def clear(): + global events + events = [] diff --git a/airflow-core/tests/unit/listeners/test_skipped_intervals_listener.py b/airflow-core/tests/unit/listeners/test_skipped_intervals_listener.py new file mode 100644 index 0000000000000..eb619bcf84b4f --- /dev/null +++ b/airflow-core/tests/unit/listeners/test_skipped_intervals_listener.py @@ -0,0 +1,98 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING + +import pytest + +from airflow._shared.timezones import timezone +from airflow.jobs.job import Job +from airflow.jobs.scheduler_job_runner import SchedulerJobRunner +from airflow.models.dag import DagModel +from airflow.providers.standard.operators.empty import EmptyOperator +from airflow.timetables.base import DataInterval +from airflow.utils.state import State +from airflow.utils.types import DagRunType + +from tests_common.test_utils.mock_executor import MockExecutor +from unit.listeners import skipped_intervals_listener + +if TYPE_CHECKING: + from sqlalchemy.orm import Session + + from tests_common.pytest_plugin import DagMaker + +pytestmark = [pytest.mark.db_test, pytest.mark.need_serialized_dag] + +DEFAULT_DATE = timezone.datetime(2016, 1, 1) + + +@pytest.fixture(autouse=True) +def clean_listener_state(): + yield + skipped_intervals_listener.clear() + + +def _configure_gap_dag_model(dag_maker: DagMaker, session: Session): + prev_start = DEFAULT_DATE + prev_end = DEFAULT_DATE + timedelta(days=1) + dag_maker.create_dagrun( + run_type=DagRunType.SCHEDULED, + logical_date=prev_start, + data_interval=DataInterval(start=prev_start, end=prev_end), + state=State.SUCCESS, + ) + + new_start = DEFAULT_DATE + timedelta(days=4) + new_end = DEFAULT_DATE + timedelta(days=5) + dag_model = DagModel.get_dagmodel(dag_maker.dag.dag_id, session=session) + if dag_model is None: + raise RuntimeError(f"DagModel for {dag_maker.dag.dag_id} not found") + dag_model.next_dagrun = new_start + dag_model.next_dagrun_data_interval = DataInterval(start=new_start, end=new_end) + dag_model.next_dagrun_create_after = new_end + session.merge(dag_model) + session.commit() + return dag_model, prev_end, new_start + + +def test_listener_notified_when_intervals_skipped(session, dag_maker, listener_manager): + listener_manager(skipped_intervals_listener) + + with dag_maker( + dag_id="test_listener_skipped_intervals", + schedule=timedelta(days=1), + start_date=DEFAULT_DATE, + catchup=False, + session=session, + ): + EmptyOperator(task_id="dummy") + + dag_model, prev_end, new_start = _configure_gap_dag_model(dag_maker, session) + + job_runner = SchedulerJobRunner(job=Job(), executors=[MockExecutor(do_update=False)]) + job_runner._create_dag_runs([dag_model], session) + session.commit() + + assert len(skipped_intervals_listener.events) == 1 + dag_id, skipped = skipped_intervals_listener.events[0] + assert dag_id == dag_maker.dag.dag_id + assert len(skipped) == 3 + assert skipped[0].start == prev_end + assert skipped[-1].end == new_start diff --git a/airflow-core/tests/unit/serialization/test_dag_serialization.py b/airflow-core/tests/unit/serialization/test_dag_serialization.py index d40f36ebf6d33..dba5fad797ee8 100644 --- a/airflow-core/tests/unit/serialization/test_dag_serialization.py +++ b/airflow-core/tests/unit/serialization/test_dag_serialization.py @@ -2537,6 +2537,37 @@ def test_dag_on_failure_callback_roundtrip(self, passed_failure_callback, expect assert deserialized_dag.has_on_failure_callback is expected_value + @pytest.mark.parametrize( + ("passed_skipped_intervals_callback", "expected_value"), + [ + ({"on_skipped_intervals_callback": lambda x: print("hi")}, True), + ({}, False), + ], + ) + def test_dag_on_skipped_intervals_callback_roundtrip( + self, passed_skipped_intervals_callback, expected_value + ): + """ + Test that when on_skipped_intervals_callback is passed to the DAG, + has_on_skipped_intervals_callback is stored in Serialized JSON blob. + """ + dag = DAG( + dag_id="test_dag_on_skipped_intervals_callback_roundtrip", + schedule=None, + **passed_skipped_intervals_callback, + ) + BaseOperator(task_id="simple_task", dag=dag, start_date=datetime(2019, 8, 1)) + + serialized_dag = DagSerialization.to_dict(dag) + if expected_value: + assert "has_on_skipped_intervals_callback" in serialized_dag["dag"] + else: + assert "has_on_skipped_intervals_callback" not in serialized_dag["dag"] + + deserialized_dag = DagSerialization.from_dict(serialized_dag) + + assert deserialized_dag.has_on_skipped_intervals_callback is expected_value + @pytest.mark.parametrize( ("dag_arg", "conf_arg", "expected"), [ From 76122652d3d4a880092c0004ddb2e76f48d1d54a Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:34:04 -0500 Subject: [PATCH 03/29] Add docs and newsfragment for skipped intervals callback and listener --- .../listeners.rst | 18 ++++++++ .../logging-monitoring/callbacks.rst | 43 +++++++++++++++++++ airflow-core/docs/core-concepts/dag-run.rst | 3 ++ airflow-core/docs/howto/listener-plugin.rst | 8 ++++ airflow-core/newsfragments/68359.feature.rst | 1 + .../example_dags/plugins/event_listener.py | 17 ++++++++ task-sdk/src/airflow/sdk/definitions/dag.py | 7 +++ 7 files changed, 97 insertions(+) create mode 100644 airflow-core/newsfragments/68359.feature.rst diff --git a/airflow-core/docs/administration-and-deployment/listeners.rst b/airflow-core/docs/administration-and-deployment/listeners.rst index cf653f2fbe5e7..aab6300065d7b 100644 --- a/airflow-core/docs/administration-and-deployment/listeners.rst +++ b/airflow-core/docs/administration-and-deployment/listeners.rst @@ -64,6 +64,22 @@ 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. 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 -------------------------------- @@ -207,3 +223,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 | ++-----------------+--------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------+ diff --git a/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst b/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst index 9b15c8f6c09f5..4507cd756b3fa 100644 --- a/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst +++ b/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst @@ -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). =========================================== ======================================================================= ================= @@ -95,6 +98,46 @@ 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. + +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_intervals`` -- a list of :class:`~airflow.timetables.base.DataInterval` objects for + the intervals that were skipped + +Example: + +.. code-block:: python + + from airflow.sdk import DAG + from airflow.providers.standard.operators.empty import EmptyOperator + + + def log_skipped_intervals(context): + for interval in context["skipped_intervals"]: + 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 -------- diff --git a/airflow-core/docs/core-concepts/dag-run.rst b/airflow-core/docs/core-concepts/dag-run.rst index 9db308ddd3c72..f0f62aabc77c2 100644 --- a/airflow-core/docs/core-concepts/dag-run.rst +++ b/airflow-core/docs/core-concepts/dag-run.rst @@ -128,6 +128,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 `) or implement the ``on_intervals_skipped`` listener hook (see :doc:`Listeners `). + .. code-block:: python """ diff --git a/airflow-core/docs/howto/listener-plugin.rst b/airflow-core/docs/howto/listener-plugin.rst index c5a0acd4a6676..d1f6864603583 100644 --- a/airflow-core/docs/howto/listener-plugin.rst +++ b/airflow-core/docs/howto/listener-plugin.rst @@ -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 @@ -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. diff --git a/airflow-core/newsfragments/68359.feature.rst b/airflow-core/newsfragments/68359.feature.rst new file mode 100644 index 0000000000000..2aa9509400f36 --- /dev/null +++ b/airflow-core/newsfragments/68359.feature.rst @@ -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. diff --git a/airflow-core/src/airflow/example_dags/plugins/event_listener.py b/airflow-core/src/airflow/example_dags/plugins/event_listener.py index 91af9f5ccc6df..2566147d2655a 100644 --- a/airflow-core/src/airflow/example_dags/plugins/event_listener.py +++ b/airflow-core/src/airflow/example_dags/plugins/event_listener.py @@ -222,3 +222,20 @@ 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, skipped_intervals: list): + """ + Called when a Dag with catchup=False skips one or more scheduled intervals. + + skipped_intervals is a list of DataInterval objects for the intervals the scheduler + advanced past without creating Dag runs. + """ + print(f"Dag {dag_id} skipped {len(skipped_intervals)} interval(s)") + for interval in skipped_intervals: + print(f" Skipped: {interval.start} -> {interval.end}") + + +# [END howto_listen_intervals_skipped] diff --git a/task-sdk/src/airflow/sdk/definitions/dag.py b/task-sdk/src/airflow/sdk/definitions/dag.py index a521c99ddf4fe..17505fab4f80e 100644 --- a/task-sdk/src/airflow/sdk/definitions/dag.py +++ b/task-sdk/src/airflow/sdk/definitions/dag.py @@ -402,6 +402,13 @@ class DAG: A context dictionary is passed as a single parameter to this function. :param on_success_callback: Much like the ``on_failure_callback`` except that it is executed when the dag succeeds. + :param on_skipped_intervals_callback: A function or list of functions invoked by the + scheduler when a Dag with ``catchup=False`` advances past one or more scheduled + data intervals without creating Dag runs for them (for example after a scheduler + restart or when a paused Dag is re-enabled). The callback receives a context + dictionary with ``dag``, ``reason`` (``"skipped_intervals"``), and + ``skipped_intervals`` (a list of :class:`~airflow.timetables.base.DataInterval` + objects). It runs in the dag processor, not in the scheduler. :param access_control: Specify optional DAG-level actions, e.g., "{'role1': {'can_read'}, 'role2': {'can_read', 'can_edit', 'can_delete'}}" or it can specify the resource name if there is a DAGs Run resource, e.g., From 8b017b5dde275c3f383906dd438e1ce93dbc81a4 Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:26:32 -0500 Subject: [PATCH 04/29] Fix callback types table, add skipped intervals callback to ignored keys Both these fixes were flagged by CI in the previous commit. Issue 1: The addition of the `on_skipped_intervals_callback` in the callback types table was not correct syntactically with rst. Issue 2: The serialization tests were failing since the `has_on_skipped_intervals_callback` key was missing but not expected. Since this callback was implemented similarly to `has_on_success_callback`, it has been added to the ignored set. --- .../logging-monitoring/callbacks.rst | 2 +- airflow-core/tests/unit/serialization/test_dag_serialization.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst b/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst index 4507cd756b3fa..0a22f4725f952 100644 --- a/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst +++ b/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst @@ -67,7 +67,7 @@ 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 +``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). =========================================== ======================================================================= ================= diff --git a/airflow-core/tests/unit/serialization/test_dag_serialization.py b/airflow-core/tests/unit/serialization/test_dag_serialization.py index dba5fad797ee8..d3f141ce5776e 100644 --- a/airflow-core/tests/unit/serialization/test_dag_serialization.py +++ b/airflow-core/tests/unit/serialization/test_dag_serialization.py @@ -1585,6 +1585,7 @@ def test_dag_serialized_fields_with_schema(self): "tasks", "has_on_success_callback", "has_on_failure_callback", + "has_on_skipped_intervals_callback", "dag_dependencies", "params", } From 0d60cb878217dc63d02a0ba553fa2d6518495701 Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:50:08 -0500 Subject: [PATCH 05/29] Apply linting fixes after rebase --- airflow-core/tests/unit/jobs/test_scheduler_job.py | 1 + 1 file changed, 1 insertion(+) diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py b/airflow-core/tests/unit/jobs/test_scheduler_job.py index 1eeba6a049dc8..9d4bc8905ef66 100644 --- a/airflow-core/tests/unit/jobs/test_scheduler_job.py +++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py @@ -12272,6 +12272,7 @@ def test_do_scheduling_dispatches_skipped_intervals_callback(self, session, dag_ request = mock_send_callback.call_args.args[0] assert isinstance(request, DagSkippedIntervalsCallbackRequest) + @pytest.mark.need_serialized_dag def test_schedule_dag_run_with_upstream_skip(dag_maker, session): """ From 06e06fb0911c387586559d2a971f48dc78b5922f Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:29:49 -0500 Subject: [PATCH 06/29] Use gap summary for skipped intervals callback and listener When a Dag with catchup=False skips many scheduled intervals (long pause, scheduler downtime, etc.), the original design enumerated every missed interval in the scheduler via iter_dagrun_infos_between(), materialized the full list in DagSkippedIntervalsCallbackRequest, and passed it through the listener hook and dag-processor callback. That is O(n) in time and memory and can stall the scheduler or produce huge callback payloads for high-frequency schedules. Replace the full interval list with a compact SkippedIntervalsSummary (count plus skipped_range envelope) and compute the count in O(1) time from the schedule and gap boundaries. Scheduler and Dag integration: - Add SkippedIntervalsSummary (count, skipped_range) in timetables.base. - Add Timetable.count_skipped_intervals_between(); default raises NotImplementedError. - Implement O(1) counting on DeltaMixin (timedelta/relativedelta schedules) and CronMixin (uniform cron periods, plus calendar shortcuts for monthly and weekly expressions). - Add SerializedDAG.summarize_skipped_intervals_between() delegating to the Dag timetable; returns None when there is no gap or counting is unsupported. - Change SchedulerJobRunner._collect_skipped_intervals() to return SkippedIntervalsSummary | None instead of a list of interval tuples. Callback and listener API: - on_intervals_skipped listener: (dag_id, summary: SkippedIntervalsSummary) instead of (dag_id, skipped_intervals: list[DataInterval]). - on_skipped_intervals_callback context: skipped_interval_count and skipped_range instead of skipped_intervals. - DagSkippedIntervalsCallbackRequest carries skipped_interval_count and skipped_range; add from_summary() / to_summary() helpers. - Dag processor builds the callback context from the summary. Documentation and examples: - Update callbacks.rst and listeners.rst for the new context and listener signature; document iter_dagrun_infos_between() for full enumeration in the dag processor. - Update SDK Dag docstring and example event_listener plugin. Schema: - Update task-sdk supervisor schema and v2026_06_16 version migration for the revised DagSkippedIntervalsCallbackRequest fields. Tests: - Add test_skipped_intervals_summary.py with parametrized O(1) vs reference counts (delta/cron daily, hourly, monthly) and summarize helpers. - Update scheduler, callback request, dag processor, and listener tests for the summary shape. Custom timetables must implement count_skipped_intervals_between() to participate; unsupported built-in timetables silently skip notification. Cron expressions outside the supported fast paths raise AirflowTimetableInvalid from CronMixin (not swallowed by summarize). --- .../listeners.rst | 3 +- .../logging-monitoring/callbacks.rst | 17 ++- .../airflow/callbacks/callback_requests.py | 36 ++++- .../src/airflow/dag_processing/processor.py | 10 +- .../example_dags/plugins/event_listener.py | 13 +- .../src/airflow/jobs/scheduler_job_runner.py | 47 +++---- .../src/airflow/listeners/spec/dag.py | 6 +- .../airflow/serialization/definitions/dag.py | 37 ++++- airflow-core/src/airflow/timetables/_cron.py | 31 +++++ airflow-core/src/airflow/timetables/_delta.py | 29 ++++ airflow-core/src/airflow/timetables/base.py | 15 ++ .../unit/callbacks/test_callback_requests.py | 18 ++- .../unit/dag_processing/test_processor.py | 74 +++++----- .../tests/unit/jobs/test_scheduler_job.py | 22 +-- .../listeners/skipped_intervals_listener.py | 8 +- .../test_skipped_intervals_listener.py | 8 +- .../test_skipped_intervals_summary.py | 131 ++++++++++++++++++ task-sdk/src/airflow/sdk/definitions/dag.py | 5 +- .../sdk/execution_time/schema/schema.json | 38 ++--- .../schema/versions/v2026_10_30.py | 3 +- 20 files changed, 416 insertions(+), 135 deletions(-) create mode 100644 airflow-core/tests/unit/timetables/test_skipped_intervals_summary.py diff --git a/airflow-core/docs/administration-and-deployment/listeners.rst b/airflow-core/docs/administration-and-deployment/listeners.rst index aab6300065d7b..621765ead8e5f 100644 --- a/airflow-core/docs/administration-and-deployment/listeners.rst +++ b/airflow-core/docs/administration-and-deployment/listeners.rst @@ -69,7 +69,8 @@ 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. This is the listener counterpart to the Dag-level +``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. diff --git a/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst b/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst index 0a22f4725f952..5a5c67f57a436 100644 --- a/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst +++ b/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst @@ -114,8 +114,13 @@ The context mapping contains: * ``dag`` -- the Dag object * ``reason`` -- always ``"skipped_intervals"`` -* ``skipped_intervals`` -- a list of :class:`~airflow.timetables.base.DataInterval` objects for - the intervals that were skipped +* ``skipped_interval_count`` -- how many scheduled data intervals were skipped +* ``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: @@ -126,7 +131,13 @@ Example: def log_skipped_intervals(context): - for interval in context["skipped_intervals"]: + print(f"Skipped {context['skipped_interval_count']} interval(s)") + 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}") diff --git a/airflow-core/src/airflow/callbacks/callback_requests.py b/airflow-core/src/airflow/callbacks/callback_requests.py index a9cbd7602a716..8a1ad0f9ff6d2 100644 --- a/airflow-core/src/airflow/callbacks/callback_requests.py +++ b/airflow-core/src/airflow/callbacks/callback_requests.py @@ -27,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: @@ -179,10 +181,40 @@ class DagSkippedIntervalsCallbackRequest(BaseCallbackRequest): """A Class with information about the skipped intervals DAG callback to be executed.""" dag_id: str - skipped_intervals: list[tuple[datetime, datetime]] - """List of (start, end) pairs for intervals skipped due to catchup=False.""" + skipped_interval_count: int + """Number of scheduled data intervals skipped due to catchup=False.""" + 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_interval_count=summary.skipped_interval_count, + skipped_range=(summary.skipped_range.start, summary.skipped_range.end), + ) + + def to_summary(self) -> SkippedIntervalsSummary: + return SkippedIntervalsSummary( + skipped_interval_count=self.skipped_interval_count, + skipped_range=DataInterval( + start=timezone.coerce_datetime(self.skipped_range[0]), + end=timezone.coerce_datetime(self.skipped_range[1]), + ), + ) + CallbackRequest = Annotated[ DagCallbackRequest | DagSkippedIntervalsCallbackRequest | TaskCallbackRequest | EmailRequest, diff --git a/airflow-core/src/airflow/dag_processing/processor.py b/airflow-core/src/airflow/dag_processing/processor.py index 373c3d6ddf560..99fa187e6432f 100644 --- a/airflow-core/src/airflow/dag_processing/processor.py +++ b/airflow-core/src/airflow/dag_processing/processor.py @@ -438,8 +438,6 @@ def _execute_dag_callbacks(dagbag: DagBag, request: DagCallbackRequest, log: Fil def _execute_dag_skipped_intervals_callback( dagbag: DagBag, request: DagSkippedIntervalsCallbackRequest, log: FilteringBoundLogger ) -> None: - from airflow._shared.timezones import timezone - from airflow.timetables.base import DataInterval dag, _ = _get_dag_with_task(dagbag, request.dag_id) callbacks = dag.on_skipped_intervals_callback @@ -448,14 +446,12 @@ def _execute_dag_skipped_intervals_callback( return callbacks = callbacks if isinstance(callbacks, list) else [callbacks] - skipped_intervals = [ - DataInterval(start=timezone.coerce_datetime(start), end=timezone.coerce_datetime(end)) - for start, end in request.skipped_intervals - ] + summary = request.to_summary() context: Context = { # type: ignore[typeddict-unknown-key] "dag": dag, "reason": "skipped_intervals", - "skipped_intervals": skipped_intervals, + "skipped_interval_count": summary.skipped_interval_count, + "skipped_range": summary.skipped_range, } for callback in callbacks: diff --git a/airflow-core/src/airflow/example_dags/plugins/event_listener.py b/airflow-core/src/airflow/example_dags/plugins/event_listener.py index 2566147d2655a..c2c87e3c818a0 100644 --- a/airflow-core/src/airflow/example_dags/plugins/event_listener.py +++ b/airflow-core/src/airflow/example_dags/plugins/event_listener.py @@ -226,16 +226,17 @@ def on_dag_run_running(dag_run: DagRun, msg: str): # [START howto_listen_intervals_skipped] @hookimpl -def on_intervals_skipped(dag_id: str, skipped_intervals: list): +def on_intervals_skipped(dag_id: str, summary): """ Called when a Dag with catchup=False skips one or more scheduled intervals. - skipped_intervals is a list of DataInterval objects for the intervals the scheduler - advanced past without creating Dag runs. + ``summary`` is a :class:`~airflow.timetables.base.SkippedIntervalsSummary` with + ``count`` and ``skipped_range``. Use + :meth:`~airflow.serialization.definitions.dag.SerializedDAG.iter_dagrun_infos_between` + if the full list of skipped intervals is required. """ - print(f"Dag {dag_id} skipped {len(skipped_intervals)} interval(s)") - for interval in skipped_intervals: - print(f" Skipped: {interval.start} -> {interval.end}") + print(f"Dag {dag_id} skipped {summary.skipped_interval_count} interval(s)") + print(f" Range: {summary.skipped_range.start} -> {summary.skipped_range.end}") # [END howto_listen_intervals_skipped] diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py b/airflow-core/src/airflow/jobs/scheduler_job_runner.py index e2f7f06dc63c6..65df15fc7a574 100644 --- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py +++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py @@ -125,7 +125,12 @@ from airflow.serialization.definitions.assets import SerializedAssetUniqueKey from airflow.serialization.definitions.notset import NOTSET from airflow.ti_deps.dependencies_states import ACTIVE_STATES, EXECUTION_STATES -from airflow.timetables.base import DataInterval, Timetable, compute_rollup_fingerprint +from airflow.timetables.base import ( + DataInterval, + SkippedIntervalsSummary, + Timetable, + compute_rollup_fingerprint, +) from airflow.triggers.base import TriggerEvent from airflow.utils.event_scheduler import EventScheduler from airflow.utils.helpers import prune_dict @@ -2711,22 +2716,22 @@ def _collect_skipped_intervals( serdag: SerializedDAG, new_data_interval: DataInterval, session: Session, - ) -> list[tuple[datetime, datetime]]: + ) -> SkippedIntervalsSummary | None: """ - Return a list of (start, end) tuples for intervals skipped due to catchup=False. + Summarize intervals skipped due to catchup=False. Computes the intervals that would have been scheduled between the previous automated DagRun's data_interval_end and the new run's data_interval_start, - had catchup been True. Returns an empty list when there is no gap or when + had catchup been True. Returns ``None`` when there is no gap or when no previous run exists. """ if serdag.catchup: - return [] + return None listener_has_impls = bool( get_listener_manager().hook.on_intervals_skipped.get_hookimpls() # type: ignore[attr-defined] ) if not serdag.has_on_skipped_intervals_callback and not listener_has_impls: - return [] + return None prev_run = session.scalar( select(DagRun) @@ -2740,22 +2745,12 @@ def _collect_skipped_intervals( .limit(1) ) if prev_run is None or prev_run.data_interval_end is None: - return [] - - prev_end = prev_run.data_interval_end - new_start = new_data_interval.start - if prev_end >= new_start: - return [] - - skipped: list[tuple[datetime, datetime]] = [] - for info in serdag.iter_dagrun_infos_between(prev_end, new_start): - if info.data_interval is None: - continue - if info.data_interval.start >= new_start: - continue - skipped.append((info.data_interval.start, info.data_interval.end)) + return None - return skipped + return serdag.summarize_skipped_intervals_between( + prev_run.data_interval_end, + new_data_interval.start, + ) def _create_dag_runs( self, dag_models: Collection[DagModel], session: Session @@ -2905,16 +2900,16 @@ def _create_dag_runs( ) if data_interval is not None: - skipped = self._collect_skipped_intervals( + skipped_summary = self._collect_skipped_intervals( serdag=serdag, new_data_interval=data_interval, session=session, ) - if skipped: + if skipped_summary is not None: try: get_listener_manager().hook.on_intervals_skipped( # type: ignore[attr-defined] dag_id=serdag.dag_id, - skipped_intervals=[DataInterval(start=s, end=e) for s, e in skipped], + summary=skipped_summary, ) except Exception: self.log.exception( @@ -2923,12 +2918,12 @@ def _create_dag_runs( ) if serdag.has_on_skipped_intervals_callback: skip_callback_requests.append( - DagSkippedIntervalsCallbackRequest( + DagSkippedIntervalsCallbackRequest.from_summary( filepath=dag_model.relative_fileloc or "", bundle_name=dag_model.bundle_name, bundle_version=created_run.bundle_version, dag_id=serdag.dag_id, - skipped_intervals=cast("list[tuple[datetime, datetime]]", skipped), + summary=skipped_summary, ) ) diff --git a/airflow-core/src/airflow/listeners/spec/dag.py b/airflow-core/src/airflow/listeners/spec/dag.py index 8def650d0c728..51f5ad7c09845 100644 --- a/airflow-core/src/airflow/listeners/spec/dag.py +++ b/airflow-core/src/airflow/listeners/spec/dag.py @@ -21,11 +21,11 @@ from pluggy import HookspecMarker if TYPE_CHECKING: - from airflow.timetables.base import DataInterval + from airflow.timetables.base import SkippedIntervalsSummary hookspec = HookspecMarker("airflow") @hookspec -def on_intervals_skipped(dag_id: str, skipped_intervals: list[DataInterval]): - """Execute when a DAG with catchup=False skips one or more scheduled intervals.""" +def on_intervals_skipped(dag_id: str, summary: SkippedIntervalsSummary): + """Execute when a Dag with catchup=False skips one or more scheduled intervals.""" diff --git a/airflow-core/src/airflow/serialization/definitions/dag.py b/airflow-core/src/airflow/serialization/definitions/dag.py index 4774891f306fe..710f81dc87da1 100644 --- a/airflow-core/src/airflow/serialization/definitions/dag.py +++ b/airflow-core/src/airflow/serialization/definitions/dag.py @@ -53,7 +53,7 @@ from airflow.serialization.decoders import decode_deadline_alert, resolve_deadline_alert_interval from airflow.serialization.definitions.deadline import DeadlineAlertFields, SerializedReferenceModels from airflow.serialization.definitions.param import SerializedParamsDict -from airflow.timetables.base import DagRunInfo, DataInterval, TimeRestriction +from airflow.timetables.base import DagRunInfo, DataInterval, SkippedIntervalsSummary, TimeRestriction from airflow.utils.helpers import prune_dict from airflow.utils.session import NEW_SESSION, provide_session from airflow.utils.sqlalchemy import PROHIBIT_COMMIT_ERROR_MESSAGE @@ -542,6 +542,41 @@ def iter_dagrun_infos_between( dag_id=self.dag_id, ) + def summarize_skipped_intervals_between( + self, + prev_interval_end: datetime.datetime, + new_interval_start: datetime.datetime, + ) -> SkippedIntervalsSummary | None: + """ + Summarize intervals skipped between two automated Dag run boundaries. + + Returns ``None`` when there is no schedulable gap or the timetable cannot + count skipped intervals in constant time. + """ + if prev_interval_end >= new_interval_start: + return None + + from airflow._shared.timezones import timezone + + prev_end = timezone.coerce_datetime(prev_interval_end) + new_start = timezone.coerce_datetime(new_interval_start) + count_fn = getattr(self.timetable, "count_skipped_intervals_between", None) + if count_fn is None: + return None + + try: + count = count_fn(prev_end, new_start) + except NotImplementedError: + return None + + if count <= 0: + return None + + return SkippedIntervalsSummary( + skipped_interval_count=count, + skipped_range=DataInterval(start=prev_end, end=new_start), + ) + @provide_session def get_concurrency_reached(self, *, session=NEW_SESSION) -> bool: """Return a boolean indicating whether the max_active_tasks limit for this DAG has been reached.""" diff --git a/airflow-core/src/airflow/timetables/_cron.py b/airflow-core/src/airflow/timetables/_cron.py index 245cc3fbc3953..eb020a38577a9 100644 --- a/airflow-core/src/airflow/timetables/_cron.py +++ b/airflow-core/src/airflow/timetables/_cron.py @@ -207,3 +207,34 @@ def _align_to_prev(self, current: DateTime) -> DateTime: if self._get_next(prev_time) != current: return prev_time return current + + def count_skipped_intervals_between( + self, + prev_interval_end: DateTime, + new_interval_start: DateTime, + ) -> int: + if new_interval_start <= prev_interval_end: + return 0 + + first_period_end = self._get_next(prev_interval_end) + first_period_seconds = (first_period_end - prev_interval_end).total_seconds() + second_period_seconds = (self._get_next(first_period_end) - first_period_end).total_seconds() + gap_seconds = (new_interval_start - prev_interval_end).total_seconds() + + if first_period_seconds > 0 and first_period_seconds == second_period_seconds: + return int(gap_seconds // first_period_seconds) + + expression = cron_presets.get(self._expression, self._expression) + fields = expression.split() + if len(fields) >= 5: + dom, month, dow = fields[2], fields[3], fields[4] + prev_local = prev_interval_end.in_timezone(self._timezone) + new_local = new_interval_start.in_timezone(self._timezone) + if dom != "*" and month == "*": + return (new_local.year - prev_local.year) * 12 + (new_local.month - prev_local.month) + if dow != "*" and dom == "*": + return (new_local.date() - prev_local.date()).days // 7 + + raise AirflowTimetableInvalid( + f"Cannot count skipped intervals for cron expression {self._expression!r}" + ) diff --git a/airflow-core/src/airflow/timetables/_delta.py b/airflow-core/src/airflow/timetables/_delta.py index 891f5da3e9ceb..5e2a3a98794e2 100644 --- a/airflow-core/src/airflow/timetables/_delta.py +++ b/airflow-core/src/airflow/timetables/_delta.py @@ -69,3 +69,32 @@ def _align_to_next(self, current: DateTime) -> DateTime: def _align_to_prev(self, current: DateTime) -> DateTime: return current + + def count_skipped_intervals_between( + self, + prev_interval_end: DateTime, + new_interval_start: DateTime, + ) -> int: + if new_interval_start <= prev_interval_end: + return 0 + period_seconds = self._schedule_period_seconds() + if period_seconds <= 0: + return 0 + gap_seconds = (new_interval_start - prev_interval_end).total_seconds() + return int(gap_seconds // period_seconds) + + def _schedule_period_seconds(self) -> float: + if isinstance(self._delta, datetime.timedelta): + return self._delta.total_seconds() + return float(self._relativedelta_in_seconds(self._delta)) + + @staticmethod + def _relativedelta_in_seconds(delta: relativedelta) -> int: + return ( + delta.years * 365 * 24 * 60 * 60 + + delta.months * 30 * 24 * 60 * 60 + + delta.days * 24 * 60 * 60 + + delta.hours * 60 * 60 + + delta.minutes * 60 + + delta.seconds + ) diff --git a/airflow-core/src/airflow/timetables/base.py b/airflow-core/src/airflow/timetables/base.py index a2325395a08e4..7b326043e97ae 100644 --- a/airflow-core/src/airflow/timetables/base.py +++ b/airflow-core/src/airflow/timetables/base.py @@ -74,6 +74,21 @@ def exact(cls, at: DateTime) -> DataInterval: return cls(start=at, end=at) +class SkippedIntervalsSummary(NamedTuple): + """ + Summary of scheduled data intervals skipped due to ``catchup=False``. + + ``skipped_range`` spans from the previous automated Dag run's + ``data_interval_end`` to the new run's ``data_interval_start``. + ``skipped_interval_count`` is derived from that range and the Dag schedule in O(1) time. + Use :meth:`~airflow.serialization.definitions.dag.SerializedDAG.iter_dagrun_infos_between` + in a callback if the full sequence is required. + """ + + skipped_interval_count: int + skipped_range: DataInterval + + class TimeRestriction(NamedTuple): """ Restriction on when a DAG can be scheduled for a run. diff --git a/airflow-core/tests/unit/callbacks/test_callback_requests.py b/airflow-core/tests/unit/callbacks/test_callback_requests.py index 3bfad6f04f728..7aa37cf816a2e 100644 --- a/airflow-core/tests/unit/callbacks/test_callback_requests.py +++ b/airflow-core/tests/unit/callbacks/test_callback_requests.py @@ -39,6 +39,7 @@ from airflow.models import DagRun from airflow.models.taskinstance import TaskInstance from airflow.serialization.definitions.baseoperator import SerializedBaseOperator +from airflow.timetables.base import DataInterval, SkippedIntervalsSummary from airflow.utils.state import State, TaskInstanceState pytestmark = pytest.mark.db_test @@ -414,18 +415,22 @@ class TestDagSkippedIntervalsCallbackRequest: def test_to_json_roundtrip(self): interval_start = timezone.datetime(2024, 1, 1) interval_end = timezone.datetime(2024, 1, 2) - request = DagSkippedIntervalsCallbackRequest( + summary = SkippedIntervalsSummary( + skipped_interval_count=1, + skipped_range=DataInterval(start=interval_start, end=interval_end), + ) + request = DagSkippedIntervalsCallbackRequest.from_summary( filepath="test.py", dag_id="test_dag", bundle_name="testing", bundle_version="v1", - skipped_intervals=[(interval_start, interval_end)], + summary=summary, ) result = DagSkippedIntervalsCallbackRequest.from_json(request.to_json()) assert result == request - assert result.skipped_intervals == [(interval_start, interval_end)] + assert result.to_summary() == summary def test_callback_request_union_with_skipped_intervals(self): interval_start = timezone.datetime(2024, 1, 1) @@ -436,9 +441,8 @@ def test_callback_request_union_with_skipped_intervals(self): "dag_id": "test_dag", "bundle_name": "testing", "bundle_version": "v1", - "skipped_intervals": [ - [interval_start.isoformat(), interval_end.isoformat()], - ], + "skipped_interval_count": 1, + "skipped_range": [interval_start.isoformat(), interval_end.isoformat()], } adapter = TypeAdapter(CallbackRequest) @@ -446,7 +450,7 @@ def test_callback_request_union_with_skipped_intervals(self): assert isinstance(callback_request, DagSkippedIntervalsCallbackRequest) assert callback_request.dag_id == "test_dag" - assert len(callback_request.skipped_intervals) == 1 + assert callback_request.skipped_interval_count == 1 class TestEmailRequest: diff --git a/airflow-core/tests/unit/dag_processing/test_processor.py b/airflow-core/tests/unit/dag_processing/test_processor.py index e39c8e376cd40..7fe35ed436eff 100644 --- a/airflow-core/tests/unit/dag_processing/test_processor.py +++ b/airflow-core/tests/unit/dag_processing/test_processor.py @@ -93,6 +93,7 @@ from airflow.sdk.execution_time.task_runner import RuntimeTaskInstance from airflow.sdk.importers import DagSourceCode from airflow.serialization.serialized_objects import DagSerialization +from airflow.timetables.base import DataInterval, SkippedIntervalsSummary from airflow.utils.session import create_session from airflow.utils.state import TaskInstanceState @@ -101,6 +102,32 @@ if TYPE_CHECKING: from kgb import SpyAgency + +def _skipped_intervals_callback_request( + *, + filepath: str = "test.py", + dag_id: str = "test_dag", + bundle_name: str = "testing", + bundle_version: str | None = None, + interval_start=None, + interval_end=None, + count: int = 1, +) -> DagSkippedIntervalsCallbackRequest: + interval_start = interval_start or timezone.utcnow() + interval_end = interval_end or timezone.utcnow() + summary = SkippedIntervalsSummary( + skipped_interval_count=count, + skipped_range=DataInterval(start=interval_start, end=interval_end), + ) + return DagSkippedIntervalsCallbackRequest.from_summary( + filepath=filepath, + dag_id=dag_id, + bundle_name=bundle_name, + bundle_version=bundle_version, + summary=summary, + ) + + pytestmark = pytest.mark.db_test @@ -984,13 +1011,7 @@ def make_request(dag_id): def test_execute_callbacks_routes_skipped_intervals_request(self): callbacks = [ - DagSkippedIntervalsCallbackRequest( - filepath="test.py", - dag_id="test_dag", - bundle_name="testing", - bundle_version="some_commit_hash", - skipped_intervals=[(timezone.utcnow(), timezone.utcnow())], - ) + _skipped_intervals_callback_request(bundle_version="some_commit_hash"), ] log = MagicMock(spec=FilteringBoundLogger) dagbag = MagicMock(spec=DagBag) @@ -1601,12 +1622,9 @@ def fake_collect_dags(self, *args, **kwargs): interval_start = timezone.datetime(2024, 1, 1) interval_end = timezone.datetime(2024, 1, 2) - request = DagSkippedIntervalsCallbackRequest( - filepath="test.py", - dag_id="test_dag", - bundle_name="testing", - bundle_version=None, - skipped_intervals=[(interval_start, interval_end)], + request = _skipped_intervals_callback_request( + interval_start=interval_start, + interval_end=interval_end, ) log = structlog.get_logger() @@ -1615,10 +1633,8 @@ def fake_collect_dags(self, *args, **kwargs): assert context_received is not None assert context_received["dag"] is dag assert context_received["reason"] == "skipped_intervals" - assert len(context_received["skipped_intervals"]) == 1 - skipped = context_received["skipped_intervals"][0] - assert skipped.start == interval_start - assert skipped.end == interval_end + assert context_received["skipped_interval_count"] == 1 + assert context_received["skipped_range"] == DataInterval(start=interval_start, end=interval_end) def test_execute_skipped_intervals_callback_multiple_callbacks(self, spy_agency): call_count = 0 @@ -1645,13 +1661,7 @@ def fake_collect_dags(self, *args, **kwargs): dagbag = DagBag() dagbag.collect_dags() - request = DagSkippedIntervalsCallbackRequest( - filepath="test.py", - dag_id="test_dag", - bundle_name="testing", - bundle_version=None, - skipped_intervals=[(timezone.utcnow(), timezone.utcnow())], - ) + request = _skipped_intervals_callback_request() _execute_dag_skipped_intervals_callback(dagbag, request, structlog.get_logger()) assert call_count == 2 @@ -1668,13 +1678,7 @@ def fake_collect_dags(self, *args, **kwargs): dagbag = DagBag() dagbag.collect_dags() - request = DagSkippedIntervalsCallbackRequest( - filepath="test.py", - dag_id="test_dag", - bundle_name="testing", - bundle_version=None, - skipped_intervals=[(timezone.utcnow(), timezone.utcnow())], - ) + request = _skipped_intervals_callback_request() log = MagicMock(spec=FilteringBoundLogger) _execute_dag_skipped_intervals_callback(dagbag, request, log) @@ -1686,13 +1690,7 @@ def fake_collect_dags(self, *args, **kwargs): def test_execute_skipped_intervals_callback_missing_dag(self): dagbag = DagBag() - request = DagSkippedIntervalsCallbackRequest( - filepath="test.py", - dag_id="missing_dag", - bundle_name="testing", - bundle_version=None, - skipped_intervals=[(timezone.utcnow(), timezone.utcnow())], - ) + request = _skipped_intervals_callback_request(dag_id="missing_dag") with pytest.raises(ValueError, match="DAG 'missing_dag' not found in DagBag"): _execute_dag_skipped_intervals_callback(dagbag, request, structlog.get_logger()) diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py b/airflow-core/tests/unit/jobs/test_scheduler_job.py index 9d4bc8905ef66..a8c800fa6f714 100644 --- a/airflow-core/tests/unit/jobs/test_scheduler_job.py +++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py @@ -12078,7 +12078,7 @@ def test_collect_skipped_intervals_returns_empty_when_catchup_true(self, session start=DEFAULT_DATE + timedelta(days=4), end=DEFAULT_DATE + timedelta(days=5), ) - assert self.job_runner._collect_skipped_intervals(serdag, new_interval, session) == [] + assert self.job_runner._collect_skipped_intervals(serdag, new_interval, session) is None def test_collect_skipped_intervals_returns_empty_without_callback_or_listener(self, session, dag_maker): with dag_maker( @@ -12105,9 +12105,9 @@ def test_collect_skipped_intervals_returns_empty_without_callback_or_listener(se ) scheduler_job = Job() self.job_runner = SchedulerJobRunner(job=scheduler_job, executors=[self.null_exec]) - assert self.job_runner._collect_skipped_intervals(serdag, new_interval, session) == [] + assert self.job_runner._collect_skipped_intervals(serdag, new_interval, session) is None - def test_collect_skipped_intervals_returns_gap_intervals(self, session, dag_maker): + def test_collect_skipped_intervals_returns_gap_summary(self, session, dag_maker): with dag_maker( dag_id="test_collect_skipped_intervals", schedule=timedelta(days=1), @@ -12133,15 +12133,15 @@ def test_collect_skipped_intervals_returns_gap_intervals(self, session, dag_make scheduler_job = Job() self.job_runner = SchedulerJobRunner(job=scheduler_job, executors=[self.null_exec]) - skipped = self.job_runner._collect_skipped_intervals( + summary = self.job_runner._collect_skipped_intervals( serdag, DataInterval(start=new_start, end=new_end), session, ) - assert len(skipped) == 3 - assert skipped[0] == (prev_end, prev_end + timedelta(days=1)) - assert skipped[-1] == (new_start - timedelta(days=1), new_start) + assert summary is not None + assert summary.skipped_interval_count == 3 + assert summary.skipped_range == DataInterval(start=prev_end, end=new_start) def test_collect_skipped_intervals_returns_empty_without_previous_run(self, session, dag_maker): with dag_maker( @@ -12160,7 +12160,7 @@ def test_collect_skipped_intervals_returns_empty_without_previous_run(self, sess start=DEFAULT_DATE + timedelta(days=1), end=DEFAULT_DATE + timedelta(days=2), ) - assert self.job_runner._collect_skipped_intervals(serdag, new_interval, session) == [] + assert self.job_runner._collect_skipped_intervals(serdag, new_interval, session) is None def test_collect_skipped_intervals_returns_empty_when_no_gap(self, session, dag_maker): with dag_maker( @@ -12185,7 +12185,7 @@ def test_collect_skipped_intervals_returns_empty_when_no_gap(self, session, dag_ scheduler_job = Job() self.job_runner = SchedulerJobRunner(job=scheduler_job, executors=[self.null_exec]) new_interval = DataInterval(start=prev_end, end=prev_end + timedelta(days=1)) - assert self.job_runner._collect_skipped_intervals(serdag, new_interval, session) == [] + assert self.job_runner._collect_skipped_intervals(serdag, new_interval, session) is None def test_create_dag_runs_returns_skipped_intervals_callback_request(self, session, dag_maker): with dag_maker( @@ -12229,8 +12229,8 @@ def test_create_dag_runs_returns_skipped_intervals_callback_request(self, sessio assert request.dag_id == dag.dag_id assert request.filepath == dag_model.relative_fileloc assert request.bundle_name == dag_model.bundle_name - assert len(request.skipped_intervals) == 3 - assert request.skipped_intervals[0] == (prev_end, prev_end + timedelta(days=1)) + assert request.skipped_interval_count == 3 + assert request.skipped_range == (prev_end, new_start) def test_do_scheduling_dispatches_skipped_intervals_callback(self, session, dag_maker): with dag_maker( diff --git a/airflow-core/tests/unit/listeners/skipped_intervals_listener.py b/airflow-core/tests/unit/listeners/skipped_intervals_listener.py index 1d492bf1b9530..4b0f52069eb59 100644 --- a/airflow-core/tests/unit/listeners/skipped_intervals_listener.py +++ b/airflow-core/tests/unit/listeners/skipped_intervals_listener.py @@ -22,14 +22,14 @@ from airflow.listeners import hookimpl if TYPE_CHECKING: - from airflow.timetables.base import DataInterval + from airflow.timetables.base import SkippedIntervalsSummary -events: list[tuple[str, list[DataInterval]]] = [] +events: list[tuple[str, SkippedIntervalsSummary]] = [] @hookimpl -def on_intervals_skipped(dag_id: str, skipped_intervals: list[DataInterval]): - events.append((dag_id, list(skipped_intervals))) +def on_intervals_skipped(dag_id: str, summary: SkippedIntervalsSummary): + events.append((dag_id, summary)) def clear(): diff --git a/airflow-core/tests/unit/listeners/test_skipped_intervals_listener.py b/airflow-core/tests/unit/listeners/test_skipped_intervals_listener.py index eb619bcf84b4f..07b16d47b0f3f 100644 --- a/airflow-core/tests/unit/listeners/test_skipped_intervals_listener.py +++ b/airflow-core/tests/unit/listeners/test_skipped_intervals_listener.py @@ -91,8 +91,8 @@ def test_listener_notified_when_intervals_skipped(session, dag_maker, listener_m session.commit() assert len(skipped_intervals_listener.events) == 1 - dag_id, skipped = skipped_intervals_listener.events[0] + dag_id, summary = skipped_intervals_listener.events[0] assert dag_id == dag_maker.dag.dag_id - assert len(skipped) == 3 - assert skipped[0].start == prev_end - assert skipped[-1].end == new_start + assert summary.skipped_interval_count == 3 + assert summary.skipped_range.start == prev_end + assert summary.skipped_range.end == new_start diff --git a/airflow-core/tests/unit/timetables/test_skipped_intervals_summary.py b/airflow-core/tests/unit/timetables/test_skipped_intervals_summary.py new file mode 100644 index 0000000000000..a8015d9e083a9 --- /dev/null +++ b/airflow-core/tests/unit/timetables/test_skipped_intervals_summary.py @@ -0,0 +1,131 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from datetime import timedelta + +import pendulum +import pytest + +from airflow.providers.standard.operators.empty import EmptyOperator +from airflow.sdk import DAG +from airflow.timetables.base import DataInterval +from airflow.timetables.interval import CronDataIntervalTimetable, DeltaDataIntervalTimetable + +from tests_common.test_utils.dag import create_scheduler_dag + +DEFAULT_DATE = pendulum.datetime(2016, 1, 1, tz="UTC") + + +def _reference_count(timetable, prev_end, new_start): + from airflow.timetables.base import TimeRestriction + + count = 0 + info = None + restriction = TimeRestriction(prev_end, new_start, catchup=True) + while True: + info = timetable.next_dagrun_info_v2(last_dagrun_info=info, restriction=restriction) + if not info or not info.data_interval: + break + if info.data_interval.start >= new_start: + break + count += 1 + return count + + +@pytest.mark.parametrize( + ("schedule", "prev_end", "new_start", "expected_count"), + [ + pytest.param( + timedelta(days=1), + DEFAULT_DATE + timedelta(days=1), + DEFAULT_DATE + timedelta(days=4), + 3, + id="delta-daily", + ), + pytest.param( + timedelta(hours=1), + DEFAULT_DATE + timedelta(hours=1), + DEFAULT_DATE + timedelta(hours=4), + 3, + id="delta-hourly", + ), + pytest.param( + "@daily", + DEFAULT_DATE + timedelta(days=1), + DEFAULT_DATE + timedelta(days=4), + 3, + id="cron-daily", + ), + pytest.param( + "@hourly", + DEFAULT_DATE + timedelta(hours=1), + DEFAULT_DATE + timedelta(hours=4), + 3, + id="cron-hourly", + ), + pytest.param( + "0 0 1 * *", + pendulum.datetime(2016, 2, 1, tz="UTC"), + pendulum.datetime(2016, 4, 1, tz="UTC"), + 2, + id="cron-monthly", + ), + ], +) +def test_count_skipped_intervals_between_matches_reference(schedule, prev_end, new_start, expected_count): + if isinstance(schedule, str): + timetable = CronDataIntervalTimetable(schedule, "UTC") + else: + timetable = DeltaDataIntervalTimetable(schedule) + + assert timetable.count_skipped_intervals_between(prev_end, new_start) == expected_count + assert _reference_count(timetable, prev_end, new_start) == expected_count + + +def test_summarize_skipped_intervals_between_returns_gap_summary(): + dag = DAG( + dag_id="test_summarize_skipped_intervals", + schedule=timedelta(days=1), + start_date=DEFAULT_DATE, + catchup=False, + ) + EmptyOperator(task_id="dummy", dag=dag) + scheduler_dag = create_scheduler_dag(dag) + + prev_end = DEFAULT_DATE + timedelta(days=1) + new_start = DEFAULT_DATE + timedelta(days=4) + + summary = scheduler_dag.summarize_skipped_intervals_between(prev_end, new_start) + + assert summary is not None + assert summary.skipped_interval_count == 3 + assert summary.skipped_range == DataInterval(start=prev_end, end=new_start) + + +def test_summarize_skipped_intervals_between_returns_none_without_gap(): + dag = DAG( + dag_id="test_summarize_skipped_intervals_no_gap", + schedule=timedelta(days=1), + start_date=DEFAULT_DATE, + catchup=False, + ) + EmptyOperator(task_id="dummy", dag=dag) + scheduler_dag = create_scheduler_dag(dag) + + boundary = DEFAULT_DATE + timedelta(days=1) + assert scheduler_dag.summarize_skipped_intervals_between(boundary, boundary) is None diff --git a/task-sdk/src/airflow/sdk/definitions/dag.py b/task-sdk/src/airflow/sdk/definitions/dag.py index 17505fab4f80e..abc4f5fa8ccc8 100644 --- a/task-sdk/src/airflow/sdk/definitions/dag.py +++ b/task-sdk/src/airflow/sdk/definitions/dag.py @@ -406,9 +406,8 @@ class DAG: scheduler when a Dag with ``catchup=False`` advances past one or more scheduled data intervals without creating Dag runs for them (for example after a scheduler restart or when a paused Dag is re-enabled). The callback receives a context - dictionary with ``dag``, ``reason`` (``"skipped_intervals"``), and - ``skipped_intervals`` (a list of :class:`~airflow.timetables.base.DataInterval` - objects). It runs in the dag processor, not in the scheduler. + dictionary with ``dag``, ``reason`` (``"skipped_intervals"``), + ``skipped_interval_count``, ``skipped_range``. It runs in the dag processor, not in the scheduler. :param access_control: Specify optional DAG-level actions, e.g., "{'role1': {'can_read'}, 'role2': {'can_read', 'can_edit', 'can_delete'}}" or it can specify the resource name if there is a DAGs Run resource, e.g., diff --git a/task-sdk/src/airflow/sdk/execution_time/schema/schema.json b/task-sdk/src/airflow/sdk/execution_time/schema/schema.json index 9552fdad991aa..e32e49db3b5af 100644 --- a/task-sdk/src/airflow/sdk/execution_time/schema/schema.json +++ b/task-sdk/src/airflow/sdk/execution_time/schema/schema.json @@ -1562,23 +1562,24 @@ "title": "Dag Id", "type": "string" }, - "skipped_intervals": { - "items": { - "maxItems": 2, - "minItems": 2, - "prefixItems": [ - { - "format": "date-time", - "type": "string" - }, - { - "format": "date-time", - "type": "string" - } - ], - "type": "array" - }, - "title": "Skipped Intervals", + "skipped_interval_count": { + "title": "Skipped Interval Count", + "type": "integer" + }, + "skipped_range": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "format": "date-time", + "type": "string" + }, + { + "format": "date-time", + "type": "string" + } + ], + "title": "Skipped Range", "type": "array" }, "type": { @@ -1593,7 +1594,8 @@ "bundle_name", "bundle_version", "dag_id", - "skipped_intervals" + "skipped_interval_count", + "skipped_range" ], "title": "DagSkippedIntervalsCallbackRequest", "type": "object" diff --git a/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py b/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py index f30c846f16bb8..fd5627f0a41f2 100644 --- a/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py +++ b/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py @@ -121,5 +121,6 @@ class AddDagSkippedIntervalsCallbackRequest(VersionChange): instructions_to_migrate_to_previous_version = ( schema(DagSkippedIntervalsCallbackRequest).field("dag_id").didnt_exist, - schema(DagSkippedIntervalsCallbackRequest).field("skipped_intervals").didnt_exist, + schema(DagSkippedIntervalsCallbackRequest).field("skipped_interval_count").didnt_exist, + schema(DagSkippedIntervalsCallbackRequest).field("skipped_range").didnt_exist, ) From 9203fa65c1d3b225325ae405800bd080c0898a7d Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:19:16 -0500 Subject: [PATCH 07/29] Type on_skipped_intervals_callback with SkippedIntervalsCallbackContext, update schema versioning The skipped-intervals callback receives a dedicated payload, not task template Context; a proper TypedDict removes the mypy ignore and matches runtime behavior. Updated the api version in the schema from 2026-06-16 -> 2026-06-23. --- .../src/airflow/dag_processing/processor.py | 4 ++-- task-sdk/src/airflow/sdk/__init__.py | 9 ++++++++- task-sdk/src/airflow/sdk/definitions/context.py | 12 +++++++++++- task-sdk/src/airflow/sdk/definitions/dag.py | 15 +++++++++------ 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/airflow-core/src/airflow/dag_processing/processor.py b/airflow-core/src/airflow/dag_processing/processor.py index 99fa187e6432f..bb0994137b5fe 100644 --- a/airflow-core/src/airflow/dag_processing/processor.py +++ b/airflow-core/src/airflow/dag_processing/processor.py @@ -90,7 +90,7 @@ 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.sdk.execution_time.supervisor import RequestHandler, RequestResult, ResponseSent @@ -447,7 +447,7 @@ def _execute_dag_skipped_intervals_callback( callbacks = callbacks if isinstance(callbacks, list) else [callbacks] summary = request.to_summary() - context: Context = { # type: ignore[typeddict-unknown-key] + context: SkippedIntervalsCallbackContext = { "dag": dag, "reason": "skipped_intervals", "skipped_interval_count": summary.skipped_interval_count, diff --git a/task-sdk/src/airflow/sdk/__init__.py b/task-sdk/src/airflow/sdk/__init__.py index c163df525777c..605c973abac47 100644 --- a/task-sdk/src/airflow/sdk/__init__.py +++ b/task-sdk/src/airflow/sdk/__init__.py @@ -85,6 +85,7 @@ "RollupMapper", "SegmentWindow", "SkipMixin", + "SkippedIntervalsCallbackContext", "SyncCallback", "StartOfDayMapper", "StartOfHourMapper", @@ -154,7 +155,12 @@ from airflow.sdk.definitions.asset.metadata import Metadata from airflow.sdk.definitions.callback import AsyncCallback, SyncCallback from airflow.sdk.definitions.connection import Connection - from airflow.sdk.definitions.context import Context, get_current_context, get_parsing_context + from airflow.sdk.definitions.context import ( + Context, + SkippedIntervalsCallbackContext, + get_current_context, + get_parsing_context, + ) from airflow.sdk.definitions.dag import DAG, dag from airflow.sdk.definitions.deadline import ( BaseDeadlineReference, @@ -301,6 +307,7 @@ "SecretCache": ".execution_time.cache", "SegmentWindow": ".definitions.partition_mappers.window", "SkipMixin": ".bases.skipmixin", + "SkippedIntervalsCallbackContext": ".definitions.context", "SyncCallback": ".definitions.callback", "StartOfDayMapper": ".definitions.partition_mappers.temporal", "StartOfHourMapper": ".definitions.partition_mappers.temporal", diff --git a/task-sdk/src/airflow/sdk/definitions/context.py b/task-sdk/src/airflow/sdk/definitions/context.py index 6f85470cc6819..0336e5f21b5bb 100644 --- a/task-sdk/src/airflow/sdk/definitions/context.py +++ b/task-sdk/src/airflow/sdk/definitions/context.py @@ -20,7 +20,7 @@ import copy import os from collections.abc import MutableMapping -from typing import TYPE_CHECKING, Any, NamedTuple, NotRequired, TypedDict, cast +from typing import TYPE_CHECKING, Any, Literal, NamedTuple, NotRequired, TypedDict, cast if TYPE_CHECKING: import jinja2 @@ -40,6 +40,7 @@ OutletEventAccessorsProtocol, RuntimeTaskInstanceProtocol, ) + from airflow.timetables.base import DataInterval # noqa: SDK002 class Context(TypedDict, total=False): @@ -92,6 +93,15 @@ class Context(TypedDict, total=False): var: Any +class SkippedIntervalsCallbackContext(TypedDict): + """Context passed to ``on_skipped_intervals_callback`` handlers.""" + + dag: DAG + reason: Literal["skipped_intervals"] + skipped_interval_count: int + skipped_range: DataInterval + + KNOWN_CONTEXT_KEYS: set[str] = set(Context.__annotations__.keys()) - {"loop"} diff --git a/task-sdk/src/airflow/sdk/definitions/dag.py b/task-sdk/src/airflow/sdk/definitions/dag.py index abc4f5fa8ccc8..b1cb4e2d51f23 100644 --- a/task-sdk/src/airflow/sdk/definitions/dag.py +++ b/task-sdk/src/airflow/sdk/definitions/dag.py @@ -47,7 +47,7 @@ from airflow.sdk.definitions._internal.node import DAGNode, validate_key from airflow.sdk.definitions._internal.types import NOTSET, ArgNotSet, is_arg_set from airflow.sdk.definitions.asset import AssetAll, BaseAsset -from airflow.sdk.definitions.context import Context +from airflow.sdk.definitions.context import Context, SkippedIntervalsCallbackContext from airflow.sdk.definitions.deadline import DeadlineAlert from airflow.sdk.definitions.param import DagParam, ParamsDict from airflow.sdk.definitions.timetables.assets import AssetTriggeredTimetable @@ -109,6 +109,7 @@ ) DagStateChangeCallback = Callable[[Context], None] +SkippedIntervalsCallback = Callable[[SkippedIntervalsCallbackContext], None] ScheduleInterval = None | str | timedelta | relativedelta ScheduleArg = Union[ScheduleInterval, BaseTimetable, "CoreTimetable", BaseAsset, Collection[BaseAsset]] @@ -405,9 +406,9 @@ class DAG: :param on_skipped_intervals_callback: A function or list of functions invoked by the scheduler when a Dag with ``catchup=False`` advances past one or more scheduled data intervals without creating Dag runs for them (for example after a scheduler - restart or when a paused Dag is re-enabled). The callback receives a context - dictionary with ``dag``, ``reason`` (``"skipped_intervals"``), - ``skipped_interval_count``, ``skipped_range``. It runs in the dag processor, not in the scheduler. + restart or when a paused Dag is re-enabled). The callback receives a + :class:`~airflow.sdk.definitions.context.SkippedIntervalsCallbackContext`. + It runs in the dag processor, not in the scheduler. :param access_control: Specify optional DAG-level actions, e.g., "{'role1': {'can_read'}, 'role2': {'can_read', 'can_edit', 'can_delete'}}" or it can specify the resource name if there is a DAGs Run resource, e.g., @@ -540,7 +541,7 @@ def __rich_repr__(self): ) on_success_callback: None | DagStateChangeCallback | list[DagStateChangeCallback] = None on_failure_callback: None | DagStateChangeCallback | list[DagStateChangeCallback] = None - on_skipped_intervals_callback: None | DagStateChangeCallback | list[DagStateChangeCallback] = None + on_skipped_intervals_callback: None | SkippedIntervalsCallback | list[SkippedIntervalsCallback] = None doc_md: str | None = attrs.field(default=None, converter=_convert_doc_md) params: ParamsDict = attrs.field( # mypy doesn't really like passing the Converter object @@ -1681,7 +1682,9 @@ def dag( catchup: bool = ..., on_success_callback: None | DagStateChangeCallback | list[DagStateChangeCallback] = None, on_failure_callback: None | DagStateChangeCallback | list[DagStateChangeCallback] = None, - on_skipped_intervals_callback: None | DagStateChangeCallback | list[DagStateChangeCallback] = None, + on_skipped_intervals_callback: None + | SkippedIntervalsCallback + | list[SkippedIntervalsCallback] = None, deadline: list[DeadlineAlert] | DeadlineAlert | None = None, doc_md: str | None = None, params: ParamsDict | dict[str, Any] | None = None, From d9a18c9a59123808269ebcc70b7521f878470f2a Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:28:07 -0500 Subject: [PATCH 08/29] Update scheduler_job_runner.py comment with correct Dag capitalization Co-authored-by: D. Ferruzzi --- airflow-core/src/airflow/jobs/scheduler_job_runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py b/airflow-core/src/airflow/jobs/scheduler_job_runner.py index 65df15fc7a574..2999a63030c82 100644 --- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py +++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py @@ -2756,7 +2756,7 @@ def _create_dag_runs( self, dag_models: Collection[DagModel], session: Session ) -> list[DagSkippedIntervalsCallbackRequest]: """ - Create a DAG run and update the dag_model to control if/when the next DAGRun should be created. + Create a Dag run and update the dag_model to control if/when the next DagRun should be created. Returns a list of skipped-intervals callback requests to dispatch after commit. """ From 8963f75c0a1a906a33952af72464343f0a741a4d Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:36:08 -0500 Subject: [PATCH 09/29] Update DagSkippedIntervalsCallbackRequest docstring, replace .in_() with == --- airflow-core/src/airflow/callbacks/callback_requests.py | 2 +- airflow-core/src/airflow/jobs/scheduler_job_runner.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/airflow-core/src/airflow/callbacks/callback_requests.py b/airflow-core/src/airflow/callbacks/callback_requests.py index 8a1ad0f9ff6d2..3d450242e88c4 100644 --- a/airflow-core/src/airflow/callbacks/callback_requests.py +++ b/airflow-core/src/airflow/callbacks/callback_requests.py @@ -178,7 +178,7 @@ class DagCallbackRequest(BaseCallbackRequest): class DagSkippedIntervalsCallbackRequest(BaseCallbackRequest): - """A Class with information about the skipped intervals DAG callback to be executed.""" + """Store skipped intervals callback data for execution by the Dag processor.""" dag_id: str skipped_interval_count: int diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py b/airflow-core/src/airflow/jobs/scheduler_job_runner.py index 2999a63030c82..57d092f018ba0 100644 --- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py +++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py @@ -2737,7 +2737,7 @@ def _collect_skipped_intervals( select(DagRun) .where( DagRun.dag_id == serdag.dag_id, - DagRun.run_type.in_([DagRunType.SCHEDULED]), + DagRun.run_type == DagRunType.SCHEDULED, DagRun.data_interval_end.is_not(None), DagRun.data_interval_end <= new_data_interval.start, ) From 424a54198c75c74908c0672b2f112c5bf0f3f926 Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:47:08 -0500 Subject: [PATCH 10/29] Fix linting errors after rebase and regenerate schema --- task-sdk/src/airflow/sdk/execution_time/schema/schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/task-sdk/src/airflow/sdk/execution_time/schema/schema.json b/task-sdk/src/airflow/sdk/execution_time/schema/schema.json index e32e49db3b5af..e91901680ed84 100644 --- a/task-sdk/src/airflow/sdk/execution_time/schema/schema.json +++ b/task-sdk/src/airflow/sdk/execution_time/schema/schema.json @@ -1512,7 +1512,7 @@ "type": "string" }, "DagSkippedIntervalsCallbackRequest": { - "description": "A Class with information about the skipped intervals DAG callback to be executed.", + "description": "Store skipped intervals callback data for execution by the Dag processor.", "properties": { "filepath": { "title": "Filepath", From 2782780edd2e912638f111c56de89db31c17b440 Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:33:47 -0500 Subject: [PATCH 11/29] Remove skipped_interval_count field from callback/listener payload Calculating the skipped_interval_count would either take O(n) time in the scheduler or would require mixin methods that would be unreliable for custom timetables. Instead, this field has been removed so that the skipped intervals callback can work reliably and efficiently. Users can access the specific skipped interval data by calling other methods in their callback/listener implementations. All source, tests, and docs have been updated to reflect this change. --- .../logging-monitoring/callbacks.rst | 2 - .../airflow/callbacks/callback_requests.py | 4 -- .../src/airflow/dag_processing/processor.py | 1 - .../example_dags/plugins/event_listener.py | 10 +-- .../airflow/serialization/definitions/dag.py | 23 ++----- airflow-core/src/airflow/timetables/_cron.py | 31 --------- airflow-core/src/airflow/timetables/_delta.py | 29 -------- airflow-core/src/airflow/timetables/base.py | 7 +- .../unit/callbacks/test_callback_requests.py | 4 +- .../unit/dag_processing/test_processor.py | 3 - .../tests/unit/jobs/test_scheduler_job.py | 2 - .../test_skipped_intervals_listener.py | 1 - .../test_skipped_intervals_summary.py | 69 ------------------- .../src/airflow/sdk/definitions/context.py | 1 - .../sdk/execution_time/schema/schema.json | 5 -- .../schema/versions/v2026_10_30.py | 1 - 16 files changed, 15 insertions(+), 178 deletions(-) diff --git a/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst b/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst index 5a5c67f57a436..2ace0a8f31706 100644 --- a/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst +++ b/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst @@ -114,7 +114,6 @@ The context mapping contains: * ``dag`` -- the Dag object * ``reason`` -- always ``"skipped_intervals"`` -* ``skipped_interval_count`` -- how many scheduled data intervals were skipped * ``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`` @@ -131,7 +130,6 @@ Example: def log_skipped_intervals(context): - print(f"Skipped {context['skipped_interval_count']} interval(s)") 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): diff --git a/airflow-core/src/airflow/callbacks/callback_requests.py b/airflow-core/src/airflow/callbacks/callback_requests.py index 3d450242e88c4..67a4a98faa875 100644 --- a/airflow-core/src/airflow/callbacks/callback_requests.py +++ b/airflow-core/src/airflow/callbacks/callback_requests.py @@ -181,8 +181,6 @@ class DagSkippedIntervalsCallbackRequest(BaseCallbackRequest): """Store skipped intervals callback data for execution by the Dag processor.""" dag_id: str - skipped_interval_count: int - """Number of scheduled data intervals skipped due to catchup=False.""" 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" @@ -202,13 +200,11 @@ def from_summary( bundle_name=bundle_name, bundle_version=bundle_version, dag_id=dag_id, - skipped_interval_count=summary.skipped_interval_count, skipped_range=(summary.skipped_range.start, summary.skipped_range.end), ) def to_summary(self) -> SkippedIntervalsSummary: return SkippedIntervalsSummary( - skipped_interval_count=self.skipped_interval_count, skipped_range=DataInterval( start=timezone.coerce_datetime(self.skipped_range[0]), end=timezone.coerce_datetime(self.skipped_range[1]), diff --git a/airflow-core/src/airflow/dag_processing/processor.py b/airflow-core/src/airflow/dag_processing/processor.py index bb0994137b5fe..7514ad8f38f9b 100644 --- a/airflow-core/src/airflow/dag_processing/processor.py +++ b/airflow-core/src/airflow/dag_processing/processor.py @@ -450,7 +450,6 @@ def _execute_dag_skipped_intervals_callback( context: SkippedIntervalsCallbackContext = { "dag": dag, "reason": "skipped_intervals", - "skipped_interval_count": summary.skipped_interval_count, "skipped_range": summary.skipped_range, } diff --git a/airflow-core/src/airflow/example_dags/plugins/event_listener.py b/airflow-core/src/airflow/example_dags/plugins/event_listener.py index c2c87e3c818a0..2a90d57534125 100644 --- a/airflow-core/src/airflow/example_dags/plugins/event_listener.py +++ b/airflow-core/src/airflow/example_dags/plugins/event_listener.py @@ -231,12 +231,14 @@ 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 - ``count`` and ``skipped_range``. Use + ``skipped_range``. Call :meth:`~airflow.serialization.definitions.dag.SerializedDAG.iter_dagrun_infos_between` - if the full list of skipped intervals is required. + with ``skipped_range.start`` and ``skipped_range.end`` if the full list of skipped + intervals is required. """ - print(f"Dag {dag_id} skipped {summary.skipped_interval_count} interval(s)") - print(f" Range: {summary.skipped_range.start} -> {summary.skipped_range.end}") + print( + f"Dag {dag_id} skipped intervals in range: {summary.skipped_range.start} -> {summary.skipped_range.end}" + ) # [END howto_listen_intervals_skipped] diff --git a/airflow-core/src/airflow/serialization/definitions/dag.py b/airflow-core/src/airflow/serialization/definitions/dag.py index 710f81dc87da1..df329c7246986 100644 --- a/airflow-core/src/airflow/serialization/definitions/dag.py +++ b/airflow-core/src/airflow/serialization/definitions/dag.py @@ -550,31 +550,18 @@ def summarize_skipped_intervals_between( """ Summarize intervals skipped between two automated Dag run boundaries. - Returns ``None`` when there is no schedulable gap or the timetable cannot - count skipped intervals in constant time. + Returns ``None`` when there is no schedulable gap. """ if prev_interval_end >= new_interval_start: return None from airflow._shared.timezones import timezone - prev_end = timezone.coerce_datetime(prev_interval_end) - new_start = timezone.coerce_datetime(new_interval_start) - count_fn = getattr(self.timetable, "count_skipped_intervals_between", None) - if count_fn is None: - return None - - try: - count = count_fn(prev_end, new_start) - except NotImplementedError: - return None - - if count <= 0: - return None - return SkippedIntervalsSummary( - skipped_interval_count=count, - skipped_range=DataInterval(start=prev_end, end=new_start), + skipped_range=DataInterval( + start=timezone.coerce_datetime(prev_interval_end), + end=timezone.coerce_datetime(new_interval_start), + ), ) @provide_session diff --git a/airflow-core/src/airflow/timetables/_cron.py b/airflow-core/src/airflow/timetables/_cron.py index eb020a38577a9..245cc3fbc3953 100644 --- a/airflow-core/src/airflow/timetables/_cron.py +++ b/airflow-core/src/airflow/timetables/_cron.py @@ -207,34 +207,3 @@ def _align_to_prev(self, current: DateTime) -> DateTime: if self._get_next(prev_time) != current: return prev_time return current - - def count_skipped_intervals_between( - self, - prev_interval_end: DateTime, - new_interval_start: DateTime, - ) -> int: - if new_interval_start <= prev_interval_end: - return 0 - - first_period_end = self._get_next(prev_interval_end) - first_period_seconds = (first_period_end - prev_interval_end).total_seconds() - second_period_seconds = (self._get_next(first_period_end) - first_period_end).total_seconds() - gap_seconds = (new_interval_start - prev_interval_end).total_seconds() - - if first_period_seconds > 0 and first_period_seconds == second_period_seconds: - return int(gap_seconds // first_period_seconds) - - expression = cron_presets.get(self._expression, self._expression) - fields = expression.split() - if len(fields) >= 5: - dom, month, dow = fields[2], fields[3], fields[4] - prev_local = prev_interval_end.in_timezone(self._timezone) - new_local = new_interval_start.in_timezone(self._timezone) - if dom != "*" and month == "*": - return (new_local.year - prev_local.year) * 12 + (new_local.month - prev_local.month) - if dow != "*" and dom == "*": - return (new_local.date() - prev_local.date()).days // 7 - - raise AirflowTimetableInvalid( - f"Cannot count skipped intervals for cron expression {self._expression!r}" - ) diff --git a/airflow-core/src/airflow/timetables/_delta.py b/airflow-core/src/airflow/timetables/_delta.py index 5e2a3a98794e2..891f5da3e9ceb 100644 --- a/airflow-core/src/airflow/timetables/_delta.py +++ b/airflow-core/src/airflow/timetables/_delta.py @@ -69,32 +69,3 @@ def _align_to_next(self, current: DateTime) -> DateTime: def _align_to_prev(self, current: DateTime) -> DateTime: return current - - def count_skipped_intervals_between( - self, - prev_interval_end: DateTime, - new_interval_start: DateTime, - ) -> int: - if new_interval_start <= prev_interval_end: - return 0 - period_seconds = self._schedule_period_seconds() - if period_seconds <= 0: - return 0 - gap_seconds = (new_interval_start - prev_interval_end).total_seconds() - return int(gap_seconds // period_seconds) - - def _schedule_period_seconds(self) -> float: - if isinstance(self._delta, datetime.timedelta): - return self._delta.total_seconds() - return float(self._relativedelta_in_seconds(self._delta)) - - @staticmethod - def _relativedelta_in_seconds(delta: relativedelta) -> int: - return ( - delta.years * 365 * 24 * 60 * 60 - + delta.months * 30 * 24 * 60 * 60 - + delta.days * 24 * 60 * 60 - + delta.hours * 60 * 60 - + delta.minutes * 60 - + delta.seconds - ) diff --git a/airflow-core/src/airflow/timetables/base.py b/airflow-core/src/airflow/timetables/base.py index 7b326043e97ae..6fb42c24e37c4 100644 --- a/airflow-core/src/airflow/timetables/base.py +++ b/airflow-core/src/airflow/timetables/base.py @@ -80,12 +80,11 @@ class SkippedIntervalsSummary(NamedTuple): ``skipped_range`` spans from the previous automated Dag run's ``data_interval_end`` to the new run's ``data_interval_start``. - ``skipped_interval_count`` is derived from that range and the Dag schedule in O(1) time. - Use :meth:`~airflow.serialization.definitions.dag.SerializedDAG.iter_dagrun_infos_between` - in a callback if the full sequence is required. + Call :meth:`~airflow.serialization.definitions.dag.SerializedDAG.iter_dagrun_infos_between` + with ``skipped_range.start`` and ``skipped_range.end`` in a callback if the full sequence + is required. """ - skipped_interval_count: int skipped_range: DataInterval diff --git a/airflow-core/tests/unit/callbacks/test_callback_requests.py b/airflow-core/tests/unit/callbacks/test_callback_requests.py index 7aa37cf816a2e..1f9bc113ec9be 100644 --- a/airflow-core/tests/unit/callbacks/test_callback_requests.py +++ b/airflow-core/tests/unit/callbacks/test_callback_requests.py @@ -416,7 +416,6 @@ def test_to_json_roundtrip(self): interval_start = timezone.datetime(2024, 1, 1) interval_end = timezone.datetime(2024, 1, 2) summary = SkippedIntervalsSummary( - skipped_interval_count=1, skipped_range=DataInterval(start=interval_start, end=interval_end), ) request = DagSkippedIntervalsCallbackRequest.from_summary( @@ -441,7 +440,6 @@ def test_callback_request_union_with_skipped_intervals(self): "dag_id": "test_dag", "bundle_name": "testing", "bundle_version": "v1", - "skipped_interval_count": 1, "skipped_range": [interval_start.isoformat(), interval_end.isoformat()], } @@ -450,7 +448,7 @@ def test_callback_request_union_with_skipped_intervals(self): assert isinstance(callback_request, DagSkippedIntervalsCallbackRequest) assert callback_request.dag_id == "test_dag" - assert callback_request.skipped_interval_count == 1 + assert callback_request.skipped_range == (interval_start, interval_end) class TestEmailRequest: diff --git a/airflow-core/tests/unit/dag_processing/test_processor.py b/airflow-core/tests/unit/dag_processing/test_processor.py index 7fe35ed436eff..e0b0197fd3899 100644 --- a/airflow-core/tests/unit/dag_processing/test_processor.py +++ b/airflow-core/tests/unit/dag_processing/test_processor.py @@ -111,12 +111,10 @@ def _skipped_intervals_callback_request( bundle_version: str | None = None, interval_start=None, interval_end=None, - count: int = 1, ) -> DagSkippedIntervalsCallbackRequest: interval_start = interval_start or timezone.utcnow() interval_end = interval_end or timezone.utcnow() summary = SkippedIntervalsSummary( - skipped_interval_count=count, skipped_range=DataInterval(start=interval_start, end=interval_end), ) return DagSkippedIntervalsCallbackRequest.from_summary( @@ -1633,7 +1631,6 @@ def fake_collect_dags(self, *args, **kwargs): assert context_received is not None assert context_received["dag"] is dag assert context_received["reason"] == "skipped_intervals" - assert context_received["skipped_interval_count"] == 1 assert context_received["skipped_range"] == DataInterval(start=interval_start, end=interval_end) def test_execute_skipped_intervals_callback_multiple_callbacks(self, spy_agency): diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py b/airflow-core/tests/unit/jobs/test_scheduler_job.py index a8c800fa6f714..6c4b28467b74f 100644 --- a/airflow-core/tests/unit/jobs/test_scheduler_job.py +++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py @@ -12140,7 +12140,6 @@ def test_collect_skipped_intervals_returns_gap_summary(self, session, dag_maker) ) assert summary is not None - assert summary.skipped_interval_count == 3 assert summary.skipped_range == DataInterval(start=prev_end, end=new_start) def test_collect_skipped_intervals_returns_empty_without_previous_run(self, session, dag_maker): @@ -12229,7 +12228,6 @@ def test_create_dag_runs_returns_skipped_intervals_callback_request(self, sessio assert request.dag_id == dag.dag_id assert request.filepath == dag_model.relative_fileloc assert request.bundle_name == dag_model.bundle_name - assert request.skipped_interval_count == 3 assert request.skipped_range == (prev_end, new_start) def test_do_scheduling_dispatches_skipped_intervals_callback(self, session, dag_maker): diff --git a/airflow-core/tests/unit/listeners/test_skipped_intervals_listener.py b/airflow-core/tests/unit/listeners/test_skipped_intervals_listener.py index 07b16d47b0f3f..42c75af09c07c 100644 --- a/airflow-core/tests/unit/listeners/test_skipped_intervals_listener.py +++ b/airflow-core/tests/unit/listeners/test_skipped_intervals_listener.py @@ -93,6 +93,5 @@ def test_listener_notified_when_intervals_skipped(session, dag_maker, listener_m assert len(skipped_intervals_listener.events) == 1 dag_id, summary = skipped_intervals_listener.events[0] assert dag_id == dag_maker.dag.dag_id - assert summary.skipped_interval_count == 3 assert summary.skipped_range.start == prev_end assert summary.skipped_range.end == new_start diff --git a/airflow-core/tests/unit/timetables/test_skipped_intervals_summary.py b/airflow-core/tests/unit/timetables/test_skipped_intervals_summary.py index a8015d9e083a9..eba0466e2a3a8 100644 --- a/airflow-core/tests/unit/timetables/test_skipped_intervals_summary.py +++ b/airflow-core/tests/unit/timetables/test_skipped_intervals_summary.py @@ -19,84 +19,16 @@ from datetime import timedelta import pendulum -import pytest from airflow.providers.standard.operators.empty import EmptyOperator from airflow.sdk import DAG from airflow.timetables.base import DataInterval -from airflow.timetables.interval import CronDataIntervalTimetable, DeltaDataIntervalTimetable from tests_common.test_utils.dag import create_scheduler_dag DEFAULT_DATE = pendulum.datetime(2016, 1, 1, tz="UTC") -def _reference_count(timetable, prev_end, new_start): - from airflow.timetables.base import TimeRestriction - - count = 0 - info = None - restriction = TimeRestriction(prev_end, new_start, catchup=True) - while True: - info = timetable.next_dagrun_info_v2(last_dagrun_info=info, restriction=restriction) - if not info or not info.data_interval: - break - if info.data_interval.start >= new_start: - break - count += 1 - return count - - -@pytest.mark.parametrize( - ("schedule", "prev_end", "new_start", "expected_count"), - [ - pytest.param( - timedelta(days=1), - DEFAULT_DATE + timedelta(days=1), - DEFAULT_DATE + timedelta(days=4), - 3, - id="delta-daily", - ), - pytest.param( - timedelta(hours=1), - DEFAULT_DATE + timedelta(hours=1), - DEFAULT_DATE + timedelta(hours=4), - 3, - id="delta-hourly", - ), - pytest.param( - "@daily", - DEFAULT_DATE + timedelta(days=1), - DEFAULT_DATE + timedelta(days=4), - 3, - id="cron-daily", - ), - pytest.param( - "@hourly", - DEFAULT_DATE + timedelta(hours=1), - DEFAULT_DATE + timedelta(hours=4), - 3, - id="cron-hourly", - ), - pytest.param( - "0 0 1 * *", - pendulum.datetime(2016, 2, 1, tz="UTC"), - pendulum.datetime(2016, 4, 1, tz="UTC"), - 2, - id="cron-monthly", - ), - ], -) -def test_count_skipped_intervals_between_matches_reference(schedule, prev_end, new_start, expected_count): - if isinstance(schedule, str): - timetable = CronDataIntervalTimetable(schedule, "UTC") - else: - timetable = DeltaDataIntervalTimetable(schedule) - - assert timetable.count_skipped_intervals_between(prev_end, new_start) == expected_count - assert _reference_count(timetable, prev_end, new_start) == expected_count - - def test_summarize_skipped_intervals_between_returns_gap_summary(): dag = DAG( dag_id="test_summarize_skipped_intervals", @@ -113,7 +45,6 @@ def test_summarize_skipped_intervals_between_returns_gap_summary(): summary = scheduler_dag.summarize_skipped_intervals_between(prev_end, new_start) assert summary is not None - assert summary.skipped_interval_count == 3 assert summary.skipped_range == DataInterval(start=prev_end, end=new_start) diff --git a/task-sdk/src/airflow/sdk/definitions/context.py b/task-sdk/src/airflow/sdk/definitions/context.py index 0336e5f21b5bb..8de306942819f 100644 --- a/task-sdk/src/airflow/sdk/definitions/context.py +++ b/task-sdk/src/airflow/sdk/definitions/context.py @@ -98,7 +98,6 @@ class SkippedIntervalsCallbackContext(TypedDict): dag: DAG reason: Literal["skipped_intervals"] - skipped_interval_count: int skipped_range: DataInterval diff --git a/task-sdk/src/airflow/sdk/execution_time/schema/schema.json b/task-sdk/src/airflow/sdk/execution_time/schema/schema.json index e91901680ed84..228624fa92bab 100644 --- a/task-sdk/src/airflow/sdk/execution_time/schema/schema.json +++ b/task-sdk/src/airflow/sdk/execution_time/schema/schema.json @@ -1562,10 +1562,6 @@ "title": "Dag Id", "type": "string" }, - "skipped_interval_count": { - "title": "Skipped Interval Count", - "type": "integer" - }, "skipped_range": { "maxItems": 2, "minItems": 2, @@ -1594,7 +1590,6 @@ "bundle_name", "bundle_version", "dag_id", - "skipped_interval_count", "skipped_range" ], "title": "DagSkippedIntervalsCallbackRequest", diff --git a/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py b/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py index fd5627f0a41f2..1c05d0adcf9ae 100644 --- a/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py +++ b/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py @@ -121,6 +121,5 @@ class AddDagSkippedIntervalsCallbackRequest(VersionChange): instructions_to_migrate_to_previous_version = ( schema(DagSkippedIntervalsCallbackRequest).field("dag_id").didnt_exist, - schema(DagSkippedIntervalsCallbackRequest).field("skipped_interval_count").didnt_exist, schema(DagSkippedIntervalsCallbackRequest).field("skipped_range").didnt_exist, ) From e1738a5c38c169786c454739cd78c4dc51d33c94 Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:14:47 -0500 Subject: [PATCH 12/29] Restore supervisor schema version 2026-06-16 for backward compatibility When the schema was bumped to 2026-06-23, version 2026-06-16 was removed from the bundle, breaking coordinator tests and any bundles compiled against that schema version. Restore the intermediate version to the bundle chain to support in-flight deployments. Also document SkippedIntervalsCallbackContext in the public API docs since it was added to __all__ but missing from the Sphinx inventory. --- task-sdk/docs/api.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/task-sdk/docs/api.rst b/task-sdk/docs/api.rst index 1de617447fded..f35632bbd9321 100644 --- a/task-sdk/docs/api.rst +++ b/task-sdk/docs/api.rst @@ -354,6 +354,10 @@ Execution Time Components .. autoapiclass:: airflow.sdk.Context The ``Context`` object represents the execution-time context available to tasks. + +.. autoapiclass:: airflow.sdk.SkippedIntervalsCallbackContext + +The ``SkippedIntervalsCallbackContext`` object is passed to ``on_skipped_intervals_callback`` handlers. It corresponds to the same context that is exposed to Jinja templates during task execution. For a complete list of available context variables (such as ``dag_run``, From 8af0353c916cbbcd8c818046071477e62d09df17 Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Sun, 21 Jun 2026 23:40:05 -0500 Subject: [PATCH 13/29] Document SkippedIntervalsCallbackContext for Sphinx inventory --- task-sdk/docs/api.rst | 8 ++++---- task-sdk/docs/index.rst | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/task-sdk/docs/api.rst b/task-sdk/docs/api.rst index f35632bbd9321..915ab84742468 100644 --- a/task-sdk/docs/api.rst +++ b/task-sdk/docs/api.rst @@ -354,16 +354,16 @@ Execution Time Components .. autoapiclass:: airflow.sdk.Context The ``Context`` object represents the execution-time context available to tasks. - -.. autoapiclass:: airflow.sdk.SkippedIntervalsCallbackContext - -The ``SkippedIntervalsCallbackContext`` object is passed to ``on_skipped_intervals_callback`` handlers. It corresponds to the same context that is exposed to Jinja templates during task execution. For a complete list of available context variables (such as ``dag_run``, ``task_instance``, ``logical_date``, etc.), see the :ref:`Templates reference `. +.. autoclass:: airflow.sdk.SkippedIntervalsCallbackContext + +The ``SkippedIntervalsCallbackContext`` object is passed to ``on_skipped_intervals_callback`` handlers. + .. rubric:: Task State .. autodata:: airflow.sdk.NEVER_EXPIRE diff --git a/task-sdk/docs/index.rst b/task-sdk/docs/index.rst index 9e15dae85e550..30da3a9c5118e 100644 --- a/task-sdk/docs/index.rst +++ b/task-sdk/docs/index.rst @@ -99,6 +99,7 @@ Why use ``airflow.sdk``? - :class:`airflow.sdk.RetryDecision` - :class:`airflow.sdk.RetryPolicy` - :class:`airflow.sdk.RetryRule` +- :class:`airflow.sdk.SkippedIntervalsCallbackContext` - :class:`airflow.sdk.TaskGroup` - :class:`airflow.sdk.TaskInstanceState` - :class:`airflow.sdk.DagRunState` From b70dca071aeddbd6f67a7f56af8528b9356090dc Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:12:27 -0500 Subject: [PATCH 14/29] Skipped intervals listener hook moved outside of retry_db_transaction Originally, this listener was called within _create_dag_runs(). This meant that the hook is called repeatedly if transaction retries occur. This change moves the listener call into _do_scheduling(), after the DB commit. --- .../src/airflow/jobs/scheduler_job_runner.py | 65 +++++++++++++------ .../tests/unit/jobs/test_scheduler_job.py | 54 ++++++++++++++- .../test_skipped_intervals_listener.py | 3 +- 3 files changed, 99 insertions(+), 23 deletions(-) diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py b/airflow-core/src/airflow/jobs/scheduler_job_runner.py index 57d092f018ba0..45616e9ba2cc1 100644 --- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py +++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py @@ -31,7 +31,7 @@ from datetime import datetime, timedelta from functools import lru_cache, partial from itertools import groupby -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, NamedTuple, cast from uuid import UUID from sqlalchemy import ( @@ -170,6 +170,14 @@ DR = DagRun DM = DagModel + +class CreateDagRunsNotifications(NamedTuple): + """Skipped-interval notifications collected during Dag run creation.""" + + skip_callback_requests: list[DagSkippedIntervalsCallbackRequest] + skipped_intervals_listener_events: list[tuple[str, SkippedIntervalsSummary]] + + TASK_STUCK_IN_QUEUED_RESCHEDULE_EVENT = "stuck in queued reschedule" """:meta private:""" @@ -2184,10 +2192,10 @@ def _do_scheduling(self, session: Session) -> int: :return: Number of TIs enqueued in this iteration """ # Put a check in place to make sure we don't commit unexpectedly - skip_callback_requests: list[DagSkippedIntervalsCallbackRequest] = [] + dag_runs_notifications = CreateDagRunsNotifications([], []) with prohibit_commit(session) as guard: if self._scheduler_use_job_schedule: - skip_callback_requests = self._create_dagruns_for_dags(guard, session) + dag_runs_notifications = self._create_dagruns_for_dags(guard, session) self._start_queued_dagruns(session) guard.commit() @@ -2220,8 +2228,9 @@ def _do_scheduling(self, session: Session) -> int: else: self.log.error("DAG '%s' not found in serialized_dag table", dag_run.dag_id) - # Dispatch skipped-intervals callbacks after commit so DatabaseCallbackSink can write to DB. - for skip_req in skip_callback_requests: + # Dispatch skipped-intervals listener hooks and Dag callbacks after commit. + self._notify_skipped_intervals_listeners(dag_runs_notifications.skipped_intervals_listener_events) + for skip_req in dag_runs_notifications.skip_callback_requests: self.executor.send_callback(skip_req) with prohibit_commit(session) as guard: @@ -2653,7 +2662,7 @@ def _create_dagruns_for_partitioned_asset_dags(self, session: Session) -> set[st @retry_db_transaction def _create_dagruns_for_dags( self, guard: CommitProhibitorGuard, session: Session - ) -> list[DagSkippedIntervalsCallbackRequest]: + ) -> CreateDagRunsNotifications: """Find Dag Models needing DagRuns and Create Dag Runs with retries in case of OperationalError.""" partition_dag_ids: set[str] = self._create_dagruns_for_partitioned_asset_dags(session) @@ -2667,7 +2676,7 @@ def _create_dagruns_for_dags( # filter asset partition triggered Dags if d.dag_id not in partition_dag_ids } - skip_callback_requests = self._create_dag_runs(non_asset_dags, session) + dag_runs_notifications = self._create_dag_runs(non_asset_dags, session) if asset_triggered_dags: self._create_dag_runs_asset_triggered( dag_models=[d for d in asset_triggered_dags if d.dag_id not in partition_dag_ids], @@ -2678,7 +2687,7 @@ def _create_dagruns_for_dags( guard.commit() # END: create dagruns - return skip_callback_requests + return dag_runs_notifications @provide_session def _mark_backfills_complete(self, *, session: Session = NEW_SESSION) -> None: @@ -2754,13 +2763,14 @@ def _collect_skipped_intervals( def _create_dag_runs( self, dag_models: Collection[DagModel], session: Session - ) -> list[DagSkippedIntervalsCallbackRequest]: + ) -> CreateDagRunsNotifications: """ Create a Dag run and update the dag_model to control if/when the next DagRun should be created. - Returns a list of skipped-intervals callback requests to dispatch after commit. + Returns skipped-interval notifications to dispatch after commit. """ skip_callback_requests: list[DagSkippedIntervalsCallbackRequest] = [] + skipped_intervals_listener_events: list[tuple[str, SkippedIntervalsSummary]] = [] # Bulk Fetch DagRuns with dag_id and logical_date same # as DagModel.dag_id and DagModel.next_dagrun # This list is used to verify if the DagRun already exist so that we don't attempt to create @@ -2906,16 +2916,10 @@ def _create_dag_runs( session=session, ) if skipped_summary is not None: - try: - get_listener_manager().hook.on_intervals_skipped( # type: ignore[attr-defined] - dag_id=serdag.dag_id, - summary=skipped_summary, - ) - except Exception: - self.log.exception( - "Error notifying listener of skipped intervals", - dag_id=serdag.dag_id, - ) + if bool( + get_listener_manager().hook.on_intervals_skipped.get_hookimpls() # type: ignore[attr-defined] + ): + skipped_intervals_listener_events.append((serdag.dag_id, skipped_summary)) if serdag.has_on_skipped_intervals_callback: skip_callback_requests.append( DagSkippedIntervalsCallbackRequest.from_summary( @@ -2941,7 +2945,26 @@ def _create_dag_runs( # TODO[HA]: Should we do a session.flush() so we don't have to keep lots of state/object in # memory for larger dags? or expunge_all() - return skip_callback_requests + return CreateDagRunsNotifications( + skip_callback_requests=skip_callback_requests, + skipped_intervals_listener_events=skipped_intervals_listener_events, + ) + + def _notify_skipped_intervals_listeners( + self, + events: list[tuple[str, SkippedIntervalsSummary]], + ) -> None: + for dag_id, summary in events: + try: + get_listener_manager().hook.on_intervals_skipped( # type: ignore[attr-defined] + dag_id=dag_id, + summary=summary, + ) + except Exception: + self.log.exception( + "Error notifying listener of skipped intervals", + dag_id=dag_id, + ) def _collect_gated_asset_events( self, *, dag: SerializedDAG, session: Session diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py b/airflow-core/tests/unit/jobs/test_scheduler_job.py index 6c4b28467b74f..66f9e9421b0ec 100644 --- a/airflow-core/tests/unit/jobs/test_scheduler_job.py +++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py @@ -12219,9 +12219,11 @@ def test_create_dag_runs_returns_skipped_intervals_callback_request(self, sessio mock_exec = MockExecutor(do_update=False) self.job_runner = SchedulerJobRunner(job=scheduler_job, executors=[mock_exec]) - skip_requests = self.job_runner._create_dag_runs([dag_model], session) + notifications = self.job_runner._create_dag_runs([dag_model], session) session.commit() + skip_requests = notifications.skip_callback_requests + assert len(skip_requests) == 1 request = skip_requests[0] assert isinstance(request, DagSkippedIntervalsCallbackRequest) @@ -12230,6 +12232,56 @@ def test_create_dag_runs_returns_skipped_intervals_callback_request(self, sessio assert request.bundle_name == dag_model.bundle_name assert request.skipped_range == (prev_end, new_start) + def test_create_dag_runs_defers_skipped_intervals_listener_notification( + self, session, dag_maker, listener_manager + ): + from unit.listeners import skipped_intervals_listener + + listener_manager(skipped_intervals_listener) + + with dag_maker( + dag_id="test_create_dag_runs_deferred_listener", + schedule=timedelta(days=1), + start_date=DEFAULT_DATE, + catchup=False, + session=session, + ): + EmptyOperator(task_id="dummy") + + prev_start = DEFAULT_DATE + prev_end = DEFAULT_DATE + timedelta(days=1) + dag_maker.create_dagrun( + run_type=DagRunType.SCHEDULED, + logical_date=prev_start, + data_interval=(prev_start, prev_end), + state=State.SUCCESS, + ) + + new_start = DEFAULT_DATE + timedelta(days=4) + new_end = DEFAULT_DATE + timedelta(days=5) + dag_model = dag_maker.dag_model + dag_model.next_dagrun = new_start + dag_model.next_dagrun_data_interval = DataInterval(start=new_start, end=new_end) + dag_model.next_dagrun_create_after = new_end + session.merge(dag_model) + session.commit() + + scheduler_job = Job() + self.job_runner = SchedulerJobRunner(job=scheduler_job, executors=[MockExecutor(do_update=False)]) + + with mock.patch.object( + self.job_runner, + "_notify_skipped_intervals_listeners", + wraps=self.job_runner._notify_skipped_intervals_listeners, + ) as mock_notify: + notifications = self.job_runner._create_dag_runs([dag_model], session) + session.commit() + mock_notify.assert_not_called() + + assert len(notifications.skipped_intervals_listener_events) == 1 + self.job_runner._notify_skipped_intervals_listeners(notifications.skipped_intervals_listener_events) + assert len(skipped_intervals_listener.events) == 1 + def test_do_scheduling_dispatches_skipped_intervals_callback(self, session, dag_maker): with dag_maker( dag_id="test_do_scheduling_skipped_intervals_callback", diff --git a/airflow-core/tests/unit/listeners/test_skipped_intervals_listener.py b/airflow-core/tests/unit/listeners/test_skipped_intervals_listener.py index 42c75af09c07c..885070d12f27d 100644 --- a/airflow-core/tests/unit/listeners/test_skipped_intervals_listener.py +++ b/airflow-core/tests/unit/listeners/test_skipped_intervals_listener.py @@ -87,8 +87,9 @@ def test_listener_notified_when_intervals_skipped(session, dag_maker, listener_m dag_model, prev_end, new_start = _configure_gap_dag_model(dag_maker, session) job_runner = SchedulerJobRunner(job=Job(), executors=[MockExecutor(do_update=False)]) - job_runner._create_dag_runs([dag_model], session) + notifications = job_runner._create_dag_runs([dag_model], session) session.commit() + job_runner._notify_skipped_intervals_listeners(notifications.skipped_intervals_listener_events) assert len(skipped_intervals_listener.events) == 1 dag_id, summary = skipped_intervals_listener.events[0] From f11c269670c9890a32bf39b2699f1509f9b9669b Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:56:25 -0500 Subject: [PATCH 15/29] Fix: version migration iterates through callback dict to remove new skipped interval fields Replaced field-level schema(...).didnt_exist instructions with a convert_response_to_previous_version_for(DagFileParseRequest) hook that filters callback_requests, dropping entries with type == "DagSkippedIntervalsCallbackRequest" when downgrading for older lang-SDK bundles. The original instructions did not accurately revert the schema version. --- .../schema/versions/v2026_10_30.py | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py b/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py index 1c05d0adcf9ae..910faec1bde49 100644 --- a/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py +++ b/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py @@ -26,10 +26,11 @@ from __future__ import annotations -from cadwyn import VersionChange, schema +import logging -from airflow.callbacks.callback_requests import DagSkippedIntervalsCallbackRequest # noqa: SDK002 -from airflow.dag_processing.processor import DagFileParsingResult # noqa: SDK002 +from cadwyn import ResponseInfo, VersionChange, convert_response_to_previous_version_for, schema + +from airflow.dag_processing.processor import DagFileParseRequest, DagFileParsingResult # noqa: SDK002 from airflow.sdk.api.datamodels._generated import PreviousTIResponse, TaskInstance, TIRunContext from airflow.sdk.execution_time.comms import ( GetXCom, @@ -39,6 +40,10 @@ TaskState, ) +logger = logging.getLogger(__name__) + +_SKIPPED_INTERVALS_CALLBACK_TYPE = "DagSkippedIntervalsCallbackRequest" + class AddLoopRuntime(VersionChange): """Carry loop context and previous-iteration reads.""" @@ -119,7 +124,24 @@ class AddDagSkippedIntervalsCallbackRequest(VersionChange): description = __doc__ - instructions_to_migrate_to_previous_version = ( - schema(DagSkippedIntervalsCallbackRequest).field("dag_id").didnt_exist, - schema(DagSkippedIntervalsCallbackRequest).field("skipped_range").didnt_exist, - ) + instructions_to_migrate_to_previous_version = () + + @convert_response_to_previous_version_for(DagFileParseRequest) # type: ignore[arg-type] + def _drop_dag_skipped_intervals_callbacks(response: ResponseInfo) -> None: # type: ignore[misc] + callbacks = response.body.get("callback_requests") + if not callbacks: + return + + filtered: list[object] = [] + for callback in callbacks: + if isinstance(callback, dict) and callback.get("type") == _SKIPPED_INTERVALS_CALLBACK_TYPE: + logger.info( + "Dropping unsupported callback request for supervisor schema downgrade", + extra={ + "callback_type": _SKIPPED_INTERVALS_CALLBACK_TYPE, + "dag_id": callback.get("dag_id"), + }, + ) + continue + filtered.append(callback) + response.body["callback_requests"] = filtered From bcf3ae6a1314bfaec7746556abea2055985b0827 Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:37:49 -0500 Subject: [PATCH 16/29] Update task-sdk schema.json and ts-sdk supervisor.ts as per hooks --- ts-sdk/src/generated/supervisor.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/ts-sdk/src/generated/supervisor.ts b/ts-sdk/src/generated/supervisor.ts index 7faf8c4877029..58d95300310dd 100644 --- a/ts-sdk/src/generated/supervisor.ts +++ b/ts-sdk/src/generated/supervisor.ts @@ -266,6 +266,12 @@ export type Name9 = string; export type Kind1 = "literal"; export type FromDefault = boolean; export type MultiTeam = boolean; +/** + * @minItems 2 + * @maxItems 2 + */ +export type SkippedRange = [string, string]; +export type TypeDagSkippedIntervalsCallbackRequest = "DagSkippedIntervalsCallbackRequest"; export type Type13 = "TaskCallbackRequest"; export type Filepath2 = string; export type BundleName3 = string; @@ -276,7 +282,12 @@ export type VersionData3 = { export type Msg2 = string | null; export type EmailType = "failure" | "retry"; export type Type14 = "EmailRequest"; -export type CallbackRequests = (DagCallbackRequest | TaskCallbackRequest | EmailRequest)[]; +export type CallbackRequests = ( + | DagCallbackRequest + | DagSkippedIntervalsCallbackRequest + | TaskCallbackRequest + | EmailRequest +)[]; export type Type15 = "DagFileParseRequest"; export type Fileloc = string; export type LastLoaded = string | null; @@ -914,6 +925,22 @@ export interface DagCallbackRequest { is_failure_callback?: IsFailureCallback; type?: Type12; } +/** + * Store skipped intervals callback data for execution by the Dag processor. + * + * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema + * via the `definition` "DagSkippedIntervalsCallbackRequest". + */ +export interface DagSkippedIntervalsCallbackRequest { + filepath: Filepath1; + bundle_name: BundleName2; + bundle_version: BundleVersion1; + version_data?: VersionData2; + msg?: Msg1; + dag_id: DagId4; + skipped_range: SkippedRange; + type?: TypeDagSkippedIntervalsCallbackRequest; +} /** * Class to pass context info from the server to build a Execution context object. * From b0c6465be6825f4e3a7467c3490b123dc2c57b68 Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:21:40 -0500 Subject: [PATCH 17/29] Update schema version references in Go sdk --- .../execution/genmodels/discriminators.gen.go | 186 +++++++++--------- go-sdk/pkg/execution/genmodels/models.gen.go | 27 +++ 2 files changed, 122 insertions(+), 91 deletions(-) diff --git a/go-sdk/pkg/execution/genmodels/discriminators.gen.go b/go-sdk/pkg/execution/genmodels/discriminators.gen.go index 8736a6e2c9730..ca3455b713607 100644 --- a/go-sdk/pkg/execution/genmodels/discriminators.gen.go +++ b/go-sdk/pkg/execution/genmodels/discriminators.gen.go @@ -24,97 +24,98 @@ import "reflect" // in the supervisor wire-schema: the single source of truth for the "type" // field's value, so callers never hand-write strings that could drift. const ( - TypeAssetEventsResult = "AssetEventsResult" - TypeAssetResult = "AssetResult" - TypeAssetStateStoreResult = "AssetStateStoreResult" - TypeAssetsByAliasResult = "AssetsByAliasResult" - TypeAwaitInputTask = "AwaitInputTask" - TypeClearAssetStateStoreByName = "ClearAssetStateStoreByName" - TypeClearAssetStateStoreByURI = "ClearAssetStateStoreByUri" - TypeClearTaskStateStore = "ClearTaskStateStore" - TypeConnectionResult = "ConnectionResult" - TypeCreateHITLDetailPayload = "CreateHITLDetailPayload" - TypeDRCount = "DRCount" - TypeDagCallbackRequest = "DagCallbackRequest" - TypeDagFileParseRequest = "DagFileParseRequest" - TypeDagFileParsingResult = "DagFileParsingResult" - TypeDagResult = "DagResult" - TypeDagRunResult = "DagRunResult" - TypeDagRunStateResult = "DagRunStateResult" - TypeDeferTask = "DeferTask" - TypeDeleteAssetStateStoreByName = "DeleteAssetStateStoreByName" - TypeDeleteAssetStateStoreByURI = "DeleteAssetStateStoreByUri" - TypeDeleteTaskStateStore = "DeleteTaskStateStore" - TypeDeleteVariable = "DeleteVariable" - TypeDeleteXCom = "DeleteXCom" - TypeEmailRequest = "EmailRequest" - TypeErrorResponse = "ErrorResponse" - TypeGetAssetByName = "GetAssetByName" - TypeGetAssetByURI = "GetAssetByUri" - TypeGetAssetEventByAsset = "GetAssetEventByAsset" - TypeGetAssetEventByAssetAlias = "GetAssetEventByAssetAlias" - TypeGetAssetStateStoreByName = "GetAssetStateStoreByName" - TypeGetAssetStateStoreByURI = "GetAssetStateStoreByUri" - TypeGetAssetsByAlias = "GetAssetsByAlias" - TypeGetConnection = "GetConnection" - TypeGetDRCount = "GetDRCount" - TypeGetDag = "GetDag" - TypeGetDagRun = "GetDagRun" - TypeGetDagRunState = "GetDagRunState" - TypeGetHITLDetailResponse = "GetHITLDetailResponse" - TypeGetPrevSuccessfulDagRun = "GetPrevSuccessfulDagRun" - TypeGetPreviousDagRun = "GetPreviousDagRun" - TypeGetPreviousTI = "GetPreviousTI" - TypeGetTICount = "GetTICount" - TypeGetTaskBreadcrumbs = "GetTaskBreadcrumbs" - TypeGetTaskRescheduleStartDate = "GetTaskRescheduleStartDate" - TypeGetTaskStateStore = "GetTaskStateStore" - TypeGetTaskStates = "GetTaskStates" - TypeGetVariable = "GetVariable" - TypeGetVariableKeys = "GetVariableKeys" - TypeGetXCom = "GetXCom" - TypeGetXComCount = "GetXComCount" - TypeGetXComSequenceItem = "GetXComSequenceItem" - TypeGetXComSequenceSlice = "GetXComSequenceSlice" - TypeHITLDetailRequestResult = "HITLDetailRequestResult" - TypeInactiveAssetsResult = "InactiveAssetsResult" - TypeMaskSecret = "MaskSecret" - TypeOKResponse = "OKResponse" - TypePrevSuccessfulDagRunResult = "PrevSuccessfulDagRunResult" - TypePreviousDagRunResult = "PreviousDagRunResult" - TypePreviousTIResult = "PreviousTIResult" - TypePutVariable = "PutVariable" - TypeRescheduleTask = "RescheduleTask" - TypeResendLoggingFD = "ResendLoggingFD" - TypeRetryTask = "RetryTask" - TypeSentFDs = "SentFDs" - TypeSetAssetStateStoreByName = "SetAssetStateStoreByName" - TypeSetAssetStateStoreByURI = "SetAssetStateStoreByUri" - TypeSetExecutionTimeout = "SetExecutionTimeout" - TypeSetRenderedFields = "SetRenderedFields" - TypeSetRenderedMapIndex = "SetRenderedMapIndex" - TypeSetTaskStateStore = "SetTaskStateStore" - TypeSetXCom = "SetXCom" - TypeSkipDownstreamTasks = "SkipDownstreamTasks" - TypeStartupDetails = "StartupDetails" - TypeSucceedTask = "SucceedTask" - TypeTICount = "TICount" - TypeTaskBreadcrumbsResult = "TaskBreadcrumbsResult" - TypeTaskCallbackRequest = "TaskCallbackRequest" - TypeTaskRescheduleStartDate = "TaskRescheduleStartDate" - TypeTaskState = "TaskState" - TypeTaskStateStoreResult = "TaskStateStoreResult" - TypeTaskStatesResult = "TaskStatesResult" - TypeTriggerDagRun = "TriggerDagRun" - TypeUpdateDagRunNote = "UpdateDagRunNote" - TypeUpdateHITLDetail = "UpdateHITLDetail" - TypeValidateInletsAndOutlets = "ValidateInletsAndOutlets" - TypeVariableKeysResult = "VariableKeysResult" - TypeVariableResult = "VariableResult" - TypeXComCountResponse = "XComCountResponse" - TypeXComResult = "XComResult" - TypeXComSequenceIndexResult = "XComSequenceIndexResult" - TypeXComSequenceSliceResult = "XComSequenceSliceResult" + TypeAssetEventsResult = "AssetEventsResult" + TypeAssetResult = "AssetResult" + TypeAssetStateStoreResult = "AssetStateStoreResult" + TypeAssetsByAliasResult = "AssetsByAliasResult" + TypeAwaitInputTask = "AwaitInputTask" + TypeClearAssetStateStoreByName = "ClearAssetStateStoreByName" + TypeClearAssetStateStoreByURI = "ClearAssetStateStoreByUri" + TypeClearTaskStateStore = "ClearTaskStateStore" + TypeConnectionResult = "ConnectionResult" + TypeCreateHITLDetailPayload = "CreateHITLDetailPayload" + TypeDRCount = "DRCount" + TypeDagCallbackRequest = "DagCallbackRequest" + TypeDagFileParseRequest = "DagFileParseRequest" + TypeDagFileParsingResult = "DagFileParsingResult" + TypeDagResult = "DagResult" + TypeDagRunResult = "DagRunResult" + TypeDagRunStateResult = "DagRunStateResult" + TypeDagSkippedIntervalsCallbackRequest = "DagSkippedIntervalsCallbackRequest" + TypeDeferTask = "DeferTask" + TypeDeleteAssetStateStoreByName = "DeleteAssetStateStoreByName" + TypeDeleteAssetStateStoreByURI = "DeleteAssetStateStoreByUri" + TypeDeleteTaskStateStore = "DeleteTaskStateStore" + TypeDeleteVariable = "DeleteVariable" + TypeDeleteXCom = "DeleteXCom" + TypeEmailRequest = "EmailRequest" + TypeErrorResponse = "ErrorResponse" + TypeGetAssetByName = "GetAssetByName" + TypeGetAssetByURI = "GetAssetByUri" + TypeGetAssetEventByAsset = "GetAssetEventByAsset" + TypeGetAssetEventByAssetAlias = "GetAssetEventByAssetAlias" + TypeGetAssetStateStoreByName = "GetAssetStateStoreByName" + TypeGetAssetStateStoreByURI = "GetAssetStateStoreByUri" + TypeGetAssetsByAlias = "GetAssetsByAlias" + TypeGetConnection = "GetConnection" + TypeGetDRCount = "GetDRCount" + TypeGetDag = "GetDag" + TypeGetDagRun = "GetDagRun" + TypeGetDagRunState = "GetDagRunState" + TypeGetHITLDetailResponse = "GetHITLDetailResponse" + TypeGetPrevSuccessfulDagRun = "GetPrevSuccessfulDagRun" + TypeGetPreviousDagRun = "GetPreviousDagRun" + TypeGetPreviousTI = "GetPreviousTI" + TypeGetTICount = "GetTICount" + TypeGetTaskBreadcrumbs = "GetTaskBreadcrumbs" + TypeGetTaskRescheduleStartDate = "GetTaskRescheduleStartDate" + TypeGetTaskStateStore = "GetTaskStateStore" + TypeGetTaskStates = "GetTaskStates" + TypeGetVariable = "GetVariable" + TypeGetVariableKeys = "GetVariableKeys" + TypeGetXCom = "GetXCom" + TypeGetXComCount = "GetXComCount" + TypeGetXComSequenceItem = "GetXComSequenceItem" + TypeGetXComSequenceSlice = "GetXComSequenceSlice" + TypeHITLDetailRequestResult = "HITLDetailRequestResult" + TypeInactiveAssetsResult = "InactiveAssetsResult" + TypeMaskSecret = "MaskSecret" + TypeOKResponse = "OKResponse" + TypePrevSuccessfulDagRunResult = "PrevSuccessfulDagRunResult" + TypePreviousDagRunResult = "PreviousDagRunResult" + TypePreviousTIResult = "PreviousTIResult" + TypePutVariable = "PutVariable" + TypeRescheduleTask = "RescheduleTask" + TypeResendLoggingFD = "ResendLoggingFD" + TypeRetryTask = "RetryTask" + TypeSentFDs = "SentFDs" + TypeSetAssetStateStoreByName = "SetAssetStateStoreByName" + TypeSetAssetStateStoreByURI = "SetAssetStateStoreByUri" + TypeSetExecutionTimeout = "SetExecutionTimeout" + TypeSetRenderedFields = "SetRenderedFields" + TypeSetRenderedMapIndex = "SetRenderedMapIndex" + TypeSetTaskStateStore = "SetTaskStateStore" + TypeSetXCom = "SetXCom" + TypeSkipDownstreamTasks = "SkipDownstreamTasks" + TypeStartupDetails = "StartupDetails" + TypeSucceedTask = "SucceedTask" + TypeTICount = "TICount" + TypeTaskBreadcrumbsResult = "TaskBreadcrumbsResult" + TypeTaskCallbackRequest = "TaskCallbackRequest" + TypeTaskRescheduleStartDate = "TaskRescheduleStartDate" + TypeTaskState = "TaskState" + TypeTaskStateStoreResult = "TaskStateStoreResult" + TypeTaskStatesResult = "TaskStatesResult" + TypeTriggerDagRun = "TriggerDagRun" + TypeUpdateDagRunNote = "UpdateDagRunNote" + TypeUpdateHITLDetail = "UpdateHITLDetail" + TypeValidateInletsAndOutlets = "ValidateInletsAndOutlets" + TypeVariableKeysResult = "VariableKeysResult" + TypeVariableResult = "VariableResult" + TypeXComCountResponse = "XComCountResponse" + TypeXComResult = "XComResult" + TypeXComSequenceIndexResult = "XComSequenceIndexResult" + TypeXComSequenceSliceResult = "XComSequenceSliceResult" ) // EnsureType returns m with its "type" discriminator set to the constant bound to @@ -174,6 +175,9 @@ func EnsureType(m any) any { case DagRunStateResult: b.Type = TypeDagRunStateResult return b + case DagSkippedIntervalsCallbackRequest: + b.Type = TypeDagSkippedIntervalsCallbackRequest + return b case DeferTask: b.Type = TypeDeferTask return b diff --git a/go-sdk/pkg/execution/genmodels/models.gen.go b/go-sdk/pkg/execution/genmodels/models.gen.go index 6e53729efe936..bcc25ffe34752 100644 --- a/go-sdk/pkg/execution/genmodels/models.gen.go +++ b/go-sdk/pkg/execution/genmodels/models.gen.go @@ -640,6 +640,33 @@ const DagRunTypeManual DagRunType = "manual" const DagRunTypeOperatorTriggered DagRunType = "operator_triggered" const DagRunTypeScheduled DagRunType = "scheduled" +// Store skipped intervals callback data for execution by the Dag processor. +type DagSkippedIntervalsCallbackRequest struct { + // BundleName corresponds to the JSON schema field "bundle_name". + BundleName string `msgpack:"bundle_name"` + + // BundleVersion corresponds to the JSON schema field "bundle_version". + BundleVersion interface{} `msgpack:"bundle_version"` + + // DagID corresponds to the JSON schema field "dag_id". + DagID string `msgpack:"dag_id"` + + // Filepath corresponds to the JSON schema field "filepath". + Filepath string `msgpack:"filepath"` + + // Msg corresponds to the JSON schema field "msg". + Msg interface{} `msgpack:"msg,omitempty"` + + // SkippedRange corresponds to the JSON schema field "skipped_range". + SkippedRange []interface{} `msgpack:"skipped_range"` + + // Type corresponds to the JSON schema field "type". + Type string `msgpack:"type,omitempty"` + + // VersionData corresponds to the JSON schema field "version_data". + VersionData *VersionData `msgpack:"version_data,omitempty"` +} + // Raw source code and its language identifier for a DAG definition. type DagSourceCode struct { // Language corresponds to the JSON schema field "language". From 6044de147b70480a3ab21df1f696c992743f9d8e Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:56:59 -0500 Subject: [PATCH 18/29] Avoid false skipped-interval callbacks for consecutive trigger timetable runs Default Airflow 3 schedules use zero-width CronTriggerTimetable / DeltaTriggerTimetable intervals, so a bare prev_end < new_start check treated every normal schedule advance as a catchup gap. Detect skips by comparing the timetable's expected next logical date with the run being created, and compute the listener-hook presence once per scheduling loop. --- .../src/airflow/jobs/scheduler_job_runner.py | 36 ++++---- .../airflow/serialization/definitions/dag.py | 28 +++++- .../tests/unit/jobs/test_scheduler_job.py | 86 +++++++++++++++---- .../test_skipped_intervals_summary.py | 49 ++++++++++- 4 files changed, 158 insertions(+), 41 deletions(-) diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py b/airflow-core/src/airflow/jobs/scheduler_job_runner.py index 45616e9ba2cc1..16ed5ebaae566 100644 --- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py +++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py @@ -126,7 +126,7 @@ from airflow.serialization.definitions.notset import NOTSET from airflow.ti_deps.dependencies_states import ACTIVE_STATES, EXECUTION_STATES from airflow.timetables.base import ( - DataInterval, + DagRunInfo, SkippedIntervalsSummary, Timetable, compute_rollup_fingerprint, @@ -2723,22 +2723,22 @@ def _mark_backfills_complete(self, *, session: Session = NEW_SESSION) -> None: def _collect_skipped_intervals( self, serdag: SerializedDAG, - new_data_interval: DataInterval, + new_info: DagRunInfo, session: Session, + *, + listener_has_impls: bool, ) -> SkippedIntervalsSummary | None: """ Summarize intervals skipped due to catchup=False. - Computes the intervals that would have been scheduled between the previous - automated DagRun's data_interval_end and the new run's data_interval_start, - had catchup been True. Returns ``None`` when there is no gap or when - no previous run exists. + Asks the timetable whether the previous automated DagRun's immediate + successor (with catchup enabled) is earlier than the new run. Returns + ``None`` when there is no schedulable gap or when no previous run exists. """ if serdag.catchup: return None - listener_has_impls = bool( - get_listener_manager().hook.on_intervals_skipped.get_hookimpls() # type: ignore[attr-defined] - ) + if new_info.data_interval is None: + return None if not serdag.has_on_skipped_intervals_callback and not listener_has_impls: return None @@ -2748,7 +2748,7 @@ def _collect_skipped_intervals( DagRun.dag_id == serdag.dag_id, DagRun.run_type == DagRunType.SCHEDULED, DagRun.data_interval_end.is_not(None), - DagRun.data_interval_end <= new_data_interval.start, + DagRun.data_interval_end <= new_info.data_interval.start, ) .order_by(DagRun.data_interval_end.desc()) .limit(1) @@ -2756,10 +2756,8 @@ def _collect_skipped_intervals( if prev_run is None or prev_run.data_interval_end is None: return None - return serdag.summarize_skipped_intervals_between( - prev_run.data_interval_end, - new_data_interval.start, - ) + prev_info = serdag.timetable.run_info_from_dag_run(dag_run=prev_run) + return serdag.summarize_skipped_intervals_between(prev_info, new_info) def _create_dag_runs( self, dag_models: Collection[DagModel], session: Session @@ -2771,6 +2769,9 @@ def _create_dag_runs( """ skip_callback_requests: list[DagSkippedIntervalsCallbackRequest] = [] skipped_intervals_listener_events: list[tuple[str, SkippedIntervalsSummary]] = [] + listener_has_impls = bool( + get_listener_manager().hook.on_intervals_skipped.get_hookimpls() # type: ignore[attr-defined] + ) # Bulk Fetch DagRuns with dag_id and logical_date same # as DagModel.dag_id and DagModel.next_dagrun # This list is used to verify if the DagRun already exist so that we don't attempt to create @@ -2912,13 +2913,12 @@ def _create_dag_runs( if data_interval is not None: skipped_summary = self._collect_skipped_intervals( serdag=serdag, - new_data_interval=data_interval, + new_info=next_info, session=session, + listener_has_impls=listener_has_impls, ) if skipped_summary is not None: - if bool( - get_listener_manager().hook.on_intervals_skipped.get_hookimpls() # type: ignore[attr-defined] - ): + if listener_has_impls: skipped_intervals_listener_events.append((serdag.dag_id, skipped_summary)) if serdag.has_on_skipped_intervals_callback: skip_callback_requests.append( diff --git a/airflow-core/src/airflow/serialization/definitions/dag.py b/airflow-core/src/airflow/serialization/definitions/dag.py index df329c7246986..5bdefc65c0ff3 100644 --- a/airflow-core/src/airflow/serialization/definitions/dag.py +++ b/airflow-core/src/airflow/serialization/definitions/dag.py @@ -544,17 +544,37 @@ def iter_dagrun_infos_between( def summarize_skipped_intervals_between( self, - prev_interval_end: datetime.datetime, - new_interval_start: datetime.datetime, + prev_info: DagRunInfo, + new_info: DagRunInfo, ) -> SkippedIntervalsSummary | None: """ - Summarize intervals skipped between two automated Dag run boundaries. + Summarize intervals skipped between two automated Dag runs. - Returns ``None`` when there is no schedulable gap. + Asks the timetable for the immediate successor of *prev_info* with + catchup enabled. Returns a summary only when that expected successor's + logical date is strictly earlier than *new_info*'s logical date — i.e. + when at least one scheduled run was skipped. + + Returns ``None`` when there is no schedulable gap (including consecutive + zero-width trigger-timetable runs, where wall-clock endpoints differ + but the expected next logical date matches the new run). """ + if prev_info.data_interval is None or new_info.data_interval is None: + return None + if new_info.logical_date is None: + return None + + prev_interval_end = prev_info.data_interval.end + new_interval_start = new_info.data_interval.start if prev_interval_end >= new_interval_start: return None + expected = self.next_dagrun_info(last_automated_run_info=prev_info, restricted=False) + if expected is None or expected.logical_date is None: + return None + if expected.logical_date >= new_info.logical_date: + return None + from airflow._shared.timezones import timezone return SkippedIntervalsSummary( diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py b/airflow-core/tests/unit/jobs/test_scheduler_job.py index 66f9e9421b0ec..85d5c6792a216 100644 --- a/airflow-core/tests/unit/jobs/test_scheduler_job.py +++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py @@ -12074,11 +12074,14 @@ def test_collect_skipped_intervals_returns_empty_when_catchup_true(self, session serdag = dag_maker.serialized_dag scheduler_job = Job() self.job_runner = SchedulerJobRunner(job=scheduler_job, executors=[self.null_exec]) - new_interval = DataInterval( - start=DEFAULT_DATE + timedelta(days=4), - end=DEFAULT_DATE + timedelta(days=5), + new_info = DagRunInfo.interval( + DEFAULT_DATE + timedelta(days=4), + DEFAULT_DATE + timedelta(days=5), + ) + assert ( + self.job_runner._collect_skipped_intervals(serdag, new_info, session, listener_has_impls=False) + is None ) - assert self.job_runner._collect_skipped_intervals(serdag, new_interval, session) is None def test_collect_skipped_intervals_returns_empty_without_callback_or_listener(self, session, dag_maker): with dag_maker( @@ -12099,13 +12102,17 @@ def test_collect_skipped_intervals_returns_empty_without_callback_or_listener(se ) serdag = dag_maker.serialized_dag - new_interval = DataInterval( - start=DEFAULT_DATE + timedelta(days=4), - end=DEFAULT_DATE + timedelta(days=5), + assert not serdag.has_on_skipped_intervals_callback + new_info = DagRunInfo.interval( + DEFAULT_DATE + timedelta(days=4), + DEFAULT_DATE + timedelta(days=5), ) scheduler_job = Job() self.job_runner = SchedulerJobRunner(job=scheduler_job, executors=[self.null_exec]) - assert self.job_runner._collect_skipped_intervals(serdag, new_interval, session) is None + assert ( + self.job_runner._collect_skipped_intervals(serdag, new_info, session, listener_has_impls=False) + is None + ) def test_collect_skipped_intervals_returns_gap_summary(self, session, dag_maker): with dag_maker( @@ -12135,8 +12142,9 @@ def test_collect_skipped_intervals_returns_gap_summary(self, session, dag_maker) summary = self.job_runner._collect_skipped_intervals( serdag, - DataInterval(start=new_start, end=new_end), + DagRunInfo.interval(new_start, new_end), session, + listener_has_impls=False, ) assert summary is not None @@ -12155,11 +12163,14 @@ def test_collect_skipped_intervals_returns_empty_without_previous_run(self, sess serdag = dag_maker.serialized_dag scheduler_job = Job() self.job_runner = SchedulerJobRunner(job=scheduler_job, executors=[self.null_exec]) - new_interval = DataInterval( - start=DEFAULT_DATE + timedelta(days=1), - end=DEFAULT_DATE + timedelta(days=2), + new_info = DagRunInfo.interval( + DEFAULT_DATE + timedelta(days=1), + DEFAULT_DATE + timedelta(days=2), + ) + assert ( + self.job_runner._collect_skipped_intervals(serdag, new_info, session, listener_has_impls=False) + is None ) - assert self.job_runner._collect_skipped_intervals(serdag, new_interval, session) is None def test_collect_skipped_intervals_returns_empty_when_no_gap(self, session, dag_maker): with dag_maker( @@ -12183,8 +12194,53 @@ def test_collect_skipped_intervals_returns_empty_when_no_gap(self, session, dag_ serdag = dag_maker.serialized_dag scheduler_job = Job() self.job_runner = SchedulerJobRunner(job=scheduler_job, executors=[self.null_exec]) - new_interval = DataInterval(start=prev_end, end=prev_end + timedelta(days=1)) - assert self.job_runner._collect_skipped_intervals(serdag, new_interval, session) is None + new_info = DagRunInfo.interval(prev_end, prev_end + timedelta(days=1)) + assert ( + self.job_runner._collect_skipped_intervals(serdag, new_info, session, listener_has_impls=False) + is None + ) + + @conf_vars({("scheduler", "create_cron_data_intervals"): "False"}) + def test_create_dag_runs_does_not_emit_skipped_intervals_for_consecutive_cron_runs( + self, session, dag_maker + ): + """Regression: consecutive CronTriggerTimetable runs must not fire skip callbacks.""" + with dag_maker( + dag_id="test_consecutive_cron_no_false_skip", + schedule="@daily", + start_date=DEFAULT_DATE, + catchup=False, + on_skipped_intervals_callback=lambda ctx: None, + session=session, + ): + EmptyOperator(task_id="dummy") + + dr1 = dag_maker.create_dagrun( + run_type=DagRunType.SCHEDULED, + logical_date=DEFAULT_DATE, + data_interval=DataInterval.exact(DEFAULT_DATE), + state=State.SUCCESS, + ) + + serdag = dag_maker.serialized_dag + next_info = serdag.next_dagrun_info( + last_automated_run_info=serdag.timetable.run_info_from_dag_run(dag_run=dr1), + restricted=False, + ) + assert next_info is not None + dag_model = dag_maker.dag_model + dag_model.next_dagrun = next_info.logical_date + dag_model.next_dagrun_data_interval = next_info.data_interval + dag_model.next_dagrun_create_after = next_info.run_after + session.merge(dag_model) + session.commit() + + scheduler_job = Job() + self.job_runner = SchedulerJobRunner(job=scheduler_job, executors=[MockExecutor(do_update=False)]) + notifications = self.job_runner._create_dag_runs([dag_model], session) + + assert notifications.skip_callback_requests == [] + assert notifications.skipped_intervals_listener_events == [] def test_create_dag_runs_returns_skipped_intervals_callback_request(self, session, dag_maker): with dag_maker( diff --git a/airflow-core/tests/unit/timetables/test_skipped_intervals_summary.py b/airflow-core/tests/unit/timetables/test_skipped_intervals_summary.py index eba0466e2a3a8..efe823715a928 100644 --- a/airflow-core/tests/unit/timetables/test_skipped_intervals_summary.py +++ b/airflow-core/tests/unit/timetables/test_skipped_intervals_summary.py @@ -21,8 +21,8 @@ import pendulum from airflow.providers.standard.operators.empty import EmptyOperator -from airflow.sdk import DAG -from airflow.timetables.base import DataInterval +from airflow.sdk import DAG, CronTriggerTimetable +from airflow.timetables.base import DagRunInfo, DataInterval from tests_common.test_utils.dag import create_scheduler_dag @@ -41,8 +41,10 @@ def test_summarize_skipped_intervals_between_returns_gap_summary(): prev_end = DEFAULT_DATE + timedelta(days=1) new_start = DEFAULT_DATE + timedelta(days=4) + prev_info = DagRunInfo.interval(DEFAULT_DATE, prev_end) + new_info = DagRunInfo.interval(new_start, new_start + timedelta(days=1)) - summary = scheduler_dag.summarize_skipped_intervals_between(prev_end, new_start) + summary = scheduler_dag.summarize_skipped_intervals_between(prev_info, new_info) assert summary is not None assert summary.skipped_range == DataInterval(start=prev_end, end=new_start) @@ -59,4 +61,43 @@ def test_summarize_skipped_intervals_between_returns_none_without_gap(): scheduler_dag = create_scheduler_dag(dag) boundary = DEFAULT_DATE + timedelta(days=1) - assert scheduler_dag.summarize_skipped_intervals_between(boundary, boundary) is None + prev_info = DagRunInfo.interval(DEFAULT_DATE, boundary) + new_info = DagRunInfo.interval(boundary, boundary + timedelta(days=1)) + assert scheduler_dag.summarize_skipped_intervals_between(prev_info, new_info) is None + + +def test_summarize_skipped_intervals_between_returns_none_for_consecutive_trigger_runs(): + """Zero-width consecutive CronTriggerTimetable runs must not look like a skip.""" + dag = DAG( + dag_id="test_summarize_consecutive_trigger", + schedule=CronTriggerTimetable("@daily", timezone="UTC"), + start_date=DEFAULT_DATE, + catchup=False, + ) + EmptyOperator(task_id="dummy", dag=dag) + scheduler_dag = create_scheduler_dag(dag) + + prev_info = DagRunInfo.exact(DEFAULT_DATE) + new_info = DagRunInfo.exact(DEFAULT_DATE + timedelta(days=1)) + assert scheduler_dag.summarize_skipped_intervals_between(prev_info, new_info) is None + + +def test_summarize_skipped_intervals_between_returns_gap_for_skipped_trigger_runs(): + """A real multi-period jump on CronTriggerTimetable must still report a skip.""" + dag = DAG( + dag_id="test_summarize_skipped_trigger", + schedule=CronTriggerTimetable("@daily", timezone="UTC"), + start_date=DEFAULT_DATE, + catchup=False, + ) + EmptyOperator(task_id="dummy", dag=dag) + scheduler_dag = create_scheduler_dag(dag) + + prev_info = DagRunInfo.exact(DEFAULT_DATE) + new_start = DEFAULT_DATE + timedelta(days=3) + new_info = DagRunInfo.exact(new_start) + + summary = scheduler_dag.summarize_skipped_intervals_between(prev_info, new_info) + + assert summary is not None + assert summary.skipped_range == DataInterval(start=DEFAULT_DATE, end=new_start) From 21c304dbc8f729081fc98745717ddc022a018693 Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:41:08 -0500 Subject: [PATCH 19/29] Remove stray blank line right after _execute_dag_skipped_intervals_callback signature --- airflow-core/src/airflow/dag_processing/processor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/airflow-core/src/airflow/dag_processing/processor.py b/airflow-core/src/airflow/dag_processing/processor.py index 7514ad8f38f9b..61659eb1fb41a 100644 --- a/airflow-core/src/airflow/dag_processing/processor.py +++ b/airflow-core/src/airflow/dag_processing/processor.py @@ -438,7 +438,6 @@ 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: From 431213464156d52ccd725c328535adb7fad28346 Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:42:24 -0500 Subject: [PATCH 20/29] Ruff formatting for callback requests imports on test scheduler job --- airflow-core/tests/unit/jobs/test_scheduler_job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py b/airflow-core/tests/unit/jobs/test_scheduler_job.py index 85d5c6792a216..265fe637d2035 100644 --- a/airflow-core/tests/unit/jobs/test_scheduler_job.py +++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py @@ -53,8 +53,8 @@ from airflow.callbacks.callback_requests import ( DagCallbackRequest, DagRunContext, - EmailRequest, DagSkippedIntervalsCallbackRequest, + EmailRequest, TaskCallbackRequest, ) from airflow.callbacks.database_callback_sink import DatabaseCallbackSink From e1aaee4fd710facbfbc7cf6bb1d61ecb532d0227 Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:14:06 -0500 Subject: [PATCH 21/29] Document callback behaviour when there are no previous Dag runs --- airflow-core/docs/administration-and-deployment/listeners.rst | 3 ++- .../logging-monitoring/callbacks.rst | 4 +++- task-sdk/src/airflow/sdk/definitions/dag.py | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/airflow-core/docs/administration-and-deployment/listeners.rst b/airflow-core/docs/administration-and-deployment/listeners.rst index 621765ead8e5f..43364e8eb7b9d 100644 --- a/airflow-core/docs/administration-and-deployment/listeners.rst +++ b/airflow-core/docs/administration-and-deployment/listeners.rst @@ -72,7 +72,8 @@ a scheduler restart or when a paused Dag is re-enabled), the scheduler invokes t ``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. +is dispatched to the dag processor. Like the callback, it does not fire on a Dag's first +automated run (no previous run to compare against). - ``on_intervals_skipped`` diff --git a/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst b/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst index 2ace0a8f31706..99495148a2426 100644 --- a/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst +++ b/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst @@ -107,7 +107,9 @@ to be notified when that happens. 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. +created for the skipped intervals. It does not fire on a Dag's first automated run (no previous +run to compare against), even when ``catchup=False`` leaves intervals after ``start_date`` +uncreated. The callback runs in the dag processor (like other Dag-level callbacks), not in the scheduler. The context mapping contains: diff --git a/task-sdk/src/airflow/sdk/definitions/dag.py b/task-sdk/src/airflow/sdk/definitions/dag.py index b1cb4e2d51f23..5168f3704ccb9 100644 --- a/task-sdk/src/airflow/sdk/definitions/dag.py +++ b/task-sdk/src/airflow/sdk/definitions/dag.py @@ -406,7 +406,8 @@ class DAG: :param on_skipped_intervals_callback: A function or list of functions invoked by the scheduler when a Dag with ``catchup=False`` advances past one or more scheduled data intervals without creating Dag runs for them (for example after a scheduler - restart or when a paused Dag is re-enabled). The callback receives a + restart or when a paused Dag is re-enabled). Does not fire on the first automated + run (no previous run to compare against). The callback receives a :class:`~airflow.sdk.definitions.context.SkippedIntervalsCallbackContext`. It runs in the dag processor, not in the scheduler. :param access_control: Specify optional DAG-level actions, e.g., From 192da360f9167d6ff84488250841074b5cdeaa13 Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:58:39 -0500 Subject: [PATCH 22/29] Narrow log_extra scope for skipped intervals callback in request Skipped intervals fires when no Dag run is made, so it will have no run_id. The former implementation expected a run_id in the request. --- airflow-core/src/airflow/dag_processing/processor.py | 4 +++- .../airflow/sdk/execution_time/schema/versions/v2026_10_30.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/airflow-core/src/airflow/dag_processing/processor.py b/airflow-core/src/airflow/dag_processing/processor.py index 61659eb1fb41a..34be2fb07f6ba 100644 --- a/airflow-core/src/airflow/dag_processing/processor.py +++ b/airflow-core/src/airflow/dag_processing/processor.py @@ -343,8 +343,10 @@ def _execute_callbacks( "run_id": request.ti.run_id, "ti_id": str(request.ti.id), } - else: + elif isinstance(request, DagCallbackRequest): log_extra = {"dag_id": request.dag_id, "run_id": request.run_id} + else: + log_extra = {"dag_id": request.dag_id} # context_from_server can carry user-supplied run conf, and the masker cannot # redact inside an already-serialized string, so keep it out of log payloads. request_json = request.to_json(exclude={"context_from_server"}) diff --git a/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py b/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py index 910faec1bde49..9b1a7e8c9672d 100644 --- a/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py +++ b/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py @@ -120,7 +120,7 @@ class AddFirstTaskRescheduleStartDateToSupervisorTIRunContext(VersionChange): class AddDagSkippedIntervalsCallbackRequest(VersionChange): - """Introduce ``DagSkippedIntervalsCallbackRequest`` in the ``CallbackRequest`` union.""" + """Introduce ``DagSkippedIntervalsCallbackRequest`` in ``CallbackRequest`` union.""" description = __doc__ From 04b4d5273ac7d2945c40c0d1a5115937ebea2306 Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:31:08 -0500 Subject: [PATCH 23/29] Align skipped-interval callback metrics and simplify guard logic Keep Dag callback exception tags consistent across callback paths and centralize skipped-interval precondition handling in one place to reduce duplicate checks. --- .../src/airflow/dag_processing/processor.py | 14 ++++++- .../src/airflow/jobs/scheduler_job_runner.py | 37 +++++++++---------- .../schema/versions/v2026_10_30.py | 2 +- 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/airflow-core/src/airflow/dag_processing/processor.py b/airflow-core/src/airflow/dag_processing/processor.py index 34be2fb07f6ba..14bcd30397f39 100644 --- a/airflow-core/src/airflow/dag_processing/processor.py +++ b/airflow-core/src/airflow/dag_processing/processor.py @@ -460,7 +460,19 @@ def _execute_dag_skipped_intervals_callback( callback(context) except Exception: log.exception("Callback failed", dag_id=request.dag_id) - stats.incr("dag.callback_exceptions", tags={"dag_id": request.dag_id}) + stats.incr( + "dag.callback_exceptions", + tags=prune_dict( + { + "dag_id": request.dag_id, + "team_name": ( + DagModel.get_team_name(request.dag_id) + if conf.getboolean("core", "multi_team") + else None + ), + } + ), + ) def _execute_task_callbacks(dagbag: DagBag, request: TaskCallbackRequest, log: FilteringBoundLogger) -> None: diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py b/airflow-core/src/airflow/jobs/scheduler_job_runner.py index 16ed5ebaae566..bb23afd10754c 100644 --- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py +++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py @@ -2910,26 +2910,25 @@ def _create_dag_runs( records=consumed_asset_records, dag_id=dag_model.dag_id, session=session ) - if data_interval is not None: - skipped_summary = self._collect_skipped_intervals( - serdag=serdag, - new_info=next_info, - session=session, - listener_has_impls=listener_has_impls, - ) - if skipped_summary is not None: - if listener_has_impls: - skipped_intervals_listener_events.append((serdag.dag_id, skipped_summary)) - if serdag.has_on_skipped_intervals_callback: - skip_callback_requests.append( - DagSkippedIntervalsCallbackRequest.from_summary( - filepath=dag_model.relative_fileloc or "", - bundle_name=dag_model.bundle_name, - bundle_version=created_run.bundle_version, - dag_id=serdag.dag_id, - summary=skipped_summary, - ) + skipped_summary = self._collect_skipped_intervals( + serdag=serdag, + new_info=next_info, + session=session, + listener_has_impls=listener_has_impls, + ) + if skipped_summary is not None: + if listener_has_impls: + skipped_intervals_listener_events.append((serdag.dag_id, skipped_summary)) + if serdag.has_on_skipped_intervals_callback: + skip_callback_requests.append( + DagSkippedIntervalsCallbackRequest.from_summary( + filepath=dag_model.relative_fileloc or "", + bundle_name=dag_model.bundle_name, + bundle_version=created_run.bundle_version, + dag_id=serdag.dag_id, + summary=skipped_summary, ) + ) # Exceptions like ValueError, ParamValidationError, etc. are raised by # DagModel.create_dagrun() when dag is misconfigured. The scheduler should not diff --git a/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py b/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py index 9b1a7e8c9672d..910faec1bde49 100644 --- a/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py +++ b/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py @@ -120,7 +120,7 @@ class AddFirstTaskRescheduleStartDateToSupervisorTIRunContext(VersionChange): class AddDagSkippedIntervalsCallbackRequest(VersionChange): - """Introduce ``DagSkippedIntervalsCallbackRequest`` in ``CallbackRequest`` union.""" + """Introduce ``DagSkippedIntervalsCallbackRequest`` in the ``CallbackRequest`` union.""" description = __doc__ From 1afffe108a10361bd4b7e14ad456d9b162ab3bc8 Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:24:16 -0500 Subject: [PATCH 24/29] Rename on_intervals_skipped listener hook to on_dag_skipped_intervals --- .../docs/administration-and-deployment/listeners.rst | 10 +++++----- airflow-core/docs/core-concepts/dag-run.rst | 2 +- airflow-core/docs/howto/listener-plugin.rst | 4 ++-- airflow-core/newsfragments/68359.feature.rst | 2 +- .../src/airflow/example_dags/plugins/event_listener.py | 6 +++--- airflow-core/src/airflow/jobs/scheduler_job_runner.py | 4 ++-- airflow-core/src/airflow/listeners/listener.py | 2 +- airflow-core/src/airflow/listeners/spec/dag.py | 2 +- .../tests/unit/listeners/skipped_intervals_listener.py | 2 +- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/airflow-core/docs/administration-and-deployment/listeners.rst b/airflow-core/docs/administration-and-deployment/listeners.rst index 43364e8eb7b9d..55c839059a16b 100644 --- a/airflow-core/docs/administration-and-deployment/listeners.rst +++ b/airflow-core/docs/administration-and-deployment/listeners.rst @@ -69,18 +69,18 @@ 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 +``on_dag_skipped_intervals`` 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. Like the callback, it does not fire on a Dag's first automated run (no previous run to compare against). -- ``on_intervals_skipped`` +- ``on_dag_skipped_intervals`` .. 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] + :start-after: [START howto_listen_dag_skipped_intervals] + :end-before: [END howto_listen_dag_skipped_intervals] TaskInstance State Change Events @@ -225,5 +225,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 | +| 3.3.0 | ``on_dag_skipped_intervals`` | New listener method added; fires when a Dag with ``catchup=False`` skips scheduled intervals | +-----------------+--------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------+ diff --git a/airflow-core/docs/core-concepts/dag-run.rst b/airflow-core/docs/core-concepts/dag-run.rst index f0f62aabc77c2..f505747dc47e1 100644 --- a/airflow-core/docs/core-concepts/dag-run.rst +++ b/airflow-core/docs/core-concepts/dag-run.rst @@ -129,7 +129,7 @@ If your Dag is not written to handle its catchup (i.e., not limited to the inter 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 `) or implement the ``on_intervals_skipped`` listener hook (see :doc:`Listeners `). +To observe those skips, set ``on_skipped_intervals_callback`` on the Dag (see :doc:`Callbacks `) or implement the ``on_dag_skipped_intervals`` listener hook (see :doc:`Listeners `). .. code-block:: python diff --git a/airflow-core/docs/howto/listener-plugin.rst b/airflow-core/docs/howto/listener-plugin.rst index d1f6864603583..9278ef40a975a 100644 --- a/airflow-core/docs/howto/listener-plugin.rst +++ b/airflow-core/docs/howto/listener-plugin.rst @@ -96,8 +96,8 @@ This example listens when the scheduler skips scheduled intervals for a Dag with .. 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] + :start-after: [START howto_listen_dag_skipped_intervals] + :end-before: [END howto_listen_dag_skipped_intervals] 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. diff --git a/airflow-core/newsfragments/68359.feature.rst b/airflow-core/newsfragments/68359.feature.rst index 2aa9509400f36..c2f778eb17c11 100644 --- a/airflow-core/newsfragments/68359.feature.rst +++ b/airflow-core/newsfragments/68359.feature.rst @@ -1 +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. +Add ``on_skipped_intervals_callback`` for Dags with ``catchup=False`` and the ``on_dag_skipped_intervals`` listener hook, so operators are notified when the scheduler advances past missed scheduled intervals without creating Dag runs. diff --git a/airflow-core/src/airflow/example_dags/plugins/event_listener.py b/airflow-core/src/airflow/example_dags/plugins/event_listener.py index 2a90d57534125..51ef38375eb5b 100644 --- a/airflow-core/src/airflow/example_dags/plugins/event_listener.py +++ b/airflow-core/src/airflow/example_dags/plugins/event_listener.py @@ -224,9 +224,9 @@ def on_dag_run_running(dag_run: DagRun, msg: str): # [END howto_listen_dagrun_running_task] -# [START howto_listen_intervals_skipped] +# [START howto_listen_dag_skipped_intervals] @hookimpl -def on_intervals_skipped(dag_id: str, summary): +def on_dag_skipped_intervals(dag_id: str, summary): """ Called when a Dag with catchup=False skips one or more scheduled intervals. @@ -241,4 +241,4 @@ def on_intervals_skipped(dag_id: str, summary): ) -# [END howto_listen_intervals_skipped] +# [END howto_listen_dag_skipped_intervals] diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py b/airflow-core/src/airflow/jobs/scheduler_job_runner.py index bb23afd10754c..25fe66392a674 100644 --- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py +++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py @@ -2770,7 +2770,7 @@ def _create_dag_runs( skip_callback_requests: list[DagSkippedIntervalsCallbackRequest] = [] skipped_intervals_listener_events: list[tuple[str, SkippedIntervalsSummary]] = [] listener_has_impls = bool( - get_listener_manager().hook.on_intervals_skipped.get_hookimpls() # type: ignore[attr-defined] + get_listener_manager().hook.on_dag_skipped_intervals.get_hookimpls() # type: ignore[attr-defined] ) # Bulk Fetch DagRuns with dag_id and logical_date same # as DagModel.dag_id and DagModel.next_dagrun @@ -2955,7 +2955,7 @@ def _notify_skipped_intervals_listeners( ) -> None: for dag_id, summary in events: try: - get_listener_manager().hook.on_intervals_skipped( # type: ignore[attr-defined] + get_listener_manager().hook.on_dag_skipped_intervals( # type: ignore[attr-defined] dag_id=dag_id, summary=summary, ) diff --git a/airflow-core/src/airflow/listeners/listener.py b/airflow-core/src/airflow/listeners/listener.py index 798312a0a6c4a..166768e03b717 100644 --- a/airflow-core/src/airflow/listeners/listener.py +++ b/airflow-core/src/airflow/listeners/listener.py @@ -32,7 +32,7 @@ def get_listener_manager() -> ListenerManager: Registers the following listeners: - lifecycle: on_starting, before_stopping - - dag: on_intervals_skipped + - dag: on_dag_skipped_intervals - dagrun: on_dag_run_running, on_dag_run_success, on_dag_run_failed - taskinstance: on_task_instance_running, on_task_instance_success, etc. - asset: on_asset_created, on_asset_changed, etc. diff --git a/airflow-core/src/airflow/listeners/spec/dag.py b/airflow-core/src/airflow/listeners/spec/dag.py index 51f5ad7c09845..194f22ded8c07 100644 --- a/airflow-core/src/airflow/listeners/spec/dag.py +++ b/airflow-core/src/airflow/listeners/spec/dag.py @@ -27,5 +27,5 @@ @hookspec -def on_intervals_skipped(dag_id: str, summary: SkippedIntervalsSummary): +def on_dag_skipped_intervals(dag_id: str, summary: SkippedIntervalsSummary): """Execute when a Dag with catchup=False skips one or more scheduled intervals.""" diff --git a/airflow-core/tests/unit/listeners/skipped_intervals_listener.py b/airflow-core/tests/unit/listeners/skipped_intervals_listener.py index 4b0f52069eb59..521ef59fe5745 100644 --- a/airflow-core/tests/unit/listeners/skipped_intervals_listener.py +++ b/airflow-core/tests/unit/listeners/skipped_intervals_listener.py @@ -28,7 +28,7 @@ @hookimpl -def on_intervals_skipped(dag_id: str, summary: SkippedIntervalsSummary): +def on_dag_skipped_intervals(dag_id: str, summary: SkippedIntervalsSummary): events.append((dag_id, summary)) From 5e5c64ec8609e843cc9b7c428fed4352ba7ee965 Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:42:32 -0500 Subject: [PATCH 25/29] Regenerate ts-sdk supervisor --- ts-sdk/src/generated/supervisor.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/ts-sdk/src/generated/supervisor.ts b/ts-sdk/src/generated/supervisor.ts index 58d95300310dd..86d069cd64471 100644 --- a/ts-sdk/src/generated/supervisor.ts +++ b/ts-sdk/src/generated/supervisor.ts @@ -283,10 +283,7 @@ export type Msg2 = string | null; export type EmailType = "failure" | "retry"; export type Type14 = "EmailRequest"; export type CallbackRequests = ( - | DagCallbackRequest - | DagSkippedIntervalsCallbackRequest - | TaskCallbackRequest - | EmailRequest + DagCallbackRequest | DagSkippedIntervalsCallbackRequest | TaskCallbackRequest | EmailRequest )[]; export type Type15 = "DagFileParseRequest"; export type Fileloc = string; From bd0c1cf67f3aa27c440d084aefc6cfa68291ccb0 Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:09:05 -0500 Subject: [PATCH 26/29] Fix skipped-interval notifications missed for CronTriggerTimetable gaps _collect_skipped_intervals() ran after create_dagrun(), so SQLAlchemy autoflush exposed the new Dag run to the previous-run lookup. With zero-width CronTriggerTimetable intervals, that run matched data_interval_end <= new_info.data_interval.start and was treated as its own predecessor, suppressing on_skipped_intervals_callback and listener notifications when the scheduler jumped multiple periods. Look up skipped intervals before creating the Dag run, and add an end-to-end scheduler test for multi-day cron gaps. --- .../src/airflow/jobs/scheduler_job_runner.py | 12 +++--- .../tests/unit/jobs/test_scheduler_job.py | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py b/airflow-core/src/airflow/jobs/scheduler_job_runner.py index 25fe66392a674..dfed8a993922b 100644 --- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py +++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py @@ -2879,6 +2879,12 @@ def _create_dag_runs( logical_date = next_info.logical_date partition_key = next_info.partition_key run_after = next_info.run_after + skipped_summary = self._collect_skipped_intervals( + serdag=serdag, + new_info=next_info, + session=session, + listener_has_impls=listener_has_impls, + ) created_run = serdag.create_dagrun( run_id=serdag.timetable.generate_run_id( run_type=DagRunType.SCHEDULED, @@ -2910,12 +2916,6 @@ def _create_dag_runs( records=consumed_asset_records, dag_id=dag_model.dag_id, session=session ) - skipped_summary = self._collect_skipped_intervals( - serdag=serdag, - new_info=next_info, - session=session, - listener_has_impls=listener_has_impls, - ) if skipped_summary is not None: if listener_has_impls: skipped_intervals_listener_events.append((serdag.dag_id, skipped_summary)) diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py b/airflow-core/tests/unit/jobs/test_scheduler_job.py index 265fe637d2035..7793e16462142 100644 --- a/airflow-core/tests/unit/jobs/test_scheduler_job.py +++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py @@ -12242,6 +12242,49 @@ def test_create_dag_runs_does_not_emit_skipped_intervals_for_consecutive_cron_ru assert notifications.skip_callback_requests == [] assert notifications.skipped_intervals_listener_events == [] + @conf_vars({("scheduler", "create_cron_data_intervals"): "False"}) + def test_create_dag_runs_emits_skipped_intervals_for_multi_day_cron_gap(self, session, dag_maker): + """CronTriggerTimetable gaps must fire skip callbacks via _create_dag_runs().""" + with dag_maker( + dag_id="test_multi_day_cron_gap_skip", + schedule="@daily", + start_date=DEFAULT_DATE, + catchup=False, + on_skipped_intervals_callback=lambda ctx: None, + session=session, + ) as dag: + EmptyOperator(task_id="dummy") + + dag_maker.create_dagrun( + run_type=DagRunType.SCHEDULED, + logical_date=DEFAULT_DATE, + data_interval=DataInterval.exact(DEFAULT_DATE), + state=State.SUCCESS, + ) + + new_start = DEFAULT_DATE + timedelta(days=3) + dag_model = dag_maker.dag_model + dag_model.next_dagrun = new_start + dag_model.next_dagrun_data_interval = DataInterval.exact(new_start) + dag_model.next_dagrun_create_after = new_start + session.merge(dag_model) + session.commit() + + scheduler_job = Job() + self.job_runner = SchedulerJobRunner(job=scheduler_job, executors=[MockExecutor(do_update=False)]) + notifications = self.job_runner._create_dag_runs([dag_model], session) + session.commit() + + skip_requests = notifications.skip_callback_requests + + assert len(skip_requests) == 1 + request = skip_requests[0] + assert isinstance(request, DagSkippedIntervalsCallbackRequest) + assert request.dag_id == dag.dag_id + assert request.filepath == dag_model.relative_fileloc + assert request.bundle_name == dag_model.bundle_name + assert request.skipped_range == (DEFAULT_DATE, new_start) + def test_create_dag_runs_returns_skipped_intervals_callback_request(self, session, dag_maker): with dag_maker( dag_id="test_create_dag_runs_skipped_intervals_callback", From 86f579fd44221bd655806f4df0677c4051627491 Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:43:46 -0400 Subject: [PATCH 27/29] Sync schema and supervisor files --- go-sdk/internal/genspec/authoring.go | 43 +++++++------- go-sdk/schema/supervisor-schema.json | 88 ++++++++++++++++++++++++++++ ts-sdk/schema/dag-schema.json | 1 + ts-sdk/schema/supervisor-schema.json | 88 ++++++++++++++++++++++++++++ 4 files changed, 199 insertions(+), 21 deletions(-) diff --git a/go-sdk/internal/genspec/authoring.go b/go-sdk/internal/genspec/authoring.go index 463eef2e44021..025392e7ce244 100644 --- a/go-sdk/internal/genspec/authoring.go +++ b/go-sdk/internal/genspec/authoring.go @@ -72,27 +72,28 @@ var authoringShapes = map[string]authoringShape{ var dagShape = authoringShape{ doc: "DagSpec holds the attributes of a Dag other than its dag_id. Dag takes one.", exclude: map[string]string{ - "dag_id": "a positional parameter of airflow.Dag, not a spec field", - "fileloc": "the path of the Dag file, which the bundle fills in", - "relative_fileloc": "the path of the Dag file, which the bundle fills in", - "_processor_dags_folder": "the Dag processor's own folder, filled in at parse time", - "bundle_name": "the name of the bundle that carries the Dag, not the Dag's", - "tasks": "the tasks dag.Task registers", - "task_group": "the groups dag.TaskGroup registers", - "edge_info": "the labels airflow.Label carries into an edge verb", - "dag_dependencies": "derived from the edges and the assets a Dag declares", - "timezone": "always UTC for a Go Dag, even when StartDate has another location", - "timetable": "the serialized form of Schedule, which is injected instead", - "allowed_run_types": "no Go authoring type yet: a list of DagRunType values", - "_concurrency": "the pre-2.2 spelling of MaxActiveTasks", - "has_on_success_callback": "derived from whether a callback is registered", - "has_on_failure_callback": "derived from whether a callback is registered", - "params": "no Go authoring type yet: a param carries a schema of its own", - "default_args": "no Go authoring type yet: the values are arbitrary and untyped", - "access_control": "no Go authoring type yet, and it is deprecated in Airflow 3", - "owner_links": "no Go authoring type yet: an object of arbitrary link targets", - "deadline": "no Go authoring type yet: a serialized deadline reference", - "rerun_with_latest_version": "no Go authoring type yet: the tri-state a null allows", + "dag_id": "a positional parameter of airflow.Dag, not a spec field", + "fileloc": "the path of the Dag file, which the bundle fills in", + "relative_fileloc": "the path of the Dag file, which the bundle fills in", + "_processor_dags_folder": "the Dag processor's own folder, filled in at parse time", + "bundle_name": "the name of the bundle that carries the Dag, not the Dag's", + "tasks": "the tasks dag.Task registers", + "task_group": "the groups dag.TaskGroup registers", + "edge_info": "the labels airflow.Label carries into an edge verb", + "dag_dependencies": "derived from the edges and the assets a Dag declares", + "timezone": "always UTC for a Go Dag, even when StartDate has another location", + "timetable": "the serialized form of Schedule, which is injected instead", + "allowed_run_types": "no Go authoring type yet: a list of DagRunType values", + "_concurrency": "the pre-2.2 spelling of MaxActiveTasks", + "has_on_success_callback": "derived from whether a callback is registered", + "has_on_failure_callback": "derived from whether a callback is registered", + "has_on_skipped_intervals_callback": "derived from whether a callback is registered", + "params": "no Go authoring type yet: a param carries a schema of its own", + "default_args": "no Go authoring type yet: the values are arbitrary and untyped", + "access_control": "no Go authoring type yet, and it is deprecated in Airflow 3", + "owner_links": "no Go authoring type yet: an object of arbitrary link targets", + "deadline": "no Go authoring type yet: a serialized deadline reference", + "rerun_with_latest_version": "no Go authoring type yet: the tri-state a null allows", }, override: map[string]propertyOverride{ "start_date": { diff --git a/go-sdk/schema/supervisor-schema.json b/go-sdk/schema/supervisor-schema.json index 77888c5adfdea..228624fa92bab 100644 --- a/go-sdk/schema/supervisor-schema.json +++ b/go-sdk/schema/supervisor-schema.json @@ -938,6 +938,7 @@ "discriminator": { "mapping": { "DagCallbackRequest": "#/$defs/DagCallbackRequest", + "DagSkippedIntervalsCallbackRequest": "#/$defs/DagSkippedIntervalsCallbackRequest", "EmailRequest": "#/$defs/EmailRequest", "TaskCallbackRequest": "#/$defs/TaskCallbackRequest" }, @@ -947,6 +948,9 @@ { "$ref": "#/$defs/DagCallbackRequest" }, + { + "$ref": "#/$defs/DagSkippedIntervalsCallbackRequest" + }, { "$ref": "#/$defs/TaskCallbackRequest" }, @@ -1507,6 +1511,90 @@ "title": "DagRunType", "type": "string" }, + "DagSkippedIntervalsCallbackRequest": { + "description": "Store skipped intervals callback data for execution by the Dag processor.", + "properties": { + "filepath": { + "title": "Filepath", + "type": "string" + }, + "bundle_name": { + "title": "Bundle Name", + "type": "string" + }, + "bundle_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Bundle Version" + }, + "version_data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Version Data" + }, + "msg": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Msg" + }, + "dag_id": { + "title": "Dag Id", + "type": "string" + }, + "skipped_range": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "format": "date-time", + "type": "string" + }, + { + "format": "date-time", + "type": "string" + } + ], + "title": "Skipped Range", + "type": "array" + }, + "type": { + "const": "DagSkippedIntervalsCallbackRequest", + "default": "DagSkippedIntervalsCallbackRequest", + "title": "Type", + "type": "string" + } + }, + "required": [ + "filepath", + "bundle_name", + "bundle_version", + "dag_id", + "skipped_range" + ], + "title": "DagSkippedIntervalsCallbackRequest", + "type": "object" + }, "DagSourceCode": { "description": "Raw source code and its language identifier for a DAG definition.", "properties": { diff --git a/ts-sdk/schema/dag-schema.json b/ts-sdk/schema/dag-schema.json index ed2251ce3dac7..51b8414ab5818 100644 --- a/ts-sdk/schema/dag-schema.json +++ b/ts-sdk/schema/dag-schema.json @@ -237,6 +237,7 @@ "is_paused_upon_creation": { "type": "boolean" }, "has_on_success_callback": { "type": "boolean", "default": false }, "has_on_failure_callback": { "type": "boolean", "default": false }, + "has_on_skipped_intervals_callback": { "type": "boolean", "default": false }, "render_template_as_native_obj": { "type": "boolean", "default": false }, "tags": { "type": "array" }, "task_group": {"anyOf": [ diff --git a/ts-sdk/schema/supervisor-schema.json b/ts-sdk/schema/supervisor-schema.json index 77888c5adfdea..228624fa92bab 100644 --- a/ts-sdk/schema/supervisor-schema.json +++ b/ts-sdk/schema/supervisor-schema.json @@ -938,6 +938,7 @@ "discriminator": { "mapping": { "DagCallbackRequest": "#/$defs/DagCallbackRequest", + "DagSkippedIntervalsCallbackRequest": "#/$defs/DagSkippedIntervalsCallbackRequest", "EmailRequest": "#/$defs/EmailRequest", "TaskCallbackRequest": "#/$defs/TaskCallbackRequest" }, @@ -947,6 +948,9 @@ { "$ref": "#/$defs/DagCallbackRequest" }, + { + "$ref": "#/$defs/DagSkippedIntervalsCallbackRequest" + }, { "$ref": "#/$defs/TaskCallbackRequest" }, @@ -1507,6 +1511,90 @@ "title": "DagRunType", "type": "string" }, + "DagSkippedIntervalsCallbackRequest": { + "description": "Store skipped intervals callback data for execution by the Dag processor.", + "properties": { + "filepath": { + "title": "Filepath", + "type": "string" + }, + "bundle_name": { + "title": "Bundle Name", + "type": "string" + }, + "bundle_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Bundle Version" + }, + "version_data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Version Data" + }, + "msg": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Msg" + }, + "dag_id": { + "title": "Dag Id", + "type": "string" + }, + "skipped_range": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "format": "date-time", + "type": "string" + }, + { + "format": "date-time", + "type": "string" + } + ], + "title": "Skipped Range", + "type": "array" + }, + "type": { + "const": "DagSkippedIntervalsCallbackRequest", + "default": "DagSkippedIntervalsCallbackRequest", + "title": "Type", + "type": "string" + } + }, + "required": [ + "filepath", + "bundle_name", + "bundle_version", + "dag_id", + "skipped_range" + ], + "title": "DagSkippedIntervalsCallbackRequest", + "type": "object" + }, "DagSourceCode": { "description": "Raw source code and its language identifier for a DAG definition.", "properties": { From 51c4d1b2a7f56cd36d758f471cdb800f22a87af0 Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:13:55 -0400 Subject: [PATCH 28/29] Regenerate SDK schema artifacts after skipped-intervals rebase Co-authored-by: Cursor --- go-sdk/pkg/execution/genmodels/models.gen.go | 142 +++--- go-sdk/schema/dag-schema.json | 1 + ts-sdk/src/generated/supervisor.ts | 480 ++++++++++--------- uv.lock | 4 + 4 files changed, 320 insertions(+), 307 deletions(-) diff --git a/go-sdk/pkg/execution/genmodels/models.gen.go b/go-sdk/pkg/execution/genmodels/models.gen.go index bcc25ffe34752..5d0e3f5a55e9f 100644 --- a/go-sdk/pkg/execution/genmodels/models.gen.go +++ b/go-sdk/pkg/execution/genmodels/models.gen.go @@ -1747,19 +1747,51 @@ type TIRunContext struct { XcomKeysToClear []string `msgpack:"xcom_keys_to_clear,omitempty"` } -type TriggerKwargs map[string]interface{} +type TaskArgBinding interface{} -type Warnings []interface{} +type TaskBreadcrumbsResult struct { + // Breadcrumbs corresponds to the JSON schema field "breadcrumbs". + Breadcrumbs []TaskBreadcrumbsResultBreadcrumbsElem `msgpack:"breadcrumbs"` -type TaskArgBinding interface{} + // Type corresponds to the JSON schema field "type". + Type string `msgpack:"type,omitempty"` +} -// Variable schema for responses with fields that are needed for Runtime. -type VariableResponse struct { - // Key corresponds to the JSON schema field "key". - Key string `msgpack:"key"` +type TaskBreadcrumbsResultBreadcrumbsElem map[string]interface{} - // Value corresponds to the JSON schema field "value". - Value interface{} `msgpack:"value"` +// Task callback status information. +// +// A Class with information about the success/failure TI callback to be executed. +// Currently, only failure +// callbacks when tasks are externally killed or experience heartbeat timeouts are +// run via DagFileProcessorProcess. +type TaskCallbackRequest struct { + // BundleName corresponds to the JSON schema field "bundle_name". + BundleName string `msgpack:"bundle_name"` + + // BundleVersion corresponds to the JSON schema field "bundle_version". + BundleVersion interface{} `msgpack:"bundle_version"` + + // ContextFromServer corresponds to the JSON schema field "context_from_server". + ContextFromServer *TIRunContext `msgpack:"context_from_server,omitempty"` + + // Filepath corresponds to the JSON schema field "filepath". + Filepath string `msgpack:"filepath"` + + // Msg corresponds to the JSON schema field "msg". + Msg interface{} `msgpack:"msg,omitempty"` + + // TaskCallbackType corresponds to the JSON schema field "task_callback_type". + TaskCallbackType interface{} `msgpack:"task_callback_type,omitempty"` + + // TI corresponds to the JSON schema field "ti". + TI TaskInstance `msgpack:"ti"` + + // Type corresponds to the JSON schema field "type". + Type string `msgpack:"type,omitempty"` + + // VersionData corresponds to the JSON schema field "version_data". + VersionData *VersionData `msgpack:"version_data,omitempty"` } type TaskIds []string @@ -1803,69 +1835,24 @@ type TaskInstance struct { TryNumber int `msgpack:"try_number"` } +type TaskInstanceState string + const TaskInstanceStateAwaitingInput TaskInstanceState = "awaiting_input" const TaskInstanceStateDeferred TaskInstanceState = "deferred" -const TaskInstanceStateSkipped TaskInstanceState = "skipped" -const TaskInstanceStateUpstreamFailed TaskInstanceState = "upstream_failed" -const TaskInstanceStateUpForReschedule TaskInstanceState = "up_for_reschedule" -const TaskInstanceStateUpForRetry TaskInstanceState = "up_for_retry" const TaskInstanceStateFailed TaskInstanceState = "failed" +const TaskInstanceStateQueued TaskInstanceState = "queued" +const TaskInstanceStateRemoved TaskInstanceState = "removed" const TaskInstanceStateRestarting TaskInstanceState = "restarting" -const TaskInstanceStateSuccess TaskInstanceState = "success" const TaskInstanceStateRunning TaskInstanceState = "running" -const TaskInstanceStateQueued TaskInstanceState = "queued" const TaskInstanceStateScheduled TaskInstanceState = "scheduled" -const TaskInstanceStateRemoved TaskInstanceState = "removed" - -type TaskInstanceState string +const TaskInstanceStateSkipped TaskInstanceState = "skipped" +const TaskInstanceStateSuccess TaskInstanceState = "success" +const TaskInstanceStateUpForReschedule TaskInstanceState = "up_for_reschedule" +const TaskInstanceStateUpForRetry TaskInstanceState = "up_for_retry" +const TaskInstanceStateUpstreamFailed TaskInstanceState = "upstream_failed" type TaskOutlets []AssetProfile -type TaskBreadcrumbsResultBreadcrumbsElem map[string]interface{} - -type TaskBreadcrumbsResult struct { - // Breadcrumbs corresponds to the JSON schema field "breadcrumbs". - Breadcrumbs []TaskBreadcrumbsResultBreadcrumbsElem `msgpack:"breadcrumbs"` - - // Type corresponds to the JSON schema field "type". - Type string `msgpack:"type,omitempty"` -} - -// Task callback status information. -// -// A Class with information about the success/failure TI callback to be executed. -// Currently, only failure -// callbacks when tasks are externally killed or experience heartbeat timeouts are -// run via DagFileProcessorProcess. -type TaskCallbackRequest struct { - // BundleName corresponds to the JSON schema field "bundle_name". - BundleName string `msgpack:"bundle_name"` - - // BundleVersion corresponds to the JSON schema field "bundle_version". - BundleVersion interface{} `msgpack:"bundle_version"` - - // ContextFromServer corresponds to the JSON schema field "context_from_server". - ContextFromServer *TIRunContext `msgpack:"context_from_server,omitempty"` - - // Filepath corresponds to the JSON schema field "filepath". - Filepath string `msgpack:"filepath"` - - // Msg corresponds to the JSON schema field "msg". - Msg interface{} `msgpack:"msg,omitempty"` - - // TaskCallbackType corresponds to the JSON schema field "task_callback_type". - TaskCallbackType interface{} `msgpack:"task_callback_type,omitempty"` - - // TI corresponds to the JSON schema field "ti". - TI TaskInstance `msgpack:"ti"` - - // Type corresponds to the JSON schema field "type". - Type string `msgpack:"type,omitempty"` - - // VersionData corresponds to the JSON schema field "version_data". - VersionData *VersionData `msgpack:"version_data,omitempty"` -} - // Response containing the first reschedule date for a task instance. type TaskRescheduleStartDate struct { // StartDate corresponds to the JSON schema field "start_date". @@ -1875,12 +1862,6 @@ type TaskRescheduleStartDate struct { Type string `msgpack:"type,omitempty"` } -type TaskStateState string - -const TaskStateStateFailed TaskStateState = "failed" -const TaskStateStateSkipped TaskStateState = "skipped" -const TaskStateStateRemoved TaskStateState = "removed" - // Update a task's state. // // If a process exits without sending one of these the state will be derived from @@ -1904,6 +1885,12 @@ type TaskState struct { Type string `msgpack:"type,omitempty"` } +type TaskStateState string + +const TaskStateStateFailed TaskStateState = "failed" +const TaskStateStateRemoved TaskStateState = "removed" +const TaskStateStateSkipped TaskStateState = "skipped" + // Response to GetTaskStateStore; wraps the generated API response for supervisor // to worker comms. type TaskStateStoreResult struct { @@ -1953,6 +1940,21 @@ type TriggerDagRun struct { Type string `msgpack:"type,omitempty"` } +type VersionData map[string]interface{} + +// Variable schema for responses with fields that are needed for Runtime. +type VariableResponse struct { + // Key corresponds to the JSON schema field "key". + Key string `msgpack:"key"` + + // Value corresponds to the JSON schema field "value". + Value interface{} `msgpack:"value"` +} + +type Warnings []interface{} + +type TriggerKwargs map[string]interface{} + type UpdateDagRunNote struct { // Note corresponds to the JSON schema field "note". Note interface{} `msgpack:"note"` @@ -1964,8 +1966,6 @@ type UpdateDagRunNote struct { Type string `msgpack:"type,omitempty"` } -type VersionData map[string]interface{} - // Update the response content part of an existing Human-in-the-loop response. type UpdateHITLDetail struct { // ChosenOptions corresponds to the JSON schema field "chosen_options". diff --git a/go-sdk/schema/dag-schema.json b/go-sdk/schema/dag-schema.json index ed2251ce3dac7..51b8414ab5818 100644 --- a/go-sdk/schema/dag-schema.json +++ b/go-sdk/schema/dag-schema.json @@ -237,6 +237,7 @@ "is_paused_upon_creation": { "type": "boolean" }, "has_on_success_callback": { "type": "boolean", "default": false }, "has_on_failure_callback": { "type": "boolean", "default": false }, + "has_on_skipped_intervals_callback": { "type": "boolean", "default": false }, "render_template_as_native_obj": { "type": "boolean", "default": false }, "tags": { "type": "array" }, "task_group": {"anyOf": [ diff --git a/ts-sdk/src/generated/supervisor.ts b/ts-sdk/src/generated/supervisor.ts index 86d069cd64471..d55d81caee217 100644 --- a/ts-sdk/src/generated/supervisor.ts +++ b/ts-sdk/src/generated/supervisor.ts @@ -209,6 +209,20 @@ export type VersionData2 = { [k: string]: unknown; } | null; export type Msg1 = string | null; +export type DagId4 = string; +/** + * @minItems 2 + * @maxItems 2 + */ +export type SkippedRange = [unknown, unknown]; +export type Type13 = "DagSkippedIntervalsCallbackRequest"; +export type Filepath2 = string; +export type BundleName3 = string; +export type BundleVersion2 = string | null; +export type VersionData3 = { + [k: string]: unknown; +} | null; +export type Msg2 = string | null; /** * All possible states that a Task Instance can be in. * @@ -266,26 +280,20 @@ export type Name9 = string; export type Kind1 = "literal"; export type FromDefault = boolean; export type MultiTeam = boolean; -/** - * @minItems 2 - * @maxItems 2 - */ -export type SkippedRange = [string, string]; -export type TypeDagSkippedIntervalsCallbackRequest = "DagSkippedIntervalsCallbackRequest"; -export type Type13 = "TaskCallbackRequest"; -export type Filepath2 = string; -export type BundleName3 = string; -export type BundleVersion2 = string | null; -export type VersionData3 = { +export type Type14 = "TaskCallbackRequest"; +export type Filepath3 = string; +export type BundleName4 = string; +export type BundleVersion3 = string | null; +export type VersionData4 = { [k: string]: unknown; } | null; -export type Msg2 = string | null; +export type Msg3 = string | null; export type EmailType = "failure" | "retry"; -export type Type14 = "EmailRequest"; +export type Type15 = "EmailRequest"; export type CallbackRequests = ( DagCallbackRequest | DagSkippedIntervalsCallbackRequest | TaskCallbackRequest | EmailRequest )[]; -export type Type15 = "DagFileParseRequest"; +export type Type16 = "DagFileParseRequest"; export type Fileloc = string; export type LastLoaded = string | null; export type SerializedDags = LazyDeserializedDAG[]; @@ -296,17 +304,17 @@ export type ImportErrors = { export type ParsedDefinitions = string[]; export type SourceCode = string; export type Language = string; -export type Type16 = "DagFileParsingResult"; -export type DagId4 = string; +export type Type17 = "DagFileParsingResult"; +export type DagId5 = string; export type IsPaused = boolean; -export type BundleName4 = string | null; -export type BundleVersion3 = string | null; +export type BundleName5 = string | null; +export type BundleVersion4 = string | null; export type RelativeFileloc = string | null; export type Owners = string | null; export type Tags = string[]; export type NextDagrun = string | null; -export type Type17 = "DagResult"; -export type DagId5 = string; +export type Type18 = "DagResult"; +export type DagId6 = string; export type RunId4 = string; export type LogicalDate2 = string | null; export type DataIntervalStart2 = string | null; @@ -324,8 +332,8 @@ export type PartitionKey4 = string | null; export type PartitionDate1 = string | null; export type Note1 = string | null; export type TeamName1 = string | null; -export type Type18 = "DagRunResult"; -export type Type19 = "DagRunStateResult"; +export type Type19 = "DagRunResult"; +export type Type20 = "DagRunStateResult"; export type State2 = "deferred"; export type Classpath = string; export type TriggerKwargs = @@ -341,24 +349,24 @@ export type NextKwargs2 = { [k: string]: JsonValue | null; } | null; export type RenderedMapIndex1 = string | null; -export type Type20 = "DeferTask"; +export type Type21 = "DeferTask"; export type Name10 = string; export type Key1 = string; -export type Type21 = "DeleteAssetStateStoreByName"; +export type Type22 = "DeleteAssetStateStoreByName"; export type Uri5 = string; export type Key2 = string; -export type Type22 = "DeleteAssetStateStoreByUri"; +export type Type23 = "DeleteAssetStateStoreByUri"; export type TiId2 = string; export type Key3 = string; -export type Type23 = "DeleteTaskStateStore"; +export type Type24 = "DeleteTaskStateStore"; export type Key4 = string; -export type Type24 = "DeleteVariable"; +export type Type25 = "DeleteVariable"; export type Key5 = string; -export type DagId6 = string; +export type DagId7 = string; export type RunId5 = string; export type TaskId2 = string; export type MapIndex1 = number | null; -export type Type25 = "DeleteXCom"; +export type Type26 = "DeleteXCom"; /** * Error types used in the API client. */ @@ -376,7 +384,7 @@ export type ErrorType = export type Detail = { [k: string]: unknown; } | null; -export type Type26 = "ErrorResponse"; +export type Type27 = "ErrorResponse"; /** * Error types used in the API client. * @@ -395,9 +403,9 @@ export type ErrorType1 = | "GENERIC_ERROR" | "API_SERVER_ERROR"; export type Name11 = string; -export type Type27 = "GetAssetByName"; +export type Type28 = "GetAssetByName"; export type Uri6 = string; -export type Type28 = "GetAssetByUri"; +export type Type29 = "GetAssetByUri"; export type Name12 = string | null; export type Uri7 = string | null; export type After = string | null; @@ -409,7 +417,7 @@ export type PartitionKeyRegexpPattern = string | null; export type Extra7 = { [k: string]: string; } | null; -export type Type29 = "GetAssetEventByAsset"; +export type Type30 = "GetAssetEventByAsset"; export type AliasName = string; export type After1 = string | null; export type Before1 = string | null; @@ -420,104 +428,104 @@ export type PartitionKeyRegexpPattern1 = string | null; export type Extra8 = { [k: string]: string; } | null; -export type Type30 = "GetAssetEventByAssetAlias"; +export type Type31 = "GetAssetEventByAssetAlias"; export type Name13 = string; export type Key6 = string; -export type Type31 = "GetAssetStateStoreByName"; +export type Type32 = "GetAssetStateStoreByName"; export type Uri8 = string; export type Key7 = string; -export type Type32 = "GetAssetStateStoreByUri"; +export type Type33 = "GetAssetStateStoreByUri"; export type AliasName1 = string; -export type Type33 = "GetAssetsByAlias"; +export type Type34 = "GetAssetsByAlias"; export type ConnId2 = string; -export type Type34 = "GetConnection"; -export type DagId7 = string; +export type Type35 = "GetConnection"; +export type DagId8 = string; export type LogicalDates = string[] | null; export type RunIds = string[] | null; export type States = string[] | null; -export type Type35 = "GetDRCount"; -export type DagId8 = string; -export type Type36 = "GetDag"; +export type Type36 = "GetDRCount"; export type DagId9 = string; -export type RunId6 = string; -export type Type37 = "GetDagRun"; +export type Type37 = "GetDag"; export type DagId10 = string; +export type RunId6 = string; +export type Type38 = "GetDagRun"; +export type DagId11 = string; export type RunId7 = string; -export type Type38 = "GetDagRunState"; +export type Type39 = "GetDagRunState"; export type TiId3 = string; -export type Type39 = "GetHITLDetailResponse"; +export type Type40 = "GetHITLDetailResponse"; export type TiId4 = string; -export type Type40 = "GetPrevSuccessfulDagRun"; -export type DagId11 = string; +export type Type41 = "GetPrevSuccessfulDagRun"; +export type DagId12 = string; export type LogicalDate3 = string; export type State3 = string | null; -export type Type41 = "GetPreviousDagRun"; -export type DagId12 = string; +export type Type42 = "GetPreviousDagRun"; +export type DagId13 = string; export type TaskId3 = string; export type LogicalDate4 = string | null; export type MapIndex2 = number; -export type Type42 = "GetPreviousTI"; -export type DagId13 = string; +export type Type43 = "GetPreviousTI"; +export type DagId14 = string; export type MapIndex3 = number | null; export type TaskIds = string[] | null; export type TaskGroupId = string | null; export type LogicalDates1 = string[] | null; export type RunIds1 = string[] | null; export type States1 = string[] | null; -export type Type43 = "GetTICount"; -export type DagId14 = string; +export type Type44 = "GetTICount"; +export type DagId15 = string; export type RunId8 = string; -export type Type44 = "GetTaskBreadcrumbs"; +export type Type45 = "GetTaskBreadcrumbs"; export type TiId5 = string; export type TryNumber1 = number; -export type Type45 = "GetTaskRescheduleStartDate"; +export type Type46 = "GetTaskRescheduleStartDate"; export type TiId6 = string; export type Key8 = string; -export type Type46 = "GetTaskStateStore"; -export type DagId15 = string; +export type Type47 = "GetTaskStateStore"; +export type DagId16 = string; export type MapIndex4 = number | null; export type TaskIds1 = string[] | null; export type TaskGroupId1 = string | null; export type LogicalDates2 = string[] | null; export type RunIds2 = string[] | null; -export type Type47 = "GetTaskStates"; +export type Type48 = "GetTaskStates"; export type Key9 = string; -export type Type48 = "GetVariable"; +export type Type49 = "GetVariable"; export type Prefix = string | null; export type Limit2 = number; export type Offset = number; -export type Type49 = "GetVariableKeys"; +export type Type50 = "GetVariableKeys"; export type PreviousIteration = boolean; export type Key10 = string; -export type DagId16 = string; +export type DagId17 = string; export type RunId9 = string; export type TaskId4 = string; export type MapIndex5 = number | null; export type IncludePriorDates = boolean; -export type Type50 = "GetXCom"; +export type Type51 = "GetXCom"; export type PreviousIteration1 = boolean; export type Key11 = string; -export type DagId17 = string; +export type DagId18 = string; export type RunId10 = string; export type TaskId5 = string; -export type Type51 = "GetXComCount"; +export type Type52 = "GetXComCount"; export type PreviousIteration2 = boolean; export type Key12 = string; -export type DagId18 = string; +export type DagId19 = string; export type RunId11 = string; export type TaskId6 = string; export type Offset1 = number; -export type Type52 = "GetXComSequenceItem"; +export type Type53 = "GetXComSequenceItem"; export type PreviousIteration3 = boolean; export type Key13 = string; -export type DagId19 = string; +export type DagId20 = string; export type RunId12 = string; export type TaskId7 = string; export type Start = number | null; export type Stop = number | null; export type Step = number | null; export type IncludePriorDates1 = boolean; -export type Type53 = "GetXComSequenceSlice"; +export type Type54 = "GetXComSequenceSlice"; export type TiId7 = string; /** * @minItems 1 @@ -531,21 +539,21 @@ export type Params1 = { [k: string]: unknown; } | null; export type AssignedUsers1 = HITLUser[] | null; -export type Type54 = "HITLDetailRequestResult"; +export type Type55 = "HITLDetailRequestResult"; export type InactiveAssets = AssetProfile[] | null; -export type Type55 = "InactiveAssetsResult"; +export type Type56 = "InactiveAssetsResult"; export type Name14 = string | null; -export type Type56 = "MaskSecret"; +export type Type57 = "MaskSecret"; export type Ok = boolean; -export type Type57 = "OKResponse"; +export type Type58 = "OKResponse"; export type DataIntervalStart3 = string | null; export type DataIntervalEnd3 = string | null; export type StartDate4 = string | null; export type EndDate3 = string | null; -export type Type58 = "PrevSuccessfulDagRunResult"; -export type Type59 = "PreviousDagRunResult"; +export type Type59 = "PrevSuccessfulDagRunResult"; +export type Type60 = "PreviousDagRunResult"; export type TaskId8 = string; -export type DagId20 = string; +export type DagId21 = string; export type RunId13 = string; export type LogicalDate5 = string | null; export type StartDate5 = string | null; @@ -556,53 +564,53 @@ export type MapIndex6 = number | null; export type RegionId1 = string | null; export type RegionIndex1 = number | null; export type Duration = number | null; -export type Type60 = "PreviousTIResult"; +export type Type61 = "PreviousTIResult"; export type Key14 = string; export type Value1 = string | null; export type Description = string | null; -export type Type61 = "PutVariable"; +export type Type62 = "PutVariable"; export type State5 = "up_for_reschedule"; export type RescheduleDate = string; export type EndDate5 = string; -export type Type62 = "RescheduleTask"; -export type Type63 = "ResendLoggingFD"; +export type Type63 = "RescheduleTask"; +export type Type64 = "ResendLoggingFD"; export type State6 = "up_for_retry"; export type EndDate6 = string; export type RenderedMapIndex2 = string | null; export type RetryDelaySeconds = number | null; export type RetryReason = string | null; -export type Type64 = "RetryTask"; -export type Type65 = "SentFDs"; +export type Type65 = "RetryTask"; +export type Type66 = "SentFDs"; export type Fds = number[]; export type Name15 = string; export type Key15 = string; -export type Type66 = "SetAssetStateStoreByName"; +export type Type67 = "SetAssetStateStoreByName"; export type Uri9 = string; export type Key16 = string; -export type Type67 = "SetAssetStateStoreByUri"; +export type Type68 = "SetAssetStateStoreByUri"; export type TimeoutSeconds = number; -export type Type68 = "SetExecutionTimeout"; -export type Type69 = "SetRenderedFields"; +export type Type69 = "SetExecutionTimeout"; +export type Type70 = "SetRenderedFields"; export type RenderedMapIndex3 = string; -export type Type70 = "SetRenderedMapIndex"; +export type Type71 = "SetRenderedMapIndex"; export type TiId8 = string; export type Key17 = string; export type ExpiresAt = string | null; -export type Type71 = "SetTaskStateStore"; +export type Type72 = "SetTaskStateStore"; export type Key18 = string; -export type DagId21 = string; +export type DagId22 = string; export type RunId14 = string; export type TaskId9 = string; export type MapIndex7 = number | null; export type DagResult1 = boolean; export type MappedLength = number | null; -export type Type72 = "SetXCom"; +export type Type73 = "SetXCom"; export type Tasks = (string | [unknown, unknown])[]; -export type Type73 = "SkipDownstreamTasks"; +export type Type74 = "SkipDownstreamTasks"; export type DagRelPath = string; export type StartDate6 = string; export type SentryIntegration = string; -export type Type74 = "StartupDetails"; +export type Type75 = "StartupDetails"; export type State7 = "success"; export type EndDate7 = string; export type TaskOutlets = AssetProfile[] | null; @@ -612,22 +620,22 @@ export type OutletEvents = }[] | null; export type RenderedMapIndex4 = string | null; -export type Type75 = "SucceedTask"; +export type Type76 = "SucceedTask"; export type Count1 = number; -export type Type76 = "TICount"; +export type Type77 = "TICount"; export type Breadcrumbs = { [k: string]: unknown; }[]; -export type Type77 = "TaskBreadcrumbsResult"; +export type Type78 = "TaskBreadcrumbsResult"; export type StartDate7 = string | null; -export type Type78 = "TaskRescheduleStartDate"; +export type Type79 = "TaskRescheduleStartDate"; export type State8 = "failed" | "skipped" | "removed"; export type EndDate8 = string | null; -export type Type79 = "TaskState"; +export type Type80 = "TaskState"; export type RenderedMapIndex5 = string | null; export type RetryReason1 = string | null; -export type Type80 = "TaskStateStoreResult"; -export type Type81 = "TaskStatesResult"; +export type Type81 = "TaskStateStoreResult"; +export type Type82 = "TaskStatesResult"; export type LogicalDate6 = string | null; export type RunAfter2 = string | null; export type Conf2 = { @@ -636,12 +644,12 @@ export type Conf2 = { export type ResetDagRun = boolean | null; export type PartitionKey7 = string | null; export type Note2 = string | null; -export type DagId22 = string; +export type DagId23 = string; export type DagRunId = string; -export type Type82 = "TriggerDagRun"; +export type Type83 = "TriggerDagRun"; export type TiId9 = string; export type Note3 = string | null; -export type Type83 = "UpdateDagRunNote"; +export type Type84 = "UpdateDagRunNote"; export type TiId10 = string; /** * @minItems 1 @@ -650,22 +658,22 @@ export type ChosenOptions = [string, ...string[]]; export type ParamsInput = { [k: string]: unknown; } | null; -export type Type84 = "UpdateHITLDetail"; +export type Type85 = "UpdateHITLDetail"; export type TiId11 = string; -export type Type85 = "ValidateInletsAndOutlets"; +export type Type86 = "ValidateInletsAndOutlets"; export type Keys = string[]; export type TotalEntries = number; -export type Type86 = "VariableKeysResult"; +export type Type87 = "VariableKeysResult"; export type Key19 = string; export type Value2 = string | null; -export type Type87 = "VariableResult"; +export type Type88 = "VariableResult"; export type Len = number; -export type Type88 = "XComCountResponse"; +export type Type89 = "XComCountResponse"; export type Key20 = string; -export type Type89 = "XComResult"; -export type Type90 = "XComSequenceIndexResult"; +export type Type90 = "XComResult"; +export type Type91 = "XComSequenceIndexResult"; export type Root = JsonValue[]; -export type Type91 = "XComSequenceSliceResult"; +export type Type92 = "XComSequenceSliceResult"; export interface SupervisorWireSchema {} /** @@ -922,22 +930,6 @@ export interface DagCallbackRequest { is_failure_callback?: IsFailureCallback; type?: Type12; } -/** - * Store skipped intervals callback data for execution by the Dag processor. - * - * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema - * via the `definition` "DagSkippedIntervalsCallbackRequest". - */ -export interface DagSkippedIntervalsCallbackRequest { - filepath: Filepath1; - bundle_name: BundleName2; - bundle_version: BundleVersion1; - version_data?: VersionData2; - msg?: Msg1; - dag_id: DagId4; - skipped_range: SkippedRange; - type?: TypeDagSkippedIntervalsCallbackRequest; -} /** * Class to pass context info from the server to build a Execution context object. * @@ -1042,7 +1034,23 @@ export interface DagFileParseRequest { bundle_path: BundlePath; bundle_name: BundleName1; callback_requests?: CallbackRequests; - type?: Type15; + type?: Type16; +} +/** + * Store skipped intervals callback data for execution by the Dag processor. + * + * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema + * via the `definition` "DagSkippedIntervalsCallbackRequest". + */ +export interface DagSkippedIntervalsCallbackRequest { + filepath: Filepath1; + bundle_name: BundleName2; + bundle_version: BundleVersion1; + version_data?: VersionData2; + msg?: Msg1; + dag_id: DagId4; + skipped_range: SkippedRange; + type?: Type13; } /** * Task callback status information. @@ -1054,15 +1062,15 @@ export interface DagFileParseRequest { * via the `definition` "TaskCallbackRequest". */ export interface TaskCallbackRequest { - filepath: Filepath1; - bundle_name: BundleName2; - bundle_version: BundleVersion1; - version_data?: VersionData2; - msg?: Msg1; + filepath: Filepath2; + bundle_name: BundleName3; + bundle_version: BundleVersion2; + version_data?: VersionData3; + msg?: Msg2; ti: TaskInstance; task_callback_type?: TaskInstanceState | null; context_from_server?: TIRunContext | null; - type?: Type13; + type?: Type14; } /** * Response schema for TaskInstance run context. @@ -1148,15 +1156,15 @@ export interface LiteralArgBinding { * via the `definition` "EmailRequest". */ export interface EmailRequest { - filepath: Filepath2; - bundle_name: BundleName3; - bundle_version: BundleVersion2; - version_data?: VersionData3; - msg?: Msg2; + filepath: Filepath3; + bundle_name: BundleName4; + bundle_version: BundleVersion3; + version_data?: VersionData4; + msg?: Msg3; ti: TaskInstance; email_type?: EmailType; context_from_server: TIRunContext; - type?: Type14; + type?: Type15; } /** * Result of DAG File Parsing. @@ -1174,7 +1182,7 @@ export interface DagFileParsingResult { import_errors?: ImportErrors; parsed_definitions?: ParsedDefinitions; dag_source_codes?: DagSourceCodes; - type?: Type16; + type?: Type17; } /** * Lazily build information from the serialized DAG structure. @@ -1210,22 +1218,22 @@ export interface DagSourceCode { * via the `definition` "DagResult". */ export interface DagResult { - dag_id: DagId4; + dag_id: DagId5; is_paused: IsPaused; - bundle_name: BundleName4; - bundle_version: BundleVersion3; + bundle_name: BundleName5; + bundle_version: BundleVersion4; relative_fileloc: RelativeFileloc; owners: Owners; tags: Tags; next_dagrun: NextDagrun; - type?: Type17; + type?: Type18; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema * via the `definition` "DagRunResult". */ export interface DagRunResult { - dag_id: DagId5; + dag_id: DagId6; run_id: RunId4; logical_date: LogicalDate2; data_interval_start: DataIntervalStart2; @@ -1243,7 +1251,7 @@ export interface DagRunResult { partition_date?: PartitionDate1; note?: Note1; team_name?: TeamName1; - type?: Type18; + type?: Type19; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1251,7 +1259,7 @@ export interface DagRunResult { */ export interface DagRunStateResult { state: DagRunState; - type?: Type19; + type?: Type20; } /** * Update a task instance state to deferred. @@ -1268,7 +1276,7 @@ export interface DeferTask { next_method: NextMethod2; next_kwargs?: NextKwargs2; rendered_map_index?: RenderedMapIndex1; - type?: Type20; + type?: Type21; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1277,7 +1285,7 @@ export interface DeferTask { export interface DeleteAssetStateStoreByName { name: Name10; key: Key1; - type?: Type21; + type?: Type22; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1286,7 +1294,7 @@ export interface DeleteAssetStateStoreByName { export interface DeleteAssetStateStoreByUri { uri: Uri5; key: Key2; - type?: Type22; + type?: Type23; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1295,7 +1303,7 @@ export interface DeleteAssetStateStoreByUri { export interface DeleteTaskStateStore { ti_id: TiId2; key: Key3; - type?: Type23; + type?: Type24; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1303,7 +1311,7 @@ export interface DeleteTaskStateStore { */ export interface DeleteVariable { key: Key4; - type?: Type24; + type?: Type25; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1311,11 +1319,11 @@ export interface DeleteVariable { */ export interface DeleteXCom { key: Key5; - dag_id: DagId6; + dag_id: DagId7; run_id: RunId5; task_id: TaskId2; map_index?: MapIndex1; - type?: Type25; + type?: Type26; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1324,7 +1332,7 @@ export interface DeleteXCom { export interface ErrorResponse { error?: ErrorType; detail?: Detail; - type?: Type26; + type?: Type27; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1332,7 +1340,7 @@ export interface ErrorResponse { */ export interface GetAssetByName { name: Name11; - type?: Type27; + type?: Type28; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1340,7 +1348,7 @@ export interface GetAssetByName { */ export interface GetAssetByUri { uri: Uri6; - type?: Type28; + type?: Type29; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1356,7 +1364,7 @@ export interface GetAssetEventByAsset { partition_key?: PartitionKey5; partition_key_regexp_pattern?: PartitionKeyRegexpPattern; extra?: Extra7; - type?: Type29; + type?: Type30; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1371,7 +1379,7 @@ export interface GetAssetEventByAssetAlias { partition_key?: PartitionKey6; partition_key_regexp_pattern?: PartitionKeyRegexpPattern1; extra?: Extra8; - type?: Type30; + type?: Type31; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1380,7 +1388,7 @@ export interface GetAssetEventByAssetAlias { export interface GetAssetStateStoreByName { name: Name13; key: Key6; - type?: Type31; + type?: Type32; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1389,7 +1397,7 @@ export interface GetAssetStateStoreByName { export interface GetAssetStateStoreByUri { uri: Uri8; key: Key7; - type?: Type32; + type?: Type33; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1397,7 +1405,7 @@ export interface GetAssetStateStoreByUri { */ export interface GetAssetsByAlias { alias_name: AliasName1; - type?: Type33; + type?: Type34; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1405,44 +1413,44 @@ export interface GetAssetsByAlias { */ export interface GetConnection { conn_id: ConnId2; - type?: Type34; + type?: Type35; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema * via the `definition` "GetDRCount". */ export interface GetDRCount { - dag_id: DagId7; + dag_id: DagId8; logical_dates?: LogicalDates; run_ids?: RunIds; states?: States; - type?: Type35; + type?: Type36; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema * via the `definition` "GetDag". */ export interface GetDag { - dag_id: DagId8; - type?: Type36; + dag_id: DagId9; + type?: Type37; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema * via the `definition` "GetDagRun". */ export interface GetDagRun { - dag_id: DagId9; + dag_id: DagId10; run_id: RunId6; - type?: Type37; + type?: Type38; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema * via the `definition` "GetDagRunState". */ export interface GetDagRunState { - dag_id: DagId10; + dag_id: DagId11; run_id: RunId7; - type?: Type38; + type?: Type39; } /** * Get the response content part of a Human-in-the-loop response. @@ -1452,7 +1460,7 @@ export interface GetDagRunState { */ export interface GetHITLDetailResponse { ti_id: TiId3; - type?: Type39; + type?: Type40; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1460,17 +1468,17 @@ export interface GetHITLDetailResponse { */ export interface GetPrevSuccessfulDagRun { ti_id: TiId4; - type?: Type40; + type?: Type41; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema * via the `definition` "GetPreviousDagRun". */ export interface GetPreviousDagRun { - dag_id: DagId11; + dag_id: DagId12; logical_date: LogicalDate3; state?: State3; - type?: Type41; + type?: Type42; } /** * Request to get previous task instance. @@ -1479,35 +1487,35 @@ export interface GetPreviousDagRun { * via the `definition` "GetPreviousTI". */ export interface GetPreviousTI { - dag_id: DagId12; + dag_id: DagId13; task_id: TaskId3; logical_date?: LogicalDate4; map_index?: MapIndex2; state?: TaskInstanceState | null; - type?: Type42; + type?: Type43; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema * via the `definition` "GetTICount". */ export interface GetTICount { - dag_id: DagId13; + dag_id: DagId14; map_index?: MapIndex3; task_ids?: TaskIds; task_group_id?: TaskGroupId; logical_dates?: LogicalDates1; run_ids?: RunIds1; states?: States1; - type?: Type43; + type?: Type44; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema * via the `definition` "GetTaskBreadcrumbs". */ export interface GetTaskBreadcrumbs { - dag_id: DagId14; + dag_id: DagId15; run_id: RunId8; - type?: Type44; + type?: Type45; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1516,7 +1524,7 @@ export interface GetTaskBreadcrumbs { export interface GetTaskRescheduleStartDate { ti_id: TiId5; try_number?: TryNumber1; - type?: Type45; + type?: Type46; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1525,20 +1533,20 @@ export interface GetTaskRescheduleStartDate { export interface GetTaskStateStore { ti_id: TiId6; key: Key8; - type?: Type46; + type?: Type47; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema * via the `definition` "GetTaskStates". */ export interface GetTaskStates { - dag_id: DagId15; + dag_id: DagId16; map_index?: MapIndex4; task_ids?: TaskIds1; task_group_id?: TaskGroupId1; logical_dates?: LogicalDates2; run_ids?: RunIds2; - type?: Type47; + type?: Type48; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1546,7 +1554,7 @@ export interface GetTaskStates { */ export interface GetVariable { key: Key9; - type?: Type48; + type?: Type49; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1556,7 +1564,7 @@ export interface GetVariableKeys { prefix?: Prefix; limit?: Limit2; offset?: Offset; - type?: Type49; + type?: Type50; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1565,12 +1573,12 @@ export interface GetVariableKeys { export interface GetXCom { previous_iteration?: PreviousIteration; key: Key10; - dag_id: DagId16; + dag_id: DagId17; run_id: RunId9; task_id: TaskId4; map_index?: MapIndex5; include_prior_dates?: IncludePriorDates; - type?: Type50; + type?: Type51; } /** * Get the number of (mapped) XCom values available. @@ -1581,10 +1589,10 @@ export interface GetXCom { export interface GetXComCount { previous_iteration?: PreviousIteration1; key: Key11; - dag_id: DagId17; + dag_id: DagId18; run_id: RunId10; task_id: TaskId5; - type?: Type51; + type?: Type52; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1593,11 +1601,11 @@ export interface GetXComCount { export interface GetXComSequenceItem { previous_iteration?: PreviousIteration2; key: Key12; - dag_id: DagId18; + dag_id: DagId19; run_id: RunId11; task_id: TaskId6; offset: Offset1; - type?: Type52; + type?: Type53; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1606,14 +1614,14 @@ export interface GetXComSequenceItem { export interface GetXComSequenceSlice { previous_iteration?: PreviousIteration3; key: Key13; - dag_id: DagId19; + dag_id: DagId20; run_id: RunId12; task_id: TaskId7; start: Start; stop: Stop; step: Step; include_prior_dates?: IncludePriorDates1; - type?: Type53; + type?: Type54; } /** * Response to CreateHITLDetailPayload request. @@ -1630,7 +1638,7 @@ export interface HITLDetailRequestResult { multiple?: Multiple1; params?: Params1; assigned_users?: AssignedUsers1; - type?: Type54; + type?: Type55; } /** * Response of InactiveAssets requests. @@ -1640,7 +1648,7 @@ export interface HITLDetailRequestResult { */ export interface InactiveAssetsResult { inactive_assets?: InactiveAssets; - type?: Type55; + type?: Type56; } /** * Add a new value to be redacted in task logs. @@ -1651,7 +1659,7 @@ export interface InactiveAssetsResult { export interface MaskSecret { value: JsonValue; name?: Name14; - type?: Type56; + type?: Type57; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1659,7 +1667,7 @@ export interface MaskSecret { */ export interface OKResponse { ok: Ok; - type?: Type57; + type?: Type58; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1670,7 +1678,7 @@ export interface PrevSuccessfulDagRunResult { data_interval_end?: DataIntervalEnd3; start_date?: StartDate4; end_date?: EndDate3; - type?: Type58; + type?: Type59; } /** * Response containing previous Dag run information. @@ -1680,7 +1688,7 @@ export interface PrevSuccessfulDagRunResult { */ export interface PreviousDagRunResult { dag_run?: DagRun | null; - type?: Type59; + type?: Type60; } /** * Schema for response with previous TaskInstance information. @@ -1690,7 +1698,7 @@ export interface PreviousDagRunResult { */ export interface PreviousTIResponse { task_id: TaskId8; - dag_id: DagId20; + dag_id: DagId21; run_id: RunId13; logical_date?: LogicalDate5; start_date?: StartDate5; @@ -1710,7 +1718,7 @@ export interface PreviousTIResponse { */ export interface PreviousTIResult { task_instance?: PreviousTIResponse | null; - type?: Type60; + type?: Type61; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1720,7 +1728,7 @@ export interface PutVariable { key: Key14; value: Value1; description: Description; - type?: Type61; + type?: Type62; } /** * Update a task instance state to reschedule/up_for_reschedule. @@ -1732,14 +1740,14 @@ export interface RescheduleTask { state?: State5; reschedule_date: RescheduleDate; end_date: EndDate5; - type?: Type62; + type?: Type63; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema * via the `definition` "ResendLoggingFD". */ export interface ResendLoggingFD { - type?: Type63; + type?: Type64; } /** * Update a task instance state to up_for_retry. @@ -1753,14 +1761,14 @@ export interface RetryTask { rendered_map_index?: RenderedMapIndex2; retry_delay_seconds?: RetryDelaySeconds; retry_reason?: RetryReason; - type?: Type64; + type?: Type65; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema * via the `definition` "SentFDs". */ export interface SentFDs { - type?: Type65; + type?: Type66; fds: Fds; } /** @@ -1771,7 +1779,7 @@ export interface SetAssetStateStoreByName { name: Name15; key: Key15; value: JsonValue; - type?: Type66; + type?: Type67; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1781,7 +1789,7 @@ export interface SetAssetStateStoreByUri { uri: Uri9; key: Key16; value: JsonValue; - type?: Type67; + type?: Type68; } /** * Tell the supervisor the task's ``execution_timeout`` so it can enforce it from outside the task process. @@ -1791,7 +1799,7 @@ export interface SetAssetStateStoreByUri { */ export interface SetExecutionTimeout { timeout_seconds: TimeoutSeconds; - type?: Type68; + type?: Type69; } /** * Payload for setting RTIF for a task instance. @@ -1801,7 +1809,7 @@ export interface SetExecutionTimeout { */ export interface SetRenderedFields { rendered_fields: RenderedFields; - type?: Type69; + type?: Type70; } export interface RenderedFields { [k: string]: JsonValue; @@ -1814,7 +1822,7 @@ export interface RenderedFields { */ export interface SetRenderedMapIndex { rendered_map_index: RenderedMapIndex3; - type?: Type70; + type?: Type71; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1825,7 +1833,7 @@ export interface SetTaskStateStore { key: Key17; value: JsonValue; expires_at: ExpiresAt; - type?: Type71; + type?: Type72; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1834,13 +1842,13 @@ export interface SetTaskStateStore { export interface SetXCom { key: Key18; value: JsonValue; - dag_id: DagId21; + dag_id: DagId22; run_id: RunId14; task_id: TaskId9; map_index?: MapIndex7; dag_result?: DagResult1; mapped_length?: MappedLength; - type?: Type72; + type?: Type73; } /** * Update state of downstream tasks within a task instance to 'skipped', while updating current task to success state. @@ -1850,7 +1858,7 @@ export interface SetXCom { */ export interface SkipDownstreamTasks { tasks: Tasks; - type?: Type73; + type?: Type74; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1863,7 +1871,7 @@ export interface StartupDetails { start_date: StartDate6; ti_context: TIRunContext; sentry_integration: SentryIntegration; - type?: Type74; + type?: Type75; } /** * Update a task's state to success. Includes task_outlets and outlet_events for registering asset events. @@ -1877,7 +1885,7 @@ export interface SucceedTask { task_outlets?: TaskOutlets; outlet_events?: OutletEvents; rendered_map_index?: RenderedMapIndex4; - type?: Type75; + type?: Type76; } /** * Response containing count of Task Instances matching certain filters. @@ -1887,7 +1895,7 @@ export interface SucceedTask { */ export interface TICount { count: Count1; - type?: Type76; + type?: Type77; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1895,7 +1903,7 @@ export interface TICount { */ export interface TaskBreadcrumbsResult { breadcrumbs: Breadcrumbs; - type?: Type77; + type?: Type78; } /** * Response containing the first reschedule date for a task instance. @@ -1905,7 +1913,7 @@ export interface TaskBreadcrumbsResult { */ export interface TaskRescheduleStartDate { start_date: StartDate7; - type?: Type78; + type?: Type79; } /** * Update a task's state. @@ -1920,7 +1928,7 @@ export interface TaskRescheduleStartDate { export interface TaskState { state: State8; end_date?: EndDate8; - type?: Type79; + type?: Type80; rendered_map_index?: RenderedMapIndex5; retry_reason?: RetryReason1; } @@ -1932,7 +1940,7 @@ export interface TaskState { */ export interface TaskStateStoreResult { value: JsonValue | null; - type?: Type80; + type?: Type81; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1940,7 +1948,7 @@ export interface TaskStateStoreResult { */ export interface TaskStatesResult { task_states: TaskStates; - type?: Type81; + type?: Type82; } export interface TaskStates { [k: string]: unknown; @@ -1956,9 +1964,9 @@ export interface TriggerDagRun { reset_dag_run?: ResetDagRun; partition_key?: PartitionKey7; note?: Note2; - dag_id: DagId22; + dag_id: DagId23; run_id: DagRunId; - type?: Type82; + type?: Type83; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1967,7 +1975,7 @@ export interface TriggerDagRun { export interface UpdateDagRunNote { ti_id: TiId9; note: Note3; - type?: Type83; + type?: Type84; } /** * Update the response content part of an existing Human-in-the-loop response. @@ -1979,7 +1987,7 @@ export interface UpdateHITLDetail { ti_id: TiId10; chosen_options: ChosenOptions; params_input?: ParamsInput; - type?: Type84; + type?: Type85; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1987,7 +1995,7 @@ export interface UpdateHITLDetail { */ export interface ValidateInletsAndOutlets { ti_id: TiId11; - type?: Type85; + type?: Type86; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -1996,7 +2004,7 @@ export interface ValidateInletsAndOutlets { export interface VariableKeysResult { keys: Keys; total_entries: TotalEntries; - type?: Type86; + type?: Type87; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -2005,7 +2013,7 @@ export interface VariableKeysResult { export interface VariableResult { key: Key19; value: Value2; - type?: Type87; + type?: Type88; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -2013,7 +2021,7 @@ export interface VariableResult { */ export interface XComCountResponse { len: Len; - type?: Type88; + type?: Type89; } /** * Response to ReadXCom request. @@ -2024,7 +2032,7 @@ export interface XComCountResponse { export interface XComResult { key: Key20; value: JsonValue | null; - type?: Type89; + type?: Type90; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -2032,7 +2040,7 @@ export interface XComResult { */ export interface XComSequenceIndexResult { root: JsonValue; - type?: Type90; + type?: Type91; } /** * This interface was referenced by `SupervisorWireSchema`'s JSON-Schema @@ -2040,7 +2048,7 @@ export interface XComSequenceIndexResult { */ export interface XComSequenceSliceResult { root: Root; - type?: Type91; + type?: Type92; } /** Cadwyn schema version this SDK was generated against. diff --git a/uv.lock b/uv.lock index 5439a258bbed6..c97ff0a5de405 100644 --- a/uv.lock +++ b/uv.lock @@ -9321,6 +9321,7 @@ dependencies = [ { name = "asgiref" }, { name = "attrs" }, { name = "babel" }, + { name = "cadwyn" }, { name = "colorlog" }, { name = "fsspec" }, { name = "greenback" }, @@ -9340,6 +9341,7 @@ dependencies = [ { name = "pydantic" }, { name = "pygtrie" }, { name = "python-dateutil" }, + { name = "pyyaml" }, { name = "structlog" }, { name = "tenacity" }, { name = "typing-extensions" }, @@ -9396,6 +9398,7 @@ requires-dist = [ { name = "asgiref", marker = "python_full_version >= '3.14'", specifier = ">=3.11.1" }, { name = "attrs", specifier = ">=24.2.0,!=25.2.0" }, { name = "babel", specifier = ">=2.17.0" }, + { name = "cadwyn", specifier = ">=6.1.1" }, { name = "colorlog", specifier = ">=6.8.2" }, { name = "datadog", marker = "extra == 'all'", specifier = ">=0.50.0" }, { name = "datadog", marker = "extra == 'datadog'", specifier = ">=0.50.0" }, @@ -9423,6 +9426,7 @@ requires-dist = [ { name = "pydantic", specifier = ">2.11.0" }, { name = "pygtrie", specifier = ">=2.5.0" }, { name = "python-dateutil", specifier = ">=2.7.0" }, + { name = "pyyaml", specifier = ">=6.0.3" }, { name = "sentry-sdk", marker = "extra == 'all'", specifier = ">=2.30.0" }, { name = "sentry-sdk", marker = "extra == 'sentry'", specifier = ">=2.30.0" }, { name = "statsd", marker = "extra == 'all'", specifier = ">=3.3.0" }, From 9a2edbd03e2e4ff2eda5bb0afbb5fe95406632d2 Mon Sep 17 00:00:00 2001 From: Teghveer Singh Ateliey <48258080+Tegh25@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:20:07 -0400 Subject: [PATCH 29/29] Handle skipped-intervals callbacks when dropping Lang-SDK callbacks Co-authored-by: Cursor --- .../src/airflow/dag_processing/manager.py | 4 +++- .../tests/unit/dag_processing/test_manager.py | 21 ++++++++++++++++++- java-sdk/sdk/schema/dag-schema.json | 1 + 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/airflow-core/src/airflow/dag_processing/manager.py b/airflow-core/src/airflow/dag_processing/manager.py index 343d72c5e002d..eecd1d3607c72 100644 --- a/airflow-core/src/airflow/dag_processing/manager.py +++ b/airflow-core/src/airflow/dag_processing/manager.py @@ -48,7 +48,7 @@ from airflow._shared.observability.metrics.stats import normalize_name_for_stats from airflow._shared.timezones import timezone from airflow.api_fastapi.execution_api.in_process import InProcessExecutionAPI -from airflow.callbacks.callback_requests import DagCallbackRequest +from airflow.callbacks.callback_requests import DagCallbackRequest, DagSkippedIntervalsCallbackRequest from airflow.configuration import conf from airflow.dag_processing.bundles.base import ( BundleUsageTrackingManager, @@ -821,6 +821,8 @@ def _add_callback_to_queue(self, request: CallbackRequest) -> None: def _log_dropped_lang_sdk_callback(self, request: CallbackRequest) -> None: if isinstance(request, DagCallbackRequest): target = f"dag_id={request.dag_id} run_id={request.run_id}" + elif isinstance(request, DagSkippedIntervalsCallbackRequest): + target = f"dag_id={request.dag_id}" else: ti = request.ti target = f"dag_id={ti.dag_id} run_id={ti.run_id} task_id={ti.task_id}" diff --git a/airflow-core/tests/unit/dag_processing/test_manager.py b/airflow-core/tests/unit/dag_processing/test_manager.py index 2a92835edba39..c6cd17876337f 100644 --- a/airflow-core/tests/unit/dag_processing/test_manager.py +++ b/airflow-core/tests/unit/dag_processing/test_manager.py @@ -47,7 +47,11 @@ from airflow._shared.timezones import timezone from airflow.api_fastapi.execution_api.datamodels.taskinstance import TaskInstance as TIDataModel -from airflow.callbacks.callback_requests import DagCallbackRequest, TaskCallbackRequest +from airflow.callbacks.callback_requests import ( + DagCallbackRequest, + DagSkippedIntervalsCallbackRequest, + TaskCallbackRequest, +) from airflow.dag_processing.bundles.base import BaseDagBundle, BundleVersion from airflow.dag_processing.bundles.manager import DagBundlesManager from airflow.dag_processing.collection import update_dag_parsing_results_in_db @@ -76,6 +80,7 @@ from airflow.sdk import DAG as SdkDAG, BaseOperator from airflow.sdk.importers import DagDefinition, DagImporterRegistry, DagImportError, DagSourceCode from airflow.serialization.serialized_objects import DagSerialization, LazyDeserializedDAG +from airflow.timetables.base import DataInterval, SkippedIntervalsSummary from airflow.utils.net import get_hostname from airflow.utils.session import create_session @@ -1746,6 +1751,20 @@ def test_create_process_keeps_the_python_parse_for_other_files(self, mock_get_lo "task_id=extract): Lang-SDK runtimes do not run callbacks", id="task", ), + pytest.param( + DagSkippedIntervalsCallbackRequest.from_summary( + filepath="dags.native", + dag_id="native_dag", + bundle_name="testing", + bundle_version="v1", + summary=SkippedIntervalsSummary( + skipped_range=DataInterval(start=DEFAULT_DATE, end=DEFAULT_DATE), + ), + ), + "Dropping DagSkippedIntervalsCallbackRequest for dags.native (dag_id=native_dag): " + "Lang-SDK runtimes do not run callbacks", + id="skipped_intervals", + ), ], ) @mock.patch.object(DagFileProcessorManager, "prepare_callback_bundle", autospec=True) diff --git a/java-sdk/sdk/schema/dag-schema.json b/java-sdk/sdk/schema/dag-schema.json index ed2251ce3dac7..51b8414ab5818 100644 --- a/java-sdk/sdk/schema/dag-schema.json +++ b/java-sdk/sdk/schema/dag-schema.json @@ -237,6 +237,7 @@ "is_paused_upon_creation": { "type": "boolean" }, "has_on_success_callback": { "type": "boolean", "default": false }, "has_on_failure_callback": { "type": "boolean", "default": false }, + "has_on_skipped_intervals_callback": { "type": "boolean", "default": false }, "render_template_as_native_obj": { "type": "boolean", "default": false }, "tags": { "type": "array" }, "task_group": {"anyOf": [