Add callback support on kubernetes executors - #70475
Conversation
Runs synchronous callbacks (e.g. Deadline Alerts) as supervised callback pods, mirroring LocalExecutor/CeleryExecutor. Adds callback dispatch in queue_workload/_process_workloads, a construct_callback_pod path, watcher key resolution via the callback_id annotation, WorkloadKey/WorkloadState type widening, and unit + k8s integration tests. Guarded by AIRFLOW_V_3_3_PLUS for provider backward compatibility.
local import to follow pre-existing file conventions
|
@ferruzzi Currently one of the big bug fixes was in |
|
@JH-A-Kim It seems worth doing, but maybe in a different PR, this one is already pretty chonky and I don't believe any execujtors currently retry callbacks. I think the right answer there is to convert |
yeah that makes sense ill create an issue when I get the time to ask about potentially adding retry logic to callbacks, or should I create a discussion on the devlist? |
An Issue is fine for now, but mark is as dependent on this PR and on #63491 both being merged, that way we can reduce merge conflicts all around. |
SameerMesiah97
left a comment
There was a problem hiding this comment.
I have left some comments.
| """Placeholder task for the failing-callback DAG.""" | ||
| print("dummy_task_failing executed") | ||
|
|
||
| dummy_task_failing() |
There was a problem hiding this comment.
Given that this DAG covers multiple scenarios rather than 'standard usage', I wonder whether this belongs in example dags at all. Could we keep the example DAG focused on demonstrating Kubernetes executor callback support and move the scheduler-restart/failure fixtures into the relevant integration test setup?
| ) | ||
| except ApiException as e: | ||
| self.log.info("Failed to adopt pod %s. Reason: %s", pod.metadata.name, e) | ||
| return |
There was a problem hiding this comment.
This seems inconsitent with what has been done for the other executors (for e.g. LambdaExecutor) wrt adding callback support. . Do we actually want callback workloads to participate in scheduler adoption here, or should the lifecycle semantics be consistent across executors?
|
|
||
| ti_id = CallbackKey(id=pod.metadata.annotations[CALLBACK_POD_ANNOTATION_KEY]) | ||
| else: | ||
| ti_id = annotations_to_key(pod.metadata.annotations) |
There was a problem hiding this comment.
The above comment on adopting callbacks applies here too.
| @@ -45,8 +69,8 @@ class FailureDetails(TypedDict, total=False): | |||
| class KubernetesResults(NamedTuple): | |||
| """Results from Kubernetes task execution.""" | |||
There was a problem hiding this comment.
| """Results from Kubernetes task execution.""" | |
| """Results from Kubernetes workload execution.""" |
| workload = command[0] | ||
| if isinstance(workload, ExecuteCallback): | ||
| self._run_next_callback(workload.key, workload, pod_template_file) | ||
| return None |
There was a problem hiding this comment.
Why does _build_pod_request now also submit the pod for callback workloads? This previously had a clear side-effect-free contract where it only constructed and returned the pod, with submission handled by the caller. Could _run_next_callback just construct and return the callback pod so we retain that separation and callbacks can follow the same pod creation path as tasks?
| pod_id = add_unique_suffix(name=f"callback-{short_id}", rand_len=8, max_len=POD_NAME_MAX_LENGTH) | ||
|
|
||
| try: | ||
| image = base_worker_pod.spec.containers[0].image |
There was a problem hiding this comment.
Is the precedence here intentional? kube_image is effectively only a fallback since we copy the image from base_worker_pod into the dynamic pod whenever it exists. This also seems different from the normal task pod construction where kube_image is passed into the dynamic pod and the normal reconciliation determines the final result. Could we follow the same approach here?
| image = base_worker_pod.spec.containers[0].image | ||
| if not image: | ||
| image = kube_image | ||
| except Exception: |
There was a problem hiding this comment.
Exception here is too broad. Not necessarily unjustified but I wish to know why a narrower exception was not used/
| if AIRFLOW_V_3_3_PLUS and CALLBACK_POD_ANNOTATION_KEY in annotations: | ||
| # Callback pod: forward only the annotations that process_watcher_task needs. | ||
| # Callback pods carry callback_id instead of task_id/try_number. | ||
| task_instance_related_annotations = { |
There was a problem hiding this comment.
nit: could be workload_related_annotations to be more accurate.
| phase = self._wait_for_pod_phase(pod_name, ["Succeeded", "Failed", "Deleted"], timeout=180) | ||
| assert phase in ("Succeeded", "Deleted"), ( | ||
| f"Callback pod {pod_name!r} reached {phase!r} after scheduler restart (expected Succeeded/Deleted)" | ||
| ) |
There was a problem hiding this comment.
Not sure if this tests actually covers what it is supposed to. Consdier a situation where the new scheduler never adopts the callback pod. The pod would continue running independently and eventually reach Succeeded anyway, so this test would still pass. Should we explicitly verify that the callback pod is adopted by the new scheduler and that its terminal state is processed?
That's if we decide that callbacks should be adopted.
| try: | ||
| # Failed pods are NOT auto-deleted (delete_worker_pods_on_failure=False default). | ||
| phase = self._wait_for_pod_phase(pod_name, ["Failed"], timeout=120) | ||
| assert phase == "Failed", f"Expected Failed phase, got {phase!r}" |
There was a problem hiding this comment.
The test description says we're verifying that the watcher emits FAILED, but asserting the Kubernetes pod phase only verifies that the callback container failed. Is there a way to assert that the failed result was actually processed by the executor?
aaron-y-chen
left a comment
There was a problem hiding this comment.
Hi, I think there's a potential bug that might needs your help to fix. 🙂
On KubernetesExecutor (Airflow 3.4.0, k8s v1.30.13), if the scheduler restarts while an ExecuteCallback pod from example_deadline_callback.py is still running, the scheduler afterwards crashes on every start and never recovers on its own.
How to reproduce
- Deploy Airflow on K8s with
KubernetesExecutor. - Raise the sleep in
slow_deadline_callbackfrom30to300(or more longer) so there is time to act. - Trigger
example_deadline_callback_slow. - Wait until the callback pod is
Running
(kubectl get pods -l airflow-workload-type=callback). - While it is still running, restart the scheduler
(kubectl delete pod -l component=scheduler). - Wait for the callback pod to reach
Succeeded, then allow up to one
orphaned_tasks_check_interval(default 300s), the crash does not start immediately.
From then on the scheduler crash-loops: every restart dies within ~20s and it never recovers on its own. The only way out was deleting the leftover callback pod by hand, and nothing in the logs or the UI points at which pod to delete.
The scheduler log shows:
File ".../executors/kubernetes_executor.py", line 1271, in _adopt_completed_pods
ti_id = annotations_to_key(pod.metadata.annotations)
File ".../kubernetes_helper_functions.py", line 171, in annotations_to_key
try_number = int(annotations["try_number"])
TypeError: int() argument must be a string, a bytes-like object or a real number, not 'NoneType'
The callback also never reaches a terminal state. Its pod logs "event":"Callback executed successfully", but callback.state stays queued indefinitely:
Note the DAG run and its task instance are both success, so this is not visible from the UI, only the callback result is lost.
Description
Related PR's: #67449
#63454
Related bug fix PR: #70148
To reiterate the main points this pr aims to implement support for callbacks on the Kubernetes executor because it currently only knows how to handle task instances. This PR makes it so it can also handle
ExecuteCallbackworkloads. These are keyed byCallbackKeyso that callbacks can execute as their own pod instead of requiring another mechanism.The core changes are the following:
AIRFLOW_V_3_3_PLUSso that behaviour older versions of Airflow can remain unchangedqueue_workloadandprocess_workloadsnow branch on the the workload is either aExecuteTaskorExecuteCallbackExecuteCallbackcalled_run_next_callbackwhich builds and submits the pod itself and returnsNone.PodGenerator.construct_callback_podwhich builds a minimal pod for a callback and tags it with acallback_idid annotation and aairflow-workload-type: callbacklabel so that the watcher can tell it apart from task podscallback_idthe watcher reconstructs aCallbackKeyinstead of aTaskInstanceKeyand state resolution treats a callback with no error asCallbackState.SUCCESSrather than querying the (nonexistent)TaskInstancerow.Was generative AI tooling used to co-author this PR?
Generated-by: Sonnet 5 following the guidelines
{pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.