Carlos Alberto da Costa Filho
04/06/2026, 8:44 PMMarvin
04/06/2026, 8:44 PMMarvin
04/06/2026, 8:48 PMfrom prefect import task
@task(timeout_seconds=60, retries=2, retry_delay_seconds=10)
def do_work(...):
...
- This is the simplest way to ensure a stuck Ray task doesn’t run forever. See “Tasks > Timeouts” in the docs: Prefect docs.
2) If you’re using prefect-ray, time-bound result collection and cancel the Ray task
- Install and use the Ray task runner:
pip install "prefect[ray]" # or: pip install prefect-ray
- In your flow, collect results with a timeout and explicitly cancel the Ray task on timeout/crash:
import ray
from prefect import flow, task
from prefect_ray import RayTaskRunner
@task(timeout_seconds=60, retries=2, retry_delay_seconds=10)
def do_work(x):
# your Ray-executed work
return x * 2
@flow(task_runner=RayTaskRunner())
def my_flow(items: list[int]):
futs = [do_work.submit(i) for i in items]
results = []
for f in futs:
try:
# Don’t block forever on a crashed worker
res = f.result(timeout=75, raise_on_failure=True)
results.append(res)
except TimeoutError:
# Stop waiting and request cancellation on the Ray side
try:
ray.cancel(getattr(f, "wrapped_future", None), force=True)
except Exception:
pass
results.append(None)
except Exception:
# Ray worker/node failures surface as exceptions; you can choose to retry
results.append(None)
return results
Notes:
- f.result(timeout=...) prevents your flow from waiting forever.
- ray.cancel(...) requests cancellation of the underlying Ray task; it’s not automatic, so call it if you want to stop any lingering work on the cluster.
- In prefect-ray, the underlying Ray ObjectRef is typically available via future.wrapped_future.
3) Retry only on Ray-crash exceptions (optional)
- You can make retries conditional on Ray-specific failures:
from prefect import task
def retry_on_ray_crash(exc: BaseException) -> bool:
try:
import ray
crash_types = (
ray.exceptions.WorkerCrashedError,
ray.exceptions.RayActorError,
getattr(ray.exceptions, "ObjectRefLostError", Exception),
)
return isinstance(exc, crash_types)
except Exception:
return False
@task(retries=3, retry_delay_seconds=10, retry_condition_fn=retry_on_ray_crash)
def do_work(...):
...
4) Defense in depth: use library-native timeouts inside the task
- For blocking I/O, set timeouts on the libraries you call (e.g., requests/httpx DB clients) so the Ray-side work can exit quickly even if the worker is under stress.
References
- Ray integration package: prefect-ray
- Main Prefect docs: Prefect docs (see Integrations > Ray and Tasks > Timeouts)
If you can share:
- Your Prefect and Ray versions
- Whether you’re using `prefect-ray`’s RayTaskRunner
- A minimal snippet of how you submit and wait for the task
…I can tailor the example exactly to your setup.Carlos Alberto da Costa Filho
04/06/2026, 8:58 PM-d81 starts: clip 01-008-15JVGPWG frames 50000–100000
193629
Driver pod receives SIGTERM: "Rescheduling flow run for resubmission in response to SIGTERM"
193630
Prefect runner reschedules flow for resubmission
193632
KubernetesWorker resubmits flow (new pod iota308-sculptoris-l427f-h5q7c)
193849
New flow checks S3 → finds 17/18 files done, 1 unprocessed
193908
New task -a6d starts for the same clip (frames 50000–100000)
195119
Task -a6d COMPLETED successfully
195122
Flow COMPLETED
What happened: The Kubernetes driver pod (not the Ray worker) was killed with SIGTERM — most likely a node drain, spot interruption, or eviction. When the driver died, its Ray job context was torn down, which force-killed the worker running task -d81 (the "Worker force exited because its job has finished" error). Prefect's runner gracefully caught the SIGTERM and rescheduled the entire flow. The new flow run checked S3, found the one missing output, and resubmitted it successfully.
The orphaned task: Task -d81 (20254874-552c-4395-8b0c-287ab7d1414c) is permanently stuck in RUNNING because the original driver process died before it could transition the task to a terminal state. The new flow created a new task run (-a6d) instead, which completed fine.
How to Fix the Orphaned Task
The flow itself is COMPLETED and all data is in S3. You just need to manually mark the orphaned task run. Run this against your Prefect API:
# Using the Prefect REST API directly
curl -X POST "https://my-prefect.com/api/task_runs/20254874-552c-4395-8b0c-287ab7d1414c/set_state" \
-H "Content-Type: application/json" \
-d '{"state": {"type": "CRASHED", "message": "Driver pod received SIGTERM; task orphaned during flow resubmission"}, "force": true}'Marvin
04/06/2026, 8:58 PMMarvin
04/06/2026, 8:58 PMCarlos Alberto da Costa Filho
04/06/2026, 8:58 PM-d81 starts: clip 01-008-15JVGPWG frames 50000–100000
193629
Driver pod receives SIGTERM: "Rescheduling flow run for resubmission in response to SIGTERM"
193630
Prefect runner reschedules flow for resubmission
193632
KubernetesWorker resubmits flow (new pod iota308-sculptoris-l427f-h5q7c)
193849
New flow checks S3 → finds 17/18 files done, 1 unprocessed
193908
New task -a6d starts for the same clip (frames 50000–100000)
195119
Task -a6d COMPLETED successfully
195122
Flow COMPLETED
What happened: The Kubernetes driver pod (not the Ray worker) was killed with SIGTERM — most likely a node drain, spot interruption, or eviction. When the driver died, its Ray job context was torn down, which force-killed the worker running task -d81 (the "Worker force exited because its job has finished" error). Prefect's runner gracefully caught the SIGTERM and rescheduled the entire flow. The new flow run checked S3, found the one missing output, and resubmitted it successfully.
The orphaned task: Task -d81 (20254874-552c-4395-8b0c-287ab7d1414c) is permanently stuck in RUNNING because the original driver process died before it could transition the task to a terminal state. The new flow created a new task run (-a6d) instead, which completed fine.
How to Fix the Orphaned Task
The flow itself is COMPLETED and all data is in S3. You just need to manually mark the orphaned task run. Run this against your Prefect API:
# Using the Prefect REST API directly
curl -X POST "https://my-prefect.com/api/task_runs/20254874-552c-4395-8b0c-287ab7d1414c/set_state" \
-H "Content-Type: application/json" \
-d '{"state": {"type": "CRASHED", "message": "Driver pod received SIGTERM; task orphaned during flow resubmission"}, "force": true}'Marvin
04/06/2026, 8:58 PMMarvin
04/06/2026, 9:02 PMfrom prefect import flow, task
import boto3, json, os
S3_BUCKET = os.getenv("S3_BUCKET", "my-bucket")
S3_PREFIX = "clips" # e.g., clips/{clip_id}/{start}-{end}/done.json
s3 = boto3.client("s3")
def done_key(clip_id: str, start: int, end: int) -> str:
return f"{S3_PREFIX}/{clip_id}/{start}-{end}/done.json"
def is_done(clip_id: str, start: int, end: int) -> bool:
try:
s3.head_object(Bucket=S3_BUCKET, Key=done_key(clip_id, start, end))
return True
except s3.exceptions.ClientError:
return False
def write_done(clip_id: str, start: int, end: int, meta: dict):
# Atomic-ish: write to tmp then copy to final key (S3 has no real rename)
tmp_key = f"{S3_PREFIX}/{clip_id}/{start}-{end}/.tmp-{os.urandom(4).hex()}.json"
body = json.dumps({"status": "done", "meta": meta}).encode()
s3.put_object(Bucket=S3_BUCKET, Key=tmp_key, Body=body)
s3.copy_object(Bucket=S3_BUCKET, CopySource={"Bucket": S3_BUCKET, "Key": tmp_key}, Key=done_key(clip_id, start, end))
s3.delete_object(Bucket=S3_BUCKET, Key=tmp_key)
@task(
# Cache + persistence make retries/resubmissions a no-op if already done
persist_result=True,
cache_expiration=None,
cache_policy="INPUTS", # cache by parameters
result_storage_key="clip-{parameters[clip_id]}-{parameters[start]}-{parameters[end]}"
)
def process_window(clip_id: str, start: int, end: int) -> dict:
if is_done(clip_id, start, end):
return {"skipped": True, "reason": "already-done-marker"}
# Do the work (Ray, GPU, etc.)
result_meta = run_heavy_clip_op(clip_id, start, end)
# Write outputs to S3 first, then mark done
write_outputs_to_s3(clip_id, start, end, result_meta)
write_done(clip_id, start, end, result_meta)
return {"skipped": False, "meta": result_meta}
@flow
def clip_job(clip_id: str, windows: list[tuple[int, int]]):
to_run = [(s, e) for (s, e) in windows if not is_done(clip_id, s, e)]
futs = [process_window.submit(clip_id, s, e) for (s, e) in to_run]
return [f.result() for f in futs]
2) Prefect caching + result persistence (defense in depth)
- Ensure persist_result=True and deterministic result_storage_key so resubmitted flows reuse results.
- For global defaults:
prefect config set PREFECT_RESULTS_PERSIST_BY_DEFAULT=true
# Optionally: set a default storage block for all results
# prefect config set PREFECT_DEFAULT_RESULT_STORAGE_BLOCK="s3-bucket/<your-block>"
3) Timeouts, retries, and Ray-aware retry conditions
- Add a hard cap so a hung submission never runs forever.Marvin
04/06/2026, 9:02 PMfrom prefect import task
from prefect.tasks import exponential_backoff
def retry_on_system_or_ray(exc: BaseException) -> bool:
msg = f"{type(exc).__name__}: {exc}"
ray_signals = ("ray.exceptions", "RayTaskError", "WorkerCrashed", "ObjectRefLost", "Task was cancelled")
kube_signals = ("SIGTERM", "Evicted", "PodUnschedulable", "DeadlineExceeded")
return any(s in msg for s in (*ray_signals, *kube_signals))
@task(
timeout_seconds=1800, # 30m cap per window
retries=5,
retry_delay_seconds=exponential_backoff(2), # 2,4,8,16,32s (jitter below)
retry_jitter_factor=0.5,
retry_condition_fn=retry_on_system_or_ray,
)
def process_window(...):
...
4) Bound result waits and cancel Ray work on timeout
If you’re using prefect-ray’s RayTaskRunner or managing Ray futures yourself, don’t block forever and cancel on timeout:
import ray
from prefect import flow, task
from prefect_ray import RayTaskRunner
@task(timeout_seconds=1800)
def process_window(...):
# Example if you’re calling ray directly:
@ray.remote
def do_work(...): ...
obj_ref = do_work.remote(...)
try:
return ray.get(obj_ref, timeout=1850) # Slightly > task timeout
except ray.exceptions.GetTimeoutError:
# Optional: request cancellation if you don't want the orphan to continue
try:
ray.cancel(obj_ref, force=True)
except Exception:
pass
raise # triggers Prefect retry per above
If you’re calling future.result() on Prefect futures from RayTaskRunner, use the timeout there too:
res = f.result(timeout=120, raise_on_failure=True)
5) Concurrency guardrails to prevent duplicate submissions
Tag your tasks and enforce a limit so that, even on a bursty resubmission, you never double-run the same clip/window at the same time.
- Add a tag to the task:
@task(tags=["clip-window"])
def process_window(...): ...
- Create a concurrency limit:
prefect concurrency-limit create clip-window --limit 50
6) Harden the Kubernetes driver pod
- Give it time to exit cleanly and flush progress:
- terminationGracePeriodSeconds: 30+
- preStop hook to stop Ray cleanly (if you init Ray in the pod):
- command: sleep 10 && ray stop --force
- Add a PodDisruptionBudget so drains don’t evict all drivers:
- minAvailable: 1 (or suitable for your fleet)
- Prefer nodes without frequent preemption for driver pods (avoid spot for the driver; run workers on spot if desired).
7) Automatic cleanup of orphaned RUNNING tasks
You did the right manual fix. To automate it, run a small periodic flow/cron that finds old RUNNING task runs and marks them CRASHED so dashboards and SLAs stay clean.
import asyncio
from datetime import datetime, timedelta, timezone
from prefect import flow
from prefect.client.orchestration import get_client
from prefect.states import Crashed
@flow
async def mark_orphans_crashed(max_age_minutes: int = 30):
cutoff = datetime.now(timezone.utc) - timedelta(minutes=max_age_minutes)
async with get_client() as client:
# Narrow this to the specific flow or tag if desired
task_runs = await client.read_task_runs(limit=1000)
to_mark = [tr for tr in task_runs if tr.state.type.value == "RUNNING" and (tr.start_time or cutoff) < cutoff]
for tr in to_mark:
await client.set_task_run_state(
tr.id,
Crashed(message="Orphaned after driver SIGTERM/resubmission"),
force=True,
)
return len(to_mark)
8) Consider decoupling the compute from the driver
If you can, submit work to a persistent Ray cluster (Ray Jobs/Serve) rather than tying the Ray job’s lifetime to the flow pod. Then a driver restart won’t kill compute. The tradeoff: you need explicit idempotency and bookkeeping since the compute outlives the flow pod.Marvin
04/06/2026, 9:02 PMtimeout_seconds + jittered retries; bound any `future.result()`/`ray.get()` waits and cancel on timeout.
- Tag + add a concurrency limit for clip-window tasks to prevent accidental double-submissions under resubmission.
- Add a small periodic cleanup flow to mark stale RUNNING tasks as CRASHED.
References
- Prefect docs (3.x): Prefect docs
- Ray integration: prefect-ray
If you can share a snippet of how you submit “clip frames 50000–100000” (with/without prefect-ray), I can tailor the code to your exact pattern (including the S3 key scheme and the best cache policy for your parameters).Nate
04/06/2026, 9:12 PMpersist_result=True + cache_policy=INPUTS on the task keyed on (clip_id, start, end) — resubmits skip done windows automatically, no manual S3 listing.
2. don't rely on @task(timeout_seconds=...) for ray hangs — it runs inside the ray worker. bound it on the driver: fut.result(timeout=...) + ray.cancel(fut.wrapped_future, force=True) on TimeoutError.1
3. for the orphan: an on_crashed / on_cancellation flow hook that pulls the current flow run id from context, lists its task runs, and force-CRASHES any still in RUNNING. runs in-process on the way out, no separate sweeper.Carlos Alberto da Costa Filho
04/06/2026, 9:57 PMNate
04/07/2026, 1:37 PM