This is an automated email from the ASF dual-hosted git repository.
kaxil 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 bb5fc37da0e Fix branch not skipping mapped tasks with custom XCom
backend (#68954)
bb5fc37da0e is described below
commit bb5fc37da0e79090dce508f1f91c23a7ded0b819
Author: Darshil Thakkar <[email protected]>
AuthorDate: Mon Sep 21 17:24:30 2026 +0530
Fix branch not skipping mapped tasks with custom XCom backend (#68954)
* Fix branch not skipping mapped tasks with custom XCom backend
* Add newsfragment for #50491
---
airflow-core/newsfragments/68954.bugfix.rst | 1 +
.../deps/test_not_previously_skipped_dep.py | 67 +++++++++++++++++++++-
.../src/airflow/sdk/execution_time/task_runner.py | 9 +++
3 files changed, 76 insertions(+), 1 deletion(-)
diff --git a/airflow-core/newsfragments/68954.bugfix.rst
b/airflow-core/newsfragments/68954.bugfix.rst
new file mode 100644
index 00000000000..eca1675dadf
--- /dev/null
+++ b/airflow-core/newsfragments/68954.bugfix.rst
@@ -0,0 +1 @@
+Fix branch operators not skipping mapped or cleared downstream tasks when a
custom XCom backend is configured.
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 58ccce35dd8..cd5b364311b 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,14 +17,17 @@
# under the License.
from __future__ import annotations
+from unittest import mock
+
import pendulum
import pytest
-from sqlalchemy import delete
+from sqlalchemy import delete, select
from airflow.models import DagRun, TaskInstance
from airflow.models.xcom import XComModel
from airflow.providers.standard.operators.empty import EmptyOperator
from airflow.providers.standard.operators.python import BranchPythonOperator
+from airflow.sdk.bases.xcom import BaseXCom
from airflow.ti_deps.dep_context import DepContext
from airflow.ti_deps.deps.not_previously_skipped_dep import (
XCOM_SKIPMIXIN_FOLLOWED,
@@ -214,3 +217,65 @@ def test_unmapped_parent_skip_mapped_downstream(session,
dag_maker):
assert len(list(dep.get_dep_statuses(tis["op2"], DepContext(),
session=session))) == 1
assert not dep.is_met(tis["op2"], session=session)
assert tis["op2"].state == State.SKIPPED
+
+
+def test_branch_skip_decision_bypasses_custom_xcom_backend(session, dag_maker):
+ """
+ A value-externalizing custom XCom backend must not break branch-skip of
+ mapped/cleared downstream tasks.
+
+ The branch decision is written through the real worker push path with such
a
+ backend configured. It must be stored readably (not as the backend's opaque
+ pointer) so that NotPreviouslySkippedDep can skip a not-yet-expanded mapped
+ downstream task, which the worker does not skip directly.
+
+ Regression test for https://github.com/apache/airflow/issues/50491.
+ """
+
+ class _PointerXComBackend(BaseXCom):
+ @staticmethod
+ def serialize_value(value, **kwargs):
+ return "xcom_s3://pointer"
+
+ @staticmethod
+ def deserialize_value(result):
+ return "xcom_s3://pointer"
+
+ start_date = pendulum.datetime(2020, 1, 1)
+ with dag_maker(
+ "test_skip_bypass_backend_dag",
+ schedule=None,
+ start_date=start_date,
+ session=session,
+ ):
+ op1 = BranchPythonOperator(task_id="op1", python_callable=lambda:
"op3")
+ op2 = EmptyOperator(task_id="op2")
+ op3 = EmptyOperator(task_id="op3")
+ op1 >> [op2, op3]
+
+ dr = dag_maker.create_dagrun(run_type=DagRunType.MANUAL,
state=State.RUNNING)
+ tis = {ti.task_id: ti for ti in dr.task_instances}
+
+ with mock.patch("airflow.sdk.execution_time.task_runner.XCom",
_PointerXComBackend):
+ run_task_instance(tis["op1"], op1)
+
+ stored = session.scalar(
+ select(XComModel.value).where(
+ XComModel.dag_id == dr.dag_id,
+ XComModel.task_id == "op1",
+ XComModel.run_id == dr.run_id,
+ XComModel.key == XCOM_SKIPMIXIN_KEY,
+ XComModel.map_index == -1,
+ )
+ )
+
+ assert stored is not None
+ assert "xcom_s3://pointer" not in str(stored)
+
+ tis["op2"].map_index = 0
+ session.merge(tis["op2"])
+ session.flush()
+
+ dep = NotPreviouslySkippedDep()
+ assert len(list(dep.get_dep_statuses(tis["op2"], DepContext(),
session=session))) == 1
+ assert tis["op2"].state == State.SKIPPED
diff --git a/task-sdk/src/airflow/sdk/execution_time/task_runner.py
b/task-sdk/src/airflow/sdk/execution_time/task_runner.py
index e94958a8d3c..8460d6bbb5e 100644
--- a/task-sdk/src/airflow/sdk/execution_time/task_runner.py
+++ b/task-sdk/src/airflow/sdk/execution_time/task_runner.py
@@ -58,6 +58,7 @@ from airflow.sdk.api.datamodels._generated import (
TIRunContext,
)
from airflow.sdk.bases.operator import BaseOperator, ExecutorSafeguard
+from airflow.sdk.bases.skipmixin import XCOM_SKIPMIXIN_KEY
from airflow.sdk.bases.xcom import BaseXCom
from airflow.sdk.configuration import conf
from airflow.sdk.definitions._internal.dag_parsing_context import
_airflow_parsing_context_manager
@@ -902,6 +903,14 @@ def _xcom_push(
# Private function, as we don't want to expose the ability to manually set
`mapped_length` to SDK
# consumers
+ if key == XCOM_SKIPMIXIN_KEY:
+ # The branch/skip decision is control-plane data the scheduler reads
(via
+ # NotPreviouslySkippedDep) to skip mapped or cleared downstream tasks.
It must
+ # bypass any custom XCom backend, which could externalize it into a
pointer the
+ # scheduler cannot interpret, silently leaving those tasks unskipped
(#50491).
+ _xcom_push_to_db(ti, key, value)
+ return
+
XCom.set(
key=key,
value=value,