Step 15 of 16Days 23–24 · Tue 17 – Wed 18 NovWeek 4Phase B · web
FastAPI and the training worker
Wrap ml_core in a small API: create a project, upload a zip, start training, stream progress live, download the model. Training runs in a background worker so the API never blocks.
Where Where to implement
Jetson
~/sutraai/apps/api/: app.py + worker.py- Three tmux windows:
redis(a system service),worker,api
Laptop
- Test with
curlagainsthttp://<jetson-ip>:8000 - Interactive docs at
/docs
Do Do it
1Install the pieces
sudo apt install -y redis-server && sudo systemctl enable --now redis-server
cd ~/sutraai && source .venv/bin/activate
pip install "fastapi[standard]" celery redis sse-starlette python-multipart
mkdir -p apps/api projects2The worker: runs sutra-run and publishes each JSON line
import json, subprocess, sys
from pathlib import Path
import redis
from celery import Celery
ROOT = Path.home() / "sutraai"
app = Celery("sutraai", broker="redis://localhost:6379/0", backend="redis://localhost:6379/1")
r = redis.Redis()
@app.task(bind=True)
def train_job(self, project_id):
proj = ROOT / "projects" / project_id
channel, history = f"job:{self.request.id}", f"job:{self.request.id}:log"
cmd = [sys.executable, "-m", "ml_core.run", str(proj / "pipeline.yaml"), "--data", str(proj / "data"), "--out", str(proj)]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
for line in proc.stdout:
line = line.strip()
if line.startswith("{"):
r.rpush(history, line); r.publish(channel, line) # history lets late listeners catch up
proc.wait()
end = json.dumps({"event": "finished", "returncode": proc.returncode})
r.rpush(history, end); r.publish(channel, end)
return proc.returncode3The API
import json, shutil, uuid, zipfile
from pathlib import Path
import redis.asyncio as aioredis
from fastapi import FastAPI, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from sse_starlette.sse import EventSourceResponse
from worker import train_job
ROOT = Path.home() / "sutraai"; PROJECTS = ROOT / "projects"
TEMPLATES = {"image", "sensor", "audio", "detect"}
app = FastAPI(title="SutraAI API", version="0.1")
app.add_middleware(CORSMiddleware, allow_origins=["http://localhost:3000"], allow_methods=["*"], allow_headers=["*"])
def project_dir(pid):
d = PROJECTS / pid
if not d.is_dir(): raise HTTPException(404, "project not found")
return d
@app.post("/projects")
def create_project(template: str):
if template not in TEMPLATES: raise HTTPException(400, f"template must be one of {sorted(TEMPLATES)}")
pid = uuid.uuid4().hex[:8]; d = PROJECTS / pid; d.mkdir(parents=True)
shutil.copy(ROOT / "configs" / f"{template}.yaml", d / "pipeline.yaml")
return {"project_id": pid, "template": template}
@app.post("/projects/{pid}/dataset")
async def upload_dataset(pid: str, file: UploadFile):
d = project_dir(pid); data = d / "data"; zpath = d / "upload.zip"
zpath.write_bytes(await file.read())
with zipfile.ZipFile(zpath) as z:
for name in z.namelist(): # refuse paths that escape the folder
if not (data / name).resolve().is_relative_to(data.resolve()):
raise HTTPException(400, "zip contains unsafe paths")
z.extractall(data)
return {"files": sum(1 for p in data.rglob("*") if p.is_file())}
@app.post("/projects/{pid}/train")
def start_training(pid: str):
project_dir(pid)
return {"job_id": train_job.delay(pid).id}
@app.get("/jobs/{job_id}/events")
async def job_events(job_id: str):
r = aioredis.from_url("redis://localhost")
async def stream():
for old in await r.lrange(f"job:{job_id}:log", 0, -1): # replay what already happened
yield {"data": old.decode()}
if b'"finished"' in old: return
pubsub = r.pubsub(); await pubsub.subscribe(f"job:{job_id}")
async for msg in pubsub.listen():
if msg["type"] != "message": continue
yield {"data": msg["data"].decode()}
if b'"finished"' in msg["data"]: break
return EventSourceResponse(stream())
@app.get("/projects/{pid}/metrics")
def metrics(pid: str):
files = sorted(project_dir(pid).glob("reports/*/metrics.json"))
if not files: raise HTTPException(404, "no metrics yet")
return json.loads(files[-1].read_text())
@app.get("/projects/{pid}/download")
def download(pid: str, fmt: str = "onnx"):
patterns = {"onnx": "*.onnx", "tflite_int8": "*full_integer_quant.tflite", "c_array": "*_model_data.cc",
"pytorch": "best.pt", "engine": "*.engine"}
if fmt not in patterns: raise HTTPException(400, f"fmt must be one of {sorted(patterns)}")
hit = next(project_dir(pid).rglob(patterns[fmt]), None)
if not hit: raise HTTPException(404, f"no {fmt} file yet")
return FileResponse(hit, filename=hit.name)For export files to appear under the project, finish the Day 14 stretch goal from Step 08: run() calls ml_core.export for each entry in the YAML's export: list and writes into <out>/exports/.
4Start everything
cd ~/sutraai/apps/api && source ~/sutraai/.venv/bin/activate
tmux new -s worker -d "celery -A worker worker --concurrency 1 -l info" # one training job at a time on the GPU
tmux new -s api -d "fastapi run app.py --host 0.0.0.0 --port 8000"
tmux lsTest Test it
Zip a dataset in the same layout the template expects (for audio: house_sparrow/*.wav, rose_ringed_parakeet/*.wav, noise/*.wav), then:
API=http://<jetson-ip>:8000
curl -X POST "$API/projects?template=audio" # → {"project_id": "ab12cd34", ...}
curl -F "file=@birds.zip" $API/projects/ab12cd34/dataset # → {"files": 312}
curl -X POST $API/projects/ab12cd34/train # → {"job_id": "..."}
curl -N $API/jobs/<job_id>/events # live JSON lines, ends with "finished"
curl $API/projects/ab12cd34/metrics
curl -o model.tflite "$API/projects/ab12cd34/download?fmt=tflite_int8"Pass: epochs stream live in the terminal, metrics match
sutra-run, a second curl -N started late still shows the full history, and the downloaded file is the int8 model.Done Done when
- Redis, Celery worker and FastAPI run in tmux on the Jetson
- Create → upload → train → events → metrics → download works with curl
- Unsafe zip paths are refused
- Only one training job runs at a time
/docsshows all endpoints
If it breaks
| Symptom | Fix |
|---|---|
| Job stays pending | Worker not running or can't import worker.py: check tmux attach -t worker. |
| No events arrive | The job finished before you listened. The history list replays it; check that job:<id>:log exists in Redis. |
| Worker can't find ml_core | Start the worker from the activated venv (pip install -e packages/ml_core done). |
| CUDA out of memory during a job | Check jtop: on 16 GB, Nemotron 4B can stay up during small jobs, but stop a second LLM (Cosmos, 9B) while training; keep --concurrency 1. |
is_relative_to missing | Needs Python 3.9+; JetPack 6 has 3.10, so check you're in the venv. |
Learn more
- Udemy: FastAPI – The Complete Course (Eric Roby). Course code: GitHub
- FastAPI tutorial · Celery first steps
- sse-starlette (Server-Sent Events)