Copilot commented on code in PR #72559:
URL: https://github.com/apache/airflow/pull/72559#discussion_r3940367843


##########
airflow-core/src/airflow/models/deadline.py:
##########
@@ -42,18 +42,110 @@
 from airflow.utils.log.logging_mixin import LoggingMixin
 from airflow.utils.session import provide_session
 from airflow.utils.sqlalchemy import UtcDateTime, get_dialect_name
-from airflow.utils.state import CallbackState
+from airflow.utils.state import CallbackState, TaskInstanceState
 
 if TYPE_CHECKING:
     from sqlalchemy.orm import Session
     from sqlalchemy.sql import ColumnElement
 
     from airflow.models.callback import CallbackDefinitionProtocol
     from airflow.models.deadline_alert import DeadlineAlert
+    from airflow.models.taskinstance import TaskInstance
 
 
 logger = logging.getLogger(__name__)
 
+
+def get_task_deadline_alerts(task: Any) -> list[Any] | None:
+    """
+    Return the deadline alerts declared on a task, whether stored on the 
operator or in partial kwargs.
+
+    A task's deadline definition travels with the operator. For a mapped task 
the definition is kept
+    in ``partial_kwargs`` (the ``SerializedMappedOperator`` has no 
``deadline`` attribute), so both
+    locations must be consulted. A single alert is normalized to a one-element 
list.
+
+    :param task: Serialized operator (or partial kwargs holder) to inspect
+    :return: List of deadline alerts, or None when the task declares none
+    """
+    deadline_alerts = getattr(task, "deadline", None)
+    if not deadline_alerts:
+        partial_kwargs = getattr(task, "partial_kwargs", None) or {}
+        deadline_alerts = partial_kwargs.get("deadline")
+    if deadline_alerts is None:
+        return None
+    if isinstance(deadline_alerts, list):
+        return deadline_alerts
+    return [deadline_alerts]
+
+
+def create_deadlines_for_task_instance(
+    *,
+    deadline_alerts: list[Any],
+    task_instance: TaskInstance,
+    bundle_name: str | None,
+    session: Session,
+) -> int:
+    """
+    Create Deadline rows for each of a task instance's DeadlineAlerts.
+
+    Used whenever a runnable task instance comes into existence: at DagRun 
creation for regular
+    tasks, and at expansion time for each ``map_index`` of a mapped task.
+
+    :param deadline_alerts: Deserialized DeadlineAlert objects attached to the 
task
+    :param task_instance: The TaskInstance that owns the deadlines
+    :param bundle_name: The bundle name for callback resolution
+    :param session: Database session
+    :return: The number of Deadline rows created
+    """
+    from airflow.serialization.definitions.deadline import 
SerializedVariableInterval
+
+    created = 0
+    for deadline_alert in deadline_alerts:
+        interval = deadline_alert.interval
+        # The variable-backed interval is resolved lazily at evaluation time. 
Every alert
+        # reaching the scheduler arrives decoded as a SerializedDeadlineAlert, 
whose interval
+        # is a timedelta or SerializedVariableInterval.
+        if isinstance(interval, SerializedVariableInterval):
+            interval = interval.resolve()
+
+        deadline_time = deadline_alert.reference.evaluate_with(
+            session=session,
+            interval=interval,
+            dag_id=task_instance.dag_id,
+            run_id=task_instance.run_id,
+        )
+
+        if deadline_time is None:
+            continue
+
+        session.add(
+            Deadline(
+                deadline_time=deadline_time,
+                callback=deadline_alert.callback,
+                dagrun_id=task_instance.dag_run.id,
+                deadline_alert_id=None,

Review Comment:
   `dagrun_id=task_instance.dag_run.id` will lazy-load the DagRun for each task 
instance unless the relationship is already populated. In 
`_process_task_deadline_alerts`, task instances come from 
`DagRun.get_task_instances()` (query-based), so this becomes an N+1 query when 
many tasks declare deadlines. Consider passing the DagRun id into 
`create_deadlines_for_task_instance()` (or assigning `ti.dag_run = orm_dagrun` 
at the call site) to avoid per-TI loads.



##########
airflow-core/tests/unit/jobs/test_scheduler_job.py:
##########
@@ -10106,6 +10107,233 @@ def test_process_expired_deadlines(self, 
mock_handle_miss, session, dag_maker):
         # Assert that all deadlines which are both expired and unhandled get 
processed.
         assert mock_handle_miss.call_count == 2
 
+    @mock.patch("airflow.models.Deadline.handle_miss")
+    def 
test_process_expired_deadlines_skips_task_deadline_of_finished_in_time_task(
+        self, mock_handle_miss, session, dag_maker
+    ):
+        """A task-level deadline whose task instance finished before the 
need-by time is pruned, not fired."""
+        scheduler_job = Job()
+        self.job_runner = SchedulerJobRunner(job=scheduler_job, num_runs=1, 
executors=[MockExecutor()])
+
+        now = timezone.utcnow()
+        callback_path = "classpath.notify"
+
+        dag_id = "test_deadline_dag_finished_in_time"
+        with dag_maker(dag_id=dag_id):
+            EmptyOperator(task_id="empty")
+        dagrun = dag_maker.create_dagrun()
+
+        task_instance = session.scalar(
+            select(TaskInstance).where(
+                TaskInstance.dag_id == dag_id,
+                TaskInstance.run_id == dagrun.run_id,
+            )
+        )
+        assert task_instance is not None
+        # The task succeeded well before the (now expired) need-by time.
+        task_instance.state = TaskInstanceState.SUCCESS
+        task_instance.end_date = now - timedelta(minutes=10)
+        session.flush()
+
+        deadline = Deadline(
+            deadline_time=now - timedelta(minutes=5),
+            callback=AsyncCallback(callback_path),
+            dagrun_id=dagrun.id,
+            dag_id=dag_id,
+            deadline_alert_id=None,
+            task_instance_id=task_instance.id,
+        )
+        session.add(deadline)
+        session.flush()
+        deadline_id = deadline.id
+
+        self.job_runner._execute()
+
+        # The deadline row was pruned instead of firing a false-alarm callback.
+        mock_handle_miss.assert_not_called()
+        assert session.get(Deadline, deadline_id) is None
+
+    @mock.patch("airflow.models.Deadline.handle_miss")
+    def 
test_process_expired_deadlines_fires_task_deadline_of_still_running_task(
+        self, mock_handle_miss, session, dag_maker
+    ):
+        """A task-level deadline whose task is still running past the need-by 
time fires normally."""
+        scheduler_job = Job()
+        self.job_runner = SchedulerJobRunner(job=scheduler_job, num_runs=1, 
executors=[MockExecutor()])
+
+        now = timezone.utcnow()
+        callback_path = "classpath.notify"
+
+        dag_id = "test_deadline_dag_still_running"
+        with dag_maker(dag_id=dag_id):
+            EmptyOperator(task_id="empty")
+        dagrun = dag_maker.create_dagrun()
+
+        task_instance = session.scalar(
+            select(TaskInstance).where(
+                TaskInstance.dag_id == dag_id,
+                TaskInstance.run_id == dagrun.run_id,
+            )
+        )
+        assert task_instance is not None
+        # The task started but has not finished as the need-by time passes.
+        task_instance.state = TaskInstanceState.RUNNING
+        task_instance.start_date = now - timedelta(minutes=10)
+        session.flush()
+
+        session.add(
+            Deadline(
+                deadline_time=now - timedelta(minutes=5),
+                callback=AsyncCallback(callback_path),
+                dagrun_id=dagrun.id,
+                dag_id=dag_id,
+                deadline_alert_id=None,
+                task_instance_id=task_instance.id,
+            )
+        )
+        session.flush()
+
+        self.job_runner._execute()
+
+        assert mock_handle_miss.call_count == 1
+
+    @mock.patch("airflow.models.Deadline.handle_miss")
+    def test_operator_declared_task_deadline_reaches_miss_loop(self, 
mock_handle_miss, session, dag_maker):
+        """An operator-declared deadline materializes at DagRun creation and 
fires in the miss loop."""
+        scheduler_job = Job()
+        self.job_runner = SchedulerJobRunner(job=scheduler_job, num_runs=1, 
executors=[MockExecutor()])
+
+        dag_id = "test_deadline_dag_operator_declared"
+        with dag_maker(dag_id=dag_id, serialized=True, 
schedule=timedelta(days=1)):
+            EmptyOperator(
+                task_id="empty",
+                deadline=SdkDeadlineAlert(
+                    reference=DeadlineReference.DAGRUN_LOGICAL_DATE,
+                    interval=timedelta(minutes=-10),
+                    callback=AsyncCallback("classpath.notify"),
+                ),
+            )
+        logical_date = timezone.utcnow() - timedelta(minutes=1)
+
+        # The deadline is anchored to the logical date, so pass a need-by time 
already elapsed.
+        with time_machine.travel(logical_date, tick=False):
+            dagrun = dag_maker.create_dagrun(logical_date=logical_date)
+            session.commit()
+
+            task_instance = session.scalar(
+                select(TaskInstance).where(
+                    TaskInstance.dag_id == dag_id,
+                    TaskInstance.run_id == dagrun.run_id,
+                )
+            )
+            assert task_instance is not None
+            deadline = session.scalar(
+                select(Deadline).where(
+                    Deadline.dagrun_id == dagrun.id,
+                    Deadline.task_instance_id == task_instance.id,
+                )
+            )
+            # create_dagrun materialized a Deadline row owned by the task 
instance, with the
+            # need-by time evaluated from the operator's DeadlineAlert 
(logical date - 10min).
+            assert deadline is not None
+            assert deadline.deadline_time < timezone.utcnow()
+
+            self.job_runner._execute()
+
+        # The scheduler also creates a new scheduled DagRun for the dag; only 
the expired
+        # deadline of the run under test fires.
+        # The scheduler also creates a new scheduled DagRun for the dag; the 
deadline of the
+        # run under test is among the ones that fired. With the 
instance-method mock, the
+        # Deadline arrives via the call's enclosing loop; match on the loop's 
expired query.

Review Comment:
   This block contains two overlapping comments explaining the same scheduler 
behavior, which is noisy and a bit contradictory ("only" vs "among"). 
Consolidating to a single comment should improve readability.



##########
airflow-core/tests/unit/jobs/test_scheduler_job.py:
##########
@@ -10106,6 +10107,233 @@ def test_process_expired_deadlines(self, 
mock_handle_miss, session, dag_maker):
         # Assert that all deadlines which are both expired and unhandled get 
processed.
         assert mock_handle_miss.call_count == 2
 
+    @mock.patch("airflow.models.Deadline.handle_miss")
+    def 
test_process_expired_deadlines_skips_task_deadline_of_finished_in_time_task(
+        self, mock_handle_miss, session, dag_maker
+    ):
+        """A task-level deadline whose task instance finished before the 
need-by time is pruned, not fired."""
+        scheduler_job = Job()
+        self.job_runner = SchedulerJobRunner(job=scheduler_job, num_runs=1, 
executors=[MockExecutor()])
+
+        now = timezone.utcnow()
+        callback_path = "classpath.notify"
+
+        dag_id = "test_deadline_dag_finished_in_time"
+        with dag_maker(dag_id=dag_id):
+            EmptyOperator(task_id="empty")
+        dagrun = dag_maker.create_dagrun()
+
+        task_instance = session.scalar(
+            select(TaskInstance).where(
+                TaskInstance.dag_id == dag_id,
+                TaskInstance.run_id == dagrun.run_id,
+            )
+        )
+        assert task_instance is not None
+        # The task succeeded well before the (now expired) need-by time.
+        task_instance.state = TaskInstanceState.SUCCESS
+        task_instance.end_date = now - timedelta(minutes=10)
+        session.flush()
+
+        deadline = Deadline(
+            deadline_time=now - timedelta(minutes=5),
+            callback=AsyncCallback(callback_path),
+            dagrun_id=dagrun.id,
+            dag_id=dag_id,
+            deadline_alert_id=None,
+            task_instance_id=task_instance.id,
+        )
+        session.add(deadline)
+        session.flush()
+        deadline_id = deadline.id
+
+        self.job_runner._execute()
+
+        # The deadline row was pruned instead of firing a false-alarm callback.
+        mock_handle_miss.assert_not_called()
+        assert session.get(Deadline, deadline_id) is None
+
+    @mock.patch("airflow.models.Deadline.handle_miss")
+    def 
test_process_expired_deadlines_fires_task_deadline_of_still_running_task(
+        self, mock_handle_miss, session, dag_maker
+    ):
+        """A task-level deadline whose task is still running past the need-by 
time fires normally."""
+        scheduler_job = Job()
+        self.job_runner = SchedulerJobRunner(job=scheduler_job, num_runs=1, 
executors=[MockExecutor()])
+
+        now = timezone.utcnow()
+        callback_path = "classpath.notify"
+
+        dag_id = "test_deadline_dag_still_running"
+        with dag_maker(dag_id=dag_id):
+            EmptyOperator(task_id="empty")
+        dagrun = dag_maker.create_dagrun()
+
+        task_instance = session.scalar(
+            select(TaskInstance).where(
+                TaskInstance.dag_id == dag_id,
+                TaskInstance.run_id == dagrun.run_id,
+            )
+        )
+        assert task_instance is not None
+        # The task started but has not finished as the need-by time passes.
+        task_instance.state = TaskInstanceState.RUNNING
+        task_instance.start_date = now - timedelta(minutes=10)
+        session.flush()
+
+        session.add(
+            Deadline(
+                deadline_time=now - timedelta(minutes=5),
+                callback=AsyncCallback(callback_path),
+                dagrun_id=dagrun.id,
+                dag_id=dag_id,
+                deadline_alert_id=None,
+                task_instance_id=task_instance.id,
+            )
+        )
+        session.flush()
+
+        self.job_runner._execute()
+
+        assert mock_handle_miss.call_count == 1
+
+    @mock.patch("airflow.models.Deadline.handle_miss")
+    def test_operator_declared_task_deadline_reaches_miss_loop(self, 
mock_handle_miss, session, dag_maker):
+        """An operator-declared deadline materializes at DagRun creation and 
fires in the miss loop."""
+        scheduler_job = Job()
+        self.job_runner = SchedulerJobRunner(job=scheduler_job, num_runs=1, 
executors=[MockExecutor()])
+
+        dag_id = "test_deadline_dag_operator_declared"
+        with dag_maker(dag_id=dag_id, serialized=True, 
schedule=timedelta(days=1)):
+            EmptyOperator(
+                task_id="empty",
+                deadline=SdkDeadlineAlert(
+                    reference=DeadlineReference.DAGRUN_LOGICAL_DATE,
+                    interval=timedelta(minutes=-10),
+                    callback=AsyncCallback("classpath.notify"),
+                ),
+            )
+        logical_date = timezone.utcnow() - timedelta(minutes=1)
+
+        # The deadline is anchored to the logical date, so pass a need-by time 
already elapsed.
+        with time_machine.travel(logical_date, tick=False):
+            dagrun = dag_maker.create_dagrun(logical_date=logical_date)
+            session.commit()
+
+            task_instance = session.scalar(
+                select(TaskInstance).where(
+                    TaskInstance.dag_id == dag_id,
+                    TaskInstance.run_id == dagrun.run_id,
+                )
+            )
+            assert task_instance is not None
+            deadline = session.scalar(
+                select(Deadline).where(
+                    Deadline.dagrun_id == dagrun.id,
+                    Deadline.task_instance_id == task_instance.id,
+                )
+            )
+            # create_dagrun materialized a Deadline row owned by the task 
instance, with the
+            # need-by time evaluated from the operator's DeadlineAlert 
(logical date - 10min).
+            assert deadline is not None
+            assert deadline.deadline_time < timezone.utcnow()
+
+            self.job_runner._execute()
+
+        # The scheduler also creates a new scheduled DagRun for the dag; only 
the expired
+        # deadline of the run under test fires.
+        # The scheduler also creates a new scheduled DagRun for the dag; the 
deadline of the
+        # run under test is among the ones that fired. With the 
instance-method mock, the
+        # Deadline arrives via the call's enclosing loop; match on the loop's 
expired query.
+        assert mock_handle_miss.call_count == 2
+
+    @mock.patch("airflow.models.Deadline.handle_miss")
+    def test_process_expired_deadlines_skips_task_deadline_of_removed_task(
+        self, mock_handle_miss, session, dag_maker
+    ):
+        """A deadline of a REMOVED (shrunk mapped) task instance is pruned: it 
will never run."""
+        scheduler_job = Job()
+        self.job_runner = SchedulerJobRunner(job=scheduler_job, num_runs=1, 
executors=[MockExecutor()])
+
+        now = timezone.utcnow()
+        callback_path = "classpath.notify"
+
+        dag_id = "test_deadline_dag_removed_task"
+        with dag_maker(dag_id=dag_id):
+            EmptyOperator(task_id="empty")
+        dagrun = dag_maker.create_dagrun()
+
+        task_instance = session.scalar(
+            select(TaskInstance).where(
+                TaskInstance.dag_id == dag_id,
+                TaskInstance.run_id == dagrun.run_id,
+            )
+        )
+        assert task_instance is not None
+        task_instance.state = TaskInstanceState.REMOVED
+        session.flush()
+
+        deadline = Deadline(
+            deadline_time=now - timedelta(minutes=5),
+            callback=AsyncCallback(callback_path),
+            dagrun_id=dagrun.id,
+            dag_id=dag_id,
+            deadline_alert_id=None,
+            task_instance_id=task_instance.id,
+        )
+        session.add(deadline)
+        session.flush()
+        deadline_id = deadline.id
+
+        self.job_runner._execute()
+
+        mock_handle_miss.assert_not_called()
+        assert session.get(Deadline, deadline_id) is None
+
+    @mock.patch("airflow.models.Deadline.handle_miss")
+    def 
test_process_expired_deadlines_skips_task_deadline_with_missing_task_instance(
+        self, mock_handle_miss, session, dag_maker
+    ):

Review Comment:
   The test name says the deadline is "skipped" when the task instance 
relationship is missing, but the assertion expects `handle_miss` to run 
(call_count == 1). Renaming the test to match the behavior will make failures 
easier to interpret.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to