Skip to content

Add callback support on kubernetes executors - #70475

Draft
JH-A-Kim wants to merge 24 commits into
apache:mainfrom
JH-A-Kim:kube-callbacks
Draft

Add callback support on kubernetes executors#70475
JH-A-Kim wants to merge 24 commits into
apache:mainfrom
JH-A-Kim:kube-callbacks

Conversation

@JH-A-Kim

@JH-A-Kim JH-A-Kim commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

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 ExecuteCallback workloads. These are keyed by CallbackKey so that callbacks can execute as their own pod instead of requiring another mechanism.

The core changes are the following:

  • New Generic types in kubernetes_executor_types.py where WorkloadKey/WorkloadState/WorkloadCommand type aliases help to widen the executors internal types from always being a task to now being able to be a task or a callback and is gated behind AIRFLOW_V_3_3_PLUS so that behaviour older versions of Airflow can remain unchanged
  • The routing in queue_workload and process_workloads now branch on the the workload is either a ExecuteTask or ExecuteCallback
  • Pods now have another build path for ExecuteCallback called _run_next_callback which builds and submits the pod itself and returns None.
  • Now with this there is a new pod constructor called PodGenerator.construct_callback_pod which builds a minimal pod for a callback and tags it with a callback_id id annotation and a airflow-workload-type: callback label so that the watcher can tell it apart from task pods
  • For the watcher when the pod events annotation contains a callback_id the watcher reconstructs a CallbackKey instead of a TaskInstanceKey and state resolution treats a callback with no error as CallbackState.SUCCESS rather than querying the (nonexistent) TaskInstance row.
  • For pod adoption after a scheduler restart, it just re-labels it with a new schedulers worker ID, since callback pods dont participate in the normal orphan recovery/retry logic that task pods do.
Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: Sonnet 5 following the guidelines


  • Read the Pull Request Guidelines for more information. Note: commit author/co-author name and email in commits become permanently public when merged.
  • For fundamental code changes, an Airflow Improvement Proposal (AIP) is needed.
  • When adding dependency, check compliance with the ASF 3rd Party License Policy.
  • For significant user-facing changes create newsfragment: {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.

sjyangkevin and others added 6 commits June 18, 2026 21:20
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.
@JH-A-Kim

JH-A-Kim commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

@ferruzzi Currently one of the big bug fixes was in _change_state() where there would be a timeout because callbacks would enter into the task retry logic for the specific case of before execution a failure would occur and would then check for dag_id which it does not have and continuously add it back to the retry queue. Which would cause tests to timeout. But do you think its worth adding logic to restart the callback pod if that were to occur? Currently my check just makes sure entrance into that particular bit of logic is only for tasks. But if the pod were to fail before running I think it might be necessary for it to try and restart in that case automatically.

@ferruzzi

ferruzzi commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

@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 _get_task_instance_state to _get_workload_state which checks Callback.state directly; Callback.state already has ACTIVE_STATES and `TERMINAL_STATES`` defined which you can check against.

@JH-A-Kim

JH-A-Kim commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@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 _get_task_instance_state to _get_workload_state which checks Callback.state directly; Callback.state already has ACTIVE_STATES and `TERMINAL_STATES`` defined which you can check against.

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?

@ferruzzi

Copy link
Copy Markdown
Contributor

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.

@JH-A-Kim
JH-A-Kim marked this pull request as ready for review August 19, 2026 18:32

@SameerMesiah97 SameerMesiah97 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have left some comments.

"""Placeholder task for the failing-callback DAG."""
print("dummy_task_failing executed")

dummy_task_failing()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"""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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 aaron-y-chen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Deploy Airflow on K8s with KubernetesExecutor.
  2. Raise the sleep in slow_deadline_callback from 30 to 300 (or more longer) so there is time to act.
  3. Trigger example_deadline_callback_slow.
  4. Wait until the callback pod is Running
    (kubectl get pods -l airflow-workload-type=callback).
  5. While it is still running, restart the scheduler
    (kubectl delete pod -l component=scheduler).
  6. 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.

Image

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:

Image

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.

Image

@JH-A-Kim
JH-A-Kim marked this pull request as draft August 25, 2026 22:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants