Yu(Irene) Chen
02/26/2026, 2:18 PM@task(task_run_name="gen_daily_incremental_backup",
retries=1,
retry_delay_seconds=120,
timeout_seconds=21600)
def gen_daily_incremental_backup(bdate: dt.datetime) -> str:
"""Take a daily incremental backup of the database using xtrabackup.
Args:
bdate (datetime.datetime): The business date for the backup.
Returns:
str: The path to the incremental backup directory.
"""
task_logger = get_logger(__name__)
bdate_str = bdate.strftime("%Y%m%d")
inc_bak_dir = os.path.join(BACKUP_DIR, f"incbak_{bdate_str}")
# Remove pre-existing directory for this date
if os.path.exists(inc_bak_dir):
subprocess.run(["rm", "-rf", inc_bak_dir], check=False)
# find the base backup to do delta backup
base_dir = find_latest_backup_dir()
os.makedirs(inc_bak_dir, exist_ok=True)
if base_dir is None:
task_logger.error(f"No previous backup found for {bdate_str}")
send_slack_notification(f"No previous backup found for {bdate_str}.\n Please check.")
raise Exception(f"No previous backup found for {bdate_str}")
cmd = [
"xtrabackup",
"--backup",
"--compress",
f"--host={MYSQL_CONFIG['host']}",
f"--port={MYSQL_CONFIG['port']}",
f"--user={MYSQL_CONFIG['user']}",
f"--password={MYSQL_CONFIG['password']}",
f"--target-dir={inc_bak_dir}",
f"--incremental-basedir={base_dir}",
"--parallel=12",
"--rsync",
]
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
if result.returncode != 0:
error_line = result.stderr.splitlines()[-1]
task_logger.error(f"Error taking incremental backup: {error_line}")
send_slack_notification(f"Incremental backup failed for {bdate_str}.\n Error: {error_line}")
raise Exception(f"Incremental backup failed for {bdate_str}")
<http://task_logger.info|task_logger.info>(f"Incremental backup completed successfully for {bdate_str}")
return inc_bak_dirMarvin
02/26/2026, 2:18 PMMarvin
02/26/2026, 2:23 PMtimeout_seconds cannot interrupt a blocking system call like subprocess.run(). If xtrabackup hangs or stops making progress, the Python thread is still blocked in subprocess.run() so the UI continues to show the task as RUNNING until that call returns. The state only updates when the function returns or raises.
Why you’re seeing “stalled file time but task is RUNNING”
- subprocess.run() is a fully blocking call. In a worker thread, Prefect can’t preempt it to enforce timeout_seconds.
- If xtrabackup hangs, exits but leaves a child process, or blocks on I/O, the task thread remains blocked so the UI never receives a state change.
- If you’re on Windows, sync task timeouts can’t interrupt blocking calls at all; you must use native timeouts.
How to make this reliable
Pick one of these patterns to ensure timeouts actually terminate the process and the task state updates promptly.
1) Add a native subprocess timeout and kill the process group (most robust for CLI tools)
- Works regardless of task runner or OS.
- Ensures the entire xtrabackup tree is terminated if it stalls.
```
from prefect import task, get_run_logger
import datetime as dt
import os, signal, subprocess, shutil
@task(
task_run_name="gen_daily_incremental_backup",
retries=1,
retry_delay_seconds=120,
timeout_seconds=21600, # Prefect-level safety net
)
def gen_daily_incremental_backup(bdate: dt.datetime) -> str:
logger = get_run_logger()
bdate_str = bdate.strftime("%Y%m%d")
inc_bak_dir = os.path.join(BACKUP_DIR, f"incbak_{bdate_str}")
# Remove pre-existing directory safely
if os.path.exists(inc_bak_dir):
shutil.rmtree(inc_bak_dir, ignore_errors=True)
base_dir = find_latest_backup_dir()
os.makedirs(inc_bak_dir, exist_ok=True)
if base_dir is None:
msg = f"No previous backup found for {bdate_str}"
logger.error(msg)
send_slack_notification(f"{msg}.\nPlease check.")
raise RuntimeError(msg)
cmd = [
"xtrabackup",
"--backup",
"--compress",
f"--host={MYSQL_CONFIG['host']}",
f"--port={MYSQL_CONFIG['port']}",
f"--user={MYSQL_CONFIG['user']}",
f"--password={MYSQL_CONFIG['password']}",
f"--target-dir={inc_bak_dir}",
f"--incremental-basedir={base_dir}",
"--parallel=12",
"--rsync",
]
# Choose a subprocess timeout that is <= Prefect timeout_seconds
sb_timeout = 6 * 60 * 60 # 6h, adjust as needed
# Start in a new process group/session so we can terminate the whole tree on timeout
popen_kwargs = {}
if os.name == "nt":
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
else:
popen_kwargs["preexec_fn"] = os.setsid
try:
result = subprocess.run(
cmd,
stdout=subprocess.PIPE, # consider redirecting to a file to avoid huge memory
stderr=subprocess.PIPE,
text=True,
check=False,
timeout=sb_timeout,
**popen_kwargs,
)
except subprocess.TimeoutExpired as e:
# Kill the entire process group
if os.name == "nt":
# Send CTRL_BREAK to the group; fall back to terminate/kill if needed
try:
os.kill(e.pid, signal.CTRL_BREAK_EVENT) # may require tracking Popen
except Exception:
pass
else:
try:
# If we used setsid, kill the whole group
os.killpg(os.getpgid(e.pid), signal.SIGTERM) # may require tracking Popen
except Exception:
pass
msg = f"Incremental backup timed out for {bdate_str} after {sb_timeout}s"
logger.error(msg)Marvin
02/26/2026, 2:23 PMNotes:
- For strict group termination on timeout, you may prefer `Popen` + `communicate(timeout=...)` so you can reference `proc.pid` to send signals to the process group, then collect remaining output. Using `run()` hides the Popen instance; the pattern is similar but a few more lines.
- Consider writing output to files instead of capturing in-memory if `xtrabackup` is chatty.
2) Run the flow with a ProcessPoolTaskRunner
- Each task runs in its own process, so Prefect can reliably enforce `timeout_seconds` by terminating the task process.
- Minimal code change at the flow level:
from prefect import flow
from prefect.task_runners import ProcessPoolTaskRunner
@flow(name="backup-flow", task_runner=ProcessPoolTaskRunner())
def backup_flow(...):
gen_daily_incremental_backup.submit(...)
3) Make the task async and use Prefect’s async process helpers
- Async cancellation is cooperative and reliably cleans up subprocesses.
- Example with `prefect.utilities.processutils.run_process`:
from prefect import task
from prefect.utilities.processutils import run_process
@task(timeout_seconds=21600)
async def gen_daily_incremental_backup_async(...):
cmd = [...]
proc = await run_process(cmd, stream_output=True)
return_code = await proc.wait()
if return_code != 0:
raise RuntimeError("xtrabackup failed")
```
What I’d like to confirm to tailor the fix
- Which task runner are you using for this flow (default ThreadPoolTaskRunner vs ProcessPoolTaskRunner)?
- OS/platform (Linux vs Windows)?
- Are you running in Docker/Kubernetes?
- Do you see a log like “Timeout configured but running in a worker thread…” when the task starts?
Relevant docs
- Task runners overview: https://docs.prefect.io/latest/concepts/task-runners/
- Orchestration basics and timeouts: https://docs.prefect.io/latest/
If you share the runner/platform and how you’re deploying, I can suggest the smallest change for your setup. In most cases, adding a native timeout= to subprocess.run() (and killing the process group on timeout) or switching to ProcessPoolTaskRunner resolves the “UI shows RUNNING while the process stalled/exited” symptom.Yu(Irene) Chen
02/26/2026, 3:21 PMMarvin
02/26/2026, 3:21 PMMarvin
02/26/2026, 3:26 PMYu(Irene) Chen
02/26/2026, 3:34 PMMarvin
02/26/2026, 3:34 PMMarvin
02/26/2026, 3:35 PMsubprocess.run(...) invocation
- Pattern: stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
- Why: subprocess.run will read the entire stdout/stderr into memory. xtrabackup can be very chatty over long runs. Hours of logs decoded to text can easily balloon memory and get your flow-run container/pod/process OOMKilled. When the infra dies abruptly, Prefect doesn’t get a final state and the UI lingers at RUNNING until a later cleanup.
- What to change:
- Don’t capture to memory; write to files or stream.
- Example (write to files and keep a “last line” for error reporting):
out_path = os.path.join(inc_bak_dir, "xtrabackup.out")
err_path = os.path.join(inc_bak_dir, "xtrabackup.err")
with open(out_path, "w") as out, open(err_path, "w") as err:
proc = subprocess.Popen(
cmd,
stdout=out,
stderr=err,
text=True,
# optional: create a new process group so you can kill the whole tree if needed
preexec_fn=os.setsid if os.name != "nt" else None,
creationflags=(subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0),
)
ret = proc.wait()
if ret != 0:
# Read just the tail instead of whole file
error_line = "see xtrabackup.err (tail)"
raise RuntimeError(f"Incremental backup failed for {bdate_str}: {error_line}")
- Or stream+tail without holding everything:
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1
)
last_line = ""
for line in proc.stdout:
last_line = line.rstrip()
# optionally write to a rotating file here
ret = proc.wait()
if ret != 0:
raise RuntimeError(f"Incremental backup failed for {bdate_str}: {last_line}")
High memory pressure from xtrabackup itself
- Lines/flags: --compress with --parallel=12
- Why: Compression + parallelism can use significant CPU and memory. In constrained containers/VMs this can trigger OOMKills independent of Python’s pipes. Combine this with in-memory log capture and it’s even riskier.
- What to change:
- Reduce --parallel or tune xtrabackup memory options.
- Ensure the container/pod has sufficient memory limits/requests.
Less likely but worth fixing
- Using rm -rf via subprocess:
- Line: subprocess.run(["rm", "-rf", inc_bak_dir], check=False)
- Why: not the cause of infra disappearance, but brittle. Prefer shutil.rmtree(inc_bak_dir, ignore_errors=True) to avoid spawning extra processes and to keep behavior predictable.
- Fragile stderr parsing:
- Line: error_line = result.stderr.splitlines()[-1]
- Why: if stderr is empty, this raises IndexError. That will fail the task (which is okay), but not cause the infra to vanish. Still, make it safe:
lines = (result.stderr or "").splitlines()
error_line = lines[-1] if lines else "no stderr captured"
- Logger and Slack usage:
- Use get_run_logger() instead of get_logger(__name__) for Prefect 3.x; not related to infra death but gets you consistent task logs.
- send_slack_notification won’t “make infra disappear”; at worst it blocks if Slack is unreachable.
How to confirm this is OOM/output-capture related
- If you run in Kubernetes: kubectl describe pod <pod> and look for OOMKilled in the container’s last state.
- If Docker: docker inspect <container> or check the daemon logs for OOM kill messages.
- Local: check dmesg / system logs for OOM killer entries around the run time.Marvin
02/26/2026, 3:35 PMstdout=PIPE, stderr=PIPE, text=True and redirect to files; re-run with the same data volume. If the “infra disappears” symptom goes away, you’ve found the cause.
If you share where this runs (local/Docker/K8s) and memory limits, I can suggest exact redirection/streaming patterns for that environment (and how to tail errors safely without loading everything into memory).Yu(Irene) Chen
02/26/2026, 4:24 PMMarvin
02/26/2026, 4:24 PMMarvin
02/26/2026, 4:25 PMKeyError: UUID('019c92ac-7d94-7785-899c-70888301db57') inside prefect.workers.base.__aexit__
- Followed by systemd: prefect-worker.service: Main process exited, code=exited, status=1/FAILURE
- When the worker dies mid-run, the flow/task states are not updated and the UI can remain stuck in RUNNING until a later cleanup/automation marks them as CRASHED.
This is not caused by your task code; it’s a known worker bug in certain Prefect 3.x versions
- Very similar stack traces were reported here: Issue #17735 – “Exception occur in worker causing restarts” (Prefect 3.2.14)
- It was fixed by: PR #19866 – “Fix KeyError crash caused by dead cancellation tracking code in worker”
- Root cause: worker cancellation tracking tried to remove a flow run ID from an internal set that was never added, raising KeyError and crashing the worker.
What to do next
1) Upgrade Prefect to the latest 3.x (the fix is included in newer releases beyond the versions shown in the issue/PR logs). After upgrading, restart your worker service.
2) Ensure your systemd unit restarts the worker on failure (Restart=always) so transient issues don’t leave runs orphaned.
3) Add an automation to fail stale RUNNING flow runs (missing heartbeats for N minutes) so the UI doesn’t linger if a worker does go away: Automations docs.
4) Optional hardening for your backup task:
- Avoid stdout=PIPE, stderr=PIPE with very chatty commands like xtrabackup (can trigger high memory); write to files or stream to a rotating log.
- Keep Prefect’s timeout_seconds as a safety net, but rely on native subprocess timeouts or process groups for termination.
Can you share:
- Your Prefect version (prefect version) and work pool/worker type?
Once I have that, I can confirm whether your version contains the fix and suggest the exact upgrade path (e.g., image tag if Docker/K8s, or Poetry/pip version bump).Yu(Irene) Chen
02/26/2026, 4:44 PMMarvin
02/26/2026, 4:44 PMMarvin
02/26/2026, 4:46 PM