This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 325360c2b92 Reject reserved serialization keys in Dag run conf (#74834)
325360c2b92 is described below

commit 325360c2b9252d9b0fe707f2fdcc6b6882504ca8
Author: Jarek Potiuk <[email protected]>
AuthorDate: Mon Oct 19 16:31:49 2026 +0200

    Reject reserved serialization keys in Dag run conf (#74834)
    
    * Reject reserved serialization keys in Dag run conf
    
    XCom values and Human-in-the-loop params already reject keys reserved by
    the serialization format. Apply the same check to Dag run conf where it
    enters through the REST API (trigger, asset materialize, backfill) and
    the Execution API trigger route. A triggerer Deadline callback whose
    context still holds reserved keys in either encoding is marked failed
    instead of queued, so the scheduler loop keeps handling other deadlines.
    
    Generated-by: Claude Opus 5 following the guidelines at 
https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions
    
    * Move the conf reserved-key check next to find_reserved_keys
    
    The backfill and Execution API datamodels import it too, so keeping it in 
dag_run.py made two unrelated models depend on the Dag run module for a shared 
validation helper.
    
    Generated-by: Claude Opus 5
---
 airflow-core/newsfragments/74834.bugfix.rst        |  1 +
 .../api_fastapi/core_api/datamodels/backfills.py   |  8 ++-
 .../api_fastapi/core_api/datamodels/common.py      | 14 +++++
 .../api_fastapi/core_api/datamodels/dag_run.py     |  8 ++-
 .../api_fastapi/execution_api/datamodels/dagrun.py |  8 ++-
 airflow-core/src/airflow/models/deadline.py        | 33 ++++++++++-
 .../core_api/datamodels/test_dag_run.py            | 66 +++++++++++++++++++++-
 .../core_api/routes/public/test_assets.py          | 15 +++++
 .../core_api/routes/public/test_backfills.py       | 27 +++++++++
 .../core_api/routes/public/test_dag_run.py         | 25 ++++++++
 .../execution_api/versions/head/test_dag_runs.py   | 21 +++++++
 airflow-core/tests/unit/jobs/test_scheduler_job.py | 59 +++++++++++++++++++
 airflow-core/tests/unit/models/test_deadline.py    | 57 ++++++++++++++++++-
 13 files changed, 334 insertions(+), 8 deletions(-)

diff --git a/airflow-core/newsfragments/74834.bugfix.rst 
b/airflow-core/newsfragments/74834.bugfix.rst
new file mode 100644
index 00000000000..0382f2b6364
--- /dev/null
+++ b/airflow-core/newsfragments/74834.bugfix.rst
@@ -0,0 +1 @@
+Reject Dag run ``conf`` holding reserved serialization keys such as 
``__classname__`` or ``__type``, as XCom and Human-in-the-loop values already 
do. The trigger Dag run, materialize asset and create backfill endpoints, and 
the Execution API trigger Dag run endpoint, now return 422 for such a payload; 
string values in ``conf`` are not inspected. A Deadline Alert ``AsyncCallback`` 
whose context still holds these keys is marked failed instead of queued, and 
the scheduler moves on to other  [...]
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/backfills.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/backfills.py
index 9788546a401..3833825479d 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/backfills.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/backfills.py
@@ -20,9 +20,10 @@ from __future__ import annotations
 from collections.abc import Iterable
 from datetime import datetime
 
-from pydantic import AliasPath, Field, NonNegativeInt
+from pydantic import AliasPath, Field, NonNegativeInt, field_validator
 
 from airflow.api_fastapi.core_api.base import BaseModel, StrictBaseModel
+from airflow.api_fastapi.core_api.datamodels.common import 
check_conf_reserved_keys
 from airflow.models.backfill import ReprocessBehavior
 from airflow.utils.state import DagRunState
 
@@ -53,6 +54,11 @@ class BackfillPostBody(StrictBaseModel):
         "Ignored by the dry-run endpoint.",
     )
 
+    @field_validator("dag_run_conf")
+    @classmethod
+    def _check_reserved_keys(cls, dag_run_conf: dict | None) -> dict | None:
+        return check_conf_reserved_keys(dag_run_conf, root="dag_run_conf")
+
 
 class BackfillResponse(BaseModel):
     """Base serializer for Backfill."""
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/common.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/common.py
index 7dc55862e67..5bae6c5888c 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/common.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/common.py
@@ -83,6 +83,20 @@ def find_reserved_keys(
     return walk(value, root)
 
 
+def check_conf_reserved_keys(conf: dict | None, *, root: str) -> dict | None:
+    """Reject a Dag run conf holding reserved serialization keys, as XCom and 
HITL values do."""
+    # Conf reaches code that runs a full serde decode (e.g. the Deadline 
callback context). Unlike
+    # XCom, nothing parses a string conf value back into a container, so 
strings stay unchecked.
+    found = find_reserved_keys(conf, root=root)
+    if found is not None:
+        path, keys = found
+        raise ValueError(
+            f"{path} contains reserved serialization keys: {', '.join(keys)}. "
+            "These keys are reserved for internal use."
+        )
+    return conf
+
+
 # Asset Scheduling Expression Data Models
 #
 # These mirror the JSON produced by ``BaseAsset.as_expression()`` (see
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dag_run.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dag_run.py
index a959d7fca19..f388bef7940 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dag_run.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dag_run.py
@@ -22,10 +22,11 @@ from datetime import datetime
 from enum import Enum
 from typing import TYPE_CHECKING, Any
 
-from pydantic import AliasPath, AwareDatetime, Field, NonNegativeInt, 
model_validator
+from pydantic import AliasPath, AwareDatetime, Field, NonNegativeInt, 
field_validator, model_validator
 
 from airflow._shared.timezones import timezone
 from airflow.api_fastapi.core_api.base import BaseModel, StrictBaseModel
+from airflow.api_fastapi.core_api.datamodels.common import 
check_conf_reserved_keys
 from airflow.api_fastapi.core_api.datamodels.dag_versions import 
DagVersionResponse
 from airflow.timetables.base import DataInterval
 from airflow.utils.state import DagRunState
@@ -244,6 +245,11 @@ class TriggerDAGRunPostBody(StrictBaseModel):
         "to complete. Requires the same permission as pausing the Dag.",
     )
 
