Skip to content

Feat/kubernetes executor callback support - #67449

Closed
sjyangkevin wants to merge 5 commits into
apache:mainfrom
sjyangkevin:feat/kubernetes-executor-callback-support
Closed

Feat/kubernetes executor callback support#67449
sjyangkevin wants to merge 5 commits into
apache:mainfrom
sjyangkevin:feat/kubernetes-executor-callback-support

Conversation

@sjyangkevin

@sjyangkevinsjyangkevin commented May 24, 2026

Copy link
Copy Markdown
Contributor

Implements supports_callbacks on KubernetesExecutor by running each ExecuteCallback workload as its own pod, alongside the existing task-pod pipeline. Gated on AIRFLOW_V_3_3_PLUS.

Approach

Use the same way as how Task is dispatched to Kubernetes but with a different configuration for pod label/annotation.

Pod annotations: tasks vs callbacks

For task pods, PodGenerator.construct_pod() writes these annotations so the watcher can reconstruct TaskInstanceKey:

dag_id=<dag_id>
task_id=<task_id>
try_number=<n>
run_id=<run_id>
map_index=<n> (if mapped)

For callback pods, there is no task_id, try_number, or map_index. The relevant identity is the callback UUID.

Proposed annotations:

callback_id=<uuid-str> ← primary identity, used to reconstruct CallbackKey
dag_id=<dag_id> ← from ExecuteCallback.log_path (executor_callbacks/<dag_id>/...)
run_id=<run_id> ← same source, for observability/log correlation

A new pod label is also needed to let the watcher distinguish callback pods from task pods without relying on annotation presence:

airflow-workload-type=callback

Pod construction for callbacks: no PodGenerator.construct_pod() changes

PodGenerator.construct_pod() is specifically built for task identity (takes dag_id, task_id, try_number, etc.). Callback pods should not go through construct_pod(). They need their own construction path that:

  1. Starts from the same base pod template (get_base_pod_from_template)
  2. Sets the correct command: workload_to_command_args(workload) for ExecuteCallback, which serializes workload to --json-string
  3. Sets labels: airflow-worker=<job_id>, kubernetes_executor=True, airflow-workload-type=callback
  4. Sets annotations: callback_id, dag_id, run_id (extracted from workload.log_path or workload.callback.data)
  5. Sets pod_id from create_unique_id("callback", callback_id_short) to avoid name collisions with task pods

The command is identical because execute_workload dispatches on the workload type at runtime — the same subprocess entry point handles both ExecuteTask and ExecuteCallback.

Differences from the task pod lifecycle:

StepTask podCallback pod
Queue dictqueued_tasks[TaskInstanceKey]queued_callbacks[CallbackKey]
Key typeTaskInstanceKey (dag_id, task_id, run_id, try_number, map_index)CallbackKey (id only)
Pod constructionconstruct_pod()construct_callback_pod()
Distinguishing annotationtask_id, try_numbercallback_id
Key reconstruction (watcher)annotations_to_key()CallbackKey(id=annotations["callback_id"])
state=None resolutionDB lookup via TaskInstance.filter_for_tisCallbackState.SUCCESS directly (no TI row)
state type in event_bufferTaskInstanceStateCallbackState
ExecuteTaskExecuteCallback
.keyTaskInstanceKey(dag_id, task_id, run_id, try_number, map_index)CallbackKey(id=<uuid-str>)
Identity fields5 structured fieldsUUID
RetriesYes (try_number)No — executes once
Pod label filteringairflow-worker=<job_id>same — same scheduler owns it

Both TaskInstanceState and CallbackState fit into WorkloadState (TaskInstanceState | CallbackState | ConnectionTestState), which is why KubernetesResults.state and KubernetesWatch.state use the WorkloadState type alias — the same queue structs carry either kind of state depending on what ran in the pod.

