diff --git a/backend/app/api/routes/compile.py b/backend/app/api/routes/compile.py index 26c9263a..803969b1 100644 --- a/backend/app/api/routes/compile.py +++ b/backend/app/api/routes/compile.py @@ -1,8 +1,11 @@ import asyncio import hashlib +import json import logging +import os import time import uuid +from pathlib import Path from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request @@ -34,6 +37,92 @@ COMPILE_JOBS: dict[str, dict[str, Any]] = {} JOB_BY_KEY: dict[str, str] = {} # content_hash → job_id, for deduplication JOB_TTL_S = 1800 # purge results 30 min after completion +# ── Artifact cache ──────────────────────────────────────────────────────── +# The dedup above only collapses builds that are still in flight, so hitting +# Run twice on the same gallery example rebuilt it from scratch: measured 28 s +# cold and 27 s warm for an ESP-IDF P4 example, all of it before the emulator +# even starts. Gallery examples are byte-identical for every visitor, so the +# first build can serve everyone. Results are stored under the build volume, +# keyed by the same content hash the dedup uses. +ARTIFACT_CACHE_DIR = Path( + os.environ.get("VELXIO_ARTIFACT_CACHE", "/var/lib/velxio-build/artifacts") +) +ARTIFACT_CACHE_MAX_ENTRIES = 400 +ARTIFACT_CACHE_MAX_AGE_S = 14 * 24 * 3600 + + +def _toolchain_epoch() -> str: + """Fingerprint the code that TURNS a request into build flags. + + A cached binary is only valid while the sdkconfig/CLI generation behind it + is unchanged — flipping CONFIG_SPIRAM_MEMTEST off, say, must not keep + serving images built with it on. Hashing the two service modules makes the + invalidation automatic: edit them, every key changes. + """ + h = hashlib.sha256() + here = Path(__file__).resolve().parents[2] / "services" + for name in ("espidf_compiler.py", "arduino_cli.py"): + try: + h.update((here / name).read_bytes()) + except OSError: + h.update(b"?") + return h.hexdigest()[:16] + + +_TOOLCHAIN_EPOCH = _toolchain_epoch() + + +def _artifact_path(key: str) -> Path: + return ARTIFACT_CACHE_DIR / f"{key}.json" + + +def _artifact_load(key: str) -> dict | None: + """Return a cached CompileResponse dict, or None. Never raises.""" + path = _artifact_path(key) + try: + if not path.is_file(): + return None + if time.time() - path.stat().st_mtime > ARTIFACT_CACHE_MAX_AGE_S: + path.unlink(missing_ok=True) + return None + data = json.loads(path.read_text()) + os.utime(path, None) # LRU: touch on read + return data if isinstance(data, dict) else None + except Exception: + logger.warning("[compile] artifact cache read failed", exc_info=True) + return None + + +def _artifact_store(key: str, result: dict) -> None: + """Persist a SUCCESSFUL build. Failures are never cached — a compile error + is usually the user's half-written code, and they will edit and retry.""" + if not result.get("success"): + return + try: + ARTIFACT_CACHE_DIR.mkdir(parents=True, exist_ok=True) + # The build log is per-run noise and can be hundreds of KB; a cache hit + # says so in its place rather than replaying someone else's ninja run. + slim = dict(result) + slim["stdout"] = "[served from the build cache - identical sources]" + tmp = _artifact_path(key).with_suffix(".tmp") + tmp.write_text(json.dumps(slim)) + tmp.replace(_artifact_path(key)) + _artifact_prune() + except Exception: + logger.warning("[compile] artifact cache write failed", exc_info=True) + + +def _artifact_prune() -> None: + """Keep the newest ARTIFACT_CACHE_MAX_ENTRIES files (LRU by mtime).""" + try: + files = sorted( + ARTIFACT_CACHE_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True + ) + for stale in files[ARTIFACT_CACHE_MAX_ENTRIES:]: + stale.unlink(missing_ok=True) + except Exception: + pass + # ── Concurrency control ────────────────────────────────────────────────────── # Two lanes. HEAVY = ESP-IDF (cmake + ninja, minutes on a cold cache): capped # at 2 — the VPS is modest (saw load avg 30 with 6 ninja processes peeling @@ -120,6 +209,10 @@ def _job_key( so the build is owner-independent). """ h = hashlib.sha256() + # Bind every key to the code that generates build flags: a binary cached + # before a sdkconfig change must not be served after it. + h.update(_TOOLCHAIN_EPOCH.encode()) + h.update(b"\0") h.update(board_fqbn.encode()) h.update(b"\0") for f in sorted(files, key=lambda x: x["name"]): @@ -554,6 +647,8 @@ async def _compile_job( # result.stdout once state=done, but having both costs nothing). "stdout_buffer": COMPILE_JOBS.get(job_id, {}).get("stdout_buffer", ""), } + if job_key: + _artifact_store(job_key, response.model_dump()) error_kind = ( None if response.success else _classify_compile_error(response.stderr, response.error) @@ -727,6 +822,39 @@ async def compile_start( logger.info(f"[compile] dedup hit — reusing job {existing_id}") return CompileStartResponse(job_id=existing_id) + # Same sources, same flags, already built: hand the stored artifact back as + # an already-finished job so the client's normal poll loop just sees `done`. + cached = _artifact_load(key) + if cached is not None: + job_id = uuid.uuid4().hex + now = time.time() + COMPILE_JOBS[job_id] = { + "state": "done", + "started_at": now, + "finished_at": now, + "result": cached, + "key": key, + "stdout_buffer": cached.get("stdout", ""), + } + logger.info("[compile] artifact cache hit — skipping the build") + await _record_async_metric( + user_id=user_id, + project_id=request.project_id, + board_fqbn=request.board_fqbn, + success=True, + duration_ms=0, + error_kind=None, + extra={ + "file_count": len(files), + "async": True, + "cached": True, + "initiated_by": request.initiated_by, + "board_kind": request.board_kind, + "example_id": request.example_id, + }, + ) + return CompileStartResponse(job_id=job_id) + job_id = uuid.uuid4().hex COMPILE_JOBS[job_id] = {"state": "pending", "started_at": time.time(), "key": key} JOB_BY_KEY[key] = job_id diff --git a/backend/app/services/espidf_compiler.py b/backend/app/services/espidf_compiler.py index f4775bca..ab6f9343 100644 --- a/backend/app/services/espidf_compiler.py +++ b/backend/app/services/espidf_compiler.py @@ -3038,10 +3038,17 @@ class ESPIDFCompiler: psram_chunks.append('CONFIG_SPIRAM_USE_MALLOC=y') psram_chunks.append('CONFIG_SPIRAM_MODE_HEX=y') psram_chunks.append('CONFIG_SPIRAM_SPEED_200M=y') + # The boot-time memory test walks the WHOLE 32 MB before app_main; + # on emulated silicon that is tens of millions of guest accesses of + # pure startup latency ("Disable this for slightly faster startup", + # Kconfig.spiram.common). The RAM is modelled, not physical - there + # is nothing to find. + psram_chunks.append('CONFIG_SPIRAM_MEMTEST=n') else: psram_chunks.append('CONFIG_SPIRAM=y') psram_chunks.append('CONFIG_SPIRAM_USE_MALLOC=y') psram_chunks.append('CONFIG_SPIRAM_SPEED_80M=y') + psram_chunks.append('CONFIG_SPIRAM_MEMTEST=n') # see the P4 note if psram_mode == 'opi': psram_chunks.append('CONFIG_SPIRAM_MODE_OCT=y') else: