thc1006 opened a new issue, #71743:
URL: https://github.com/apache/airflow/issues/71743

   ### Under which category would you file this issue?
   
   Providers
   
   ### Apache Airflow version
   
   3.3.1
   
   ### What happened and how to reproduce it?
   
   **Issue Description**
   
   KPO durable reattach save the pod identity into task state store, but it 
only save name and namespace, no uid.
   
   `_persist_pod_identity_to_task_state_store` in 
`providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/pod.py`:
   
   ```python
   task_state_store.set(
       POD_IDENTIFIER_STATE_KEY,
       {"name": pod.metadata.name, "namespace": pod.metadata.namespace},
   )
   ```
   
   and on retry `_get_pod_from_task_state_store` read it back like this:
   
   ```python
   pod = self.hook.get_pod(name, namespace)
   ```
   
   In Kubernetes the name is only a slot, it is not identity. After a pod is 
deleted, the same name can be take by another pod. Only `metadata.uid` is 
stable. So this lookup have no fence, it only trust the name.
   
   Old `find_pod()` path is not like this. `_build_find_pod_label_selector()` 
use dag_id, task_id, run_id and map_index, so a pod from other dag run can not 
match. But on Airflow 3.3+ `durable` is default True, and once the identity is 
persisted the label search is skip. So the fence become weaker than before.
   
   The only guard left is the `already_checked` label, but a pod from another 
run does not have this label.
   
   **Steps to reproduce**
   
   Need the pod name can repeat, so `random_name_suffix=False`, or a fixed 
`metadata.name` from `pod_template_file` / `full_pod_spec`.
   
   1. dag run A create pod `my-pod`, uid A. store keep `{"name": "my-pod", 
"namespace": "..."}`
   2. worker die, and later `my-pod` is remove by node drain, gc, cleanup job, 
or someone do `kubectl delete pod`
   3. dag run B start and create pod `my-pod` again, uid B
   4. dag run A retry, `_get_pod_from_task_state_store()` read `my-pod` and get 
uid B
   
   I test it on real cluster, k8s v1.37.0-rc.0, with airflow 3.3.1 and 
apache-airflow-providers-cncf-kubernetes 10.21.0:
   
   ```
   STEP 1  dag run A creates its pod (this is what gets persisted)
           run_id = manual__2026-08-18T01:00:00+00:00
           pod    = kpo-uid-test/my-pod
           uid    = 405af140-31e7-4529-9969-38407688a061
   
           task_state_store['pod_identifier'] = {'name': 'my-pod', 'namespace': 
'kpo-uid-test'}
   
   STEP 2  that pod is gone (node drain / GC / kubectl delete / cleanup job)
           deleted
   
   STEP 3  dag run B creates a pod, same name (random_name_suffix=False)
           run_id = manual__2026-08-18T02:00:00+00:00
           pod    = kpo-uid-test/my-pod
           uid    = 366da1af-7790-4c27-9158-d548b002c2be   <-- different pod
   
   STEP 4  dag run A retries -> _get_pod_from_task_state_store()
   Reconnecting to pod kpo-uid-test/my-pod via identity persisted in task state 
store. loc=pod.py:685
   Found matching pod my-pod with labels {'dag_id': 'uid_demo', 
'kubernetes_pod_operator': 'True', 'run_id': 
'manual__2026-08-18t02_00_00_00_00', 'task_id': 'my-pod', 'try_number': '1'} 
loc=pod.py:646
   `try_number` of task_instance: 2 loc=pod.py:647
   `try_number` of pod: 1 loc=pod.py:648
   
           returned pod uid    = 366da1af-7790-4c27-9158-d548b002c2be
           returned pod run_id = manual__2026-08-18t02_00_00_00_00
   
           *** run A reattached to run B's pod ***
   
   STEP 5  the real caller: get_or_create_pod()
   Reconnecting to pod kpo-uid-test/my-pod via identity persisted in task state 
store. loc=pod.py:685
   Found matching pod my-pod with labels {'dag_id': 'uid_demo', 
'kubernetes_pod_operator': 'True', 'run_id': 
'manual__2026-08-18t02_00_00_00_00', 'task_id': 'my-pod', 'try_number': '1'} 
loc=pod.py:646
   Reusing existing pod 'my-pod' (phase=Running, reason=) since it is not 
terminated or evicted. loc=pod.py:712
           returned pod uid = 366da1af-7790-4c27-9158-d548b002c2be
   
   STEP 6  same situation, but the OLD label search path find_pod()
           returned None -> run_id label does not match, run B's pod rejected
   ```
   
   Step 4 log also show run_id of the pod is run B, and try_number of the pod 
is 1 but task instance is 2. The operator print them and still continue.
   
   Step 5 is the real caller. Pod B is Running so `get_or_create_pod()` just 