sequenceDiagram
participant Sched as Scheduler
participant KE as KubernetesExecutor
participant AKS as AirflowKubernetesScheduler
participant KJW as "KubernetesJobWatcher (Process)"
participant K8s as "Kubernetes API"
Note over Sched,KE: Phase 1 — Queuing (CallbackKey has only .id — no dag_id)
Sched->>KE: queue_workload(ExecuteCallback, session)
Note right of KE: queued_callbacks[CallbackKey] = ExecuteCallback
Note over Sched,KE: Phase 2 — Heartbeat dispatches callback
Sched->>KE: heartbeat() → trigger_tasks() → _process_workloads()
KE->>KE: key = workload.callback.key (CallbackKey)
KE->>KE: execute_async(key=CallbackKey, command=[ExecuteCallback])
Note right of KE: task_queue.put(KubernetesJob)<br/>running.add(CallbackKey)
Note over KE,K8s: Phase 3 — sync() builds and submits callback pod
Sched->>KE: sync()
KE->>AKS: run_next(KubernetesJob)
AKS->>AKS: isinstance(command[0], ExecuteCallback) → True
AKS->>AKS: _run_next_callback()<br/>workload_to_command_args(ExecuteCallback)<br/>→ ["python", "-m", "execute_workload", "--json-string", ...]
AKS->>AKS: construct_callback_pod()<br/>annotations: {callback_id: uuid, dag_id, run_id}<br/>labels: {airflow-workload-type: callback, airflow-worker: ...}
AKS->>K8s: create_namespaced_pod(callback pod spec)
Note right of K8s: Pod: Pending → Running<br/>(Running MODIFIED: not re-queued,<br/>same behaviour as task pods)
Note over KJW,K8s: Phase 4 — Watcher streams terminal pod event
KJW->>K8s: watch.Watch().stream(label_selector=airflow-worker={job_id})
Note right of K8s: Pod: Running → Succeeded
K8s-->>KJW: event{type:MODIFIED, phase:Succeeded}
KJW->>AKS: watcher_queue.put(KubernetesWatch(<br/> state=None, ← Succeeded maps to None (same as tasks)<br/> annotations={callback_id: uuid, dag_id, run_id}<br/>))
Note over KE,AKS: Phase 5 — Promote to result: callback_id → CallbackKey
Sched->>KE: sync() (next heartbeat)
KE->>AKS: kube_scheduler.sync()
AKS->>AKS: process_watcher_task()<br/>"callback_id" in annotations → CallbackKey(id=uuid)<br/>(falls back to annotations_to_key() for task pods)
AKS->>KE: result_queue.put(KubernetesResults(<br/> key=CallbackKey, ← WorkloadKey union<br/> state=None ← WorkloadState | None<br/>))
Note over Sched,KE: Phase 6 — _change_state resolves None→SUCCESS without DB
KE->>KE: _change_state(key=CallbackKey, state=None)<br/>running.remove(CallbackKey)<br/>state is None → AIRFLOW_V_3_3_PLUS and not hasattr(key,"dag_id")<br/> → state = CallbackState.SUCCESS (no TaskInstance row exists)<br/>event_buffer[CallbackKey] = (CallbackState.SUCCESS, None)
KE->>AKS: delete_pod(callback pod)
KE->>Sched: event_buffer consumed → callback result written to DB
Loading

Test Cases

Unit Test

