You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When a flow run is set to Cancelling (via UI, API, or automations), BaseWorker._cancel_run silently skips killing infrastructure if the flow has already started (start_time is not None). This assumes the flow engine inside the pod will detect the Cancelling state and self-terminate. If the flow is hung for any reason — swallowed exception, stuck future, blocked I/O — the pod lives forever with no mechanism to clean it up.
All major worker integrations implement kill_infrastructure (Kubernetes deletes the Job, ECS stops the task, Docker stops the container, etc.), but it is never called for running flows. The only call site is _cancel_run, which bails out early:
# Only cancel if the flow run was pending (never started)ifflow_run.start_timeisnotNone:
return
There is no other actor in the system that will kill the infrastructure:
The Kopf observer only fires when the K8s Job exceeds backoffLimit — a hung pod is "healthy" from Kubernetes' perspective
The foreman service only monitors worker heartbeats, not flow run liveness
Flow run heartbeats continue even when hung (they run on a separate OS thread)
Minimal Reproducible Example
importconcurrent.futuresfromprefectimportflow, taskfromprefect.task_runnersimportProcessPoolTaskRunner@taskdefbad_task():
return"result"@flow(task_runner=ProcessPoolTaskRunner())defmy_flow():
# If deserialization of the task result fails silently# (e.g. due to a code/image version mismatch), the future# is never resolved and the flow hangs forever.future=bad_task.submit()
returnfuture.result()
To reproduce the zombie pod scenario:
Deploy my_flow to a Kubernetes work pool
Trigger a run — it enters Running state and a pod is created
Simulate a hang: the flow blocks on future.result() indefinitely (in the real case, this was caused by a cloudpickle deserialization error silently swallowed by concurrent.futures.Future._invoke_callbacks)
Cancel the flow run from the UI or API
Expected: The worker kills the Kubernetes Job and marks the run as Cancelled
Actual:_cancel_run returns immediately without calling kill_infrastructure because start_time is not None. The pod continues running forever, consuming cluster resources.
In a parent/subflow scenario, this compounds: the parent retries and spawns new subflows, each of which also hangs, accumulating hundreds of zombie pods.
Proposed Fix
Remove the start_time guard in _cancel_run:
async def _cancel_run(self, flow_run_id: UUID) -> None:
"""
- Cancel a flow run by killing its infrastructure and marking it cancelled.-- Only cancels flow runs that were pending (not yet started).+ Cancel a flow run by killing its infrastructure and marking it cancelled.
"""
try:
flow_run = await self.client.read_flow_run(flow_run_id)
@@ -1764,10 +1762,6 @@
run_logger = self.get_flow_run_logger(flow_run)
- # Only cancel if the flow run was pending (never started)- if flow_run.start_time is not None:- return-
# No infrastructure to kill if no pid
if not flow_run.infrastructure_pid:
All worker integrations (Kubernetes, ECS, Docker, Azure Container Instances, Cloud Run, Vertex AI) already implement kill_infrastructure and handle the InfrastructureNotFound case for infrastructure that has already exited. The change is safe for flows that have already completed naturally — kill_infrastructure will raise InfrastructureNotFound, which _cancel_run already catches and handles gracefully.
The ProcessWorker is less affected because it spawns a local subprocess and waits for it — the parent process can be killed. But for Kubernetes, ECS, and other remote infrastructure workers where run() returns immediately after creating the job, there is no parent process managing the lifecycle.
Bug summary
When a flow run is set to
Cancelling(via UI, API, or automations),BaseWorker._cancel_runsilently skips killing infrastructure if the flow has already started (start_time is not None). This assumes the flow engine inside the pod will detect theCancellingstate and self-terminate. If the flow is hung for any reason — swallowed exception, stuck future, blocked I/O — the pod lives forever with no mechanism to clean it up.All major worker integrations implement
kill_infrastructure(Kubernetes deletes the Job, ECS stops the task, Docker stops the container, etc.), but it is never called for running flows. The only call site is_cancel_run, which bails out early:https://github.com/PrefectHQ/prefect/blob/3d18732034/src/prefect/workers/base.py#L1767-L1769
There is no other actor in the system that will kill the infrastructure:
backoffLimit— a hung pod is "healthy" from Kubernetes' perspectiveMinimal Reproducible Example
To reproduce the zombie pod scenario:
my_flowto a Kubernetes work poolRunningstate and a pod is createdfuture.result()indefinitely (in the real case, this was caused by a cloudpickle deserialization error silently swallowed byconcurrent.futures.Future._invoke_callbacks)Cancelled_cancel_runreturns immediately without callingkill_infrastructurebecausestart_time is not None. The pod continues running forever, consuming cluster resources.In a parent/subflow scenario, this compounds: the parent retries and spawns new subflows, each of which also hangs, accumulating hundreds of zombie pods.
Proposed Fix
Remove the
start_timeguard in_cancel_run:async def _cancel_run(self, flow_run_id: UUID) -> None: """ - Cancel a flow run by killing its infrastructure and marking it cancelled. - - Only cancels flow runs that were pending (not yet started). + Cancel a flow run by killing its infrastructure and marking it cancelled. """ try: flow_run = await self.client.read_flow_run(flow_run_id) @@ -1764,10 +1762,6 @@ run_logger = self.get_flow_run_logger(flow_run) - # Only cancel if the flow run was pending (never started) - if flow_run.start_time is not None: - return - # No infrastructure to kill if no pid if not flow_run.infrastructure_pid:All worker integrations (Kubernetes, ECS, Docker, Azure Container Instances, Cloud Run, Vertex AI) already implement
kill_infrastructureand handle theInfrastructureNotFoundcase for infrastructure that has already exited. The change is safe for flows that have already completed naturally —kill_infrastructurewill raiseInfrastructureNotFound, which_cancel_runalready catches and handles gracefully.Additional Context
_UnpicklingFuture), but the infrastructure cleanup gap is a separate systemic issue affecting any hung flow regardless of cause.ProcessWorkeris less affected because it spawns a local subprocess and waits for it — the parent process can be killed. But for Kubernetes, ECS, and other remote infrastructure workers whererun()returns immediately after creating the job, there is no parent process managing the lifecycle.Version info
Additional context
Related to #21616 which causes hanging runs