Merge pull request #150 from davidmonterocrespo24/async-compile

feat(compile): async compile + status polling — no more 524 timeouts
This commit is contained in:
David Montero Crespo 2026-05-09 01:54:29 -03:00 committed by GitHub
commit 761bd83a75
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 328 additions and 75 deletions

View File

@ -1,12 +1,15 @@
import asyncio
import logging import logging
import time import time
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user from app.core.dependencies import get_current_user
from app.database.session import get_db from app.database.session import AsyncSessionLocal, get_db
from app.models.user import User from app.models.user import User
from app.services.arduino_cli import ArduinoCLIService from app.services.arduino_cli import ArduinoCLIService
from app.services.metrics import record_compile from app.services.metrics import record_compile
@ -17,6 +20,29 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
arduino_cli = ArduinoCLIService() arduino_cli = ArduinoCLIService()
# ── Async compile job registry ───────────────────────────────────────────────
# In-process job dict for /compile/start + /compile/status/{job_id}. Cold ESP-IDF
# builds can take 5-7 minutes — far longer than Cloudflare's 100s edge timeout
# that hits any single HTTP request. The async path lets the client poll a
# short-lived status endpoint instead of holding one long-lived POST open.
#
# Single-instance only: if velxio ever scales to multiple FastAPI workers, this
# needs to move to Redis or the sqlite database. For now one process is fine.
COMPILE_JOBS: dict[str, dict[str, Any]] = {}
JOB_TTL_S = 1800 # purge results 30 min after completion
def _purge_expired_jobs() -> None:
"""Drop completed jobs older than JOB_TTL_S so the dict doesn't grow forever."""
now = time.time()
stale = [
jid for jid, job in COMPILE_JOBS.items()
if job.get("state") in ("done", "error")
and now - job.get("finished_at", now) > JOB_TTL_S
]
for jid in stale:
COMPILE_JOBS.pop(jid, None)
class SketchFile(BaseModel): class SketchFile(BaseModel):
name: str name: str
@ -61,6 +87,147 @@ def _classify_compile_error(stderr: str, error: str | None) -> str:
return "unknown" return "unknown"
def _resolve_files(request: CompileRequest) -> list[dict[str, str]]:
"""Normalise the multi-file vs legacy single-file request bodies."""
if request.files:
return [{"name": f.name, "content": f.content} for f in request.files]
if request.code is not None:
return [{"name": "sketch.ino", "content": request.code}]
raise HTTPException(
status_code=422,
detail="Provide either 'files' or 'code' in the request body.",
)
async def _run_compile(
request: CompileRequest,
files: list[dict[str, str]],
) -> CompileResponse:
"""Do the actual compile (ESP-IDF for esp32:*, arduino-cli otherwise)."""
if request.board_fqbn.startswith("esp32:") and espidf_compiler.available:
logger.info(f"[compile] Using ESP-IDF for {request.board_fqbn}")
result = await espidf_compiler.compile(files, request.board_fqbn)
return CompileResponse(
success=result["success"],
hex_content=result.get("hex_content"),
binary_content=result.get("binary_content"),
binary_type=result.get("binary_type"),
has_wifi=result.get("has_wifi", False),
stdout=result.get("stdout", ""),
stderr=result.get("stderr", ""),
error=result.get("error"),
)
# AVR, RP2040, and ESP32 fallback: use arduino-cli
core_status = await arduino_cli.ensure_core_for_board(request.board_fqbn)
core_log = core_status.get("log", "")
if core_status.get("needed") and not core_status.get("installed"):
return CompileResponse(
success=False,
stdout="",
stderr=core_log,
error=f"Failed to install required core: {core_status.get('core_id')}",
)
result = await arduino_cli.compile(files, request.board_fqbn)
return CompileResponse(
success=result["success"],
hex_content=result.get("hex_content"),
binary_content=result.get("binary_content"),
binary_type=result.get("binary_type"),
stdout=result.get("stdout", ""),
stderr=result.get("stderr", ""),
error=result.get("error"),
core_install_log=core_log if core_log else None,
)
async def _record_async_metric(
*,
user_id: int | None,
project_id: str | None,
board_fqbn: str,
success: bool,
duration_ms: int,
error_kind: str | None,
extra: dict[str, Any],
) -> None:
"""Open a fresh DB session and record one compile metric.
Used by the async path: the request-scoped session is gone by the time
the background task finishes, so we open our own short-lived one.
Country/IP tagging is dropped on this path (the original Request is no
longer alive); user_id and success/duration still flow through.
"""
try:
async with AsyncSessionLocal() as session:
user = None
if user_id is not None:
user = await session.get(User, user_id)
await record_compile(
session,
user=user,
project_id=project_id,
board_fqbn=board_fqbn,
success=success,
duration_ms=duration_ms,
error_kind=error_kind,
extra=extra,
request=None,
)
except Exception as exc:
logger.warning(f"[compile] async metric record failed: {exc}")
async def _compile_job(
job_id: str,
request: CompileRequest,
files: list[dict[str, str]],
user_id: int | None,
) -> None:
"""Background worker: run the compile, store result in COMPILE_JOBS."""
started = time.monotonic()
try:
COMPILE_JOBS[job_id]["state"] = "running"
response = await _run_compile(request, files)
COMPILE_JOBS[job_id] = {
"state": "done",
"started_at": COMPILE_JOBS[job_id]["started_at"],
"finished_at": time.time(),
"result": response.model_dump(),
}
error_kind = (
None if response.success
else _classify_compile_error(response.stderr, response.error)
)
await _record_async_metric(
user_id=user_id,
project_id=request.project_id,
board_fqbn=request.board_fqbn,
success=response.success,
duration_ms=int((time.monotonic() - started) * 1000),
error_kind=error_kind,
extra={"file_count": len(files), "has_wifi": response.has_wifi, "async": True},
)
except Exception as exc:
logger.exception(f"[compile] async job {job_id} failed")
COMPILE_JOBS[job_id] = {
"state": "error",
"started_at": COMPILE_JOBS[job_id]["started_at"],
"finished_at": time.time(),
"error": str(exc)[:500],
}
await _record_async_metric(
user_id=user_id,
project_id=request.project_id,
board_fqbn=request.board_fqbn,
success=False,
duration_ms=int((time.monotonic() - started) * 1000),
error_kind="exception",
extra={"file_count": len(files), "exception": str(exc)[:200], "async": True},
)
@router.post("/", response_model=CompileResponse) @router.post("/", response_model=CompileResponse)
async def compile_sketch( async def compile_sketch(
request: CompileRequest, request: CompileRequest,
@ -69,64 +236,21 @@ async def compile_sketch(
current_user: User | None = Depends(get_current_user), current_user: User | None = Depends(get_current_user),
): ):
""" """
Compile Arduino sketch and return hex/binary. Compile Arduino sketch and return hex/binary in a single response.
Synchronous path: held open until the build finishes. Works for AVR /
RP2040 builds (seconds), but ESP-IDF cold builds can run 5-7 minutes
and will hit Cloudflare's 100s edge timeout (HTTP 524). Use the async
path (`/compile/start` + `/compile/status/{job_id}`) for those.
Accepts either `files` (multi-file) or legacy `code` (single file). Accepts either `files` (multi-file) or legacy `code` (single file).
Auto-installs the required board core if not present. Auto-installs the required board core if not present.
""" """
# Resolve files list files = _resolve_files(request)
if request.files:
files = [{"name": f.name, "content": f.content} for f in request.files]
elif request.code is not None:
files = [{"name": "sketch.ino", "content": request.code}]
else:
raise HTTPException(
status_code=422,
detail="Provide either 'files' or 'code' in the request body.",
)
started = time.monotonic() started = time.monotonic()
response: CompileResponse
try: try:
# ESP32 targets: use ESP-IDF compiler for QEMU-compatible output response = await _run_compile(request, files)
if request.board_fqbn.startswith("esp32:") and espidf_compiler.available:
logger.info(f"[compile] Using ESP-IDF for {request.board_fqbn}")
result = await espidf_compiler.compile(files, request.board_fqbn)
response = CompileResponse(
success=result["success"],
hex_content=result.get("hex_content"),
binary_content=result.get("binary_content"),
binary_type=result.get("binary_type"),
has_wifi=result.get("has_wifi", False),
stdout=result.get("stdout", ""),
stderr=result.get("stderr", ""),
error=result.get("error"),
)
else:
# AVR, RP2040, and ESP32 fallback: use arduino-cli
core_status = await arduino_cli.ensure_core_for_board(request.board_fqbn)
core_log = core_status.get("log", "")
if core_status.get("needed") and not core_status.get("installed"):
response = CompileResponse(
success=False,
stdout="",
stderr=core_log,
error=f"Failed to install required core: {core_status.get('core_id')}",
)
else:
result = await arduino_cli.compile(files, request.board_fqbn)
response = CompileResponse(
success=result["success"],
hex_content=result.get("hex_content"),
binary_content=result.get("binary_content"),
binary_type=result.get("binary_type"),
stdout=result.get("stdout", ""),
stderr=result.get("stderr", ""),
error=result.get("error"),
core_install_log=core_log if core_log else None,
)
except Exception as e: except Exception as e:
# Even on hard failure we want the metric.
await record_compile( await record_compile(
db, db,
user=current_user, user=current_user,
@ -140,7 +264,6 @@ async def compile_sketch(
) )
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# Best-effort metrics — never blocks the response.
duration_ms = int((time.monotonic() - started) * 1000) duration_ms = int((time.monotonic() - started) * 1000)
await record_compile( await record_compile(
db, db,
@ -156,6 +279,63 @@ async def compile_sketch(
return response return response
class CompileStartResponse(BaseModel):
job_id: str
class CompileStatusResponse(BaseModel):
state: str # 'pending' | 'running' | 'done' | 'error'
started_at: float
finished_at: float | None = None
result: CompileResponse | None = None
error: str | None = None
@router.post("/start", response_model=CompileStartResponse)
async def compile_start(
request: CompileRequest,
current_user: User | None = Depends(get_current_user),
):
"""
Queue a compile and return a `job_id` immediately.
The actual compile runs in a background task; clients then poll
`GET /compile/status/{job_id}` every couple of seconds until state is
`done` or `error`. This sidesteps Cloudflare's 100s HTTP edge timeout —
each individual request returns in milliseconds.
"""
files = _resolve_files(request)
_purge_expired_jobs()
job_id = uuid.uuid4().hex
COMPILE_JOBS[job_id] = {"state": "pending", "started_at": time.time()}
asyncio.create_task(
_compile_job(
job_id=job_id,
request=request,
files=files,
user_id=current_user.id if current_user else None,
),
)
return CompileStartResponse(job_id=job_id)
@router.get("/status/{job_id}", response_model=CompileStatusResponse)
async def compile_status(job_id: str):
"""Poll the status of an async compile job submitted via /compile/start."""
job = COMPILE_JOBS.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="job not found or expired")
return CompileStatusResponse(
state=job["state"],
started_at=job["started_at"],
finished_at=job.get("finished_at"),
result=job.get("result"),
error=job.get("error"),
)
@router.get("/setup-status") @router.get("/setup-status")
async def setup_status(): async def setup_status():
return await arduino_cli.get_setup_status() return await arduino_cli.get_setup_status()

View File

@ -19,39 +19,112 @@ export interface CompileResult {
core_install_log?: string; core_install_log?: string;
} }
interface CompileStartResponse {
job_id: string;
}
interface CompileStatusResponse {
state: 'pending' | 'running' | 'done' | 'error';
started_at: number;
finished_at: number | null;
result: CompileResult | null;
error: string | null;
}
const POLL_INTERVAL_MS = 2000;
const MAX_POLL_DURATION_MS = 15 * 60 * 1000; // 15 minutes — covers cold ESP-IDF builds
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/**
* Compile a sketch via the async job pipeline.
*
* POST /compile/start { job_id }
* GET /compile/status/<job_id> (×N) { state, result?, error? }
*
* Each individual request returns in milliseconds, so Cloudflare's 100s edge
* timeout never kicks in even when the underlying ESP-IDF cold build runs
* for 5-7 minutes. Falls back to throwing an Error after MAX_POLL_DURATION_MS.
*/
export async function compileCode( export async function compileCode(
files: SketchFile[], files: SketchFile[],
board: string = 'arduino:avr:uno', board: string = 'arduino:avr:uno',
projectId?: string | null, projectId?: string | null,
): Promise<CompileResult> { ): Promise<CompileResult> {
console.log('Sending compilation request to:', `${API_BASE}/compile/start`);
console.log('Board:', board);
console.log(
'Files:',
files.map((f) => f.name),
);
let jobId: string;
try { try {
console.log('Sending compilation request to:', `${API_BASE}/compile`); const startResp = await axios.post<CompileStartResponse>(
console.log('Board:', board); `${API_BASE}/compile/start`,
console.log(
'Files:',
files.map((f) => f.name),
);
const response = await axios.post<CompileResult>(
`${API_BASE}/compile/`,
{ files, board_fqbn: board, project_id: projectId ?? null }, { files, board_fqbn: board, project_id: projectId ?? null },
{ withCredentials: true, timeout: 600000 }, { withCredentials: true, timeout: 30000 },
); );
jobId = startResp.data.job_id;
console.log('Compilation response status:', response.status); console.log('[compile] queued job', jobId);
return response.data;
} catch (error) { } catch (error) {
console.error('Compilation request failed:', error); console.error('Compilation request failed:', error);
if (axios.isAxiosError(error) && error.response) {
// Server returned a structured error (422, 500, etc.) — surface as a
// failed CompileResult so the editor can show stderr/error.
return error.response.data as CompileResult;
}
throw error instanceof Error
? error
: new Error('No response from server. Is the backend running?');
}
if (axios.isAxiosError(error)) { const startedAt = Date.now();
if (error.response) { // Initial small delay so we don't hit /status before the background task
console.error('Error response data:', error.response.data); // has even moved past 'pending'.
return error.response.data; await sleep(500);
} else if (error.request) {
throw new Error('No response from server. Is the backend running on port 8001?'); while (true) {
} if (Date.now() - startedAt > MAX_POLL_DURATION_MS) {
throw new Error(
`Compile timed out client-side after ${Math.round(MAX_POLL_DURATION_MS / 1000)}s`,
);
} }
throw error; let status: CompileStatusResponse;
try {
const resp = await axios.get<CompileStatusResponse>(
`${API_BASE}/compile/status/${jobId}`,
{ withCredentials: true, timeout: 30000 },
);
status = resp.data;
} catch (error) {
// Transient poll error — log, wait, retry. Only abort on 404 (job
// expired or never existed).
if (axios.isAxiosError(error) && error.response?.status === 404) {
throw new Error(`Compile job ${jobId} not found (server may have restarted)`);
}
console.warn('[compile] status poll error, retrying:', error);
await sleep(POLL_INTERVAL_MS);
continue;
}
if (status.state === 'done' && status.result) {
const elapsed = Math.round((Date.now() - startedAt) / 1000);
console.log(`[compile] job ${jobId} done in ${elapsed}s`);
return status.result;
}
if (status.state === 'error') {
console.error(`[compile] job ${jobId} errored:`, status.error);
return {
success: false,
stdout: '',
stderr: '',
error: status.error || 'Compile failed',
};
}
await sleep(POLL_INTERVAL_MS);
} }
} }