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 a7ac9379cf9 Read XCom without object reconstruction during dependency 
evaluation (#72718)
a7ac9379cf9 is described below

commit a7ac9379cf9d0a443319dd740849c63fff95359c
Author: Jarek Potiuk <[email protected]>
AuthorDate: Mon Oct 19 16:56:52 2026 +0200

    Read XCom without object reconstruction during dependency evaluation 
(#72718)
    
    * Read XCom without object reconstruction during dependency evaluation
    
    The scheduler evaluates task dependencies in-process, and two of those deps 
read
    XCom: NotPreviouslySkippedDep reads skipmixin_key for every parent that 
inherits
    from SkipMixin, and both it and TriggerRuleDep read past_depends_met when
    wait_for_past_depends_before_skipping applies.
    
    Both went through TaskInstance.xcom_pull -> XComModel.deserialize_value, 
which is
    json.loads(value, cls=XComDecoder). XComDecoder's object_hook calls the 
Airflow
    serialization module with full=True, so any nested dict carrying 
__classname__ is
    imported and instantiated. The values these deps actually consume are 
primitives:
    a mapping of lists of task ids, and a boolean. Nothing here needs an object
    rebuilt, so the scheduler can read these values as plain JSON.
    
    Add XComModel.deserialize_value_primitives, a plain json.loads with no
    object_hook, and LazyXComPrimitivesSelectSequence alongside it. Thread 
full=True
    through TaskInstance.xcom_pull and pass full=False at the four dep call 
sites.
    All three of xcom_pull's return paths are covered -- the direct one, and 
both
    lazy-sequence ones, which is the path a mapped task instance takes. The skip
    decisions of SkipMixin tasks in the same mapped task group, which
    NotPreviouslySkippedDep reads with one query per pass, are decoded the same 
way.
    
    NotPreviouslySkippedDep also now checks that the skipmixin payload is a 
mapping
    before testing membership in it. It reads whatever the parent task wrote, 
so a
    scalar there previously raised TypeError inside dependency evaluation 
rather than
    being ignored. Because the payload is now plain JSON, a set or tuple of 
task ids
    arrives as its serde wrapper; its items are read from the wrapper without
    constructing anything, and a decision whose task ids are not a list of 
strings is
    ignored the same way.
    
    Task code pulling another task's return value is unchanged and still gets 
the
    original Python object; only the dependency-evaluation reads are narrowed.
    
    Generated-by: Claude Opus 5
    
    * Treat only a literal True as past-depends-met during dependency evaluation
    
    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
    
    * Ignore skip decisions with a non-string classname
    
    Generated-by: Claude Opus 5
---
 airflow-core/src/airflow/models/taskinstance.py    |  19 +-
 airflow-core/src/airflow/models/xcom.py            |  43 +++
 .../ti_deps/deps/not_previously_skipped_dep.py     |  66 ++++-
 .../src/airflow/ti_deps/deps/prev_dagrun_dep.py    |  14 +
 .../src/airflow/ti_deps/deps/trigger_rule_dep.py   |  12 +-
 airflow-core/tests/unit/models/test_xcom.py        |  90 ++++++
 .../deps/test_not_previously_skipped_dep.py        | 311 +++++++++++++++++++++
 .../unit/ti_deps/deps/test_trigger_rule_dep.py     |  62 +++-
 .../src/tests_common/test_utils/mock_context.py    |   1 +
 9 files changed, 597 insertions(+), 21 deletions(-)

diff --git a/airflow-core/src/airflow/models/taskinstance.py 
b/airflow-core/src/airflow/models/taskinstance.py
index 4a0ed099426..f569fc4f36f 100644
--- a/airflow-core/src/airflow/models/taskinstance.py
+++ b/airflow-core/src/airflow/models/taskinstance.py
@@ -118,7 +118,7 @@ from airflow.models.hitl import HITLDetail  # noqa: F401
 from airflow.models.log import Log
 from airflow.models.taskinstancekey import TaskInstanceKey
 from airflow.models.taskreschedule import TaskReschedule
-from airflow.models.xcom import XCOM_RETURN_KEY, LazyXComSelectSequence
+from airflow.models.xcom import XCOM_RETURN_KEY, 
LazyXComPrimitivesSelectSequence, LazyXComSelectSequence
 from airflow.serialization.enums import stringify_encoding_keys
 from airflow.settings import task_instance_mutation_hook
 from airflow.task.priority_strategy import 
validate_and_load_priority_weight_strategy
@@ -2839,9 +2839,16 @@ class TaskInstance(Base, LoggingMixin, BaseWorkload):
         map_indexes: int | Iterable[int] | None = None,
         default: Any = None,
         run_id: str | None = None,
+        full: bool = True,
     ) -> Any:
         """:meta private:"""  # noqa: D400
         # This is only kept for compatibility in tests for now while AIP-72 is 
in progress.
+        #
+        # `full=False` returns plain JSON types and never runs the Airflow
+        # serialization module, so a stored `__classname__` envelope is not 
imported or
+        # instantiated. Callers that evaluate task dependencies -- the 
ti_deps, run by
+        # the scheduler and by the API server's task instance dependencies 
endpoint --
+        # pass it, since they only need primitive values.
 
         from airflow.models.xcom import XComModel, xcom_entity
 
@@ -2879,9 +2886,12 @@ class TaskInstance(Base, LoggingMixin, BaseWorkload):
                 return default
 
             if map_indexes is not None or first.map_index < 0:
-                return XComModel.deserialize_value(first)
+                if full:
+                    return XComModel.deserialize_value(first)
+                return XComModel.deserialize_value_primitives(first)
 
-            return LazyXComSelectSequence.from_select(
+            sequence_cls = LazyXComSelectSequence if full else 
LazyXComPrimitivesSelectSequence
+            return sequence_cls.from_select(
                 query.with_only_columns(entity.value).order_by(None),
                 order_by=[entity.map_index.expression],
                 session=session,
@@ -2907,7 +2917,8 @@ class TaskInstance(Base, LoggingMixin, BaseWorkload):
             ordering.append(case(map_index_whens, value=entity.map_index))
         else:
             ordering.append(entity.map_index)
-        return LazyXComSelectSequence.from_select(
+        sequence_cls = LazyXComSelectSequence if full else 
LazyXComPrimitivesSelectSequence
+        return sequence_cls.from_select(
             query.with_only_columns(entity.value).order_by(None),
             order_by=ordering,
             session=session,
diff --git a/airflow-core/src/airflow/models/xcom.py 
b/airflow-core/src/airflow/models/xcom.py
index 2054ba1368a..b86ade0fa5e 100644
--- a/airflow-core/src/airflow/models/xcom.py
+++ b/airflow-core/src/airflow/models/xcom.py
@@ -456,6 +456,34 @@ class _XComOperations:
             # Already deserialized (e.g., set via Task Execution API)
             return result.value
 
+    @staticmethod
+    def deserialize_value_primitives(result: Any) -> Any:
+        """
+        Deserialize an XCom value to plain JSON types only.
+
+        Unlike :meth:`deserialize_value` this never runs the Airflow 
serialization
+        module, so a ``__classname__`` envelope in the stored value is 
returned as an
+        ordinary ``dict`` instead of being imported and instantiated.
+
+        This is what dependency evaluation uses, in the scheduler and in the 
API
+        server's task instance dependencies endpoint. The values it consumes 
are
+        primitives, so XCom is read without reconstructing objects in that 
process. Callers that genuinely need the
+        original Python object (task code pulling another task's return value) 
keep
+        using :meth:`deserialize_value`.
+
+        :param result: The XCom database row or object containing a ``value`` 
attribute.
+        :return: The value as plain JSON types (``dict``, ``list``, ``str``, 
``int``,
+            ``float``, ``bool`` or ``None``).
+        """
+        if result.value is None:
+            return None
+
+        try:
+            return json.loads(result.value)
+        except (ValueError, TypeError):
+            # Already deserialized (e.g., set via Task Execution API)
+            return result.value
+
 
 def _rows():
     from airflow.models.dagrun import DagRun
@@ -687,6 +715,21 @@ class LazyXComSelectSequence(LazySelectSequence[Any]):
         return get_xcom_model().deserialize_value(row)
 
 
+class LazyXComPrimitivesSelectSequence(LazyXComSelectSequence):
+    """
+    Like :class:`LazyXComSelectSequence`, but never reconstructs Python 
objects.
+
+    Used by XCom reads during dependency evaluation. See
+    :meth:`XComModel.deserialize_value_primitives`.
+
+    :meta private:
+    """
+
+    @staticmethod
+    def _process_row(row: Row) -> Any:
+        return get_xcom_model().deserialize_value_primitives(row)
+
+
 __compat_imports = {
     "BaseXCom": "airflow.sdk.bases.xcom",
     "XCom": "airflow.sdk.execution_time.xcom",
diff --git 
a/airflow-core/src/airflow/ti_deps/deps/not_previously_skipped_dep.py 
b/airflow-core/src/airflow/ti_deps/deps/not_previously_skipped_dep.py
index d0bef5f7665..80c3380f877 100644
--- a/airflow-core/src/airflow/ti_deps/deps/not_previously_skipped_dep.py
+++ b/airflow-core/src/airflow/ti_deps/deps/not_previously_skipped_dep.py
@@ -17,11 +17,12 @@
 # under the License.
 from __future__ import annotations
 
+import logging
 from typing import TYPE_CHECKING
 
-from airflow.models.taskinstance import PAST_DEPENDS_MET
 from airflow.models.xcom import XComModel, xcom_entity
 from airflow.ti_deps.deps.base_ti_dep import BaseTIDep
+from airflow.ti_deps.deps.prev_dagrun_dep import is_past_depends_met
 from airflow.utils.state import TaskInstanceState
 
 if TYPE_CHECKING:
@@ -34,6 +35,8 @@ if TYPE_CHECKING:
     from airflow.ti_deps.dep_context import DepContext
     from airflow.ti_deps.deps.base_ti_dep import TIDepStatus
 
+log = logging.getLogger(__name__)
+
 # The following constants are taken from the SkipMixin class in the standard 
provider
 # The key used by SkipMixin to store XCom data.
 XCOM_SKIPMIXIN_KEY = "skipmixin_key"
@@ -89,18 +92,25 @@ class NotPreviouslySkippedDep(BaseTIDep):
                         if dep_context.has_regions(ti, session=session)
                         else ti.region_index
                     )
+                # `full=False`: the payload is a plain mapping of lists of 
task ids, so
+                # the scheduler only needs primitive values here and reads 
XCom without
+                # reconstructing objects.
                 prev_result = ti.xcom_pull(
                     task_ids=parent.task_id,
                     key=XCOM_SKIPMIXIN_KEY,
                     session=session,
                     map_indexes=xcom_map_index,
+                    full=False,
                 )
 
                 if prev_result is None:
                     # This can happen if the parent task has not yet run.
                     continue
 
-                if _should_skip(prev_result, ti.task_id, 
is_direct_parent=True):
+                if (decision := _skip_decision(prev_result, parent.task_id)) 
is None:
+                    continue
+
+                if _should_skip(decision, ti.task_id, is_direct_parent=True):
                     yield from self._skip(ti, dep_context, parent.task_id, 
session=session)
                     return
 
@@ -131,10 +141,7 @@ class NotPreviouslySkippedDep(BaseTIDep):
         # ti should be skipped, set ti.state to SKIPPED and fail the rule so 
that the
         # ti does not execute.
         if dep_context.wait_for_past_depends_before_skipping:
-            past_depends_met = ti.xcom_pull(
-                task_ids=ti.task_id, key=PAST_DEPENDS_MET, session=session, 
default=False
-            )
-            if not past_depends_met:
+            if not is_past_depends_met(ti, session=session):
                 yield self._failing_status(reason="Task should be skipped but 
the past depends are not met")
                 return
         ti.set_state(TaskInstanceState.SKIPPED, session=session)
@@ -143,6 +150,49 @@ class NotPreviouslySkippedDep(BaseTIDep):
         )
 
 
+# Collection types serde writes as ``{"__classname__": ..., "__version__": 
..., "__data__": [...]}``.
+_SERDE_COLLECTION_CLASSNAMES = frozenset(
+    {"builtins.list", "builtins.tuple", "builtins.set", "builtins.frozenset"}
+)
+
+
+def _task_id_list(value: object) -> list[str] | None:
+    # The decision is read as plain JSON, so a set or tuple of task ids 
arrives as the serde
+    # wrapper dict; take its items without importing or constructing anything.
+    if isinstance(value, dict):
+        classname = value.get("__classname__")
+        if not (isinstance(classname, str) and classname in 
_SERDE_COLLECTION_CLASSNAMES):
+            return None
+        value = value.get("__data__")
+    if isinstance(value, (list, tuple)) and all(isinstance(task_id, str) for 
task_id in value):
+        return list(value)
+    return None
+
+
+def _skip_decision(prev_result: object, parent_task_id: str) -> dict[str, 
list[str]] | None:
+    """
+    Return the SkipMixin decision with its task id collections as lists, or 
``None`` if malformed.
+
+    A task may write anything under this key, so a value of another shape is 
treated as absent
+    rather than failing the dep.
+    """
+    if not isinstance(prev_result, dict):
+        problem = f"expected a mapping, got {type(prev_result).__name__}"
+    else:
+        decision = {}
+        for field in (XCOM_SKIPMIXIN_FOLLOWED, XCOM_SKIPMIXIN_SKIPPED):
+            if field not in prev_result:
+                continue
+            if (task_ids := _task_id_list(prev_result[field])) is None:
+                problem = f"{field!r} is not a collection of task ids"
+                break
+            decision[field] = task_ids
+        else:
+            return decision
+    log.warning("Ignoring malformed %s XCom from task %s: %s", 
XCOM_SKIPMIXIN_KEY, parent_task_id, problem)
+    return None
+
+
 def _should_skip(prev_result: dict, task_id: str, *, is_direct_parent: bool) 
-> bool:
     # "followed" only lists a branch operator's direct downstream tasks, so it 
says nothing
     # about a task further down, which follows its own trigger rule instead.
@@ -190,7 +240,9 @@ def _mapped_group_skip_decisions(
         for row in rows:
             if (state := finished_states.get((row.task_id, row.map_index))) is 
None:
                 continue
-            if (decision := XComModel.deserialize_value(row)) is not None:
+            # Primitives only, as for a direct parent's decision above.
+            stored = XComModel.deserialize_value_primitives(row)
+            if stored is not None and (decision := _skip_decision(stored, 
row.task_id)) is not None:
                 decisions.setdefault(row.map_index, []).append(
                     (row.task_id, state == TaskInstanceState.SUCCESS, decision)
                 )
diff --git a/airflow-core/src/airflow/ti_deps/deps/prev_dagrun_dep.py 
b/airflow-core/src/airflow/ti_deps/deps/prev_dagrun_dep.py
index 056c0c667b8..1762abb49cd 100644
--- a/airflow-core/src/airflow/ti_deps/deps/prev_dagrun_dep.py
+++ b/airflow-core/src/airflow/ti_deps/deps/prev_dagrun_dep.py
@@ -24,6 +24,7 @@ from sqlalchemy import func, or_, select
 from airflow.models.backfill import BackfillDagRun
 from airflow.models.dagrun import DagRun
 from airflow.models.taskinstance import PAST_DEPENDS_MET, TaskInstance as TI
+from airflow.models.xcom import LazyXComSelectSequence
 from airflow.ti_deps.deps.base_ti_dep import BaseTIDep
 from airflow.utils.db import exists_query
 from airflow.utils.session import provide_session
@@ -37,6 +38,19 @@ if TYPE_CHECKING:
 _SUCCESSFUL_STATES = (TaskInstanceState.SKIPPED, TaskInstanceState.SUCCESS)
 
 
+def is_past_depends_met(ti: TI, *, session: Session) -> bool:
+    """Return whether this task instance recorded that its past depends were 
met."""
+    value = ti.xcom_pull(
+        task_ids=ti.task_id, key=PAST_DEPENDS_MET, session=session, 
default=False, full=False
+    )
+    # XCom is read as plain JSON here, so only the literal ``True`` this dep 
pushes counts; a
+    # non-empty mapping or other truthy value does not. A mapped task 
instance's read returns
+    # the value of every map index.
+    if isinstance(value, LazyXComSelectSequence):
+        return any(item is True for item in value)
+    return value is True
+
+
 class PrevDagrunDep(BaseTIDep):
     """
     Is the past dagrun in a state that allows this task instance to run.
diff --git a/airflow-core/src/airflow/ti_deps/deps/trigger_rule_dep.py 
b/airflow-core/src/airflow/ti_deps/deps/trigger_rule_dep.py
index 3b52d4db366..9ccc4adcb13 100644
--- a/airflow-core/src/airflow/ti_deps/deps/trigger_rule_dep.py
+++ b/airflow-core/src/airflow/ti_deps/deps/trigger_rule_dep.py
@@ -26,9 +26,9 @@ from uuid import UUID
 
 from sqlalchemy import and_, func, or_, select
 
-from airflow.models.taskinstance import PAST_DEPENDS_MET
 from airflow.task.trigger_rule import TriggerRule as TR
 from airflow.ti_deps.deps.base_ti_dep import BaseTIDep
+from airflow.ti_deps.deps.prev_dagrun_dep import is_past_depends_met
 from airflow.utils.state import TaskInstanceState
 
 if TYPE_CHECKING:
@@ -370,10 +370,7 @@ class TriggerRuleDep(BaseTIDep):
                     new_state == TaskInstanceState.SKIPPED
                     and dep_context.wait_for_past_depends_before_skipping
                 ):
-                    past_depends_met = ti.xcom_pull(
-                        task_ids=ti.task_id, key=PAST_DEPENDS_MET, 
session=session, default=False
-                    )
-                    if not past_depends_met:
+                    if not is_past_depends_met(ti, session=session):
                         yield (
                             self._failing_status(
                                 reason="Task should be skipped but the past 
depends are not met"
@@ -531,10 +528,7 @@ class TriggerRuleDep(BaseTIDep):
                     new_state == TaskInstanceState.SKIPPED
                     and dep_context.wait_for_past_depends_before_skipping
                 ):
-                    past_depends_met = ti.xcom_pull(
-                        task_ids=ti.task_id, key=PAST_DEPENDS_MET, 
session=session, default=False
-                    )
-                    if not past_depends_met:
+                    if not is_past_depends_met(ti, session=session):
                         yield self._failing_status(
                             reason="Task should be skipped but the past 
depends are not met"
                         )
diff --git a/airflow-core/tests/unit/models/test_xcom.py 
b/airflow-core/tests/unit/models/test_xcom.py
index 422985e6c9e..1a413aabfb0 100644
--- a/airflow-core/tests/unit/models/test_xcom.py
+++ b/airflow-core/tests/unit/models/test_xcom.py
@@ -587,3 +587,93 @@ class TestXComRoundTrip:
         deserialized_value = XComModel.deserialize_value(mock_xcom)
 
         assert deserialized_value == expected_value
+
+
+_TUPLE_ENVELOPE = {"__classname__": "builtins.tuple", "__version__": 1, 
"__data__": [1, 2]}
+
+
[email protected]
+def no_serde_deserialize():
+    with mock.patch("airflow.sdk.serde.deserialize", 
side_effect=AssertionError("must not construct")) as m:
+        yield m
+
+
+class TestXComPrimitives:
+    @pytest.mark.parametrize(
+        ("value", "expected"),
+        [
+            pytest.param(json.dumps(_TUPLE_ENVELOPE), _TUPLE_ENVELOPE, 
id="classname-str"),
+            pytest.param(json.dumps({"a": [_TUPLE_ENVELOPE]}), {"a": 
[_TUPLE_ENVELOPE]}, id="nested"),
+            pytest.param(_TUPLE_ENVELOPE, _TUPLE_ENVELOPE, 
id="already-loaded"),
+            pytest.param(None, None, id="none"),
+        ],
+    )
+    def test_deserialize_value_primitives(self, value, expected, 
no_serde_deserialize):
+        result = XComModel.deserialize_value_primitives(MagicMock(value=value))
+
+        assert result == expected
+        no_serde_deserialize.assert_not_called()
+
+    @pytest.fixture
+    def mapped_tis(self, session, task_instance):
+        tis = []
+        for map_index in (0, 1):
+            ti = create_task_instance(
+                EmptyOperator(task_id="mapped"),
+                run_id=task_instance.run_id,
+                dag_version_id=task_instance.dag_version_id,
+                map_index=map_index,
+            )
+            ti.dag_id = task_instance.dag_id
+            session.add(ti)
+            tis.append(ti)
+        session.commit()
+        return tis
+
+    @staticmethod
+    def _set_envelope(session, ti):
+        XComModel.set(
+            key="xcom_1",
+            value=json.dumps(_TUPLE_ENVELOPE),
+            serialize=False,
+            dag_id=ti.dag_id,
+            task_id=ti.task_id,
+            run_id=ti.run_id,
+            map_index=ti.map_index,
+            session=session,
+        )
+
+    def test_xcom_pull_full_reconstructs(self, session, task_instance):
+        self._set_envelope(session, task_instance)
+
+        assert task_instance.xcom_pull(task_ids="task_1", key="xcom_1", 
session=session) == (1, 2)
+
+    def test_xcom_pull_primitives_single_value(self, session, task_instance, 
no_serde_deserialize):
+        self._set_envelope(session, task_instance)
+
+        result = task_instance.xcom_pull(task_ids="task_1", key="xcom_1", 
session=session, full=False)
+
+        assert result == _TUPLE_ENVELOPE
+        no_serde_deserialize.assert_not_called()
+
+    def test_xcom_pull_primitives_multiple_tasks(self, session, 
task_instances, no_serde_deserialize):
+        for ti in task_instances:
+            self._set_envelope(session, ti)
+
+        result = task_instances[0].xcom_pull(
+            task_ids=["task_1", "task_2"], key="xcom_1", session=session, 
full=False
+        )
+
+        assert type(result).__name__ == "LazyXComPrimitivesSelectSequence"
+        assert list(result) == [_TUPLE_ENVELOPE, _TUPLE_ENVELOPE]
+        no_serde_deserialize.assert_not_called()
+
+    def test_xcom_pull_primitives_mapped_task(self, session, task_instance, 
mapped_tis, no_serde_deserialize):
+        for ti in mapped_tis:
+            self._set_envelope(session, ti)
+
+        result = task_instance.xcom_pull(task_ids="mapped", key="xcom_1", 
session=session, full=False)
+
+        assert type(result).__name__ == "LazyXComPrimitivesSelectSequence"
+        assert list(result) == [_TUPLE_ENVELOPE, _TUPLE_ENVELOPE]
+        no_serde_deserialize.assert_not_called()
diff --git 
a/airflow-core/tests/unit/ti_deps/deps/test_not_previously_skipped_dep.py 
b/airflow-core/tests/unit/ti_deps/deps/test_not_previously_skipped_dep.py
index b5b79f62fb7..0c0fa53b524 100644
--- a/airflow-core/tests/unit/ti_deps/deps/test_not_previously_skipped_dep.py
+++ b/airflow-core/tests/unit/ti_deps/deps/test_not_previously_skipped_dep.py
@@ -17,6 +17,7 @@
 # under the License.
 from __future__ import annotations
 
+import json
 from unittest import mock
 
 import pendulum
@@ -24,6 +25,7 @@ import pytest
 from sqlalchemy import delete, select
 
 from airflow.models import DagRun, TaskInstance
+from airflow.models.taskinstance import PAST_DEPENDS_MET
 from airflow.models.xcom import XComModel
 from airflow.providers.standard.operators.empty import EmptyOperator
 from airflow.providers.standard.operators.python import BranchPythonOperator
@@ -36,6 +38,7 @@ from airflow.ti_deps.deps.not_previously_skipped_dep import (
     XCOM_SKIPMIXIN_SKIPPED,
     NotPreviouslySkippedDep,
 )
+from airflow.ti_deps.deps.prev_dagrun_dep import is_past_depends_met
 from airflow.utils.state import State
 from airflow.utils.types import DagRunType
 
@@ -653,3 +656,311 @@ def 
test_branch_skip_decision_bypasses_custom_xcom_backend(session, dag_maker):
     dep = NotPreviouslySkippedDep()
     assert len(list(dep.get_dep_statuses(tis["op2"], DepContext(), 
session=session))) == 1
     assert tis["op2"].state == State.SKIPPED
+
+
+def test_skipmixin_xcom_with_classname_is_not_reconstructed(session, 
dag_maker):
+    """
+    A serde envelope stored under the skipmixin key is read as a plain mapping.
+
+    The Execution API stores the caller's value verbatim. The scheduler only 
needs
+    primitive values to evaluate this dep, so XCom is read without importing a 
class
+    or reconstructing an object.
+    """
+    start_date = pendulum.datetime(2020, 1, 1)
+    with dag_maker(
+        "test_skipmixin_xcom_with_classname_dag",
+        schedule=None,
+        start_date=start_date,
+        session=session,
+    ):
+        op1 = BranchPythonOperator(task_id="op1", python_callable=lambda: 
"op2")
+        op2 = EmptyOperator(task_id="op2")
+        op1 >> op2
+
+    dagrun = dag_maker.create_dagrun(run_type=DagRunType.MANUAL, 
state=State.RUNNING)
+    ti, ti2 = dagrun.task_instances
+    run_task_instance(ti, op1)
+
+    # Overwrite the parent's skipmixin XCom with a serialization envelope, the 
shape
+    # `XComDecoder.object_hook` would hand to `deserialize(..., full=True)`.
+    # `serialize=False` with a pre-serialized string is exactly what the 
Execution API
+    # does with a caller-supplied value -- it stores the task's bytes verbatim.
+    XComModel.set(
+        key=XCOM_SKIPMIXIN_KEY,
+        value='{"__classname__": "builtins.dict", "__version__": 1, 
"__data__": {}}',
+        serialize=False,
+        task_id=ti.task_id,
+        dag_id=ti.dag_id,
+        run_id=ti.run_id,
+        map_index=ti.map_index,
+        session=session,
+    )
+    session.commit()
+
+    dep = NotPreviouslySkippedDep()
+
+    # The envelope is read as an inert mapping. It carries neither "followed" 
nor
+    # "skipped", so it yields no skip decision and the dep passes.
+    assert len(list(dep.get_dep_statuses(ti2, DepContext(), session=session))) 
== 0
+    assert dep.is_met(ti2, session=session)
+    assert ti2.state != State.SKIPPED
+
+
+def test_non_mapping_skipmixin_xcom_is_ignored(session, dag_maker):
+    """
+    A skipmixin XCom that is not a mapping is ignored rather than crashing the 
scheduler.
+
+    Without the shape check the membership test runs against whatever the task 
wrote, so
+    a scalar raises TypeError inside dependency evaluation.
+    """
+    start_date = pendulum.datetime(2020, 1, 1)
+    with dag_maker(
+        "test_non_mapping_skipmixin_xcom_dag",
+        schedule=None,
+        start_date=start_date,
+        session=session,
+    ):
+        op1 = BranchPythonOperator(task_id="op1", python_callable=lambda: 
"op2")
+        op2 = EmptyOperator(task_id="op2")
+        op1 >> op2
+
+    dagrun = dag_maker.create_dagrun(run_type=DagRunType.MANUAL, 
state=State.RUNNING)
+    ti, ti2 = dagrun.task_instances
+    run_task_instance(ti, op1)
+
+    XComModel.set(
+        key=XCOM_SKIPMIXIN_KEY,
+        value="5",  # a bare scalar: `"followed" in 5` raises TypeError
+        serialize=False,
+        task_id=ti.task_id,
+        dag_id=ti.dag_id,
+        run_id=ti.run_id,
+        map_index=ti.map_index,
+        session=session,
+    )
+    session.commit()
+
+    dep = NotPreviouslySkippedDep()
+    assert len(list(dep.get_dep_statuses(ti2, DepContext(), session=session))) 
== 0
+    assert ti2.state != State.SKIPPED
+
+
[email protected](
+    "stored",
+    [
+        pytest.param('{"__classname__": "builtins.dict", "__version__": 1, 
"__data__": {}}', id="classname"),
+        pytest.param("5", id="scalar"),
+    ],
+)
+def test_mapped_task_group_skip_decision_read_as_primitives(session, 
dag_maker, stored):
+    """A decision of a SkipMixin task in the same mapped task group is read 
the same way as a direct parent's."""
+    with dag_maker("test_mapped_group_primitives_dag", schedule=None, 
session=session):
+
+        @task.short_circuit(task_id="gate")
+        def gate(value):
+            return value
+
+        @task_group
+        def group(value):
+            gate(value) >> EmptyOperator(task_id="child")
+
+        group.expand(value=[True, False])
+
+    dr, tis = _create_run(dag_maker)
+    _finish_with_skip_decisions(
+        dr, tis, "group.gate", {0: {XCOM_SKIPMIXIN_SKIPPED: ["group.child"]}}, 
session=session
+    )
+    XComModel.set(
+        key=XCOM_SKIPMIXIN_KEY,
+        value=stored,
+        serialize=False,
+        dag_id=dr.dag_id,
+        task_id="group.gate",
+        run_id=dr.run_id,
+        map_index=1,
+        session=session,
+    )
+    session.flush()
+
+    dep = NotPreviouslySkippedDep()
+
+    assert not dep.is_met(tis[("group.child", 0)], session=session)
+    assert tis[("group.child", 0)].state == State.SKIPPED
+    assert dep.is_met(tis[("group.child", 1)], session=session)
+    assert tis[("group.child", 1)].state != State.SKIPPED
+
+
+def _serde_collection(classname, items):
+    return {"__classname__": classname, "__version__": 1, "__data__": 
list(items)}
+
+
[email protected](
+    ("decision", "serialize", "expected_skipped"),
+    [
+        pytest.param({XCOM_SKIPMIXIN_FOLLOWED: {"op3"}}, True, {"op2"}, 
id="followed-set"),
+        pytest.param({XCOM_SKIPMIXIN_SKIPPED: frozenset({"op2"})}, True, 
{"op2"}, id="skipped-frozenset"),
+        pytest.param(
+            json.dumps({XCOM_SKIPMIXIN_FOLLOWED: 
_serde_collection("builtins.tuple", ["op3"])}),
+            False,
+            {"op2"},
+            id="followed-tuple",
+        ),
+        pytest.param(
+            json.dumps({XCOM_SKIPMIXIN_SKIPPED: 
_serde_collection("builtins.list", ["op3"])}),
+            False,
+            {"op3"},
+            id="skipped-list",
+        ),
+        pytest.param({XCOM_SKIPMIXIN_FOLLOWED: ["op2", "op3"]}, True, set(), 
id="followed-list"),
+    ],
+)
+def test_skipmixin_xcom_collection_wrapper_is_read_as_task_ids(
+    session, dag_maker, decision, serialize, expected_skipped
+):
+    """A set or tuple of task ids, stored as a serde wrapper, is read as its 
items without constructing it."""
+    with dag_maker("test_skipmixin_collection_wrapper_dag", schedule=None, 
session=session):
+        op1 = BranchPythonOperator(task_id="op1", python_callable=lambda: 
"op3")
+        op1 >> [EmptyOperator(task_id="op2"), EmptyOperator(task_id="op3")]
+
+    dr = dag_maker.create_dagrun(run_type=DagRunType.MANUAL, 
state=State.RUNNING)
+    tis = {ti.task_id: ti for ti in dr.task_instances}
+    tis["op1"].state = State.SUCCESS
+    session.merge(tis["op1"])
+    XComModel.set(
+        key=XCOM_SKIPMIXIN_KEY,
+        value=decision,
+        serialize=serialize,
+        dag_id=dr.dag_id,
+        task_id="op1",
+        run_id=dr.run_id,
+        session=session,
+    )
+    session.flush()
+
+    dep = NotPreviouslySkippedDep()
+    with mock.patch("airflow.sdk.serde.deserialize", 
side_effect=AssertionError("must not construct")):
+        for task_id in ("op2", "op3"):
+            assert dep.is_met(tis[task_id], session=session) is (task_id not 
in expected_skipped)
+    assert {task_id for task_id in ("op2", "op3") if tis[task_id].state == 
State.SKIPPED} == expected_skipped
+
+
[email protected](
+    "stored",
+    [
+        pytest.param({XCOM_SKIPMIXIN_FOLLOWED: "op3"}, id="followed-str"),
+        pytest.param({XCOM_SKIPMIXIN_SKIPPED: [1]}, id="skipped-non-str"),
+        pytest.param(
+            {XCOM_SKIPMIXIN_FOLLOWED: {"__classname__": "builtins.set", 
"__version__": 1, "__data__": "op3"}},
+            id="wrapper-data-not-list",
+        ),
+        pytest.param(
+            {XCOM_SKIPMIXIN_FOLLOWED: 
_serde_collection("collections.OrderedDict", ["op3"])},
+            id="wrapper-other-class",
+        ),
+        pytest.param({XCOM_SKIPMIXIN_FOLLOWED: {"__classname__": []}}, 
id="wrapper-classname-list"),
+        pytest.param({XCOM_SKIPMIXIN_SKIPPED: {"__classname__": {}}}, 
id="wrapper-classname-dict"),
+    ],
+)
+def test_malformed_skipmixin_task_id_collection_is_ignored(session, dag_maker, 
stored):
+    """A decision whose task id collection has another shape is ignored, so no 
task is skipped by it."""
+    with dag_maker("test_malformed_task_id_collection_dag", schedule=None, 
session=session):
+        op1 = BranchPythonOperator(task_id="op1", python_callable=lambda: 
"op3")
+        op1 >> [EmptyOperator(task_id="op2"), EmptyOperator(task_id="op3")]
+
+    dr = dag_maker.create_dagrun(run_type=DagRunType.MANUAL, 
state=State.RUNNING)
+    tis = {ti.task_id: ti for ti in dr.task_instances}
+    tis["op1"].state = State.SUCCESS
+    session.merge(tis["op1"])
+    XComModel.set(
+        key=XCOM_SKIPMIXIN_KEY,
+        value=json.dumps(stored),
+        serialize=False,
+        dag_id=dr.dag_id,
+        task_id="op1",
+        run_id=dr.run_id,
+        session=session,
+    )
+    session.flush()
+
+    dep = NotPreviouslySkippedDep()
+    for task_id in ("op2", "op3"):
+        assert dep.is_met(tis[task_id], session=session)
+        assert tis[task_id].state != State.SKIPPED
+
+
+_PAST_DEPENDS_MET_VALUES = [
+    pytest.param(True, True, True, id="true"),
+    pytest.param(False, True, False, id="false"),
+    pytest.param(
+        '{"__classname__": "builtins.tuple", "__version__": 1, "__data__": 
[1]}', False, False, id="envelope"
+    ),
+]
+
+
[email protected](("stored", "serialize", "met"), 
_PAST_DEPENDS_MET_VALUES)
+def test_wait_for_past_depends_before_skipping_reads_stored_value(session, 
dag_maker, stored, serialize, met):
+    """Only a stored literal ``True`` lets the task be skipped, and the value 
is never reconstructed."""
+    with dag_maker("test_past_depends_met_dag", schedule=None, 
session=session):
+        op1 = BranchPythonOperator(task_id="op1", python_callable=lambda: 
"op3")
+        op1 >> [EmptyOperator(task_id="op2"), EmptyOperator(task_id="op3")]
+
+    dr = dag_maker.create_dagrun(run_type=DagRunType.MANUAL, 
state=State.RUNNING)
+    tis = {ti.task_id: ti for ti in dr.task_instances}
+    tis["op1"].state = State.SUCCESS
+    session.merge(tis["op1"])
+    for task_id, key, value, serialize_value in (
+        ("op1", XCOM_SKIPMIXIN_KEY, {XCOM_SKIPMIXIN_FOLLOWED: ["op3"]}, True),
+        ("op2", PAST_DEPENDS_MET, stored, serialize),
+    ):
+        XComModel.set(
+            key=key,
+            value=value,
+            serialize=serialize_value,
+            dag_id=dr.dag_id,
+            task_id=task_id,
+            run_id=dr.run_id,
+            session=session,
+        )
+    session.flush()
+
+    dep_context = DepContext(wait_for_past_depends_before_skipping=True)
+    with mock.patch("airflow.sdk.serde.deserialize", 
side_effect=AssertionError("must not construct")) as m:
+        statuses = list(NotPreviouslySkippedDep().get_dep_statuses(tis["op2"], 
dep_context, session=session))
+    m.assert_not_called()
+
+    assert len(statuses) == 1
+    assert not statuses[0].passed
+    if met:
+        assert tis["op2"].state == State.SKIPPED
+    else:
+        assert "past depends are not met" in statuses[0].reason
+        assert tis["op2"].state != State.SKIPPED
+
+
[email protected](("stored", "serialize", "met"), 
_PAST_DEPENDS_MET_VALUES)
+def test_is_past_depends_met_for_mapped_task_instance(session, dag_maker, 
stored, serialize, met):
+    """A mapped task instance's read returns a sequence; it counts only a 
literal ``True`` in it."""
+    with dag_maker("test_past_depends_met_mapped_dag", schedule=None, 
session=session):
+
+        @task_group
+        def group(value):
+            EmptyOperator(task_id="child")
+
+        group.expand(value=[1, 2])
+
+    dr, tis = _create_run(dag_maker)
+    child = tis[("group.child", 0)]
+    XComModel.set(
+        key=PAST_DEPENDS_MET,
+        value=stored,
+        serialize=serialize,
+        dag_id=dr.dag_id,
+        task_id=child.task_id,
+        run_id=dr.run_id,
+        map_index=child.map_index,
+        session=session,
+    )
+    session.flush()
+
+    with mock.patch("airflow.sdk.serde.deserialize", 
side_effect=AssertionError("must not construct")):
+        assert is_past_depends_met(child, session=session) is met
diff --git a/airflow-core/tests/unit/ti_deps/deps/test_trigger_rule_dep.py 
b/airflow-core/tests/unit/ti_deps/deps/test_trigger_rule_dep.py
index 53d33165d0e..27fb20d1b78 100644
--- a/airflow-core/tests/unit/ti_deps/deps/test_trigger_rule_dep.py
+++ b/airflow-core/tests/unit/ti_deps/deps/test_trigger_rule_dep.py
@@ -31,7 +31,8 @@ from sqlalchemy import delete, event
 import airflow.settings
 from airflow.models.dag_version import DagVersion
 from airflow.models.dynamic_region import SENTINEL_REGION_ID, DynamicRegion
-from airflow.models.taskinstance import TaskInstance
+from airflow.models.taskinstance import PAST_DEPENDS_MET, TaskInstance
+from airflow.models.xcom import XComModel
 from airflow.providers.standard.operators.empty import EmptyOperator
 from airflow.providers.standard.operators.python import PythonOperator
 from airflow.sdk import task, task_group
@@ -2295,6 +2296,65 @@ def 
test_setup_constraint_wait_for_past_depends_before_skipping(
         )
 
 
+_PAST_DEPENDS_MET_VALUES = [
+    pytest.param(True, True, True, id="true"),
+    pytest.param(False, True, False, id="false"),
+    pytest.param(
+        '{"__classname__": "builtins.tuple", "__version__": 1, "__data__": 
[1]}', False, False, id="envelope"
+    ),
+    pytest.param("1", False, False, id="truthy-int"),
+]
+
+
[email protected](("stored", "serialize", "met"), 
_PAST_DEPENDS_MET_VALUES)
[email protected](
+    ("task_kwargs", "skipped_reason"),
+    [
+        pytest.param(
+            {"trigger_rule": TriggerRule.ALL_SUCCESS, "normal_tasks": 
["FakeTaskID", "OtherFakeTaskID"]},
+            "requires all upstream tasks to have succeeded, but found 1",
+            id="trigger-rule",
+        ),
+        pytest.param(
+            {"trigger_rule": TriggerRule.ALL_DONE, "setup_tasks": 
["FakeTaskID", "OtherFakeTaskID"]},
+            "",
+            id="setup-constraint",
+        ),
+    ],
+)
+def test_wait_for_past_depends_before_skipping_reads_stored_value(
+    session, get_task_instance, task_kwargs, skipped_reason, stored, 
serialize, met
+):
+    """Only a stored literal ``True`` lets the task be skipped, and the value 
is never reconstructed."""
+    ti = get_task_instance(
+        success=1, skipped=1, failed=0, removed=0, upstream_failed=0, done=2, 
**task_kwargs
+    )
+    XComModel.set(
+        key=PAST_DEPENDS_MET,
+        value=stored,
+        serialize=serialize,
+        dag_id=ti.dag_id,
+        task_id=ti.task_id,
+        run_id=ti.run_id,
+        map_index=ti.map_index,
+        session=session,
+    )
+    session.flush()
+
+    with mock.patch("airflow.sdk.serde.deserialize", 
side_effect=AssertionError("must not construct")) as m:
+        _test_trigger_rule(
+            ti=ti,
+            session=session,
+            flag_upstream_failed=True,
+            wait_for_past_depends_before_skipping=True,
+            expected_ti_state=SKIPPED if met else None,
+            expected_reason=(
+                skipped_reason if met else "Task should be skipped but the 
past depends are not met"
+            ),
+        )
+    m.assert_not_called()
+
+
 @pytest.mark.parametrize(("flag_upstream_failed", "expected_ti_state"), 
[(True, SKIPPED), (False, None)])
 @pytest.mark.need_serialized_dag
 def test_setup_mapped_task_group_finished_upstream_before_expand(
diff --git a/devel-common/src/tests_common/test_utils/mock_context.py 
b/devel-common/src/tests_common/test_utils/mock_context.py
index 4e7aa7884f2..70c5f8c6729 100644
--- a/devel-common/src/tests_common/test_utils/mock_context.py
+++ b/devel-common/src/tests_common/test_utils/mock_context.py
@@ -51,6 +51,7 @@ def mock_context(task) -> Context:
             map_indexes: int | Iterable[int] | None = None,
             default: Any = None,
             run_id: str | None = None,
+            full: bool = True,
         ) -> Any:
             key = f"{self.task_id}_{self.dag_id}_{key}"
             if map_indexes is not None and (not isinstance(map_indexes, int) 
or map_indexes >= 0):

Reply via email to