armin
07/20/2026, 4:06 PMmax_concurrent=1000) via Kubernetes work pool. At this scale we hit EC2 ICE / Pending backlog; many Jobs later fail together on activeDeadlineSeconds=3600 (DeadlineExceeded).
3 worker replicas each run the K8s observer. During the deadline burst, _mark_flow_run_as_crashed → propose_state(Crashed) raises Abort: already in terminal state COMPLETED/CRASHED. kopf treats that as unrecoverable and stops the Jobs watcher. Later failures never get crash-marked → zombie runs stuck Running in the UI (Job already Failed, pod gone). Also see httpx.PoolTimeout (client pool max 16). Workers not OOM’d.
Any recommendations or release that addresses this?Marvin
07/20/2026, 4:06 PMMarvin
07/20/2026, 4:08 PMactiveDeadlineSeconds.
- But the bad Prefect behavior is that the Kubernetes Jobs observer appears to die when crash-marking a run hits Abort: already in terminal state ..., which then leaves later failed Jobs unobserved and flow runs stuck Running.
- The httpx.PoolTimeout you’re seeing is also consistent with observer/API pressure during that burst and likely makes the recovery path worse.
I couldn’t fully inspect source / issues from tools right now because GitHub access and the deeper research agent both failed in this session, so I don’t want to overclaim a specific fixed version. But based on the symptoms, I’d recommend treating this as two problems: burst control and observer robustness.
Recommendations
1. Upgrade off 3.6.22 if you can
You’re on an older 3.x patch line. Even though I couldn’t confirm the exact PR/release for this watcher behavior, moving to the latest 3.x patch is the first thing I’d try. Current latest release notes available to me show active bugfix work continuing in 3.7.x:
Prefect releases
If you’re self-hosting workers plus server, I’d upgrade both Prefect core and any `prefect-kubernetes`/worker image components together.
2. Reduce the fan-out burst at the orchestration layer
max_concurrent=1000 against scarce GPU capacity plus EC2 ICE is effectively creating a synchronized failure wave. Even if Prefect handled terminal-state races perfectly, that’s still an ugly load shape for:
- K8s API
- Prefect API
- observer reconciliation
- worker HTTP client pools
I’d strongly consider:
- lowering max_concurrent
- adding queue/work-pool concurrency limits
- batching subflow submission
- staggering launches instead of a single 1000-run burst
In practice, keeping the number of simultaneously submitted infrastructure runs closer to actual schedulable GPU capacity + some headroom is usually much more stable than letting 1000 sit Pending.
3. Revisit activeDeadlineSeconds=3600
If jobs can sit Pending for a long time before ever getting a GPU node, activeDeadlineSeconds can create exactly the synchronized DeadlineExceeded burst you’re seeing.
If the deadline is meant to bound runtime rather than queue wait, consider:
- increasing it substantially, or
- handling timeout inside the flow code instead of only with K8s Job deadline, or
- using a submission pattern that avoids long Pending queues in the first place
4. Treat observer restartability as critical
Since the watcher dying is what creates zombie Running UI state, make sure the worker pods:
- auto-restart on failure
- have liveness/readiness probes that catch a dead/stuck observer process if possible
- emit logs/alerts specifically on kopf watcher exits
Even if the underlying bug remains, rapid restart reduces the zombie window.
5. Investigate / tune API client pressure
Your httpx.PoolTimeout with pool max 16 strongly suggests the worker/observer is saturating its API connection pool during the failure burst.
I wasn’t able to confirm an exposed Prefect setting for that pool size from tools here, so I don’t want to invent one. But operationally:
- reduce concurrent state-update pressure first
- check whether you’ve customized worker polling / prefetch aggressively
- inspect server latency / DB saturation during the burst
- if you have ingress / LB connection bottlenecks, check those too
The pool timeout may be a symptom, but it can absolutely amplify the watcher failure.
6. Backfill zombie runs
For runs already stuck Running after the Job is gone, you’ll likely need a cleanup process:Marvin
07/20/2026, 4:08 PMRunning flow runs from the affected deployment/work pool and age window
- reconcile against K8s Job existence/final status
- mark missing/failed ones as Crashed or Failed
If this is recurring, a temporary reconciliation script is worth having until you upgrade/stabilize.
What this most likely is
This smells like a race where:
- K8s observer receives terminal Job events in a burst
- multiple worker replicas / observer paths attempt to mark the same flow run terminal
- Prefect correctly rejects duplicate terminal transition with Abort
- but kopf/observer incorrectly treats that exception as fatal for the watch loop
That should be handled as an expected/idempotent condition, not as an unrecoverable watcher error.
What I’d do next, in order
1. Upgrade from 3.6.22 to latest stable 3.x.
2. Cut max_concurrent sharply and/or enforce work-pool concurrency aligned to GPU capacity.
3. Increase or rethink activeDeadlineSeconds.
4. Add alerting on worker observer restarts / kopf handler crashes.
5. Add a reconciliation script for orphaned Running runs.
If you want, I can help with either of these next:
- a concrete mitigation plan for EKS + Kubernetes work pool at this scale
- a zombie-run reconciler script for Prefect 3.x self-hosted
- a GitHub issue writeup template with the exact logs/details maintainers would want
If you can paste one stack trace from the kopf watcher death and one httpx.PoolTimeout trace, I can help narrow whether this is purely duplicate-terminal-state handling or also a separate client-pool bottleneck.armin
07/20/2026, 4:16 PMERROR | kopf._core.reactor.orchestration - Watcher for jobs.v1.batch@<namespace> has failed: Event processing has failed with an unrecoverable error. This seems to be a framework bug. The operator will stop to prevent damage.
Traceback (most recent call last):
File ".../kopf/_cogs/aiokits/aiotasks.py", line 375, in _task_cleaner
await task
File ".../kopf/_core/reactor/queueing.py", line 333, in worker
newer_patch_version = await processor(
File ".../kopf/_core/reactor/processing.py", line 110, in process_resource_event
delays, matched = await process_resource_causes(
File ".../kopf/_core/reactor/processing.py", line 248, in process_resource_causes
await process_watching_cause(
File ".../kopf/_core/reactor/processing.py", line 351, in process_watching_cause
outcomes = await execution.execute_handlers_once(
File ".../kopf/_core/actions/execution.py", line 198, in execute_handlers_once
outcome = await execute_handler_once(
File ".../kopf/_core/actions/execution.py", line 254, in execute_handler_once
result = await invoke_handler(
File ".../kopf/_core/actions/execution.py", line 400, in invoke_handler
result = await invocation.invoke(
File ".../kopf/_core/actions/invocation.py", line 115, in invoke
result = await fn(**kwargs)
File ".../prefect_kubernetes/observer.py", line 345, in _mark_flow_run_as_crashed
await propose_state(
File ".../prefect/utilities/engine.py", line 397, in propose_state
raise prefect.exceptions.Abort(response.details.reason)
prefect.exceptions.Abort: Run is already in terminal state COMPLETED.
ERROR | kopf.objects - Handler '_mark_flow_run_as_crashed' failed with an exception and will ignore it: PoolTimeout('')
Traceback (most recent call last):
...
File ".../httpcore/_async/connection_pool.py", line 232, in handle_async_request
connection = await pool_request.wait_for_connection(timeout=timeout)
...
httpcore.PoolTimeout
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File ".../kopf/_core/actions/execution.py", line 254, in execute_handler_once
result = await invoke_handler(
...
File ".../prefect_kubernetes/observer.py", line 288, in _mark_flow_run_as_crashed
flow_run = await orchestration_client.read_flow_run(
File ".../prefect/client/orchestration/_flow_runs/client.py", line 718, in read_flow_run
response = await self.request(
...
File ".../httpx/_transports/default.py", line 118, in map_httpcore_exceptions
raise mapped_exc(message) from exc
httpx.PoolTimeoutMarvin
07/20/2026, 4:23 PMMarvin
07/20/2026, 4:24 PMprefect_kubernetes.observer._mark_flow_run_as_crashed
- calls propose_state(...)
- Prefect returns Abort because the run is already terminal (COMPLETED here)
- that exception bubbles out of the handler
- kopf treats it as unrecoverable and stops the Jobs watcher entirely
So yes: this is an observer robustness bug / idempotency bug, not just “too much load”.
What that means
A duplicate terminal-state race is expected under bursty conditions:
- Job event arrives late
- another path already marked the run COMPLETED or CRASHED
- observer tries to set Crashed
- API rejects it with Abort
That part is fine. The bug is that the observer does not swallow Abort as an expected no-op.
My recommendation
1. Upgrade first
I’d move off 3.6.22 to the latest 3.x available before doing anything else:
Prefect releases
I can’t confirm from tools here which exact release fixes this specific path, but this is exactly the kind of bug that may have been fixed in later patch lines.
2. Lower concurrency / smooth launch rate immediately
Even if you upgrade, 1000 concurrent GPU subflows with frequent Pending/ICE means you’ll keep producing the race window and a lot of API churn. I’d reduce:
- deployment concurrency
- work-pool / work-queue concurrency
- parent flow fan-out rate
3. Increase or remove the synchronized deadline burst
activeDeadlineSeconds=3600 is creating a huge coordinated failure event. If runs can spend most of that time Pending, that deadline is effectively a “mass-fail timer”.
4. Run multiple workers, but assume observer can still fail
You already have 3 replicas, which is good for availability, but if each replica runs its own watcher and each can hit the same unhandled Abort, that alone doesn’t solve it.
Short-term mitigation ideas
Best mitigation without code changes:
- reduce fan-out
- reduce long Pending backlog
- avoid synchronized DeadlineExceeded
- upgrade Prefect
If you are comfortable patching your worker image temporarily:
wrap the observer crash-marking call so prefect.exceptions.Abort is treated as expected and logged at warning/debug, not raised to kopf.
Conceptually, the bad section is effectively:
await propose_state(client=client, flow_run_id=flow_run_id, state=Crashed(...))
and the safer behavior would be more like:
try:
await propose_state(client=client, flow_run_id=flow_run_id, state=Crashed(...))
except prefect.exceptions.Abort:
# run already terminal; ignore
return
If you maintain a custom worker image, this is probably the most direct protection until you confirm an upstream fix.
Why you’re getting zombie Running runs
Once kopf stops watching Jobs:
- later failed Jobs are no longer reconciled back into Prefect
- no one marks the corresponding flow runs terminal
- the flow runs remain Running in Prefect even though the K8s Job is already Failed or deleted
That matches your symptom exactly.
About the httpx.PoolTimeout
I think it’s likely secondary but important:
- during the failure burst, observers/workers are all trying to update states
- the client connection pool saturates
- some updates get delayed or dropped
- the system becomes more race-prone and less able to recover
So I’d treat it as a scaling signal, but the watcher death is still the bigger correctness issue.
Practical next steps I’d use
- Today: reduce max_concurrent substantially
- Today: increase activeDeadlineSeconds or stop using it as the primary timeout mechanism
- Next maintenance window: upgrade to latest 3.x worker/server images
- If issue persists after upgrade: patch prefect_kubernetes.observer locally to swallow AbortMarvin
07/20/2026, 4:24 PMRunning flow runs older than N minutes where the K8s Job no longer exists
If you want, I can help with one of these right now:
1. a monkey patch / custom image patch for the Kubernetes observer to ignore Abort
2. a reconciler script to clean up zombie Running runs
3. a fan-out redesign for launching GPU subflows without creating the Pending/deadline stormarmin
07/20/2026, 4:37 PMMarvin
07/20/2026, 4:37 PMMarvin
07/20/2026, 4:37 PMRunning flow runs, check whether their corresponding K8s Job still exists, and mark missing/failed ones as Crashed.
Assumptions
- Self-hosted Prefect 3.x
- Kubernetes work pool creates Jobs whose names include the flow run ID
- You can reach:
- Prefect API via PREFECT_API_URL
- Kubernetes API via in-cluster config or local kubeconfig
- You want a safe script with --dry-run first
What it does
- reads Running flow runs older than some age
- optionally filters by deployment / work pool name
- tries to map each flow run to a K8s Job by flow run ID
- if Job is:
- Failed → mark run Crashed
- Missing → optionally mark run Crashed
- Active / Pending / Running → leave alone
Because Prefect/K8s naming can vary a bit, I made the K8s lookup use the flow run ID substring in Job name. That’s usually the most robust recovery approach.
```
import argparse
import asyncio
import os
from datetime import datetime, timedelta, timezone
from typing import Optional
from kubernetes import client as k8s_client
from kubernetes import config as k8s_config
from kubernetes.client import BatchV1Api
from kubernetes.client.rest import ApiException
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import (
FlowRunFilter,
FlowRunFilterState,
FlowRunFilterStateName,
FlowRunFilterStartTime,
DeploymentFilter,
DeploymentFilterName,
WorkPoolFilter,
WorkPoolFilterName,
)
from prefect.client.schemas.sorting import FlowRunSort
from prefect.states import Crashed
from prefect.exceptions import Abort
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def state_name(state) -> str:
if not state:
return "UNKNOWN"
return getattr(state, "name", None) or getattr(state, "type", None) or "UNKNOWN"
def load_kube():
try:
k8s_config.load_incluster_config()
print("Loaded in-cluster Kubernetes config")
except Exception:
k8s_config.load_kube_config()
print("Loaded local kubeconfig")
def job_status_summary(job) -> str:
status = job.status
if status is None:
return "unknown"
if getattr(status, "succeeded", 0):
return "succeeded"
if getattr(status, "failed", 0):
return "failed"
if getattr(status, "active", 0):
return "active"
conditions = getattr(status, "conditions", None) or []
for cond in conditions:
if cond.type == "Failed" and cond.status == "True":
return "failed"
if cond.type == "Complete" and cond.status == "True":
return "succeeded"
return "unknown"
def is_job_failed(job) -> bool:
status = job.status
if status is None:
return False
if getattr(status, "failed", 0):
return True
conditions = getattr(status, "conditions", None) or []
for cond in conditions:
if cond.type == "Failed" and cond.status == "True":
return True
return False
def is_job_active(job) -> bool:
status = job.status
if status is None:
return False
return bool(getattr(status, "active", 0))
def is_job_succeeded(job) -> bool:
status = job.status
if status is None:
return False
if getattr(status, "succeeded", 0):
return True
conditions = getattr(status, "conditions", None) or []
for cond in conditions:
if cond.type == "Complete" and cond.status == "True":
return True
return False
def find_jobs_for_flow_run(batch_api: BatchV1Api, namespace: str, flow_run_id: str):
jobs = []
cont = None
while True:
resp = batch_api.list_namespaced_job(
namespace=namespace,
limit=200,
_continue=cont,
)
for job in resp.items:
name = job.metadata.name or ""
if flow_run_id in name:
jobs.append(job)Marvin
07/20/2026, 4:37 PMMarvin
07/20/2026, 4:37 PM*Install deps*
pip install "prefect==3.6.22" kubernetes
*Example usage*
*Dry run first:*
export PREFECT_API_URL="http://your-prefect-api:4200/api"
python reconcile_prefect_k8s_runs.py \
--namespace your-jobs-namespace \
--min-running-minutes 90 \
--work-pool-name kubernetes-pool \
--mark-missing-as-crashed
*Actually apply:*
export PREFECT_API_URL="http://your-prefect-api:4200/api"
python reconcile_prefect_k8s_runs.py \
--namespace your-jobs-namespace \
--min-running-minutes 90 \
--work-pool-name kubernetes-pool \
--mark-missing-as-crashed \
--apply
```
Notes / caveats
1. Force mode
I used force=True when setting the state so the reconciler can clean up runs even if orchestration would otherwise reject the transition.
2. Missing Job behavior
--mark-missing-as-crashed is useful in your scenario because the Job may already be deleted after failure. But I recommend:
- use it only with a sufficiently high --min-running-minutes
- and ideally filter to the affected deployment/work pool
3. Name matchingMarvin
07/20/2026, 4:37 PMfind_jobs_for_flow_run currently scans Jobs in the namespace. That’s okay for incident cleanup, but not ideal for very large namespaces. If needed, I can give you a more efficient version that:
- narrows by label selector, or
- uses Job annotations if Prefect sets the flow run ID there in your setup
5. Failed vs Crashed
I used Crashed because this is infrastructure-observer reconciliation, not application exception reporting. That usually fits best.
If you want, I can provide a v2 of this script that:
- matches Jobs via labels/annotations instead of name substring
- deletes stale K8s Jobs too
- emits a CSV/JSON report
- runs as a CronJob in-cluster on EKSarmin
07/20/2026, 4:43 PMMarvin
07/20/2026, 4:43 PMMarvin
07/20/2026, 4:46 PMRunning but stops heartbeating. It is useful, but it is not a perfect replacement for the Kubernetes Jobs observer:
- If the flow process never starts heartbeating, this automation won’t fire.
- If the observer dies after the run has been heartbeating, this automation can clean up the stuck Running run later.
So in your situation, I’d view it as a good safety net, not the primary fix.
What to create
You can create an automation that:
- watches for prefect.flow-run.heartbeat
- expects continued prefect.flow-run.* events for each flow run
- if no heartbeat arrives within a window, marks the flow run CRASHED
Example:
from datetime import timedelta
from prefect.automations import Automation
from prefect.client.schemas.objects import StateType
from prefect.events.actions import ChangeFlowRunState
from prefect.events.schemas.automations import EventTrigger, Posture
from prefect.events.schemas.events import ResourceSpecification
automation = Automation(
name="Crash zombie flow runs",
trigger=EventTrigger(
after={"prefect.flow-run.heartbeat"},
expect={"prefect.flow-run.*"},
match=ResourceSpecification(
{"prefect.resource.id": ["prefect.flow-run.*"]}
),
for_each={"prefect.resource.id"},
posture=Posture.Proactive,
threshold=1,
within=timedelta(minutes=9),
),
actions=[
ChangeFlowRunState(
state=StateType.CRASHED,
message="Flow run marked as crashed due to missing heartbeats.",
)
],
)
if __name__ == "__main__":
automation.create()
How to use it
1. Ensure your self-hosted server is running automation services.
2. Run the script in an environment pointed at your Prefect API:
export PREFECT_API_URL="<http://your-prefect-api:4200/api>"
python create_zombie_automation.py
Why within=9 minutes?
Prefect flow heartbeats are typically every 180 seconds, so 9 minutes gives tolerance for about 3 missed heartbeats. That’s the same general pattern described in the docs.
Docs
- Zombie flow detection: Detect zombie flows
- Automations: Automations
For self-hosted: confirm services are running
Your server needs the background services that evaluate automations. If you split server components, make sure prefect server services start is running somewhere.
If you want, I can also give you:
1. a YAML automation definition instead of Python, or
2. a version tuned for your EKS workload with a longer timeout like 15–20 minutes.Nate
07/20/2026, 5:27 PMNate
07/20/2026, 5:28 PMarmin
07/20/2026, 5:53 PMAbort from _mark_flow_run_as_crashed → kopf kills the Jobs watcher). Opened an issue github.com/PrefectHQ/prefect/issues/22553Nate
07/20/2026, 5:58 PMFélix Pelletier
07/29/2026, 5:24 PM{
"type": "event",
"match": {
"prefect.resource.id": "prefect.flow-run.*"
},
"match_related": {
"prefect.resource.id": [
"prefect.flow.5dec07f4-f468-4cdf-99a3-5016aa05c383",
"prefect.flow.acdba04f-fc39-4e67-990b-b9ff25947e24",
"prefect.flow.baebc35a-bcaf-47ab-9819-a80f873d88b5",
"prefect.flow.400db30b-8136-48b0-93bf-f391bded4377",
"prefect.flow.d0b6d613-82d4-42a4-b2c0-113b186d817b",
"prefect.flow.ee31395e-3769-4db5-a72c-6fe6a0470e1a",
"prefect.flow.50bc3efe-2c90-4d06-b82b-08db0faf1a8b",
"prefect.flow.b992f437-56c4-4644-90a8-4ef6305993c4",
"prefect.flow.293b63ca-651d-4e8a-b518-9c63d124bf5a"
],
"prefect.resource.role": "flow"
},
"after": [
"prefect.flow-run.heartbeat"
],
"expect": [
"prefect.flow-run.*"
],
"for_each": [
"prefect.resource.id"
],
"posture": "Proactive",
"threshold": 1,
"within": 600
}
For some reason, a minute or two after the flow run completes successfully, I can see the flow still triggers, which I would have not expected.
I can see a prefect.flow-run.Completed event for one flow at 124950 EST and the automation still triggered at 125807 EST for that particular flow.
What am I doing wrong?Marvin
07/29/2026, 5:24 PMMarvin
07/29/2026, 5:27 PMCompleted event
Your trigger is:
after: ["prefect.flow-run.heartbeat"]
expect: ["prefect.flow-run.*"]
posture: "Proactive"
threshold: 1
within: 600
for_each: ["prefect.resource.id"]
That means:
- a heartbeat starts the evaluation window
- Prefect then expects at least 1 matching event within 600s
- because posture is Proactive, it fires when not enough expected events are seen
In theory, prefect.flow-run.Completed should count as an expected event because it matches prefect.flow-run.*.
So if you still see the automation fire ~8–10 minutes later after completion, that matches a bug that has affected this pattern. The practical symptom is exactly what you saw: a run completes successfully, then the zombie automation still triggers later.
Recommended fixes
1. Upgrade Prefect if possible
You’re on 3.6.22, and newer releases include automation fixes. I’d strongly recommend testing on the latest 3.x first:
Prefect releases
2. Use explicit terminal events instead of only the wildcard
Even though the wildcard should work, I’d change the automation to explicitly include terminal flow-run events:
{
"type": "event",
"match": {
"prefect.resource.id": "prefect.flow-run.*"
},
"match_related": {
"prefect.resource.id": [
"prefect.flow.5dec07f4-f468-4cdf-99a3-5016aa05c383",
"prefect.flow.acdba04f-fc39-4e67-990b-b9ff25947e24",
"prefect.flow.baebc35a-bcaf-47ab-9819-a80f873d88b5",
"prefect.flow.400db30b-8136-48b0-93bf-f391bded4377",
"prefect.flow.d0b6d613-82d4-42a4-b2c0-113b186d817b",
"prefect.flow.ee31395e-3769-4db5-a72c-6fe6a0470e1a",
"prefect.flow.50bc3efe-2c90-4d06-b82b-08db0faf1a8b",
"prefect.flow.b992f437-56c4-4644-90a8-4ef6305993c4",
"prefect.flow.293b63ca-651d-4e8a-b518-9c63d124bf5a"
],
"prefect.resource.role": "flow"
},
"after": [
"prefect.flow-run.heartbeat"
],
"expect": [
"prefect.flow-run.heartbeat",
"prefect.flow-run.Completed",
"prefect.flow-run.Failed",
"prefect.flow-run.Cancelled",
"prefect.flow-run.Crashed"
],
"for_each": [
"prefect.resource.id"
],
"posture": "Proactive",
"threshold": 1,
"within": 600
}
That makes the intent much clearer:
- heartbeat starts the timer
- another heartbeat or a terminal state satisfies it
- only true silence should trigger
3. Consider a slightly shorter or longer window based on heartbeat frequency
If your heartbeat frequency is 180s, then:
- within: 600 is okay
- within: 540 is a clean “3 missed heartbeats” rule
Important nuance
A proactive trigger does not automatically “shut off” just because a run completed. It only knows whether the bucket of expected events was satisfied according to trigger logic. That’s why explicit terminal events are helpful, and why this bug is so noticeable with heartbeat-based detection.
Docs
- Zombie flow detection: Detect zombie flows
- Automations concepts: Automations
My concrete recommendation for you
- Update the automation to list terminal events explicitly
- If possible, upgrade Prefect to latest 3.x
- Test with one short-lived flow and verify that no automation fires ~10 minutes after Completed
If you want, I can also rewrite your JSON into:
- a full automation payload for API/CLI use, or
- a Python Automation(...) definition you can apply directly.Félix Pelletier
07/29/2026, 5:30 PMFélix Pelletier
07/29/2026, 5:34 PMMarvin
07/29/2026, 5:35 PMMarvin
07/29/2026, 5:35 PMCompleted event and the later automation firing
- your Prefect version / Cloud workspace context
- a note that this appears related to proactive zombie-flow automation firing after terminal completion
If you want, I can help you turn what you’ve gathered into a concise bug report.