gateway.py 6.02 KB
"""四个 H3 实例前面的调度层。

api.py 是单 worker 串行,一个进程独占一张卡,所以并发的唯一办法是往不同端口投。
这一层把「投哪个端口」收掉:对外只有一个地址,接口和 api.py 逐字相同,
调用方原来怎么写现在还怎么写,只把端口换成 8000。

自己不存任何状态。任务 id 归哪个后端,问一遍就知道——四个后端一次扫描的代价可以忽略,
换来的是这个进程随时能重启,不丢任何东西(api.py 的 JOBS 本来也在内存里,
调度层再存一份 id→后端 的映射,就是第二个会丢的地方)。
"""
import os, json, threading, urllib.request, urllib.error
from concurrent.futures import ThreadPoolExecutor

from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse

BACKENDS = [f"http://127.0.0.1:{port.strip()}" for port in
            os.environ.get("H3_BACKENDS", "8010,8011,8012,8013").split(",")]
POOL = ThreadPoolExecutor(max_workers=16)
# 最闲的那个后端已经压了这么多条时,再来的提交直接 429 顶回去。
# 没有这个上限的话队列是无界的:投 1000 条全部返回 202,最后一条两天后才开始跑,
# 调用方却收到「已接受」。宁可当场说满了。默认 4 条 ≈ 45 分钟积压。
MAX_QUEUE = int(os.environ.get("H3_MAX_QUEUE", "4"))
# 选后端是「读一遍各家队列深度,再往最闲的那个投」。这中间不能插进另一个提交,
# 否则同时来的请求会读到同一份快照、一起挑中同一个后端,全堆在一张卡上。
# 锁只圈住选 + 投这一段(约 30 毫秒),任务本身要跑十几分钟,序列化的代价可以忽略。
PICK = threading.Lock()


def fetch(url, data=None, timeout=30):
    req = urllib.request.Request(url, data=data,
                                 headers={"Content-Type": "application/json"} if data else {})
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return json.load(resp)


def health_all():
    def one(backend):
        try:
            return dict(fetch(backend + "/health", timeout=5), backend=backend)
        except Exception as e:
            return {"backend": backend, "status": "down", "error": f"{type(e).__name__}: {e}"}
    return list(POOL.map(one, BACKENDS))


def find_owner(jid):
    """任务归谁,挨个问。后端对陌生 id 返回 404,urlopen 抛异常,接住当没有。"""
    def one(backend):
        try:
            return backend, fetch(f"{backend}/v1/videos/{jid}", timeout=5)
        except Exception:
            return backend, None
    for backend, job in POOL.map(one, BACKENDS):
        if job is not None:
            return backend, job
    raise HTTPException(404, "无此任务")


app = FastAPI(title="MiniMax-H3 Gateway", version="1.0")


@app.get("/health")
def health():
    backends = health_all()
    ready = [b for b in backends if b.get("status") == "ok"]
    return {"status": "ok" if ready else "unavailable",
            "ready": len(ready), "total": len(backends),
            "queued": sum(b.get("queued", 0) for b in ready),
            "running": sum(b.get("running", 0) for b in ready),
            "backends": backends}


# 同步 def,让 FastAPI 把它丢进线程池。写成 async def 会让下面这些阻塞的 HTTP 调用
# 卡住事件循环——那样确实也能歪打正着地把提交排成队,但任何一个后端卡住,
# 5 秒超时就会连轮询一起堵死。要串行就用锁明说。
@app.post("/v1/videos", status_code=202)
def create(req: dict):
    # 给了首末帧就只能走 fl2va;其余都走 ref2va——ref2va 不给参考图就退回纯文生视频,
    # 所以「没有首末帧」这一类不需要再细分。
    want = "fl2va" if (req.get("first_frame_url") or req.get("last_frame_url")) else "ref2va"
    with PICK:
        candidates = [b for b in health_all()
                      if b.get("status") == "ok" and b.get("partition") == want]
        if not candidates:
            raise HTTPException(503, f"没有就绪的 {want} 实例")
        # 排队最短的那个。queued 是还在队列里的,running 是正在跑的那条,两个都要算。
        pick = min(candidates, key=lambda b: b["queued"] + b["running"])
        ahead = pick["queued"] + pick["running"]
        if ahead >= MAX_QUEUE:
            raise HTTPException(429, f"队列满:{len(candidates)} 个 {want} 实例每个都压着 "
                                     f"{ahead} 条(上限 {MAX_QUEUE})。等一会儿再投,"
                                     f"或调大 H3_MAX_QUEUE")
        try:
            job = fetch(pick["backend"] + "/v1/videos", data=json.dumps(req).encode())
        except urllib.error.HTTPError as e:
            raise HTTPException(e.code, e.read().decode())
    # eta_seconds 是这一条自己的执行时间,不含排队。前面还压着 ahead 条,
    # 真正的等待还要加上它们——它们规格可能不同,这里不替调用方瞎乘。
    return dict(job, backend=pick["backend"], queue_ahead=ahead, poll=f"/v1/videos/{job['id']}")


@app.get("/v1/videos/{jid}")
def get(jid: str):
    backend, job = find_owner(jid)
    return dict(job, backend=backend)


@app.get("/v1/videos/{jid}/file")
def download(jid: str):
    backend, job = find_owner(jid)
    if job["status"] != "done":
        raise HTTPException(409, f"任务尚未完成(当前 {job['status']})")
    resp = urllib.request.urlopen(f"{backend}/v1/videos/{jid}/file", timeout=300)
    return StreamingResponse(resp, media_type="video/mp4",
                             headers={"Content-Disposition": f'attachment; filename="{jid}.mp4"'})


@app.get("/v1/videos")
def listjobs(limit: int = 50):
    def one(backend):
        try:
            return [dict(j, backend=backend)
                    for j in fetch(f"{backend}/v1/videos?limit={limit}", timeout=5)["jobs"]]
        except Exception:
            return []
    jobs = [job for part in POOL.map(one, BACKENDS) for job in part]
    jobs.sort(key=lambda j: j["created_at"], reverse=True)
    return {"jobs": jobs[:limit]}