return it. Then run A monitor run B pod, read its log, take its exit code, and 
with default `on_finish_action=delete_pod` it delete run B pod in the end. So 
run B also break.
   
   <details><summary>script I use</summary>
   
   ```python
   import subprocess, time, types
   from kubernetes import client as k8s, config as k8s_config
   from airflow.providers.cncf.kubernetes.operators.pod import 
KubernetesPodOperator
   
   NS, POD_NAME, DAG_ID, TASK_ID = "kpo-uid-test", "my-pod", "uid_demo", 
"my-pod"
   RUN_A = "manual__2026-08-18T01:00:00+00:00"
   RUN_B = "manual__2026-08-18T02:00:00+00:00"
   
   def body(run_id):
       return {
           "apiVersion": "v1", "kind": "Pod",
           "metadata": {"name": POD_NAME, "namespace": NS, "labels": {
               "dag_id": DAG_ID, "task_id": TASK_ID, "try_number": "1",
               "kubernetes_pod_operator": "True",
               "run_id": run_id.replace(":", "_").replace("+", "_").lower()}},
           "spec": {"restartPolicy": "Never", "containers": [
               {"name": "base", "image": "registry.k8s.io/pause:3.9"}]},
       }
   
   subprocess.run(["kubectl", "delete", "ns", NS, "--ignore-not-found"])
   subprocess.run(["kubectl", "create", "ns", NS])
   k8s_config.load_kube_config()
   core = k8s.CoreV1Api()
   
   uid_a = core.create_namespaced_pod(NS, body(RUN_A)).metadata.uid
   persisted = {"name": POD_NAME, "namespace": NS}   # what the provider writes 
today
   core.delete_namespaced_pod(POD_NAME, NS, 
body=k8s.V1DeleteOptions(grace_period_seconds=0))
   while True:
       try:
           core.read_namespaced_pod(POD_NAME, NS); time.sleep(1)
       except k8s.rest.ApiException:
           break
   uid_b = core.create_namespaced_pod(NS, body(RUN_B)).metadata.uid
   print("uid A", uid_a, "/ uid B", uid_b)
   
   op = KubernetesPodOperator(task_id=TASK_ID, name=POD_NAME, namespace=NS,
                              image="registry.k8s.io/pause:3.9", 
random_name_suffix=False,
                              in_cluster=False, kubernetes_conn_id=None, 
do_xcom_push=False)
   
   class Store:
       def get(self, key, default=None):
           return persisted if key == "pod_identifier" else default
   
   context = {"ti": types.SimpleNamespace(dag_id=DAG_ID, task_id=TASK_ID, 
map_index=-1, try_number=2),
              "run_id": RUN_A, "dag": types.SimpleNamespace(dag_id=DAG_ID), 
"task_state_store": Store()}
   
   print("from store    :", 
op._get_pod_from_task_state_store(context).metadata.uid)
   req = k8s.V1Pod(metadata=k8s.V1ObjectMeta(name=POD_NAME, namespace=NS))
   print("get_or_create :", op.get_or_create_pod(pod_request_obj=req, 
context=context).metadata.uid)
   print("find_pod      :", op.find_pod(NS, context=context))
   ```
   
   </details>
   
   ### What you think should happen instead?
   
   The uid should be save together with name and namespace, and be check when 
read back. If the uid is not the same, treat it like the pod is gone and go to 
the label search fallback, same as the 404 case today.
   
   PR #69914 description already say the uid is there:
   
   > On Airflow 3.3+, `durable=True` persists the running pod's identity (name, 
namespace, uid) to task state store
   
   But in the merged code the uid is not written, and `operators.rst` say only 
(name, namespace). So I think the uid part is just lost somewhere during the 
review.
   
   The dev list announce for durable execution also say the criteria is:
   
   > The job has a stable tracking ID that survives the worker process
   
   Other operator persist a server side id, like spark app id, glue job run id 
or snowflake query id. In Kubernetes that id is `metadata.uid`. The pod name is 
choose by the client and can repeat, so it is not this kind of id.
   
   ### Operating System
   
   Ubuntu 26.04 LTS
   
   ### Deployment
   
   Virtualenv installation
   
   ### Apache Airflow Provider(s)
   
   cncf-kubernetes
   
   ### Deployment details
   
   kubeadm cluster, k8s v1.37.0-rc.0, single node.
   
   apache-airflow==3.3.1, apache-airflow-providers-cncf-kubernetes==10.21.0, 
kubernetes==36.0.3
   
   ### Anything else?
   
   This only happen when the pod name can repeat. Default 
`random_name_suffix=True` add 8 random char so it is almost impossible. But 
`random_name_suffix=False` is a public parameter and people use it.
   
   #21169 is the same kind of problem before, `random_name_suffix=False` make 
KPO delete the wrong pod. It was fixed by #22092 which add a `find_pod` check 
before the delete. The durable path go around that check now.
   
   Two more place look like the same problem, but maybe better to do in a 
separate PR:
   
   - `KubernetesPodTrigger` only serialize `pod_name` and `pod_namespace`, so 
the deferrable path also have no uid
   - `PodManager.delete_pod()` use `V1DeleteOptions()` without 
`preconditions.uid`
   
   ### Are you willing to submit PR?
   
   - [X] Yes I am willing to submit a PR!
   
   ### Code of Conduct
   
   - [X] I agree to follow this project's [Code of 
Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to