This is an automated email from the ASF dual-hosted git repository.
jscheffl 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 da87130071d Consider XCOM sidecar container during pod cleanup (#64962)
da87130071d is described below
commit da87130071d54fb7a88fdeff492f5dbf0534c16a
Author: Daniel Wolf <[email protected]>
AuthorDate: Wed Apr 15 21:22:04 2026 +0200
Consider XCOM sidecar container during pod cleanup (#64962)
* Consider XCOM sidecar container during pod cleanup
* Fix failing unit tests
* Apply Copilot review findings
* Implement Copilot review finding regarding failed status
* Fix some more unit tests
* More fixes for unit tests
---
.../test_kubernetes_pod_operator.py | 5 ++
.../providers/cncf/kubernetes/operators/pod.py | 11 ++--
.../providers/cncf/kubernetes/utils/pod_manager.py | 18 +++++-
.../decorators/test_kubernetes_commons.py | 11 +++-
.../unit/cncf/kubernetes/operators/test_pod.py | 75 ++++++++++++++++++++++
.../unit/cncf/kubernetes/utils/test_pod_manager.py | 30 +++++++++
6 files changed, 141 insertions(+), 9 deletions(-)
diff --git
a/kubernetes-tests/tests/kubernetes_tests/test_kubernetes_pod_operator.py
b/kubernetes-tests/tests/kubernetes_tests/test_kubernetes_pod_operator.py
index 64cc3dc0757..652ee0ad954 100644
--- a/kubernetes-tests/tests/kubernetes_tests/test_kubernetes_pod_operator.py
+++ b/kubernetes-tests/tests/kubernetes_tests/test_kubernetes_pod_operator.py
@@ -965,6 +965,11 @@ class TestKubernetesPodOperatorSystem:
)
pod_mock = MagicMock()
pod_mock.status.phase = "Succeeded"
+ base_container_status = MagicMock()
+ base_container_status.name = "base"
+ base_container_status.state.terminated.exit_code = 0
+ pod_mock.status.container_statuses = [base_container_status]
+ pod_mock.status.init_container_statuses = None
await_pod_completion_mock.return_value = pod_mock
context = create_context(k)
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/pod.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/pod.py
index 67494995f0a..eb290a881f3 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/pod.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/pod.py
@@ -759,7 +759,7 @@ class KubernetesPodOperator(BaseOperator):
result = self.extract_xcom(pod=self.pod)
istio_enabled = self.is_istio_enabled(self.pod)
self.remote_pod = self.pod_manager.await_pod_completion(
- self.pod, istio_enabled, self.base_container_name
+ self.pod, istio_enabled, self.base_container_name,
self.do_xcom_push
)
finally:
pod_to_clean = self.pod or self.pod_request_obj
@@ -1025,7 +1025,7 @@ class KubernetesPodOperator(BaseOperator):
if event["status"] != "timeout":
try:
self.pod = self.pod_manager.await_pod_completion(
- self.pod, istio_enabled, self.base_container_name
+ self.pod, istio_enabled, self.base_container_name,
self.do_xcom_push
)
except ApiException as e:
if e.status == 404:
@@ -1110,9 +1110,10 @@ class KubernetesPodOperator(BaseOperator):
):
self.patch_already_checked(remote_pod, reraise=False)
- failed = (pod_phase != PodPhase.SUCCEEDED and not istio_enabled) or (
- istio_enabled and not container_is_succeeded(remote_pod,
self.base_container_name)
- )
+ if istio_enabled or self.do_xcom_push:
+ failed = not container_is_succeeded(remote_pod,
self.base_container_name)
+ else:
+ failed = pod_phase != PodPhase.SUCCEEDED
if failed:
if self.do_xcom_push and xcom_result and context:
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/utils/pod_manager.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/utils/pod_manager.py
index 5b95a4c7fea..3cc0229ea61 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/utils/pod_manager.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/utils/pod_manager.py
@@ -741,7 +741,11 @@ class PodManager(LoggingMixin):
time.sleep(polling_time)
def await_pod_completion(
- self, pod: V1Pod, istio_enabled: bool = False, container_name: str =
"base"
+ self,
+ pod: V1Pod,
+ istio_enabled: bool = False,
+ container_name: str = "base",
+ do_xcom_push: bool = False,
) -> V1Pod:
"""
Monitor a pod and return the final state.
@@ -749,13 +753,21 @@ class PodManager(LoggingMixin):
:param istio_enabled: whether istio is enabled in the namespace
:param pod: pod spec that will be monitored
:param container_name: name of the container within the pod
- :return: tuple[State, str | None]
+ :param do_xcom_push: whether to push XComs
+ :return: V1Pod
"""
while True:
remote_pod = self.read_pod(pod)
if remote_pod.status.phase in PodPhase.terminal_states:
break
- if istio_enabled and container_is_completed(remote_pod,
container_name):
+ if (istio_enabled or do_xcom_push) and
container_is_completed(remote_pod, container_name):
+ self.log.info(
+ "Container '%s' completed but pod %s still has phase %s "
+ "(likely due to a sidecar container). Skipping waiting for
pod completion.",
+ container_name,
+ pod.metadata.name,
+ remote_pod.status.phase,
+ )
break
# abort waiting if defined issues are detected
if detect_pod_terminate_early_issues(remote_pod):
diff --git
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/decorators/test_kubernetes_commons.py
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/decorators/test_kubernetes_commons.py
index b062ab3a310..29f261b6989 100644
---
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/decorators/test_kubernetes_commons.py
+++
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/decorators/test_kubernetes_commons.py
@@ -99,7 +99,16 @@ class TestKubernetesDecoratorsBase:
self.mock_extract_xcom.return_value = '{"key1": "value1", "key2":
"value2"}'
self.mock_await_pod_completion =
mock.patch(f"{POD_MANAGER_CLASS}.await_pod_completion").start()
- self.mock_await_pod_completion.return_value =
mock.MagicMock(**{"status.phase": "Succeeded"})
+ base_container_status = mock.MagicMock()
+ base_container_status.name = "base"
+ base_container_status.state.terminated.exit_code = 0
+ self.mock_await_pod_completion.return_value = mock.MagicMock(
+ **{
+ "status.phase": "Succeeded",
+ "status.container_statuses": [base_container_status],
+ "status.init_container_statuses": None,
+ }
+ )
self.mock_hook = mock.patch(HOOK_CLASS).start()
# Without this patch each time pod manager would try to extract logs
from the pod
diff --git
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/operators/test_pod.py
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/operators/test_pod.py
index 412e0e08a63..5a28eb2ff6d 100644
--- a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/operators/test_pod.py
+++ b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/operators/test_pod.py
@@ -266,6 +266,11 @@ class TestKubernetesPodOperator:
remote_pod_mock = MagicMock()
remote_pod_mock.status.phase = "Succeeded"
+ base_container_status = MagicMock()
+ base_container_status.name = operator.base_container_name
+ base_container_status.state.terminated.exit_code = 0
+ remote_pod_mock.status.container_statuses = [base_container_status]
+ remote_pod_mock.status.init_container_statuses = None
self.await_pod_mock.return_value = remote_pod_mock
operator.execute(context=context)
return self.await_start_mock.call_args.kwargs["pod"], context
@@ -1055,6 +1060,71 @@ class TestKubernetesPodOperator:
else:
delete_pod_mock.assert_not_called()
+ @pytest.mark.parametrize(
+ ("base_container_exit_code", "expect_failure"),
+ [
+ pytest.param(0, False, id="base-succeeded"),
+ pytest.param(1, True, id="base-failed"),
+ ],
+ )
+ @patch(f"{POD_MANAGER_CLASS}.extract_xcom")
+ @patch(f"{POD_MANAGER_CLASS}.await_xcom_sidecar_container_start")
+ @patch(f"{POD_MANAGER_CLASS}.delete_pod")
+ @patch(f"{POD_MANAGER_CLASS}.await_pod_completion")
+ @patch(f"{KPO_MODULE}.KubernetesPodOperator.find_pod")
+ def test_cleanup_with_xcom_sidecar_uses_base_container_status(
+ self,
+ find_pod_mock,
+ await_pod_completion_mock,
+ delete_pod_mock,
+ mock_await_xcom_sidecar,
+ mock_extract_xcom,
+ base_container_exit_code,
+ expect_failure,
+ ):
+ """
+ When do_xcom_push=True, cleanup should determine success/failure based
on
+ the base container's exit status, not the pod phase. The xcom sidecar
may
+ keep the pod in Running phase after the base container completes.
+ """
+ mock_extract_xcom.return_value = "{}"
+ mock_await_xcom_sidecar.return_value = None
+
+ base_status = MagicMock()
+ base_status.name = "base"
+ base_status.state.terminated.exit_code = base_container_exit_code
+ base_status.state.terminated.message = "task failed" if
base_container_exit_code else None
+
+ xcom_sidecar_status = MagicMock()
+ xcom_sidecar_status.name = "airflow-xcom-sidecar"
+ xcom_sidecar_status.state.running = True
+ xcom_sidecar_status.state.terminated = None
+
+ # Pod is still Running because xcom sidecar is alive
+ remote_pod = MagicMock()
+ remote_pod.status.phase = "Running"
+ remote_pod.status.container_statuses = [base_status,
xcom_sidecar_status]
+ remote_pod.metadata.name = "pod-with-xcom-sidecar"
+ remote_pod.metadata.namespace = "default"
+ remote_pod.spec.containers = [MagicMock(), MagicMock()]
+
+ await_pod_completion_mock.return_value = remote_pod
+ find_pod_mock.return_value = remote_pod
+
+ k = KubernetesPodOperator(
+ task_id="task",
+ do_xcom_push=True,
+ )
+
+ context = create_context(k)
+ context["ti"].xcom_push = MagicMock()
+
+ if expect_failure:
+ with pytest.raises(AirflowException, match="task failed"):
+ k.execute(context=context)
+ else:
+ k.execute(context=context)
+
@pytest.mark.parametrize(
("task_kwargs", "should_be_deleted"),
[
@@ -1883,6 +1953,11 @@ class TestKubernetesPodOperator:
mock_extract_xcom.return_value = "{}"
remote_pod_mock = MagicMock()
remote_pod_mock.status.phase = "Succeeded"
+ base_container_status = MagicMock()
+ base_container_status.name = "base"
+ base_container_status.state.terminated.exit_code = 0
+ remote_pod_mock.status.container_statuses = [base_container_status]
+ remote_pod_mock.status.init_container_statuses = None
self.await_pod_mock.return_value = remote_pod_mock
pod, _ = self.run_pod(k)
diff --git
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/utils/test_pod_manager.py
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/utils/test_pod_manager.py
index b5c57c49167..c48a6608992 100644
---
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/utils/test_pod_manager.py
+++
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/utils/test_pod_manager.py
@@ -1079,6 +1079,36 @@ class TestPodManager:
assert result is running2
assert mock_sleep.call_count == 1
+ @mock.patch("time.sleep")
+ def
test_await_pod_completion_breaks_on_xcom_sidecar_container_completed(self,
mock_sleep, pod_factory):
+ """When do_xcom_push=True and base container has completed, stop
waiting even if pod is still Running."""
+ running1 = pod_factory(pod_phase=PodPhase.RUNNING,
container_name="base", terminated=False)
+ running2 = pod_factory(pod_phase=PodPhase.RUNNING,
container_name="base", terminated=True)
+
+ self.pod_manager.read_pod = mock.MagicMock(side_effect=[running1,
running2])
+
+ result = self.pod_manager.await_pod_completion(
+ pod=mock.MagicMock(), istio_enabled=False, container_name="base",
do_xcom_push=True
+ )
+
+ assert result is running2
+ assert mock_sleep.call_count == 1
+
+ @mock.patch("time.sleep")
+ def test_await_pod_completion_waits_for_pod_phase_without_sidecars(self,
mock_sleep, pod_factory):
+ """Without istio or xcom sidecar, await_pod_completion waits for
terminal pod phase."""
+ running1 = pod_factory(pod_phase=PodPhase.RUNNING,
container_name="base", terminated=True)
+ succeeded = pod_factory(pod_phase=PodPhase.SUCCEEDED,
container_name="base", terminated=True)
+
+ self.pod_manager.read_pod = mock.MagicMock(side_effect=[running1,
succeeded])
+
+ result = self.pod_manager.await_pod_completion(
+ pod=mock.MagicMock(), istio_enabled=False, container_name="base",
do_xcom_push=False
+ )
+
+ assert result is succeeded
+ assert mock_sleep.call_count == 1
+
@mock.patch("time.sleep")
def test_await_pod_completion_breaks_on_early_termination_issue(self,
mock_sleep, pod_factory):
running1 = pod_factory(pod_phase=PodPhase.PENDING,
container_name="base")