Files
Conal Mullan f52c627ce9 FIX: drain R2 cleanup on error paths too (upscale, sadtalker, qwen3_tts, dewatermark) (#73)
The four cloud GPU tools that do clean up R2 only did so on the happy path. Each
collected keys in an r2_keys_to_cleanup list and drained it with a loop at the
end of the function, so every early error return skipped cleanup entirely — a
failed upload, an endpoint error, a missing result. Those are the runs most
likely to bail out, and each stranded whatever it had already uploaded.
Exceptions skipped the drain too, since nothing wrapped it in finally.

A second, distinct leak: the output key was registered only on the
download_from_r2 branch, so a successful presigned-URL fallback downloaded the
result but never registered it for deletion.

Adds an r2_cleanup() context manager to file_transfer.py that drains in finally,
so success, early return and exception all clean up the same way, and registers
the output key on both download branches. Most of the diff is re-indentation
from wrapping the function bodies; git diff -w shows the real change.

Different bug class from #72, which fixed tools that never cleaned up at all —
these clean up correctly, but only when nothing goes wrong.

Verified on all four against the live bucket with the endpoint stubbed to fail
after upload, comparing against main as a control: upscale 0->1, dewatermark
0->1, sadtalker 42->44 (image+audio per run), qwen3_tts 2->3 all leaked before;
all hold steady after. Happy path re-checked end-to-end on Modal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:46:13 +01:00

246 lines
7.5 KiB
Python

"""Shared file transfer helpers for cloud GPU tools.
Provides R2 upload/download with fallback to free services (litterbox, 0x0.st).
Used by all cloud GPU tools to avoid duplicating file transfer logic.
"""
from __future__ import annotations
import os
import sys
from contextlib import contextmanager
from pathlib import Path
def get_r2_client():
"""Get boto3 S3 client configured for Cloudflare R2.
Returns (client, config_dict) or (None, None) if R2 is not configured.
"""
sys.path.insert(0, str(Path(__file__).parent))
try:
from config import get_r2_config
r2_config = get_r2_config()
except ImportError:
r2_config = None
if not r2_config:
return None, None
try:
import boto3
from botocore.config import Config
client = boto3.client(
"s3",
endpoint_url=r2_config["endpoint_url"],
aws_access_key_id=r2_config["access_key_id"],
aws_secret_access_key=r2_config["secret_access_key"],
region_name="auto",
config=Config(signature_version="s3v4"),
)
return client, r2_config
except ImportError:
print(" boto3 not installed, skipping R2", file=sys.stderr)
return None, None
def upload_to_r2(file_path: str, prefix: str) -> tuple[str | None, str | None]:
"""Upload to Cloudflare R2 and return presigned download URL.
Returns (presigned_url, object_key) or (None, None) on failure.
"""
client, config = get_r2_client()
if not client:
return None, None
import uuid
file_name = Path(file_path).name
object_key = f"{prefix}/{uuid.uuid4().hex[:8]}_{file_name}"
try:
client.upload_file(file_path, config["bucket_name"], object_key)
url = client.generate_presigned_url(
"get_object",
Params={"Bucket": config["bucket_name"], "Key": object_key},
ExpiresIn=7200,
)
return url, object_key
except Exception as e:
print(f" R2 upload error: {e}", file=sys.stderr)
return None, None
def download_from_r2(object_key: str, output_path: str) -> bool:
"""Download object from R2 to local path."""
client, config = get_r2_client()
if not client:
return False
try:
client.download_file(config["bucket_name"], object_key, output_path)
return True
except Exception as e:
print(f" R2 download error: {e}", file=sys.stderr)
return False
def delete_from_r2(object_key: str) -> bool:
"""Delete object from R2 after job completion."""
client, config = get_r2_client()
if not client or not object_key:
return False
try:
client.delete_object(Bucket=config["bucket_name"], Key=object_key)
return True
except Exception:
return False
@contextmanager
def r2_cleanup():
"""Collect R2 keys to delete, and delete them however the block exits.
Cloud GPU tools upload inputs and download results through R2, then delete
those objects. Doing that with a plain list plus a loop at the end of the
happy path leaks every object whenever the tool returns an error in
between -- a failed upload, an endpoint error, a missing result -- which is
exactly when a run is most likely to bail out. Draining in `finally` means
success, early return and exception all clean up the same way.
Usage:
with r2_cleanup() as r2_keys_to_cleanup:
...
r2_keys_to_cleanup.append(key)
Deletion is best-effort: delete_from_r2 swallows its own errors, so a key
that cannot be removed never masks the result of the block.
"""
keys: list[str] = []
try:
yield keys
finally:
for key in keys:
delete_from_r2(key)
def _upload_to_litterbox(file_path: str, file_name: str) -> str | None:
"""Upload to litterbox.catbox.moe (200MB limit, 24h retention)."""
import subprocess
result = subprocess.run(
[
"curl", "-s",
"-F", "reqtype=fileupload",
"-F", "time=24h",
"-F", f"fileToUpload=@{file_path}",
"https://litterbox.catbox.moe/resources/internals/api.php",
],
capture_output=True,
text=True,
timeout=300,
)
if result.returncode == 0:
url = result.stdout.strip()
if url.startswith("http"):
return url
return None
def _upload_to_0x0(file_path: str, file_name: str) -> str | None:
"""Upload to 0x0.st (512MB limit, 30 day retention)."""
import subprocess
result = subprocess.run(
["curl", "-s", "-F", f"file=@{file_path}", "https://0x0.st"],
capture_output=True,
text=True,
timeout=300,
)
if result.returncode == 0:
url = result.stdout.strip()
if url.startswith("http"):
return url
return None
def upload_to_storage(file_path: str, prefix: str) -> tuple[str | None, str | None]:
"""Upload a file to temporary storage for cloud GPU job input.
Tries R2 first, falls back to litterbox (24h/200MB), then 0x0.st (30d/512MB).
Returns (url, r2_key) where r2_key is set only for R2 uploads (for cleanup).
"""
file_size = Path(file_path).stat().st_size
file_name = Path(file_path).name
print(f"Uploading {file_name} ({file_size // 1024}KB)...", file=sys.stderr)
url, r2_key = upload_to_r2(file_path, prefix)
if url:
print(f" Upload complete (R2)", file=sys.stderr)
return url, r2_key
# Fall back to free services
for service_name, upload_func in [("litterbox", _upload_to_litterbox), ("0x0.st", _upload_to_0x0)]:
try:
url = upload_func(file_path, file_name)
if url:
print(f" Upload complete ({service_name})", file=sys.stderr)
return url, None
except Exception as e:
print(f" {service_name} failed: {e}", file=sys.stderr)
continue
print("All upload services failed", file=sys.stderr)
return None, None
def download_from_url(url: str, output_path: str, verbose: bool = True) -> bool:
"""Download file from URL to local path with streaming."""
import requests
try:
if verbose:
print(f"Downloading result...", file=sys.stderr)
response = requests.get(url, stream=True, timeout=300)
response.raise_for_status()
with open(output_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
if verbose:
size_kb = Path(output_path).stat().st_size // 1024
print(f" Downloaded: {output_path} ({size_kb}KB)", file=sys.stderr)
return True
except Exception as e:
print(f"Download error: {e}", file=sys.stderr)
return False
def get_r2_payload_config() -> dict | None:
"""Get R2 config dict for embedding in cloud GPU job payloads.
Returns the dict to include as payload["input"]["r2"], or None if R2
is not configured. This is the format expected by RunPod/Modal handlers.
"""
sys.path.insert(0, str(Path(__file__).parent))
try:
from config import get_r2_config
r2_config = get_r2_config()
except ImportError:
r2_config = None
if not r2_config:
return None
return {
"endpoint_url": r2_config["endpoint_url"],
"access_key_id": r2_config["access_key_id"],
"secret_access_key": r2_config["secret_access_key"],
"bucket_name": r2_config["bucket_name"],
}