breeze testing providers-tests --test-type "Providers[cncf.kubernetes]"
Screenshot from 2026-06-07 13-32-16
TestDB TestWhat it verifies
test_supports_callbacks_attributeNoexecutor.supports_callbacks is True
test_queue_callback_workloadNoqueue_workload(callback_workload, session) stores workload in executor.queued_callbacks[callback_workload.callback.key] and does NOT touch queued_tasks
test_queue_task_and_callback_are_independentNoQueue a task and a callback; assert queued_tasks has 1 entry, queued_callbacks has 1 entry, no cross-contamination
test_process_workloads_callbackNoAfter _process_workloads([callback_workload]): key removed from queued_callbacks, key present in running, kube_scheduler.run_next called once
test_process_workloads_mixedNoProcess one ExecuteTask and one ExecuteCallback together; both go to run_next, both in running
test_execute_async_callback_commandNoexecute_async(key=callback_key, command=[callback_workload]) puts a KubernetesJob on task_queue whose command is ["python", "-m", "airflow.sdk.execution_time.execute_workload", "--json-string", ...]
test_change_state_callback_success_explicit_stateYesKubernetesResults(key=CallbackKey, state=CallbackState.SUCCESS); event_buffer[callback_key] set, pod deleted, key removed from running
test_change_state_callback_success_state_noneYesKubernetesResults(key=CallbackKey, state=None); implementation must NOT call TaskInstance.filter_for_tis (wrong key type) and must instead set state=CallbackState.SUCCESS without querying DB
test_change_state_callback_failedYesstate=FAILED; event_buffer[callback_key] set to failed state; pod deleted (with failure-deletion config)
test_change_state_callback_pod_not_deleted_if_keep_podsYesdelete_worker_pods=False; patch_pod_executor_done called instead of delete_pod
test_sync_drains_callback_resultsYesPut KubernetesResults(key=CallbackKey) on result_queue; sync() drains it and updates event_buffer
TestWhat it verifies
test_run_next_callback_creates_podrun_next(KubernetesJob(key=CallbackKey, command=[callback_workload], ...)) calls kube_client.create_namespaced_pod
test_callback_pod_has_callback_id_annotationPod spec has annotation callback_id=<uuid>
test_callback_pod_has_workload_type_labelPod spec has label airflow-workload-type=callback
test_callback_pod_has_dag_id_and_run_id_annotationsPod annotations include dag_id and run_id extracted from workload.log_path
test_callback_pod_command_is_execute_workloadContainer command is ["python", "-m", "airflow.sdk.execution_time.execute_workload", "--json-string", <json>]; <json> round-trips back to the original ExecuteCallback
test_callback_pod_does_not_have_task_annotationsPod annotations do NOT contain task_id, try_number, or map_index
test_callback_pod_name_does_not_collide_with_task_podCallback pod name differs from a task pod name sharing the same dag/run (different naming prefix)
TestWhat it verifies
test_process_watcher_task_callback_succeededprocess_watcher_task(KubernetesWatch(..., annotations={"callback_id": "..."})) puts KubernetesResults(key=CallbackKey(...)) on result_queue
test_process_watcher_task_callback_failedSame with state=TaskInstanceState.FAILED
test_process_watcher_task_task_still_uses_task_keyWatch event with task_id/try_number annotations (no callback_id) still resolves to TaskInstanceKey — regression check
test_process_watcher_task_unknown_annotations_droppedWatch event with neither callback_id nor task_id annotations is silently dropped (no crash, no queue entry)
test_process_status_callback_pod_succeededKubernetesJobWatcher.process_status() with phase Succeeded and callback_id annotation puts correct KubernetesWatch on watcher_queue
test_process_status_callback_pod_failedPhase Failed; failure_details included in the queued watch
test_process_status_callback_pod_runningPhase Running puts KubernetesWatch with state=TaskInstanceState.RUNNING
test_process_status_callback_pod_revoked_label_skippedPod with airflow_pod_revoked=True label is skipped even if it has callback_id annotation
TestWhat it verifies
test_task_execution_unaffected_on_older_airflowWith AIRFLOW_V_3_3_PLUS=False patched, processing an ExecuteTask workload puts it on task_queue without touching callback code
test_queue_non_callback_non_task_raisesQueuing an unknown workload type raises RuntimeError
test_supports_callbacks_does_not_break_old_airflowqueue_workload(ExecuteTask, session) succeeds and stores in queued_tasks (regression guard for older Airflow)

k8s Tests

breeze k8s deploy-cluster
breeze k8s deploy-airflow --executor Kubernetes
breeze k8s tests --executor Kubernetes
Screenshot from 2026-06-07 15-47-45