+    @field_validator("conf")
+    @classmethod
+    def _check_reserved_keys(cls, conf: dict | None) -> dict | None:
+        return check_conf_reserved_keys(conf, root="conf")
+
     @model_validator(mode="after")
     def check_data_intervals(self):
         if (self.data_interval_start is None) != (self.data_interval_end is 
None):
diff --git 
a/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/dagrun.py 
b/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/dagrun.py
index dd1f1aeb866..15631a2cb35 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/dagrun.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/dagrun.py
@@ -17,10 +17,11 @@
 
 from __future__ import annotations
 
-from pydantic import Field
+from pydantic import Field, field_validator
 
 from airflow.api_fastapi.common.types import UtcDateTime
 from airflow.api_fastapi.core_api.base import BaseModel, StrictBaseModel
+from airflow.api_fastapi.core_api.datamodels.common import 
check_conf_reserved_keys
 from airflow.utils.state import DagRunState
 
 
@@ -34,6 +35,11 @@ class TriggerDAGRunPayload(StrictBaseModel):
     partition_key: str | None = None
     note: str | None = None
 
+    @field_validator("conf")
+    @classmethod
+    def _check_reserved_keys(cls, conf: dict) -> dict | None:
+        return check_conf_reserved_keys(conf, root="conf")
+
 
 class DagRunStateResponse(BaseModel):
     """Schema for DAG Run State response."""
