Raymond Lin
12/08/2025, 7:54 PMMarvin
12/08/2025, 7:54 PMMarvin
12/08/2025, 8:00 PMfrom prefect import flow
import time
@flow(timeout_seconds=5)
def parent():
state = child(return_state=True) # waits for child
return state
@flow(timeout_seconds=30)
def child():
time.sleep(20)
return "done"
# Result:
# - parent: Failed (TimedOut) after ~5s
# - child: Continues and completes after ~20s
If you need the parent timing out to also stop children, you’ll need to explicitly cancel those child flow runs (e.g., via the UI/API). Let me know your desired behavior and I can suggest a pattern.Raymond Lin
12/08/2025, 8:06 PMMarvin
12/08/2025, 8:06 PMMarvin
12/08/2025, 8:08 PMtimeout_seconds)
- prefect.flows API
- Task decorator (shows timeout_seconds)
- prefect.tasks API
- Timeout utilities used by the engines
- prefect.utilities.timeout
Source code (Prefect 3.x)
- Flow timeouts are enforced locally in the flow engine via a timeout context; on timeout, the flow is marked TimedOut — no blanket cancellation is performed.
- Flow run context and timeout handling
- src/prefect/flow_engine.py
- Look for:
- run_context(...) which wraps execution in `timeout()`/`timeout_async()`
- handle_timeout(...) which sets state to Failed(name="TimedOut")
- cancel_all_tasks(...) exists, but note it is NOT called from handle_timeout
- Task timeouts are enforced locally in the task engine and are retryable; each retry attempt gets a fresh timeout window.
- Task timeout and retry interaction
- src/prefect/task_engine.py
- Look for:
- handle_timeout(...) calling handle_retry(...) when a task times out
- Cancellation propagation: the engine calls cancel_all_tasks() on external termination signals, not on timeouts.
- External termination handling (where cancel_all_tasks() is used)
- src/prefect/flow_engine.py
- Look for a try/except catching TerminationSignal and then calling cancel_all_tasks()
- Termination signal definition
- src/prefect/exceptions.py (search for class TerminationSignal)
- Task runner cancellation implementation (what cancel_all() actually does)
- src/prefect/task_runners.py (search for cancel_all in thread/process task runners)
- Timeout utilities used by the engines (local enforcement, not server-side)
- src/prefect/utilities/timeout.py
- Cancellation scopes used under the hood
- src/prefect/_internal/concurrency/cancellation.py
How these prove the behavior you asked about
- Parent vs child (subflow) timeouts are independent:
- Each flow run is wrapped in its own timeout context (see run_context in flow_engine.py), so the parent’s timeout does not propagate into the child’s execution.
- When a parent flow times out:
- handle_timeout(...) marks the parent as Failed(TimedOut). There’s no call to cancel_all_tasks() here, and there’s no logic to cancel subflow runs. Subflows continue to run independently.
- When a child times out:
- The child’s engine marks it Failed(TimedOut). The parent just receives that state and can continue logic accordingly.
- Retries + timeouts (per-attempt):Marvin
12/08/2025, 8:08 PMtask_engine.py, handle_timeout(...) invokes handle_retry(...), confirming timeouts are retryable and each retry gets its own timeout window.
If you want, I can paste the exact snippets from those files showing handle_timeout, the TerminationSignal catch, and the cancel_all_tasks usage.Raymond Lin
12/08/2025, 8:09 PMExecution was cancelled by the runtime environment.
When does this happen and why? Please provide docs/evidenceMarvin
12/08/2025, 8:09 PMMarvin
12/08/2025, 8:11 PMexception_to_crashed_state sets:
- “Execution was cancelled by the runtime environment.” when isinstance(exc, anyio.get_cancelled_exc_class())
- Link: src/prefect/states.py
- src/prefect/_states.py → sync equivalent exception_to_crashed_state_sync
- Link: src/prefect/_states.py
- Where it’s used (flows)
- Flow engines catch BaseException and call handle_crash(...), which uses the above converter and sets a Crashed state
- Sync flow engine: src/prefect/flow_engine.py (see try/except around execution; handle_crash path)
- Async flow engine: same file, async section; uses CancelScope(shield=True) to ensure the crash state is sent to the API even during cancellation
- Where it’s used (tasks)
- Task engines do the same for BaseException: call handle_crash(...) which uses the same converter
- src/prefect/task_engine.py
- Test that proves it
- Cancelling an anyio task group running a flow produces this exact message in the Crashed state:
- tests/public/flows/test_flow_crashes.py → test_anyio_cancellation_crashes_flow
- Link: tests/public/flows/test_flow_crashes.py
When does it happen in practice?
- Worker or infrastructure cancels your run’s async scope (e.g., Kubernetes pod eviction, Docker container stop, worker shutdown)
- An anyio/asyncio cancellation (e.g., CancelScope.cancel() or asyncio.CancelledError) propagates out of user code
- Prefect catches that cancellation, treats it as an infrastructure-level interruption, and sets the run to Crashed with that message
Related docs
- States and “Crashed” definition (terminal, infrastructure issue)
- States concept
- Notes indicate CRASHED is typically due to infra issues like OOM, evicted pod, termination signals, etc.
If you share which runtime you’re on (Kubernetes, Docker, ECS, etc.), I can point to the most common cancellation sources and how to prevent them.Raymond Lin
12/08/2025, 8:12 PMMarvin
12/08/2025, 8:12 PMMarvin
12/08/2025, 8:13 PMhandle_timeout() sets a TimedOut state and does not call `cancel_all_tasks()`:
- flow timeout handling: src/prefect/flow_engine.py (see the timeout try/except and handle_timeout)
- no task cancellation on timeout: same file; cancel_all_tasks() is not called from handle_timeout
2) Parent flow is cancelled by the runtime/infrastructure (e.g., worker shuts down, pod is terminated)
- Behavior: An async cancellation (e.g., asyncio.CancelledError) propagates. The engine converts this to a Crashed state with the message “Execution was cancelled by the runtime environment.”
- Child tasks may also be cancelled if they share the same async cancellation scope (common for async tasks). Thread/process pool tasks may not be interrupted mid-flight, but pending ones can be cancelled.
- Evidence for the message and when it’s emitted:
- Message string and condition (anyio cancellation class) → “Execution was cancelled by the runtime environment.”:
- async: src/prefect/states.py (exception_to_crashed_state)
- sync: src/prefect/_states.py (exception_to_crashed_state_sync)
- Flow engine crash path (catches BaseException outside user code and sets Crashed):
- src/prefect/flow_engine.py (see try/except that calls `handle_crash(...)`; async uses CancelScope(shield=True) to ensure state is set)
- Task engine crash path:
- src/prefect/task_engine.py (BaseException → handle_crash(...))
- Test proving cancellation produces this exact message:
- tests/public/flows/test_flow_crashes.py (test_anyio_cancellation_crashes_flow)
- States docs: CRASHED is a terminal state for infrastructure interruptions:
- States concept
Extra nuance: A termination signal (e.g., SIGTERM) path explicitly calls cancel_all_tasks() to try to cancel in-flight tasks:
- See the TerminationSignal catch and cancel_all_tasks() usage in the flow engine:
- src/prefect/flow_engine.py (look for except TerminationSignal calling cancel_all_tasks())
- Thread/process task runners best-effort cancel pending futures; running threads/processes may continue until the process terminates:
- src/prefect/task_runners.py (search for cancel_all)
If you meant a different scenario (e.g., “when the parent flow is explicitly cancelled via API/UI”), say the word and I’ll tailor the behavior and code pointers for that path too.Raymond Lin
12/08/2025, 8:14 PMMarvin
12/08/2025, 8:14 PMMarvin
12/08/2025, 8:17 PMfrom uuid import UUID
from prefect.client.orchestration import get_client
from prefect.client.schemas.objects import StateType
async def show_states(flow_run_id: str):
async with get_client() as client:
states = await client.read_flow_run_states(UUID(flow_run_id))
for s in states:
print(s.timestamp, s.type.name, s.name, s.message)
- Evidence that Prefect generates the “Execution was cancelled by the runtime environment.” message on cancellation:
- State conversion code: src/prefect/states.py and src/prefect/_states.py
- The engines call this on BaseException cancellations:
- Flow engine: src/prefect/flow_engine.py
- Task engine: src/prefect/task_engine.py
- Test proving cancellation produces that exact message: tests/public/flows/test_flow_crashes.py (see test_anyio_cancellation_crashes_flow)
- States docs (CRASHED = infra interruption): States concept
2) Identify who/what initiated the cancellation
- Use the Events API to find the actor (user, automation, worker) and the event sequence around the cancellation.
from uuid import UUID
from prefect.client.orchestration import get_client
from prefect.events.filters import EventFilter, EventResourceFilter, EventIDFilter, EventNameFilter
async def show_cancellation_events(flow_run_id: str):
async with get_client() as client:
f = EventFilter(
resource=EventResourceFilter(
id=EventIDFilter(id=[f"prefect.flow-run.{flow_run_id}"])
),
event=EventNameFilter(name=["prefect.flow-run-state-changed"]),
)
page = await client.read_events(filter=f, limit=100)
for ev in page.events:
print(ev.occurred, ev.event)
print("resource:", ev.resource)
print("payload:", ev.payload)
for rel in ev.related or []:
print("related:", rel)
- Look for related resources indicating a user, automation, or worker as the source.
3) Correlate with worker and infrastructure logs
- Worker logs around the timestamp: look for messages indicating shutdown, stop signals, or job cancellation.
- Infrastructure:
- Kubernetes: kubectl describe pod <pod> (check Events for eviction, preemption, node drain), OOMKilled, terminationGracePeriod.
- Docker: docker logs, container stop/kill events.
- ECS/Batch/etc.: service stop reasons, scaling events.
- Common real causes: scaling down workers, pod eviction/preemption, OOM kill, node drain, explicit cancel via UI/automation.
4) Turn on more verbosity to capture breadcrumbs
- Prefect debug logging:
prefect config set logging.level=DEBUG
# or env var:
# PREFECT_LOGGING_LEVEL=DEBUG
- asyncio debug:
# In your environment
PYTHONASYNCIODEBUG=1
- Add breadcrumbs in your flow/task:
```
import anyio, asyncio, logging
from prefect import flow, task
logger = logging.getLogger(name)
@task
async def work():
try:
logger.info("task starting")Marvin
12/08/2025, 8:17 PManyio.get_cancelled_exc_class() and converts it into a Crashed state with the message “Execution was cancelled by the runtime environment.”
- Evidence: states.py and test at test_flow_crashes.py
If you can share which infrastructure you’re running on (K8s/Docker/ECS/etc.) and a flow run ID/timestamp window, I can suggest the most likely root causes and where to look next.Raymond Lin
12/08/2025, 8:34 PMMarvin
12/08/2025, 8:34 PMMarvin
12/08/2025, 8:36 PManyio.get_cancelled_exc_class() and sets that exact message)
- Engines convert this into a Crashed state:
- Flow engine: flow_engine.py (BaseException → handle_crash(...))
- Task engine: task_engine.py (BaseException → handle_crash(...))
- Test proving cancellation yields that message: tests/public/flows/test_flow_crashes.py
- State types and meaning (CRASHED = infra interruption): States concept
- ECS service/task events and CloudWatch logs
- In the ECS console, open the service and the specific task:
- Check stoppedReason, lastStatus/desiredStatus, deployment events, and container exit codes
- Common causes: rolling deployment replacing tasks, health check failures, memory/CPU pressure, ALB target health flapping, Fargate spot interruptions
Prefect-side debugging steps
1) Inspect state history and messages for the run
from uuid import UUID
from prefect.client.orchestration import get_client
async def show_states(flow_run_id: str):
async with get_client() as client:
states = await client.read_flow_run_states(UUID(flow_run_id))
for s in states:
print(s.timestamp, s.type.name, s.name, s.message)
2) See who/what initiated the state change (events API)
from uuid import UUID
from prefect.client.orchestration import get_client
from prefect.events.filters import EventFilter, EventResourceFilter, EventIDFilter, EventNameFilter
async def show_cancellation_events(flow_run_id: str):
async with get_client() as client:
f = EventFilter(
resource=EventResourceFilter(id=EventIDFilter(id=[f"prefect.flow-run.{flow_run_id}"])),
event=EventNameFilter(name=["prefect.flow-run-state-changed"]),
)
page = await client.read_events(filter=f, limit=100)
for ev in page.events:
print(ev.occurred, ev.event)
print("resource:", ev.resource)
print("payload:", ev.payload)
for rel in ev.related or []:
print("related:", rel)
3) Add targeted logging to catch cancellations in your code
import asyncio
from prefect import flow, task
from prefect import get_run_logger
@task
async def long_task():
log = get_run_logger()
try:
<http://log.info|log.info>("task starting")
await asyncio.sleep(600)
<http://log.info|log.info>("task finished")
except asyncio.CancelledError:
log.exception("task received CancelledError")
raise
@flow
async def served_flow():
try:
await long_task()
except asyncio.CancelledError:
get_run_logger().exception("flow received CancelledError")
raise
ECS-specific checklist to stop the churn
- Rolling deployments/updates
- Confirm the service isn’t constantly redeploying (task definition changes, image tags, autoscaling events)Marvin
12/08/2025, 8:36 PMPREFECT_RUNNER_SERVER_ENABLE=true, port 8080 exposed
- Docs: Serve flows in a long‑lived container
- Flapping health checks cause ECS to kill and replace tasks
- Resource constraints
- Check CloudWatch/container stats for CPU/memory; OOM or throttling often precede termination
- For Fargate, also check ephemeral storage limits
- Spot interruptions / service scaling down
- If using Fargate Spot or aggressive scaling, tasks may be stopped; you’ll see this in the task stoppedReason
- Shutdown grace period
- ECS stopTimeout (task definition) controls how long the container has to shut down gracefully; increasing it can let Prefect finish state updates/logging during shutdown
Relevant Prefect docs for your setup
- ECS worker overview and deployment (even if you’re using serve, these pages cover ECS-specific patterns and troubleshooting)
- ECS worker overview
- Manual ECS worker deployment
- Serve flows in long-lived containers (health endpoint, platform notes including ECS)
- Serve flows in long‑lived Docker container
- Worker concepts and healthchecks (useful for long-running services)
- Workers concept
- Worker healthchecks
Why this produces asyncio CancelledError (evidence)
- AnyIO cancellation is converted by Prefect into a Crashed state with message “Execution was cancelled by the runtime environment.”
- Source: states.py, _states.py
- Engines catch BaseException and call `handle_crash(...)`:
- flow_engine.py, task_engine.py
- Verified by tests: test_flow_crashes.py
If you can share an example task ARN or flow run ID + timestamp window, I can help correlate the ECS stoppedReason with the Prefect run state/event timeline and suggest a concrete fix (e.g., health check config, deployment settings, or resource tuning).