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

jason810496 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 1c3f78e1d57 Validate a serialized Dag and fill its unset settings from 
the Airflow config (#74041)
1c3f78e1d57 is described below

commit 1c3f78e1d57f24bd211e426ffcfbb5f8d1583ce5
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Mon Oct 5 11:38:11 2026 +0800

    Validate a serialized Dag and fill its unset settings from the Airflow 
config (#74041)
    
    * Check that a serialized Dag can be stored and loaded
    
    DagSerialization.validate_serialized_dag checks the JSON schema,
    deserializes a copy and looks for a cycle in the task graph. It raises
    DeserializationError naming the Dag, and for a cycle a task on it. It is
    meant for a serialized Dag that no Python code built, such as one a
    Lang-SDK runtime returns, so the Python SDK's cycle check never ran on
    it. The cycle check runs the shared detect_cycle over the serialized
    downstream edges.
    
    * Fill a serialized Dag's unset settings from the Airflow config
    
    A Lang-SDK runtime cannot read the Airflow config, so it leaves out the
    Dag settings that a Python Dag reads from it when unset. The new
    DagSerialization.fill_config_defaults fills max_active_tasks,
    max_active_runs, max_consecutive_failed_dag_runs, catchup and
    disable_bundle_versioning in a serialized Dag from the config. A value
    the Dag sets is kept.
    
    * Reject malformed task entries in a serialized Dag
    
    The schema put additionalProperties on the tasks array, which JSON
    Schema ignores, so a task entry like {} passed validation. Each entry
    must now be an operator with a string task_id, and
    validate_serialized_dag rejects a repeated task id. A schema error
    now names the failing field's path.
    
    Also pin both boolean config defaults to different values in the
    fill_config_defaults tests, and cover the unwrapped cause of a
    deserialization error.
    
    * Check SDK Dags with the Dag processor's validation in conformance
    
    receive() now calls DagSerialization.fill_config_defaults and
    validate_serialized_dag, so the conformance job runs the same checks
    as the Dag processor and the config-backed field list lives in one
    place.
    
    * Note in the parsing ADR that runtimes may omit config-backed fields
    
    * Serialize the SDK Dag in tests that write a dag_maker Dag
    
    These tests passed the dag_maker proxy of the serialized Dag to
    LazyDeserializedDAG.from_dag, which serialized each task as its repr.
    The tasks schema now rejects that, so they serialize the SDK Dag.
    
    * Sync the Java SDK copy of the Dag schema
    
    * Sync the Go SDK copy of the Dag schema
    
    * Check a serialized Dag as the SDK Dag is checked and return it
    
    validate_serialized_dag now applies the max_active_runs and catchup rules 
the
    SDK Dag runs when it is built, to the Dag it deserializes. The cycle check
    walks that Dag's tasks, so edges from the legacy _downstream_task_ids key 
and
    from client_defaults count too. It returns the deserialized Dag.
    
    * Report a schema error's path the same way under every jsonschema version
    
    jsonschema 4.26 writes $.dag.tasks[2]['__type'] where 4.23 writes
    $.dag.tasks[2].__type, so the import error and three tests changed with
    the installed version. Build the path from the error's absolute path.
---
 airflow-core/adr/lang-sdk/0004-dag-parsing.md      |  12 +-
 airflow-core/src/airflow/serialization/schema.json |  13 +-
 .../airflow/serialization/serialized_objects.py    |  96 +++++++++
 airflow-core/tests/unit/models/test_dagrun.py      |   6 +-
 .../tests/unit/models/test_serialized_dag.py       |   4 +-
 .../unit/serialization/test_dag_serialization.py   | 215 +++++++++++++++++++++
 .../unit/serialization/test_dag_version_diff.py    |   1 -
 go-sdk/schema/dag-schema.json                      |  13 +-
 java-sdk/sdk/schema/dag-schema.json                |  13 +-
 .../ci/lang_sdk_serialization/serialize_python.py  |  23 +--
 ts-sdk/schema/dag-schema.json                      |  13 +-
 11 files changed, 376 insertions(+), 33 deletions(-)

diff --git a/airflow-core/adr/lang-sdk/0004-dag-parsing.md 
b/airflow-core/adr/lang-sdk/0004-dag-parsing.md
index d4e85acde89..064f44767d2 100644
--- a/airflow-core/adr/lang-sdk/0004-dag-parsing.md
+++ b/airflow-core/adr/lang-sdk/0004-dag-parsing.md
@@ -178,7 +178,7 @@ Airflow Supervisor                    Bridge              
Language Runtime
 
 ### DagFileParsingResult Format
 
-The language runtime must produce a `DagFileParsingResult` that matches Python 
Airflow's DagSerialization format exactly. The Airflow scheduler deserializes 
this into its internal model — any divergence causes parsing failures.
+The language runtime must produce a `DagFileParsingResult` that matches Python 
Airflow's DagSerialization format exactly, except that it may omit the 
config-backed Dag fields marked in the table below, which Airflow fills in from 
its config before it loads the Dag. The Airflow scheduler deserializes this 
into its internal model — any divergence causes parsing failures.
 
 **Envelope:**
 
@@ -216,9 +216,11 @@ The language runtime must produce a `DagFileParsingResult` 
that matches Python A
 | `start_date` | float (epoch) | if set | Unwrapped from `__type`/`__var` |
 | `end_date` | float (epoch) | if set | Unwrapped from `__type`/`__var` |
 | `tags` | list | if non-empty | Unwrapped from `__type`/`__var` |
-| `catchup` | bool | if `true` | |
-| `max_active_tasks` | int | if non-default | |
-| `max_active_runs` | int | if non-default | |
+| `catchup` | bool | if set | Airflow fills an unset field from its config |
+| `max_active_tasks` | int | if set | Airflow fills an unset field from its 
config |
+| `max_active_runs` | int | if set | Airflow fills an unset field from its 
config |
+| `max_consecutive_failed_dag_runs` | int | if set | Airflow fills an unset 
field from its config |
+| `disable_bundle_versioning` | bool | if set | Airflow fills an unset field 
from its config |
 
 **Timetable encoding:**
 
@@ -404,7 +406,7 @@ uv run validation/serialization/compare.py \
     validation/serialization/serialized_java.json
 ```
 
-Both share test cases defined in `test_dags.yaml`, ensuring the Java SDK 
produces byte-identical output to Python's `DagSerialization.serialize_dag()` 
for the same inputs.
+Both share test cases defined in `test_dags.yaml`, ensuring the Java SDK 
produces byte-identical output to Python's `DagSerialization.serialize_dag()` 
for the same inputs, apart from the config-backed Dag fields it may omit, which 
Airflow fills in before it loads the Dag.
 
 ## Consequences
 
diff --git a/airflow-core/src/airflow/serialization/schema.json 
b/airflow-core/src/airflow/serialization/schema.json
index 086bccb34bb..6ecea324714 100644
--- a/airflow-core/src/airflow/serialization/schema.json
+++ b/airflow-core/src/airflow/serialization/schema.json
@@ -257,7 +257,18 @@
     },
     "tasks": {
       "type": "array",
-      "additionalProperties": { "$ref": "#/definitions/operator" }
+      "items": {
+        "type": "object",
+        "required": [ "__type", "__var" ],
+        "properties": {
+          "__type": { "const": "operator" },
+          "__var": {
+            "type": "object",
+            "required": [ "task_id" ],
+            "properties": { "task_id": { "type": "string" } }
+          }
+        }
+      }
     },
     "params": {
       "type": "array",
diff --git a/airflow-core/src/airflow/serialization/serialized_objects.py 
b/airflow-core/src/airflow/serialization/serialized_objects.py
index b58bb2505b0..8a6b2599944 100644
--- a/airflow-core/src/airflow/serialization/serialized_objects.py
+++ b/airflow-core/src/airflow/serialization/serialized_objects.py
@@ -22,6 +22,7 @@ from __future__ import annotations
 
 import collections.abc
 import contextlib
+import copy
 import datetime
 import enum
 import itertools
@@ -39,11 +40,14 @@ import attrs
 import lazy_object_proxy
 import pydantic
 from dateutil import relativedelta
+from jsonschema import ValidationError
 from pendulum.tz.timezone import FixedTimezone, Timezone
 
+from airflow._shared.dagnode.cycle import detect_cycle
 from airflow._shared.module_loading import qualname
 from airflow._shared.timezones.timezone import from_timestamp, parse_timezone, 
utcnow
 from airflow.callbacks.callback_requests import DagCallbackRequest, 
TaskCallbackRequest
+from airflow.configuration import conf
 from airflow.exceptions import AirflowException, DeserializationError, 
SerializationError
 from airflow.models.connection import Connection
 from airflow.models.expandinput import SchedulerMappedArgument, 
create_expand_input
@@ -296,6 +300,16 @@ def _decode_start_trigger_args(var: dict[str, Any]) -> 
StartTriggerArgs:
     )
 
 
+def _build_json_path(error: ValidationError) -> str:
+    """
+    Return where *error* is in the document, such as ``$.dag.tasks[2].__type``.
+
+    Unlike ``ValidationError.json_path``, whose quoting of a key such as 
``__type`` depends on the
+    jsonschema version, this is the same under every version.
+    """
+    return "$" + "".join(f"[{part}]" if isinstance(part, int) else f".{part}" 
for part in error.absolute_path)
+
+
 class _XComRef(NamedTuple):
     """
     Store info needed to create XComArg.
@@ -2131,6 +2145,88 @@ class DagSerialization(BaseSerialization):
         # Pass client_defaults directly to deserialize_dag
         return cls.deserialize_dag(serialized_obj["dag"], client_defaults)
 
+    @classmethod
+    def fill_config_defaults(cls, serialized_obj: dict[str, Any]) -> None:
+        """
+        Fill in the Dag settings a serialized Dag leaves unset from the 
Airflow config, as a Python Dag does.
+
+        A Lang-SDK runtime cannot read the Airflow config, so it leaves 
``max_active_tasks``,
+        ``max_active_runs``, ``max_consecutive_failed_dag_runs``, ``catchup`` 
and
+        ``disable_bundle_versioning`` out unless the Dag sets them. A value 
the Dag sets is kept.
+        *serialized_obj* is changed in place.
+        """
+        dag = serialized_obj.get("dag")
+        if not isinstance(dag, dict):
+            # validate_serialized_dag rejects it.
+            return
+        for key, get, section, option in (
+            ("max_active_tasks", conf.getint, "core", 
"max_active_tasks_per_dag"),
+            ("max_active_runs", conf.getint, "core", 
"max_active_runs_per_dag"),
+            (
+                "max_consecutive_failed_dag_runs",
+                conf.getint,
+                "core",
+                "max_consecutive_failed_dag_runs_per_dag",
+            ),
+            ("catchup", conf.getboolean, "scheduler", "catchup_by_default"),
+            ("disable_bundle_versioning", conf.getboolean, "dag_processor", 
"disable_bundle_versioning"),
+        ):
+            if key not in dag:
+                dag[key] = get(section, option)
+
+    @classmethod
+    def validate_serialized_dag(cls, serialized_obj: dict[str, Any]) -> 
SerializedDAG:
+        """
+        Check that a serialized Dag, such as one a Lang-SDK runtime produced, 
can be stored and loaded.
+
+        It must match the JSON schema, have unique task ids and deserialize. 
Like a Dag built with the SDK,
+        it must not set a ``max_active_runs`` its timetable forbids, nor 
``catchup`` without a
+        ``start_date``, and its task graph must have no cycle. 
*serialized_obj* is not changed.
+
+        :return: The deserialized Dag.
+        :raises DeserializationError: if it does not.
+        """
+        dag = serialized_obj.get("dag")
+        dag_id = dag.get("dag_id") if isinstance(dag, dict) else None
+        try:
+            cls.validate_schema(serialized_obj)
+        except ValidationError as e:
+            raise DeserializationError(
+                dag_id, f"Dag {dag_id!r} does not match the schema at 
{_build_json_path(e)}: {e.message}"
+            ) from e
+        task_ids = collections.Counter(
+            task[Encoding.VAR]["task_id"] for task in 
serialized_obj["dag"]["tasks"]
+        )
+        if duplicates := sorted(task_id for task_id, count in task_ids.items() 
if count > 1):
+            raise DeserializationError(
+                dag_id, f"Dag {dag_id!r} has more than one task with id {', 
'.join(map(repr, duplicates))}"
+            )
+        try:
+            deserialized = cls.from_dict(copy.deepcopy(serialized_obj))
+        except Exception as e:
+            cause = e.__cause__ if isinstance(e, DeserializationError) and 
e.__cause__ else e
+            raise DeserializationError(
+                dag_id, f"Dag {dag_id!r} cannot be deserialized: 
{type(cause).__name__}: {cause}"
+            ) from e
+        if (
+            limit := deserialized.timetable.active_runs_limit
+        ) is not None and deserialized.max_active_runs > limit:
+            raise DeserializationError(
+                dag_id,
+                f"Dag {dag_id!r} sets max_active_runs 
{deserialized.max_active_runs}, "
+                f"but {type(deserialized.timetable).__name__} allows at most 
{limit}",
+            )
+        if (
+            deserialized.catchup
+            and deserialized.timetable.can_be_scheduled
+            and not (deserialized.start_date or "start_date" in 
deserialized.default_args)
+        ):
+            raise DeserializationError(dag_id, f"Dag {dag_id!r} sets catchup 
but no start_date")
+        downstream = {task_id: task.downstream_task_ids for task_id, task in 
deserialized.task_dict.items()}
+        if (task_id := detect_cycle(downstream, downstream.__getitem__)) is 
not None:
+            raise DeserializationError(dag_id, f"Dag {dag_id!r} has a cycle 
through task {task_id!r}")
+        return deserialized
+
 
 class TaskGroupSerialization(BaseSerialization):
     """JSON serializable representation of a task group."""
diff --git a/airflow-core/tests/unit/models/test_dagrun.py 
b/airflow-core/tests/unit/models/test_dagrun.py
index 47343eeefae..e85713943d6 100644
--- a/airflow-core/tests/unit/models/test_dagrun.py
+++ b/airflow-core/tests/unit/models/test_dagrun.py
@@ -617,7 +617,8 @@ class TestDagRun:
             "test_state_succeeded2": TaskInstanceState.SUCCESS,
         }
         dag.relative_fileloc = relative_fileloc
-        SerializedDagModel.write_dag(LazyDeserializedDAG.from_dag(dag), 
bundle_name="dag_maker")
+        dag_maker.dag.relative_fileloc = relative_fileloc
+        
SerializedDagModel.write_dag(LazyDeserializedDAG.from_dag(dag_maker.dag), 
bundle_name="dag_maker")
         session.commit()
 
         dag_run = self.create_dag_run(dag=dag, 
task_states=initial_task_states, session=session)
@@ -664,7 +665,8 @@ class TestDagRun:
             "test_state_failed2": TaskInstanceState.FAILED,
         }
         dag.relative_fileloc = relative_fileloc
-        SerializedDagModel.write_dag(LazyDeserializedDAG.from_dag(dag), 
bundle_name="dag_maker")
+        dag_maker.dag.relative_fileloc = relative_fileloc
+        
SerializedDagModel.write_dag(LazyDeserializedDAG.from_dag(dag_maker.dag), 
bundle_name="dag_maker")
         session.commit()
 
         dag_run = self.create_dag_run(dag=dag, 
task_states=initial_task_states, session=session)
diff --git a/airflow-core/tests/unit/models/test_serialized_dag.py 
b/airflow-core/tests/unit/models/test_serialized_dag.py
index 7609c57ba01..7b47f70fdf2 100644
--- a/airflow-core/tests/unit/models/test_serialized_dag.py
+++ b/airflow-core/tests/unit/models/test_serialized_dag.py
@@ -885,11 +885,11 @@ class TestSerializedDagModel:
         Test that dynamic DAG update gracefully handles case where 
SerializedDagModel doesn't exist.
         This preserves the null-check fix from PR #56422 and tests the direct 
UPDATE path.
         """
-        with dag_maker(dag_id="test_missing_serdag", serialized=True, 
session=session) as dag:
+        with dag_maker(dag_id="test_missing_serdag", serialized=True, 
session=session):
             EmptyOperator(task_id="task1")
 
         # Write the DAG first
-        lazy_dag = LazyDeserializedDAG.from_dag(dag)
+        lazy_dag = LazyDeserializedDAG.from_dag(dag_maker.dag)
         SDM.write_dag(
             dag=lazy_dag,
             bundle_name="test_bundle",
diff --git a/airflow-core/tests/unit/serialization/test_dag_serialization.py 
b/airflow-core/tests/unit/serialization/test_dag_serialization.py
index 279a7bb02a1..ce2c2fbb8a1 100644
--- a/airflow-core/tests/unit/serialization/test_dag_serialization.py
+++ b/airflow-core/tests/unit/serialization/test_dag_serialization.py
@@ -5155,3 +5155,218 @@ class TestWeightRule:
         op = BaseOperator(task_id="empty_task", 
weight_rule=NotRegisteredPriorityWeightStrategy())
         with pytest.raises(ValueError, match="Unknown priority strategy"):
             OperatorSerialization.serialize(op)
+
+
+class TestValidateSerializedDag:
+    @staticmethod
+    def _serialize() -> dict:
+        with DAG(dag_id="checked_dag", schedule=None) as dag:
+            BaseOperator(task_id="extract") >> BaseOperator(task_id="load")
+        return DagSerialization.to_dict(dag)
+
+    def test_accepts_a_dag_that_loads(self):
+        data = self._serialize()
+        data["__version"] = 2
+        before = copy.deepcopy(data)
+
+        DagSerialization.validate_serialized_dag(data)
+
+        assert data == before
+
+    @pytest.mark.parametrize(
+        ("change", "error"),
+        [
+            pytest.param(
+                {"max_active_runs": "many"},
+                "Dag 'checked_dag' does not match the schema at 
$.dag.max_active_runs: "
+                "'many' is not of type 'number'",
+                id="schema",
+            ),
+            pytest.param(
+                {"timetable": {"__type": "no.such.Timetable", "__var": {}}},
+                "Dag 'checked_dag' cannot be deserialized: 
TimetableNotRegistered: ",
+                id="deserialize",
+            ),
+        ],
+    )
+    def test_rejects_a_dag_that_does_not_load(self, change, error):
+        data = self._serialize()
+        data["dag"].update(change)
+
+        with pytest.raises(DeserializationError, match=f"^{re.escape(error)}"):
+            DagSerialization.validate_serialized_dag(data)
+
+    def test_returns_the_deserialized_dag(self):
+        dag = DagSerialization.validate_serialized_dag(self._serialize())
+
+        assert isinstance(dag, SerializedDAG)
+        assert dag.dag_id == "checked_dag"
+        assert dag.task_dict["extract"].downstream_task_ids == {"load"}
+
+    @pytest.mark.parametrize(
+        ("entry", "json_path"),
+        [
+            pytest.param({}, "$.dag.tasks[2]", id="empty"),
+            pytest.param("x", "$.dag.tasks[2]", id="not-an-object"),
+            pytest.param(
+                {"__type": "dag", "__var": {"task_id": "t"}}, 
"$.dag.tasks[2].__type", id="wrong-type"
+            ),
+            pytest.param({"__type": "operator", "__var": {}}, 
"$.dag.tasks[2].__var", id="no-task-id"),
+            pytest.param(
+                {"__type": "operator", "__var": {"task_id": 5}},
+                "$.dag.tasks[2].__var.task_id",
+                id="task-id-not-a-string",
+            ),
+        ],
+    )
+    def test_rejects_a_task_entry_that_is_not_an_operator(self, entry, 
json_path):
+        data = self._serialize()
+        data["dag"]["tasks"].append(entry)
+
+        with pytest.raises(
+            DeserializationError,
+            match=rf"^Dag 'checked_dag' does not match the schema at 
{re.escape(json_path)}: ",
+        ):
+            DagSerialization.validate_serialized_dag(data)
+
+    def test_rejects_a_repeated_task_id(self):
+        data = self._serialize()
+        data["dag"]["tasks"].append(copy.deepcopy(data["dag"]["tasks"][0]))
+
+        with pytest.raises(
+            DeserializationError, match="^Dag 'checked_dag' has more than one 
task with id 'extract'$"
+        ):
+            DagSerialization.validate_serialized_dag(data)
+
+    def test_reports_the_cause_of_a_deserialization_error(self):
+        data = self._serialize()
+        data["dag"]["tasks"][0]["__var"]["downstream_task_ids"] = ["ghost"]
+
+        with pytest.raises(
+            DeserializationError, match="^Dag 'checked_dag' cannot be 
deserialized: KeyError: 'ghost'$"
+        ):
+            DagSerialization.validate_serialized_dag(data)
+
+    def test_rejects_a_dag_with_a_cycle(self):
+        data = self._serialize()
+        load = next(task for task in data["dag"]["tasks"] if 
task["__var"]["task_id"] == "load")
+        load["__var"]["downstream_task_ids"] = ["extract"]
+
+        with pytest.raises(DeserializationError, match="^Dag 'checked_dag' has 
a cycle through task 'load'$"):
+            DagSerialization.validate_serialized_dag(data)
+
+    def test_rejects_a_cycle_in_the_legacy_downstream_key(self):
+        data = self._serialize()
+        load = next(task for task in data["dag"]["tasks"] if 
task["__var"]["task_id"] == "load")
+        load["__var"]["_downstream_task_ids"] = ["extract"]
+
+        with pytest.raises(DeserializationError, match="^Dag 'checked_dag' has 
a cycle through task 'load'$"):
+            DagSerialization.validate_serialized_dag(data)
+
+    @pytest.mark.parametrize(
+        ("change", "error"),
+        [
+            pytest.param(
+                {},
+                "Dag 'checked_dag' sets max_active_runs 16, but 
ContinuousTimetable allows at most 1",
+                id="unset",
+            ),
+            pytest.param(
+                {"max_active_runs": 2},
+                "Dag 'checked_dag' sets max_active_runs 2, but 
ContinuousTimetable allows at most 1",
+                id="above-the-limit",
+            ),
+        ],
+    )
+    def test_rejects_max_active_runs_above_the_timetable_limit(self, change, 
error):
+        data = DagSerialization.to_dict(DAG(dag_id="checked_dag", 
schedule="@continuous", max_active_runs=1))
+        del data["dag"]["max_active_runs"]
+        data["dag"].update(change)
+
+        with pytest.raises(DeserializationError, 
match=f"^{re.escape(error)}$"):
+            DagSerialization.validate_serialized_dag(data)
+
+    def test_rejects_catchup_without_a_start_date(self):
+        data = DagSerialization.to_dict(DAG(dag_id="checked_dag", 
schedule="@daily"))
+        data["dag"]["catchup"] = True
+
+        with pytest.raises(DeserializationError, match="^Dag 'checked_dag' 
sets catchup but no start_date$"):
+            DagSerialization.validate_serialized_dag(data)
+
+    @pytest.mark.parametrize(
+        "kwargs",
+        [
+            pytest.param(
+                {
+                    "schedule": "@daily",
+                    "default_args": {"start_date": datetime(2024, 1, 1, 
tzinfo=dt_timezone.utc)},
+                },
+                id="start-date-in-default-args",
+            ),
+            pytest.param({"schedule": None}, id="unscheduled"),
+        ],
+    )
+    def test_accepts_catchup_the_sdk_dag_accepts(self, kwargs):
+        dag = DAG(dag_id="checked_dag", catchup=True, **kwargs)
+
+        assert 
DagSerialization.validate_serialized_dag(DagSerialization.to_dict(dag)).catchup 
is True
+
+
+class TestFillConfigDefaults:
+    CONFIG = {
+        ("core", "max_active_tasks_per_dag"): "7",
+        ("core", "max_active_runs_per_dag"): "3",
+        ("core", "max_consecutive_failed_dag_runs_per_dag"): "5",
+        ("scheduler", "catchup_by_default"): "True",
+        ("dag_processor", "disable_bundle_versioning"): "False",
+    }
+    FIELDS = (
+        "max_active_tasks",
+        "max_active_runs",
+        "max_consecutive_failed_dag_runs",
+        "catchup",
+        "disable_bundle_versioning",
+    )
+
+    @staticmethod
+    def _serialize() -> dict:
+        return DagSerialization.to_dict(DAG(dag_id="native_dag", 
schedule=None))
+
+    @conf_vars(CONFIG)
+    def test_fills_an_unset_setting_from_the_config(self):
+        data = self._serialize()
+        for field in self.FIELDS:
+            del data["dag"][field]
+
+        DagSerialization.fill_config_defaults(data)
+
+        assert {field: data["dag"][field] for field in self.FIELDS} == {
+            "max_active_tasks": 7,
+            "max_active_runs": 3,
+            "max_consecutive_failed_dag_runs": 5,
+            "catchup": True,
+            "disable_bundle_versioning": False,
+        }
+
+    @conf_vars(CONFIG)
+    def test_keeps_a_setting_the_dag_sets(self):
+        data = self._serialize()
+        data["dag"].update(
+            max_active_tasks=16,
+            max_active_runs=16,
+            max_consecutive_failed_dag_runs=0,
+            catchup=False,
+            disable_bundle_versioning=True,
+        )
+        before = copy.deepcopy(data)
+
+        DagSerialization.fill_config_defaults(data)
+
+        assert data == before
+
+    def test_leaves_a_payload_without_a_dag_to_validation(self):
+        data = {"__version": 3}
+
+        DagSerialization.fill_config_defaults(data)
+
+        assert data == {"__version": 3}
diff --git a/airflow-core/tests/unit/serialization/test_dag_version_diff.py 
b/airflow-core/tests/unit/serialization/test_dag_version_diff.py
index c3b027b6e31..7f92223f604 100644
--- a/airflow-core/tests/unit/serialization/test_dag_version_diff.py
+++ b/airflow-core/tests/unit/serialization/test_dag_version_diff.py
@@ -2896,7 +2896,6 @@ def 
test_diff_ignores_wrapper_types_missing_on_both_sides(include_values, proven
     base["dag"]["tasks"][0].pop("__type")
     base["dag"].pop("task_group")
     target = copy.deepcopy(base)
-    DagSerialization.validate_schema(base)
     assert DagSerialization.from_dict(copy.deepcopy(base)).task_dict == {}
     target_version = "new" if provenance_changed else "old"
 
diff --git a/go-sdk/schema/dag-schema.json b/go-sdk/schema/dag-schema.json
index 086bccb34bb..6ecea324714 100644
--- a/go-sdk/schema/dag-schema.json
+++ b/go-sdk/schema/dag-schema.json
@@ -257,7 +257,18 @@
     },
     "tasks": {
       "type": "array",
-      "additionalProperties": { "$ref": "#/definitions/operator" }
+      "items": {
+        "type": "object",
+        "required": [ "__type", "__var" ],
+        "properties": {
+          "__type": { "const": "operator" },
+          "__var": {
+            "type": "object",
+            "required": [ "task_id" ],
+            "properties": { "task_id": { "type": "string" } }
+          }
+        }
+      }
     },
     "params": {
       "type": "array",
diff --git a/java-sdk/sdk/schema/dag-schema.json 
b/java-sdk/sdk/schema/dag-schema.json
index 086bccb34bb..6ecea324714 100644
--- a/java-sdk/sdk/schema/dag-schema.json
+++ b/java-sdk/sdk/schema/dag-schema.json
@@ -257,7 +257,18 @@
     },
     "tasks": {
       "type": "array",
-      "additionalProperties": { "$ref": "#/definitions/operator" }
+      "items": {
+        "type": "object",
+        "required": [ "__type", "__var" ],
+        "properties": {
+          "__type": { "const": "operator" },
+          "__var": {
+            "type": "object",
+            "required": [ "task_id" ],
+            "properties": { "task_id": { "type": "string" } }
+          }
+        }
+      }
     },
     "params": {
       "type": "array",
diff --git a/scripts/ci/lang_sdk_serialization/serialize_python.py 
b/scripts/ci/lang_sdk_serialization/serialize_python.py
index 3bb1406cb0e..0647b53ac37 100644
--- a/scripts/ci/lang_sdk_serialization/serialize_python.py
+++ b/scripts/ci/lang_sdk_serialization/serialize_python.py
@@ -19,8 +19,8 @@ Serialize the Dags of test_dags.yaml with Airflow's own 
serializer.
 
 Each Dag is built with the Python authoring API and written as 
``DagSerialization.to_dict`` returns it,
 keyed by Dag id. ``--receive`` also takes a language SDK's output as Airflow 
receives it: it fills in the
-Dag fields the SDK leaves to Airflow's config, writes the result, and loads 
every Dag through
-``DagSerialization.validate_schema`` and ``from_dict``. compare.py runs it as::
+Dag fields the SDK leaves to Airflow's config with 
``DagSerialization.fill_config_defaults``, writes the
+result, and checks every Dag with 
``DagSerialization.validate_serialized_dag``. compare.py runs it as::
 
     uv run --project airflow-core --no-dev python 
scripts/ci/lang_sdk_serialization/serialize_python.py \
         scripts/ci/lang_sdk_serialization/test_dags.yaml 
serialized_python.json \
@@ -30,30 +30,17 @@ Dag fields the SDK leaves to Airflow's config, writes the 
result, and loads ever
 from __future__ import annotations
 
 import argparse
-import copy
 import datetime
 import json
 import sys
-from collections.abc import Callable
 from pathlib import Path
 from typing import Any
 
 import yaml
 
-from airflow.configuration import conf
 from airflow.sdk import DAG, BaseOperator, TaskGroup
 from airflow.serialization.serialized_objects import DagSerialization
 
-# The Dag fields the Python DAG reads from config when they are left unset. A 
language SDK cannot read
-# Airflow's config, so it leaves them out, and Airflow fills them in when it 
receives the SDK's Dags.
-CONFIG_BACKED_DAG_FIELDS: dict[str, Callable[[], Any]] = {
-    "max_active_tasks": lambda: conf.getint("core", 
"max_active_tasks_per_dag"),
-    "max_active_runs": lambda: conf.getint("core", "max_active_runs_per_dag"),
-    "max_consecutive_failed_dag_runs": lambda: conf.getint("core", 
"max_consecutive_failed_dag_runs_per_dag"),
-    "catchup": lambda: conf.getboolean("scheduler", "catchup_by_default"),
-    "disable_bundle_versioning": lambda: conf.getboolean("dag_processor", 
"disable_bundle_versioning"),
-}
-
 
 class NoopOperator(BaseOperator):
     """Stands in for a language SDK's task: a task with no Python behaviour."""
@@ -102,13 +89,11 @@ def get_node(dag: DAG, groups: dict[str, TaskGroup], 
node_id: str):
 def receive(sdk_output: Path, received_output: Path) -> None:
     received = json.loads(sdk_output.read_text())
     for data in received.values():
-        for key, read_config in CONFIG_BACKED_DAG_FIELDS.items():
-            data["dag"].setdefault(key, read_config())
+        DagSerialization.fill_config_defaults(data)
     received_output.write_text(json.dumps(received, indent=2) + "\n")
     for dag_id, data in received.items():
         try:
-            DagSerialization.validate_schema(data)
-            DagSerialization.from_dict(copy.deepcopy(data))
+            DagSerialization.validate_serialized_dag(data)
         except Exception:
             print(f"Airflow cannot load Dag {dag_id!r} as the SDK wrote it", 
file=sys.stderr)
             raise
diff --git a/ts-sdk/schema/dag-schema.json b/ts-sdk/schema/dag-schema.json
index 086bccb34bb..6ecea324714 100644
--- a/ts-sdk/schema/dag-schema.json
+++ b/ts-sdk/schema/dag-schema.json
@@ -257,7 +257,18 @@
     },
     "tasks": {
       "type": "array",
-      "additionalProperties": { "$ref": "#/definitions/operator" }
+      "items": {
+        "type": "object",
+        "required": [ "__type", "__var" ],
+        "properties": {
+          "__type": { "const": "operator" },
+          "__var": {
+            "type": "object",
+            "required": [ "task_id" ],
+            "properties": { "task_id": { "type": "string" } }
+          }
+        }
+      }
     },
     "params": {
       "type": "array",

Reply via email to