diff --git a/airflow-core/src/airflow/models/deadline.py 
b/airflow-core/src/airflow/models/deadline.py
index b9c212dc834..b2c80d1b576 100644
--- a/airflow-core/src/airflow/models/deadline.py
+++ b/airflow-core/src/airflow/models/deadline.py
@@ -289,10 +289,33 @@ class Deadline(Base):
             data["kwargs"] = kwargs
             return data
 
+        callback_failed = False
         if isinstance(self.callback, TriggererCallback):
-            self.callback.data = callback_data_with_context()
+            from airflow.api_fastapi.core_api.datamodels.common import 
find_reserved_keys
 
-            self.callback.queue(session=session)
+            data = callback_data_with_context()
+            # The triggerer decodes the context with full serde, which 
rebuilds a value in either
+            # encoding as an object, and strict serialize raises on the 
current one. Fail the
+            # callback instead of queueing it: raising here would abort the 
scheduler loop, and the
+            # deadline would be picked up and raise again on every restart.
+            if found := find_reserved_keys(data["kwargs"]["context"], 
root="context"):
+                _, keys = found
+                logger.error(
+                    "Not queueing the callback of deadline %s for Dag %s, run 
%s: its context holds "
+                    "reserved serialization keys %s",
+                    self.id,
+                    self.dagrun.dag_id,
+                    self.dagrun.run_id,
+                    ", ".join(keys),
+                )
+                self.callback.state = CallbackState.FAILED
+                self.callback.output = (
+                    f"Not queued: the callback context holds reserved 
serialization keys {', '.join(keys)}"
+                )
+                callback_failed = True
+            else:
+                self.callback.data = data
+                self.callback.queue(session=session)
             session.add(self.callback)
             session.flush()
 
@@ -324,6 +347,12 @@ class Deadline(Base):
                 {"dag_id": self.dagrun.dag_id, "dagrun_id": 
self.dagrun.run_id, "team_name": team_name}
             ),
         )
+        if callback_failed:
+            stats.incr(
+                **self.callback.get_metric_info(
+                    CallbackState.FAILED, self.callback.output, 
team_name=team_name
+                )
+            )
 
 
 class ReferenceModels:
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_dag_run.py 
b/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_dag_run.py
index 4fd4adb845b..d1f841041ee 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_dag_run.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_dag_run.py
@@ -16,11 +16,20 @@
 # under the License.
 from __future__ import annotations
 
+import json
 from datetime import UTC, datetime
 
 import pytest
+from pydantic import ValidationError
 
-from airflow.api_fastapi.core_api.datamodels.dag_run import 
BulkDAGRunClearBody, ClearPartitionsBody
+from airflow._shared.serialization import FORBIDDEN_XCOM_KEYS
+from airflow.api_fastapi.core_api.datamodels.assets import MaterializeAssetBody
+from airflow.api_fastapi.core_api.datamodels.backfills import BackfillPostBody
+from airflow.api_fastapi.core_api.datamodels.dag_run import (
+    BulkDAGRunClearBody,
+    ClearPartitionsBody,
+    TriggerDAGRunPostBody,
+)
 
 _DATE_A = datetime(2024, 1, 1, tzinfo=UTC)
 _DATE_B = datetime(2024, 1, 31, tzinfo=UTC)
@@ -67,3 +76,58 @@ class TestDateWindowOrder:
     def test_accepts_start_before_end(self, model_cls):
         body = model_cls(partition_date_start=_DATE_A, 
partition_date_end=_DATE_B)
         assert body.partition_date_start < body.partition_date_end