These tests use breeze k8s to deploy a local cluster and run against the Kubernetes API. They follow the polling pattern of BaseK8STest: trigger a Dag run via the REST API, poll for the callback's state, and assert the terminal outcome.

Test:test_deadline_callback_executes_on_kubernetes

1. Trigger DAG run for a Dag that has a short deadline already passed.
2. Poll until callback state = "success" (via GET /api/v2/callbacks/{id} or equivalent).
3. Assert exactly one callback pod was created in the k8s namespace with:
- label airflow-workload-type=callback
- annotation callback_id matching the callback UUID
4. Assert the callback pod was cleaned up (deleted or marked done) after completion.

Test:test_deadline_callback_pod_failure_marks_callback_failed

1. Configure the Dag's deadline callback to reference a non-existent function path
(import will fail inside the pod).
2. Trigger the Dag run; wait for the callback pod to reach "Failed" phase.
3. Poll until callback state = "failed".
4. Assert executor event_buffer (via executor logs or API state) records FAILED,
not hanging in RUNNING.

Test:test_callback_pod_annotations_and_labels

1. Trigger a Dag run that fires a Deadline Alert callback.
2. While the callback pod is Running, query the k8s API for the pod spec.
3. Assert annotations contain callback_id (UUID format).
4. Assert annotations contain dag_id and run_id matching the triggering Dag run.
5. Assert labels contain airflow-workload-type=callback and kubernetes_executor=True.
6. Assert the container command contains "execute_workload" and "--json-string".

Test:test_callback_pod_survives_scheduler_restart

1. Trigger a Dag run with a Deadline Alert callback, and pause at pod Running phase
(use a slow callback function that sleeps).
2. Delete the scheduler pod (_delete_airflow_pod("scheduler")).
3. Wait for scheduler to be healthy again (ensure_resource_health("airflow-scheduler")).
4. Assert the callback eventually completes successfully (the new scheduler adopts the pod).
5. Assert callback state = "success".

Test:test_callback_pod_is_cleaned_up_after_success

1. Run the happy-path test (8.1).
2. After callback state reaches "success", assert zero pods with label
airflow-workload-type=callback remain in the namespace.
Uses _num_pods_in_namespace() with a label selector filter.

Samples

Watch callback pods

Screenshot from 2026-06-06 22-54-07

Check a callback pod's logs

