Commit af31f0a885999c0cdf677028c75fdec886708394
1 parent
25a489dc
h3-console: 加批量取消/删除接口
清理过载事故留下的重复排队任务时发现,取消/删除都只能按单条 id 调用,
脚本化清理只能一个个来。新增 POST /v1/videos/batch-cancel 和
/batch-delete,body 传 {"ids":[...]} 或 {status,kind,q} 筛选条件二选一;
筛选条件命中数超过 300 直接 400,避免条件写宽了误伤一大片。单条 cancel/
delete 的核心逻辑抽成 _cancel_job/_delete_job 供两边复用,批量场景里
一条失败(比如已经是终态)不影响其它条,成功/失败分别列出来。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFw9ZBh3eVjssk6quYQ4Ki
Showing
1 changed file
with
86 additions
and
5 deletions
h3-console/server.py
| @@ -396,8 +396,10 @@ async def create_video(req: dict): | @@ -396,8 +396,10 @@ async def create_video(req: dict): | ||
| 396 | return _row_view(row) | 396 | return _row_view(row) |
| 397 | 397 | ||
| 398 | 398 | ||
| 399 | -@api.post("/v1/videos/{jid}/cancel") | ||
| 400 | -async def cancel_video(jid: str): | 399 | +async def _cancel_job(jid: str) -> dict: |
| 400 | + """真正干活的取消/停止逻辑。单条接口和下面的批量接口共用——批量场景需要一条失败不 | ||
| 401 | + 连累其它条,所以这里只管抛 HTTPException,由调用方决定是直接透传(单条)还是 | ||
| 402 | + catch 下来记进 failed 列表(批量)。""" | ||
| 401 | with db() as con: | 403 | with db() as con: |
| 402 | row = con.execute("SELECT * FROM jobs WHERE id=?", (jid,)).fetchone() | 404 | row = con.execute("SELECT * FROM jobs WHERE id=?", (jid,)).fetchone() |
| 403 | if row is None: | 405 | if row is None: |
| @@ -434,6 +436,11 @@ async def cancel_video(jid: str): | @@ -434,6 +436,11 @@ async def cancel_video(jid: str): | ||
| 434 | return _row_view(row) | 436 | return _row_view(row) |
| 435 | 437 | ||
| 436 | 438 | ||
| 439 | +@api.post("/v1/videos/{jid}/cancel") | ||
| 440 | +async def cancel_video(jid: str): | ||
| 441 | + return await _cancel_job(jid) | ||
| 442 | + | ||
| 443 | + | ||
| 437 | @api.post("/v1/videos/{jid}/prioritize") | 444 | @api.post("/v1/videos/{jid}/prioritize") |
| 438 | async def prioritize_video(jid: str): | 445 | async def prioritize_video(jid: str): |
| 439 | with db() as con: | 446 | with db() as con: |
| @@ -588,9 +595,8 @@ async def list_videos(limit: int = 20, offset: int = 0, trash: bool = False, q: | @@ -588,9 +595,8 @@ async def list_videos(limit: int = 20, offset: int = 0, trash: bool = False, q: | ||
| 588 | return {"jobs": [_row_view(r) for r in rows], "total": total} | 595 | return {"jobs": [_row_view(r) for r in rows], "total": total} |
| 589 | 596 | ||
| 590 | 597 | ||
| 591 | -@api.delete("/v1/videos/{jid}") | ||
| 592 | -async def delete_video(jid: str): | ||
| 593 | - """软删除:只挪出主列表,不碰 gateway/api.py 上的任务本身(可能还在跑),不删磁盘文件。""" | 598 | +def _delete_job(jid: str) -> dict: |
| 599 | + """真正干活的软删除逻辑,单条/批量共用,理由同 _cancel_job。""" | ||
| 594 | with db() as con: | 600 | with db() as con: |
| 595 | cur = con.execute("UPDATE jobs SET deleted_at=? WHERE id=? AND deleted_at IS NULL", (time.time(), jid)) | 601 | cur = con.execute("UPDATE jobs SET deleted_at=? WHERE id=? AND deleted_at IS NULL", (time.time(), jid)) |
| 596 | if cur.rowcount == 0: | 602 | if cur.rowcount == 0: |
| @@ -598,6 +604,12 @@ async def delete_video(jid: str): | @@ -598,6 +604,12 @@ async def delete_video(jid: str): | ||
| 598 | return {"id": jid, "deleted": True} | 604 | return {"id": jid, "deleted": True} |
| 599 | 605 | ||
| 600 | 606 | ||
| 607 | +@api.delete("/v1/videos/{jid}") | ||
| 608 | +async def delete_video(jid: str): | ||
| 609 | + """软删除:只挪出主列表,不碰 gateway/api.py 上的任务本身(可能还在跑),不删磁盘文件。""" | ||
| 610 | + return _delete_job(jid) | ||
| 611 | + | ||
| 612 | + | ||
| 601 | @api.post("/v1/videos/{jid}/restore") | 613 | @api.post("/v1/videos/{jid}/restore") |
| 602 | async def restore_video(jid: str): | 614 | async def restore_video(jid: str): |
| 603 | with db() as con: | 615 | with db() as con: |
| @@ -607,6 +619,75 @@ async def restore_video(jid: str): | @@ -607,6 +619,75 @@ async def restore_video(jid: str): | ||
| 607 | return {"id": jid, "deleted": False} | 619 | return {"id": jid, "deleted": False} |
| 608 | 620 | ||
| 609 | 621 | ||
| 622 | +# 2026-09-05 加:一次过载事故后要清理一批内容重复的排队任务,才发现只有按单条 id 操作的 | ||
| 623 | +# cancel/delete,脚本化清理只能一个个调,慢且容易漏。下面两个批量接口给 ids 列表和"筛选 | ||
| 624 | +# 条件"两种入参二选一——ids 优先且没有数量上限(调用方既然把 id 一个个列出来了,就是明确 | ||
| 625 | +# 知道要动哪些);筛选条件复用跟 GET /v1/videos 一样的 status/kind/q 语义,但设了 | ||
| 626 | +# _BATCH_MAX_BY_FILTER 上限,防止筛选条件写宽了误伤一大片跟预期不符的任务——命中数超过 | ||
| 627 | +# 上限直接 400,让调用方缩小范围或换成显式 ids,而不是静默截断一部分。 | ||
| 628 | +_BATCH_MAX_BY_FILTER = 300 | ||
| 629 | + | ||
| 630 | + | ||
| 631 | +def _resolve_batch_targets(body: dict) -> list[str]: | ||
| 632 | + ids = body.get("ids") | ||
| 633 | + if ids: | ||
| 634 | + return list(dict.fromkeys(ids)) # 去重但保留顺序,防止调用方列表里手滑写重了 | ||
| 635 | + status, kind, q = body.get("status") or "", body.get("kind") or "", body.get("q") or "" | ||
| 636 | + if not (status or kind or q): | ||
| 637 | + raise HTTPException(400, "必须提供 ids 列表,或至少一个筛选条件(status/kind/q),避免误操作全部任务") | ||
| 638 | + where = ["deleted_at IS NULL"] | ||
| 639 | + params: list = [] | ||
| 640 | + if q: | ||
| 641 | + where.append("(id LIKE ? OR req_json LIKE ?)") | ||
| 642 | + params += [f"%{q}%", f"%{q}%"] | ||
| 643 | + if status: | ||
| 644 | + where.append("status = ?") | ||
| 645 | + params.append(status) | ||
| 646 | + if kind: | ||
| 647 | + where.append("partition = ?") | ||
| 648 | + params.append(kind) | ||
| 649 | + with db() as con: | ||
| 650 | + total = con.execute(f"SELECT COUNT(*) FROM jobs WHERE {' AND '.join(where)}", params).fetchone()[0] | ||
| 651 | + if total > _BATCH_MAX_BY_FILTER: | ||
| 652 | + raise HTTPException( | ||
| 653 | + 400, | ||
| 654 | + f"筛选条件命中 {total} 条,超过单次批量操作上限 {_BATCH_MAX_BY_FILTER}," | ||
| 655 | + "请缩小筛选范围(加 status/kind/q)或改成显式 ids 列表", | ||
| 656 | + ) | ||
| 657 | + rows = con.execute(f"SELECT id FROM jobs WHERE {' AND '.join(where)}", params).fetchall() | ||
| 658 | + return [r["id"] for r in rows] | ||
| 659 | + | ||
| 660 | + | ||
| 661 | +@api.post("/v1/videos/batch-cancel") | ||
| 662 | +async def batch_cancel_videos(body: dict): | ||
| 663 | + """批量取消/停止。body 传 {"ids": [...]} 或 {"status": "queued", "kind": "...", "q": "..."} | ||
| 664 | + 筛选条件(至少给一个)。逐条按现有单条 cancel 语义处理,某一条已经是终态之类的失败不影响 | ||
| 665 | + 其它条,成功/失败分别列出来。""" | ||
| 666 | + ids = _resolve_batch_targets(body) | ||
| 667 | + succeeded, failed = [], [] | ||
| 668 | + for jid in ids: | ||
| 669 | + try: | ||
| 670 | + succeeded.append((await _cancel_job(jid))["id"]) | ||
| 671 | + except HTTPException as e: | ||
| 672 | + failed.append({"id": jid, "error": e.detail}) | ||
| 673 | + return {"requested": len(ids), "succeeded": succeeded, "failed": failed} | ||
| 674 | + | ||
| 675 | + | ||
| 676 | +@api.post("/v1/videos/batch-delete") | ||
| 677 | +async def batch_delete_videos(body: dict): | ||
| 678 | + """批量软删除(移入回收站),语义同单条 DELETE /v1/videos/{jid}——只是把任务从列表里挪走, | ||
| 679 | + 不取消底层计算;真要连底层任务一起停掉,配合上面的 batch-cancel 一起调(谁先谁后不影响 | ||
| 680 | + 结果,两个操作互不冲突)。""" | ||
| 681 | + ids = _resolve_batch_targets(body) | ||
| 682 | + succeeded, failed = [], [] | ||
| 683 | + for jid in ids: | ||
| 684 | + try: | ||
| 685 | + succeeded.append(_delete_job(jid)["id"]) | ||
| 686 | + except HTTPException as e: | ||
| 687 | + failed.append({"id": jid, "error": e.detail}) | ||
| 688 | + return {"requested": len(ids), "succeeded": succeeded, "failed": failed} | ||
| 689 | + | ||
| 690 | + | ||
| 610 | @api.get("/v1/backends") | 691 | @api.get("/v1/backends") |
| 611 | async def backends(): | 692 | async def backends(): |
| 612 | resp = await client.get(f"{GATEWAY_URL}/health") | 693 | resp = await client.get(f"{GATEWAY_URL}/health") |