+
+
+_CONF_BODIES = [
+    pytest.param(TriggerDAGRunPostBody, "conf", {"logical_date": None}, 
id="trigger"),
+    pytest.param(MaterializeAssetBody, "conf", {}, id="materialize"),
+    pytest.param(
+        BackfillPostBody,
+        "dag_run_conf",
+        {"dag_id": "dag1", "from_date": _DATE_A, "to_date": _DATE_B},
+        id="backfill",
+    ),
+]
+
+
[email protected](("model_cls", "field", "base_kwargs"), _CONF_BODIES)
+class TestConfReservedKeys:
+    """Dag run conf rejects reserved serialization keys the way XCom values 
do."""
+
+    @pytest.mark.parametrize("reserved_key", sorted(FORBIDDEN_XCOM_KEYS))
+    def test_rejects_reserved_key_at_top_level(self, model_cls, field, 
base_kwargs, reserved_key):
+        with pytest.raises(ValidationError, match="reserved serialization 
keys") as ctx:
+            model_cls(**base_kwargs, **{field: {reserved_key: "x"}})
+        assert reserved_key in str(ctx.value)
+
+    @pytest.mark.parametrize(
+        "conf",
+        [
+            pytest.param(
+                {"a": {"__classname__": "builtins.dict", "__version__": 0, 
"__data__": {}}},
+                id="current-nested",
+            ),
+            pytest.param({"a": [{"b": {"__type": "datetime", "__var": 0.0}}]}, 
id="legacy-nested-in-list"),
+        ],
+    )
+    def test_rejects_nested_reserved_keys(self, model_cls, field, base_kwargs, 
conf):
+        with pytest.raises(ValidationError, match=rf"{field}\.a.* contains 
reserved serialization keys"):
+            model_cls(**base_kwargs, **{field: conf})
+
+    @pytest.mark.parametrize(
+        "conf",
+        [
+            pytest.param(None, id="none"),
+            pytest.param({}, id="empty"),
+            pytest.param({"env": "dev", "nested": {"type": "x", "var": [1, 
2]}}, id="plain"),
+            pytest.param({"a": json.dumps({"safe": "data"})}, 
id="benign-json-string"),
+            pytest.param({"a": json.dumps({"__type": "datetime", "__var": 
0.0})}, id="legacy-in-json-string"),
+            pytest.param(
+                {"a": json.dumps({"b": {"__classname__": "builtins.dict"}})},
+                id="current-nested-in-json-string",
+            ),
+            pytest.param({"a": '{"not valid json'}, id="not-json"),
+        ],
+    )
+    def test_accepts_conf_without_reserved_keys(self, model_cls, field, 
base_kwargs, conf):
+        assert getattr(model_cls(**base_kwargs, **{field: conf}), field) == 
conf
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
index 5cf95214368..310cc8470fd 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
@@ -2522,6 +2522,21 @@ class TestPostAssetMaterialize(TestAssets):
         assert response.json()["partition_key"] == "2026-03-23"
         assert response.json()["run_type"] == "asset_materialization"
 
+    @pytest.mark.parametrize(
+        ("key", "conf"),
+        [
+            ("__classname__", {"__classname__": "builtins.dict", 
"__version__": 0, "__data__": {}}),
+            ("__type", {"nested": {"__type": "datetime", "__var": 0.0}}),
+        ],
+    )
+    def test_should_respond_422_for_reserved_conf_keys(self, test_client, 
session, key, conf):
+        response = test_client.post("/assets/1/materialize", json={"conf": 
conf})
+        assert response.status_code == 422
+        detail = str(response.json()["detail"])
+        assert "reserved serialization keys" in detail
+        assert key in detail
+        assert session.scalar(select(func.count()).select_from(DagRun)) == 0
+
     @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
     def test_should_respond_200_with_trigger_fields_without_dag_run_id(self, 
test_client):
         payload = {
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_backfills.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_backfills.py
index 8f2638f062b..5c852117c1d 100644
--- 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_backfills.py
+++ 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_backfills.py
@@ -1109,6 +1109,33 @@ class TestCreateBackfill(TestBackfillEndpoint):
         response = unauthorized_test_client.post("/backfills", json=data)
         assert response.status_code == 403
 
+    @pytest.mark.parametrize("url", ["/backfills", "/backfills/dry_run"])
+    @pytest.mark.parametrize(
+        ("key", "dag_run_conf"),
+        [
+            ("__classname__", {"__classname__": "builtins.dict", 
"__version__": 0, "__data__": {}}),
+            ("__type", {"nested": {"__type": "datetime", "__var": 0.0}}),
+        ],
+    )
+    def test_create_backfill_rejects_reserved_conf_keys(
+        self, session, dag_maker, test_client, url, key, dag_run_conf
+    ):
+        with dag_maker(session=session, dag_id="TEST_DAG_CONF_KEYS", 
schedule="0 * * * *") as dag:
+            EmptyOperator(task_id="mytask")
+        session.commit()
+        data = {
+            "dag_id": dag.dag_id,
+            "from_date": to_iso(pendulum.parse("2024-01-01")),
+            "to_date": to_iso(pendulum.parse("2024-01-02")),
+            "dag_run_conf": dag_run_conf,
+        }
+        response = test_client.post(url, json=data)
+        assert response.status_code == 422
+        detail = str(response.json()["detail"])
+        assert "reserved serialization keys" in detail
+        assert key in detail
+        assert session.scalar(select(func.count()).select_from(Backfill)) == 0
+
 
 class TestCreateBackfillDryRun(TestBackfillEndpoint):
     def test_create_backfill_dry_run_database_locked(self, session, dag_maker, 
test_client):
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
index 68753886a13..36b9e4b6822 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
@@ -17,6 +17,7 @@
 
 from __future__ import annotations
 
+import json
 import math
 from datetime import datetime, timedelta
 from typing import TYPE_CHECKING
@@ -3659,6 +3660,30 @@ class TestTriggerDagRun:
         assert response.status_code == 422
         assert response.json() == expected_detail
 
+    @pytest.mark.parametrize(
+        ("key", "conf"),
+        [
+            ("__classname__", {"__classname__": "builtins.dict", 
"__version__": 0, "__data__": {}}),
+            ("__type", {"nested": {"__type": "datetime", "__var": 0.0}}),
+        ],
+    )
+    def test_trigger_dag_run_rejects_reserved_conf_keys(self, test_client, 
session, key, conf):
+        count_runs = 
select(func.count()).select_from(DagRun).where(DagRun.dag_id == DAG1_ID)
+        runs_before = session.scalar(count_runs)
+        response = test_client.post(f"/dags/{DAG1_ID}/dagRuns", json={"conf": 
conf, "logical_date": None})
+        assert response.status_code == 422
+        detail = str(response.json()["detail"])
+        assert "reserved serialization keys" in detail
+        assert key in detail
+        assert session.scalar(count_runs) == runs_before
+
+    @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
+    def test_trigger_dag_run_keeps_json_string_conf_value(self, test_client):
+        conf = {"nested": json.dumps({"__type": "datetime", "__var": 0.0})}
+        response = test_client.post(f"/dags/{DAG1_ID}/dagRuns", json={"conf": 
conf, "logical_date": None})
+        assert response.status_code == 200
+        assert response.json()["conf"] == conf
+
     def test_post_dag_runs_with_empty_payload(self, test_client):
         response = test_client.post(
             f"/dags/{DAG1_ID}/dagRuns", data={}, headers={"Content-Type": 
"application/json"}
diff --git 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_dag_runs.py
 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_dag_runs.py
index cd78af871c5..87398769a5c 100644
--- 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_dag_runs.py
+++ 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_dag_runs.py
@@ -70,6 +70,27 @@ class TestDagRunTrigger:
         assert dag_run.logical_date == logical_date
         assert dag_run.run_type == DagRunType.OPERATOR_TRIGGERED
 
+    @pytest.mark.parametrize(
+        ("key", "conf"),
+        [
+            ("__classname__", {"__classname__": "builtins.dict", 
"__version__": 0, "__data__": {}}),
+            ("__type", {"nested": [{"__type": "datetime", "__var": 0.0}]}),
+        ],
+    )
+    def test_trigger_dag_run_rejects_reserved_conf_keys(self, client, session, 
dag_maker, key, conf):
+        dag_id = "test_trigger_dag_run_reserved_conf"
+        with dag_maker(dag_id=dag_id, session=session, serialized=True):
+            EmptyOperator(task_id="test_task")
+        session.commit()
+
+        response = client.post(f"/execution/dag-runs/{dag_id}/test_run_id", 
json={"conf": conf})
+
+        assert response.status_code == 422
+        detail = str(response.json()["detail"])
+        assert "reserved serialization keys" in detail
+        assert key in detail
+        assert session.scalars(select(DagRun).where(DagRun.dag_id == 
dag_id)).all() == []
+
     def test_trigger_dag_run_with_partition_key(self, client, session, 
dag_maker):
         """partition_key accepted when Dag uses a partitioned timetable 
(happy-path guard)."""
         dag_id = "test_trigger_dag_run_partition_key"
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py 
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index 8b575a8369a..9363b694775 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -11357,6 +11357,65 @@ class TestSchedulerJob:
         # Assert that all deadlines which are both expired and unhandled get 
processed.
         assert mock_handle_miss.call_count == 2
 
+    @pytest.mark.parametrize(
+        "reserved_value",
+        [
+            pytest.param({"__type": "airflow.ConfProbe", "__var": {}}, 
id="legacy"),
+            pytest.param(
+                {"__classname__": "airflow.ConfProbe", "__version__": 0, 
"__data__": {}}, id="current"
+            ),
+        ],
+    )
+    def 
test_expired_deadline_with_reserved_keys_in_conf_does_not_stop_other_deadlines(
+        self, session, dag_maker, reserved_value
+    ):
+        """The scheduler keeps handling other deadlines and does not pick this 
one up again."""
+        dag_id = "test_deadline_reserved_conf"
+        with dag_maker(dag_id=dag_id, session=session):
+            EmptyOperator(task_id="empty")
+        reserved_run = dag_maker.create_dagrun(run_id="reserved", 
conf={"probe": reserved_value})
+        other_run = dag_maker.create_dagrun(
+            run_id="other", logical_date=timezone.utcnow() - 
timedelta(days=1), conf={"probe": "plain"}
+        )
+        past_date = timezone.utcnow() - timedelta(minutes=5)
+        reserved_deadline = Deadline(
+            deadline_time=past_date,
+            callback=AsyncCallback("classpath.notify"),
+            dagrun_id=reserved_run.id,
+            dag_id=dag_id,
+            deadline_alert_id=None,
+        )
+        other_deadline = Deadline(
+            deadline_time=past_date,
+            callback=AsyncCallback("classpath.notify"),
+            dagrun_id=other_run.id,
+            dag_id=dag_id,
+            deadline_alert_id=None,
+        )
+        session.add_all([reserved_deadline, other_deadline])
+        session.commit()
+
+        with mock.patch.object(
+            Deadline, "handle_miss", autospec=True, 
side_effect=Deadline.handle_miss
+        ) as spy:
+            for _ in range(2):
+                self.job_runner = SchedulerJobRunner(job=Job(), num_runs=1, 
executors=[MockExecutor()])
+                self.job_runner._execute()
+
+        assert spy.call_count == 2
+        session.expire_all()
+        reserved_deadline = session.get(Deadline, reserved_deadline.id)
+        other_deadline = session.get(Deadline, other_deadline.id)
+        assert reserved_deadline.missed
+        assert reserved_deadline.callback.state == CallbackState.FAILED
+        assert other_deadline.missed
+        assert other_deadline.callback.state == CallbackState.QUEUED
+        assert [t.id for t in session.scalars(select(Trigger))] == 
[other_deadline.callback.trigger_id]
+
+        # The shared cleanup deletes triggers before callbacks.
+        session.execute(update(Callback).values(trigger_id=None))
+        session.commit()
+
     @mock.patch("airflow.models.Deadline.handle_miss")
     def test_process_expired_deadlines_no_deadlines_found(self, 
mock_handle_miss, session):
         """Test handling when there are no deadlines to process."""
diff --git a/airflow-core/tests/unit/models/test_deadline.py 
b/airflow-core/tests/unit/models/test_deadline.py
index 6c09ff069b1..fa009620f0b 100644
--- a/airflow-core/tests/unit/models/test_deadline.py
+++ b/airflow-core/tests/unit/models/test_deadline.py
@@ -30,8 +30,9 @@ from sqlalchemy import select
 from airflow.api_fastapi.core_api.datamodels.dag_run import DAGRunResponse
 from airflow.models import DagRun
 from airflow.models.deadline import Deadline, ReferenceModels
+from airflow.models.trigger import Trigger
 from airflow.providers.standard.operators.empty import EmptyOperator
-from airflow.sdk import timezone
+from airflow.sdk import serde, timezone
 from airflow.sdk.definitions.callback import AsyncCallback, SyncCallback
 from airflow.sdk.definitions.deadline import (
     AverageRuntimeDeadline,
@@ -43,7 +44,7 @@ from airflow.sdk.definitions.deadline import (
     deadline_reference,
 )
 from airflow.serialization.definitions.deadline import 
SerializedReferenceModels
-from airflow.utils.state import DagRunState
+from airflow.utils.state import CallbackState, DagRunState
 
 from tests_common.test_utils import db
 from unit.models import DEFAULT_DATE
@@ -297,6 +298,58 @@ class TestDeadline:
         callback.trigger = None
         session.commit()
 
+    @pytest.mark.db_test
+    @pytest.mark.parametrize(
+        ("reserved_value", "keys"),
+        [
+            pytest.param({"__type": "airflow.ConfProbe", "__var": {}}, 
"__type, __var", id="legacy"),
+            pytest.param(
+                {"__classname__": "airflow.ConfProbe", "__version__": 0, 
"__data__": {}},
+                "__classname__, __data__, __version__",
+                id="current",
+            ),
+        ],
+    )
+    @mock.patch("airflow.models.deadline.stats")
+    def 
test_handle_miss_fails_triggerer_callback_with_reserved_keys_in_context(
+        self, mock_stats, dagrun, session, reserved_value, keys
+    ):
+        dagrun.conf = {"nested": {"probe": reserved_value}}
+        session.flush()
+        deadline_orm = Deadline(
+            deadline_time=DEFAULT_DATE,
+            callback=AsyncCallback(TEST_CALLBACK_PATH, TEST_CALLBACK_KWARGS),
+            dagrun_id=dagrun.id,
+            dag_id=dagrun.dag_id,
+            deadline_alert_id=None,
+        )
+        session.add(deadline_orm)
+        session.flush()
+
+        probe = mock.MagicMock()
+        real_import_string = serde.import_string
+
+        def import_string(path):
+            return probe if path == "airflow.ConfProbe" else 
real_import_string(path)
+
+        with mock.patch.object(serde, "import_string", 
side_effect=import_string):
+            deadline_orm.handle_miss(session)
+            session.flush()
+            for trigger in session.scalars(select(Trigger)):
+                Trigger._decrypt_kwargs(trigger.encrypted_kwargs)
+
+        probe.assert_not_called()
+        probe.deserialize.assert_not_called()
+        assert session.scalars(select(Trigger)).all() == []
+        callback = deadline_orm.callback
+        assert callback.trigger is None
+        assert callback.state == CallbackState.FAILED
+        assert callback.output == f"Not queued: the callback context holds 
reserved serialization keys {keys}"
+        assert "context" not in callback.data["kwargs"]
+        assert deadline_orm.missed
+        assert session.scalars(select(Deadline).where(~Deadline.missed)).all() 
== []
+        
mock_stats.incr.assert_any_call(stat="deadline_alerts.callback_failed", 
tags=mock.ANY)
+
     @pytest.mark.db_test
     def test_handle_miss_persists_executor_callback_routing_data(self, dagrun, 
session):
         deadline_orm = Deadline(

Reply via email to