irflow.configuration] loc=configuration.py:448
2026-06-07T01:50:34.912235Z [debug ] Adding <function default_action_log at 0x7f63d396d480> to pre execution callback [airflow.utils.cli_action_loggers] loc=cli_action_loggers.py:51
{"timestamp":"2026-06-07T01:50:35.259660Z","level":"info","event":"Executing workload","workload":"ExecuteCallback(dag_rel_path=PurePosixPath('example_deadline_callback.py'), bundle_info=BundleInfo(name='dags-folder', version=None), log_path='executor_callbacks/example_deadline_callback/manual__2026-06-07T01:50:23.457423+00:00/019e9fc6-254d-726e-a295-664c7d8728e3', callback=CallbackDTO(id='019e9fc6-254d-726e-a295-664c7d8728e3', fetch_method=<CallbackFetchMethod.IMPORT_PATH: 'import_path'>, data={'path': 'airflow.example_dags.example_deadline_callback.deadline_callback', 'dag_id': 'example_deadline_callback', 'kwargs': {'context': {'dag_run': {'dag_run_id': 'manual__2026-06-07T01:50:23.457423+00:00', 'dag_id': 'example_deadline_callback', 'logical_date': '2026-06-07T01:50:23.448713Z', 'queued_at': '2026-06-07T01:50:23.463195Z', 'start_date': '2026-06-07T01:50:24.538774Z', 'end_date': None, 'duration': None, 'data_interval_start': '2026-06-07T01:50:23.448713Z', 'data_interval_end': '2026-06-07T01:50:23.448713Z', 'run_after': '2026-06-07T01:50:23.457423Z', 'last_scheduling_decision': '2026-06-07T01:50:24.558640Z', 'run_type': 'manual', 'state': 'running', 'triggered_by': 'rest_api', 'triggering_user_name': 'admin', 'conf': {}, 'note': None, 'dag_versions': [{'id': '019e9fb7-3b95-72ac-ae8d-f27f87a4a42d', 'version_number': 1, 'dag_id': 'example_deadline_callback', 'bundle_name': 'dags-folder', 'bundle_version': None, 'created_at': '2026-06-07T01:34:06.229528Z', 'dag_display_name': 'example_deadline_callback', 'bundle_url': None}], 'bundle_version': None, 'dag_display_name': 'example_deadline_callback', 'partition_key': None}, 'deadline': {'id': '019e9fc6-254f-7edd-9487-aaf3b93bc47e', 'deadline_time': '2020-01-01T01:00:00Z'}}}, 'prefix': 'deadline_alerts', 'executor': None, 'deadline_id': '019e9fc6-254f-7edd-9487-aaf3b93bc47e', 'dag_run_id': '2'}), type='ExecuteCallback')","logger":"__main__","filename":"execute_workload.py","lineno":51}
{"timestamp":"2026-06-07T01:50:35.804194Z","level":"info","event":"Using Public CAs from certifi","logger":"airflow.sdk.api.client","filename":"client.py","lineno":1155}
{"timestamp":"2026-06-07T01:50:35.822780Z","level":"debug","event":"Connecting to execution API server","server":"http://airflow-api-server:8080/execution/","logger":"supervisor","filename":"supervisor.py","lineno":1210}
{"timestamp":"2026-06-07T01:50:35.839307Z","level":"info","event":"DAG bundles loaded: dags-folder","logger":"airflow.dag_processing.bundles.manager.DagBundlesManager","filename":"manager.py","lineno":209}
{"timestamp":"2026-06-07T01:50:35.839896Z","level":"debug","event":"Added bundle path to sys.path","bundle_name":"dags-folder","path":"/opt/airflow/dags","logger":"callback_runner","filename":"callback_supervisor.py","lineno":209}
{"timestamp":"2026-06-07T01:50:35.984210Z","level":"debug","event":"Initializing Provider Manager[taskflow_decorators]","logger":"airflow.sdk._shared.providers_discovery.providers_discovery","filename":"providers_discovery.py","lineno":285}
{"timestamp":"2026-06-07T01:50:35.984610Z","level":"debug","event":"Initialization of Provider Manager[taskflow_decorators] took 0.00 seconds","logger":"airflow.sdk._shared.providers_discovery.providers_discovery","filename":"providers_discovery.py","lineno":288}
{"timestamp":"2026-06-07T01:50:35.986541Z","level":"debug","event":"Executing callback","callback_path":"airflow.example_dags.example_deadline_callback.deadline_callback","callback_kwargs":{"context":{"dag_run":{"dag_run_id":"manual__2026-06-07T01:50:23.457423+00:00","dag_id":"example_deadline_callback","logical_date":"2026-06-07T01:50:23.448713Z","queued_at":"2026-06-07T01:50:23.463195Z","start_date":"2026-06-07T01:50:24.538774Z","end_date":null,"duration":null,"data_interval_start":"2026-06-07T01:50:23.448713Z","data_interval_end":"2026-06-07T01:50:23.448713Z","run_after":"2026-06-07T01:50:23.457423Z","last_scheduling_decision":"2026-06-07T01:50:24.558640Z","run_type":"manual","state":"running","triggered_by":"rest_api","triggering_user_name":"admin","conf":{},"note":null,"dag_versions":[{"id":"019e9fb7-3b95-72ac-ae8d-f27f87a4a42d","version_number":1,"dag_id":"example_deadline_callback","bundle_name":"dags-folder","bundle_version":null,"created_at":"2026-06-07T01:34:06.229528Z","dag_display_name":"example_deadline_callback","bundle_url":null}],"bundle_version":null,"dag_display_name":"example_deadline_callback","partition_key":null},"deadline":{"id":"019e9fc6-254f-7edd-9487-aaf3b93bc47e","deadline_time":"2020-01-01T01:00:00Z"}}},"logger":"callback_runner","filename":"callback_supervisor.py","lineno":117}
{"timestamp":"2026-06-07T01:50:35.986817Z","level":"info","event":"Callback executed successfully","callback_path":"airflow.example_dags.example_deadline_callback.deadline_callback","logger":"callback_runner","filename":"callback_supervisor.py","lineno":140}
{"timestamp":"2026-06-07T01:50:35.988301Z","level":"info","event":"[deadline_callback] Deadline alert fired for dag_id=example_deadline_callback run_id=manual__2026-06-07T01:50:23.457423+00:00","logger":"task.stdout"}
{"timestamp":"2026-06-07T01:50:35.992169Z","level":"info","event":"Workload finished","workload_type":"ExecutorCallback","workload_id":"019e9fc6-254d-726e-a295-664c7d8728e3","exit_code":0,"duration":0.22140842999988308,"logger":"callback_supervisor","filename":"callback_supervisor.py","lineno":398}
(kind-airflow-python-3.10-v1.30.13:KubernetesExecutor)> 

