kaxil commented on code in PR #72721:
URL: https://github.com/apache/airflow/pull/72721#discussion_r3979587882


##########
airflow-core/src/airflow/models/dagrun.py:
##########
@@ -1511,30 +1517,66 @@ def notify_dagrun_state_changed(self, msg: str):
         # we can't get all the state changes on SchedulerJob,
         # or LocalTaskJob, so we don't want to "falsely advertise" we notify 
about that
 
+    def _build_callback_last_ti(self, relevant_ti: TI, *, session: Session) -> 
TIDataModel | None:
+        """
+        Build a callback context's ``last_ti``, standing in a Dag version if 
the record has none.
+
+        ``session`` is required, not defaulted: ``settings.Session`` is a 
``scoped_session``,
+        so acquiring one here would hand back the caller's own and then commit 
and close it.
+        """
+        from airflow.api_fastapi.execution_api.datamodels.taskinstance import 
TaskInstance as TIDataModel
+        from airflow.models.dag_version import DagVersion
+
+        if relevant_ti.dag_version_id is not None:
+            return TIDataModel.model_validate(relevant_ti, 
from_attributes=True)
+
+        dag_version_id = self.created_dag_version_id

Review Comment:
   `_ensure_ti_has_dag_version_id` (`jobs/scheduler_job_runner.py:225`) already 
solves this for `TaskCallbackRequest` and `EmailRequest` with the same 
docstring reasoning, and it has four call sites in the file this diff also 
touches. Your tier order looks right, it matches the existing convention at 
`models/taskinstance.py:449-452`, but that helper differs on the other two 
axes: it resolves latest-only, and it persists the backfill onto the row. The 
persistence is the part that bites, because whichever callback fires first for 
a legacy TI then decides what the other sees: once a heartbeat-purge callback 
heals the row to `latest`, this branch never fires and the run's own version is 
never reported, and the grid picks which serialized Dag to draw from 
`ti.dag_version_id` (`api_fastapi/core_api/routes/ui/grid.py:417`). Converge on 
one helper, or add a line saying why the Dag-callback path deliberately does 
not persist?



##########
airflow-core/tests/unit/models/test_dagrun.py:
##########
@@ -4172,18 +4222,67 @@ def on_failure(context):
         dr.dag_model = DagModel.get_dagmodel(dag.dag_id, session=session)
         ti = dr.get_task_instance("test_task")
         ti.dag_version_id = None
+        dr.created_dag_version_id = None
         session.flush()
 
         dag.on_failure_callback = on_failure
         dag.has_on_failure_callback = True
 
-        dr.produce_dag_callback(dag=dag, success=False, relevant_ti=ti, 
reason="task_failure", execute=True)
+        dr.produce_dag_callback(
+            dag=dag,
+            success=False,
+            relevant_ti=ti,
+            reason="task_failure",
+            execute=True,
+            session=session,
+        )
 
-        # Callback still fires with the minimal fallback context (no last_ti 
template vars).
         assert context_received is not None
         assert context_received["reason"] == "task_failure"
-        assert "ti" not in context_received
-        assert context_received["run_id"] == dr.run_id
+        assert context_received["ti"].task_id == "test_task"
+        assert context_received["ti"].run_id == dr.run_id
+        assert (
+            context_received["ti"].dag_version_id
+            == DagVersion.get_latest_version(dag.dag_id, session=session).id

Review Comment:
   This recomputes the expression the production fallback itself uses at 
`dagrun.py:1535`, so a bug inside `get_latest_version` would keep the test 
green. `dag_maker` creates exactly one version here, so capturing 
`dr.created_dag_version_id` before line 4225 nulls it and asserting that 
literal would pin the value independently.



##########
airflow-core/src/airflow/models/dagrun.py:
##########
@@ -1511,30 +1517,66 @@ def notify_dagrun_state_changed(self, msg: str):
         # we can't get all the state changes on SchedulerJob,
         # or LocalTaskJob, so we don't want to "falsely advertise" we notify 
about that
 
+    def _build_callback_last_ti(self, relevant_ti: TI, *, session: Session) -> 
TIDataModel | None:
+        """
+        Build a callback context's ``last_ti``, standing in a Dag version if 
the record has none.
+
+        ``session`` is required, not defaulted: ``settings.Session`` is a 
``scoped_session``,
+        so acquiring one here would hand back the caller's own and then commit 
and close it.
+        """
+        from airflow.api_fastapi.execution_api.datamodels.taskinstance import 
TaskInstance as TIDataModel
+        from airflow.models.dag_version import DagVersion
+
+        if relevant_ti.dag_version_id is not None:
+            return TIDataModel.model_validate(relevant_ti, 
from_attributes=True)
+
+        dag_version_id = self.created_dag_version_id
+        if dag_version_id is None:
+            latest_dag_version = DagVersion.get_latest_version(self.dag_id, 
session=session)
+            dag_version_id = latest_dag_version.id if latest_dag_version else 
None
+        if dag_version_id is None:
+            self.log.warning(
+                "Task instance %s has no dag_version_id and Dag %s has no 
version to stand in; "
+                "omitting last_ti from the Dag callback context.",
+                relevant_ti,
+                self.dag_id,
+            )
+            return None
+        self.log.warning(

Review Comment:
   This message calls the row a pre-versioning record, but it covers both tiers 
and they are different populations. Tier 2 (`get_latest_version`) is the 
genuine Airflow 2 migration case, whereas tier 1 fires when the run has a 
version and the TI does not, which is an Airflow 3 state that 
`models/taskinstance.py:449-452` and `_pin_versionless_tis_to_run_version` both 
exist to handle. So an operator reading the scheduler log gets told a current 
row predates Dag versioning; splitting the two messages would keep the 
diagnosis honest.



##########
airflow-core/tests/unit/models/test_dagrun.py:
##########
@@ -4138,27 +4152,63 @@ def on_failure(context):
         assert context_received["ti"].dag_id == "test_dag"
         assert context_received["ti"].run_id == dr.run_id
 
-    def test_produce_dag_callback_drops_last_ti_without_dag_version(self, 
dag_maker, session):
-        """A historical TI with dag_version_id=None must not crash callback 
construction."""
+    @pytest.mark.parametrize("run_keeps_version", [True, False])
+    def test_produce_dag_callback_stands_in_version_for_versionless_last_ti(
+        self, dag_maker, session, run_keeps_version
+    ):
+        """A historical TI with dag_version_id=None still reaches the 
callback, under a stand-in version."""
+        with dag_maker("test_dag", session=session) as dag:
+            BashOperator(task_id="test_task", bash_command="echo 1")
+
+        dr = dag_maker.create_dagrun()
+        dr.dag_model = DagModel.get_dagmodel(dag.dag_id, session=session)
+        run_version_id = dr.created_dag_version_id
+        ti = dr.get_task_instance("test_task")

Review Comment:
   `get_task_instance` without `session=` is the defaulting you call out at 
line 4262 and fixed at 4044, 4080, 4118 and 4144. Here it commits and closes 
the fixture session, so `ti` and `dr` come back detached: I ran this shape in 
breeze and `ti in session` is `False`, and after `session.flush()` the row 
still holds the run's version while the in-memory attribute is `None`. The 
assertions pass because `_build_callback_last_ti` reads the attribute, which 
means the tests carrying the PR's new coverage never put a versionless row in 
the database at all. Same call shape at 4195 and 4223, and `write_dag` at 4171 
closes it a second time, so `session=session` on all four would make these 
`flush()` calls mean something.



-- 
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