Check a callback pod's details

(kind-airflow-python-3.10-v1.30.13:KubernetesExecutor)> kubectl describe pod -n airflow callback-019e9fc6-2bng48qf
Name: callback-019e9fc6-2bng48qf
Namespace: airflow
Priority: 0
Service Account: airflow-worker
Node: airflow-python-3.10-v1.30.13-worker/172.18.0.3
Start Time: Sat, 06 Jun 2026 21:50:24 -0400
Labels: airflow-worker=3
airflow-workload-type=callback
component=worker
kubernetes_executor=True
release=airflow
tier=airflow
Annotations: callback_id: 019e9fc6-254d-726e-a295-664c7d8728e3
cluster-autoscaler.kubernetes.io/safe-to-evict: false
dag_id: example_deadline_callback
run_id: manual__2026-06-07T01:50:23.457423+00:00
Status: Running
IP: 10.244.1.19
IPs:
IP: 10.244.1.19
Containers:
base:
Container ID: containerd://ca41a4c7c9e5662c1133fb5646b5eebadd1fc17f5918d165a96749a8d34d20b9
Image: ghcr.io/apache/airflow/main/prod/python3.10-kubernetes:latest
Image ID: sha256:57e4539751f0c186ea32ed48fa6356d8bb506181a01ffaeae82fb03d5370f22d
Port: <none>
Host Port: <none>
Args:
python
-m
airflow.sdk.execution_time.execute_workload
--json-string
{"token":"<token>","dag_rel_path":"example_deadline_callback.py","bundle_info":{"name":"dags-folder","version":null},"log_path":"executor_callbacks/example_deadline_callback/manual__2026-06-07T01:50:23.457423+00:00/019e9fc6-254d-726e-a295-664c7d8728e3","callback":{"id":"019e9fc6-254d-726e-a295-664c7d8728e3","fetch_method":"import_path","data":{"path":"airflow.example_dags.example_deadline_callback.deadline_callback","dag_id":"example_deadline_callback","kwargs":{"context":{"dag_run":{"dag_run_id":"manual__2026-06-07T01:50:23.457423+00:00","dag_id":"example_deadline_callback","logical_date":"2026-06-07T01:50:23.448713Z","queued_at":"2026-06-07T01:50:23.463195Z","start_date":"2026-06-07T01:50:24.538774Z","end_date":null,"duration":null,"data_interval_start":"2026-06-07T01:50:23.448713Z","data_interval_end":"2026-06-07T01:50:23.448713Z","run_after":"2026-06-07T01:50:23.457423Z","last_scheduling_decision":"2026-06-07T01:50:24.558640Z","run_type":"manual","state":"running","triggered_by":"rest_api","triggering_user_name":"admin","conf":{},"note":null,"dag_versions":[{"id":"019e9fb7-3b95-72ac-ae8d-f27f87a4a42d","version_number":1,"dag_id":"example_deadline_callback","bundle_name":"dags-folder","bundle_version":null,"created_at":"2026-06-07T01:34:06.229528Z","dag_display_name":"example_deadline_callback","bundle_url":null}],"bundle_version":null,"dag_display_name":"example_deadline_callback","partition_key":null},"deadline":{"id":"019e9fc6-254f-7edd-9487-aaf3b93bc47e","deadline_time":"2020-01-01T01:00:00Z"}}},"prefix":"deadline_alerts","executor":null,"deadline_id":"019e9fc6-254f-7edd-9487-aaf3b93bc47e","dag_run_id":"2"}},"type":"ExecuteCallback"}
State: Running
Started: Sat, 06 Jun 2026 21:50:25 -0400
Ready: True
Restart Count: 0
Environment:
AIRFLOW__CORE__EXECUTOR: KubernetesExecutor
AIRFLOW_HOME: /opt/airflow
AIRFLOW__CORE__FERNET_KEY: <set to the key 'fernet-key' in secret 'airflow-fernet-key'> Optional: false
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: <set to the key 'connection' in secret 'airflow-metadata'> Optional: false
AIRFLOW_CONN_AIRFLOW_DB: <set to the key 'connection' in secret 'airflow-metadata'> Optional: false
AIRFLOW__API__SECRET_KEY: <set to the key 'api-secret-key' in secret 'airflow-api-secret-key'> Optional: false
AIRFLOW_IS_K8S_EXECUTOR_POD: True
Mounts:
/opt/airflow/airflow.cfg from config (ro,path="airflow.cfg")
/opt/airflow/logs from logs (rw)
/var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-t49wv (ro)
Conditions:
Type Status
PodReadyToStartContainers True Initialized True Ready True ContainersReady True PodScheduled True Volumes:
logs:
Type: EmptyDir (a temporary directory that shares a pod's lifetime)
Medium: SizeLimit: <unset>
config:
Type: ConfigMap (a volume populated by a ConfigMap)
Name: airflow-config
Optional: false
kube-api-access-t49wv:
Type: Projected (a volume that contains injected data from multiple sources)
TokenExpirationSeconds: 3607
ConfigMapName: kube-root-ca.crt
ConfigMapOptional: <nil>
DownwardAPI: true
QoS Class: BestEffort
Node-Selectors: <none>
Tolerations: node.kubernetes.io/not-ready:NoExecute op=Exists for 300s
node.kubernetes.io/unreachable:NoExecute op=Exists for 300s
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 10s default-scheduler Successfully assigned airflow/callback-019e9fc6-2bng48qf to airflow-python-3.10-v1.30.13-worker
Normal Pulled 9s kubelet Container image "ghcr.io/apache/airflow/main/prod/python3.10-kubernetes:latest" already present on machine
Normal Created 9s kubelet Created container: base
Normal Started 9s kubelet Started container base
(kind-airflow-python-3.10-v1.30.13:KubernetesExecutor)> 

Was generative AI tooling used to co-author this PR?
  • Yes

Generated-by: [Claude Code (Sonnet 4.6)] 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.

@boring-cyborgboring-cyborgBot added area:providers area:task-sdk provider:cncf-kubernetes Kubernetes (k8s) provider related issues labels May 24, 2026

@ferruzziferruzzi 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.

The approach is solid and the test coverage looks good. The main issues are backward compatibility issues. Look at how ECS (#63657) and Celery (#63888) did these and follow their leads.

key: TaskInstanceKey
command: Sequence[str]
key: WorkloadKey
command: Sequence[Any]

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 liked the way the ECS Executor handled this; they did

 if AIRFLOW_V_3_3_PLUS:
CommandType: TypeAlias = Sequence[str] | Sequence[ExecuteTask] | Sequence[ExecuteCallback]
else:
CommandType: TypeAlias = Sequence[str]

then defined command as a CommandType instead of Sequence[All]

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Created a WorkloadCommand to implement the same handle. The reason not name is as CommandType is because at L92, there is already a definition. Not sure if that would be safe to remove, but it might cause some shadowing when using the same name.

@sjyangkevin

sjyangkevin commented Jun 1, 2026

Copy link
Copy Markdown
ContributorAuthor

Hi @ferruzzi , I am still working on resolving the compatibility issue and also looking into the trade-off for the feedback (#67449 (comment))

After creating the CommandType according to the ECS implementation, it seems like we need to do a cast on the following (args=list(cast("Sequence[str]", command))) and also when constructing the pod for the callback (callback_workload = cast("ExecuteCallback", next_job.command[0])):

I am looking into a way if this typing can be handled a bit more better (without using cast), and also understanding better the dispatch logic.

Sorry, my progress is very slow since past months due to bandwidth limit. I will try to address the the review feedback by this week.

@ferruzzi

Copy link
Copy Markdown
Contributor

Hi @ferruzzi , I am still working on resolving the compatibility issue and also looking into the trade-off for the feedback (#67449 (comment))

After creating the CommandType according to the ECS implementation, it seems like we need to do a cast on the following (args=list(cast("Sequence[str]", command))) and also when constructing the pod for the callback (callback_workload = cast("ExecuteCallback", next_job.command[0])):

I am looking into a way if this typing can be handled a bit more better (without using cast), and also understanding better the dispatch logic.

Sorry, my progress is very slow since past months due to bandwidth limit. I will try to address the the review feedback by this week.

I just did a very quick look, so don't hold me to this, but I think you can make _run_next_callback accept the workload itself instead of the next_job: def _run_next_callback(self, callback_workload: ExecuteCallback) -> None: then in run_next you'd call it with return self._run_next_callback(command[0]). I think if you do that then the typing should be handled correctly since it's going through the isinstance? Maybe? MyPy can be fickle. I think between that and adding the list[str] return hint to workload_to_command_args you should be good without casting anything.

@sjyangkevin
sjyangkevinforce-pushed the feat/kubernetes-executor-callback-support branch 5 times, most recently from 9d88f0b to 66afca9CompareJune 7, 2026 20:25
Comment on lines +57 to +58
from airflow.executors.workloads import ExecuteCallback
from airflow.models.callback import CallbackKey

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

The two imports here are under TYPE_CHECKING with is not guard by the AIRFLOW_V_3_3_PLUS. These are used for type hint in the following funciton.

def_run_next_callback(
self, key: CallbackKey, workload: ExecuteCallback, pod_template_file: str|None
) ->None:

@sjyangkevin

Copy link
Copy Markdown
ContributorAuthor

Hi @ferruzzi , I've done a rework on the PR, this should be a cleaner implementation for the callback support. I am looking into the CI failures related to Serialization. Feel free to let me know if you have more feedback. Thanks!

Screenshot from 2026-06-07 17-10-47

@sjyangkevin

Copy link
Copy Markdown
ContributorAuthor

Opened the PR #68195 , attempt to address the issue in CI.

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.
@sjyangkevin
sjyangkevinforce-pushed the feat/kubernetes-executor-callback-support branch from 66afca9 to 259646aCompareJune 19, 2026 01:24
@potiuk

Copy link
Copy Markdown
Member

Note

🛠️ Maintainer triage note for @sjyangkevin · by @potiuk · 2026-07-08 16:05 UTC

This draft has been inactive for ~20 days, so I'm closing it to keep the review queue tidy — no judgment on the work itself.

No rush — reopen it (or open a fresh PR) whenever you're ready to continue; nothing is lost.

Automated triage — may be imperfect; a maintainer takes the next look.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:providersarea:task-sdkprovider:cncf-kubernetesKubernetes (k8s) provider related issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@sjyangkevin@ferruzzi@potiuk