# 统一异步任务调度方案

> **状态：已完成（2026-04-17）** — 本文档所述方案已全面实施，Celery 已完全移除，所有第三方异步任务现由 Async Engine Scheduler 统一调度。

> 本文档用于替代原 Celery 在"提交→轮询→收尾"类第三方异步任务中的角色，解决视频生成、形象克隆等任务在队列中频繁出现的拥堵、死锁和状态不一致问题。

---

## 1. 背景与问题

### 1.1 当前架构的缺陷

目前项目使用 Celery Worker 处理所有第三方异步任务，包括：

- **视频生成** (`video`)：提交 Kling 分镜 → 轮询状态 → 下载上传
- **形象克隆** (`avatar_clone`)：提交音色 → 轮询 → 提交主体 → 轮询
- **字幕对齐** (`subtitle`)
- **图片生成** (`image`)

这些任务都被放进 Celery 队列，由 Worker 并发消费。但 Kling 视频生成本质上是**"占用并发槽位并长时间等待"**的过程，当前设计存在三个结构性问题：

1. **轮询任务风暴**：`poll_video_task` 用 Celery `retry(countdown=5)` 模拟轮询，一个 8 分钟的 Kling 任务会产生近百个 Celery Task，淹没队列调度器和 Redis Result Backend。
2. **快慢任务混排**：下载上传（IO 密集型）和提交/轮询（轻量 HTTP）共用 `video` 队列，Worker 被长任务占满，新任务饿死。
3. **状态死锁**：`download_upload_shot` 作为独立 Celery Task，一旦被 Worker 强制 Kill（如超时），shot 状态永远卡在 `downloading`，而轮询任务又不再处理它，导致整个任务假死。

### 1.2 核心认知

Kling 是一个有**严格并发上限**（20 槽位）的第三方异步执行池。我们需要的是一个 **Slot-Based Scheduler**（槽位调度器），而不是一个任务队列（Celery）。

> **任务队列**擅长"把独立任务尽快分发出去"；
> **槽位调度器**擅长"在有限资源下，周期性补货、轮询和收尾"。

Kling 视频生成和形象克隆属于后者。

---

## 2. 架构总览

```
┌─────────────┐      HTTP      ┌──────────────────┐
│  Tauri App  │ ◄────────────► │   FastAPI API    │
│  (React)    │                │   (Gateway)      │
└─────────────┘                └────────┬─────────┘
                                        │
                    ┌───────────────────┼───────────────────┐
                    │                   │                   │
                    ▼                   ▼                   ▼
            ┌──────────────┐    ┌──────────────┐    ┌──────────────┐
            │  PostgreSQL  │    │    Redis     │    │ Object Store │
            │  (持久化/审计) │    │  (运行时状态) │    │  (七牛/本地)  │
            └──────────────┘    └──────┬───────┘    └──────────────┘
                                       │
                              ┌────────┴────────┐
                              │   Async Engine  │
                              │  (Slot Scheduler)│
                              │  python main.py │
                              └────────┬────────┘
                                       │
                    ┌──────────────────┼──────────────────┐
                    ▼                  ▼                  ▼
            ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
            │ Video Handler│    │Avatar Handler│    │Future Handler│
            │  max_slots=18│    │  max_slots=2 │    │  (subtitle…) │
            └─────────────┘    └─────────────┘    └─────────────┘
```

### 2.1 核心组件

| 组件 | 职责 | 技术选型 |
|------|------|----------|
| **FastAPI API** | 接收前端请求、创建任务、写入状态、供前端轮询 | 现有 FastAPI |
| **Redis** | 存储任务的**运行时状态**（running shots、当前 stage、slot 占用集合） | 现有 Redis |
| **PostgreSQL** | 存储任务的**持久化记录**（创建时间、最终结果、成本统计、失败原因） | 现有 PostgreSQL |
| **Async Engine** | 独立的调度进程，每 10 秒一次 **Tick**，驱动所有任务状态推进 | Python `asyncio` |
| **Handler** | 插件化模块，每个第三方平台一个实现 | 面向接口的 Python 类 |

---

## 3. 核心机制

### 3.1 统一状态机

无论 video 还是 avatar_clone，所有第三方异步任务单元收敛到 **5 个统一状态**：

```
pending    →   submitted   →   succeed   →   completed
   │                              │
   └──────────────────────────────┘
                           ↓
                        failed
```

- **`pending`**：在队列里等待全局 slot 空闲
- **`submitted`**：已占用 slot，已提交给 Kling，等待轮询结果
- **`succeed`**：Kling 返回成功，Async Engine 立即触发下载/收尾（后台异步执行）
- **`failed`**：Kling 返回失败或提交异常
- **`completed`**：下载、上传、DB 写入全部完成

对于 avatar_clone 这种**多阶段**任务，内部用 Sub-State 嵌套，但每个阶段仍遵循同一模式：

```
voice_pending → voice_submitted → voice_succeed
                      ↓
           element_pending → element_submitted → element_succeed → completed
```

### 3.2 Slot Manager（全局并发控制器）

基于 **Redis SET + Lua 脚本** 实现严格的原子槽位管理：

```python
class SlotManager:
    async def acquire(self, slot_key: str, slot_id: str, max_slots: int) -> bool:
        """Lua 脚本原子执行：SADD -> SCARD -> 超限则 SREM"""
        lua = """
        local key = KEYS[1]
        local slot_id = ARGV[1]
        local max_slots = tonumber(ARGV[2])
        redis.call('sadd', key, slot_id)
        local count = redis.call('scard', key)
        if count > max_slots then
            redis.call('srem', key, slot_id)
            return 0
        end
        redis.call('expire', key, 1800)
        return 1
        """
        return await self.redis.eval(lua, 1, slot_key, slot_id, str(max_slots)) == 1

    async def release(self, slot_key: str, slot_id: str) -> None:
        await self.redis.srem(slot_key, slot_id)
```

当前配置：

- **Video 槽位池**：`kling:video_slots`，上限 **18**
- **Avatar 槽位池**：`kling:avatar_slots`，上限 **2**

> **为什么 Lua 脚本？** 确保 `SADD + SCARD + 条件 SREM` 原子执行。即使未来启动第二个 Scheduler 实例做 HA，也不会出现并发超发。

### 3.3 Async Engine Tick 循环

Scheduler 是一个独立的 `asyncio` 进程，主循环如下：

```python
async def main():
    engine = AsyncEngine()
    while True:
        tick_start = time.monotonic()

        # 1. 加载所有 running 的任务
        tasks = await engine.registry.get_running_tasks()

        # 2. 按 Handler 分组，并行执行各自的 tick
        changes = await asyncio.gather(*[
            handler.tick(tasks_for_handler, engine.slots)
            for handler in engine.handlers.values()
        ])

        # 3. 批量应用状态变更（Pipeline 写入 Redis）
        await engine.registry.apply_changes(flatten(changes))

        # 4. 对 completed/failed 的任务，持久化到 PostgreSQL
        await engine.persist_finished_tasks()

        # 5. 控制 Tick 间隔（固定 10 秒，执行过久时至少休息 2 秒）
        elapsed = time.monotonic() - tick_start
        await asyncio.sleep(max(10 - elapsed, 2))
```

### 3.4 Handler 插件化接口

每个第三方平台实现一个 Handler：

```python
class AsyncHandler(ABC):
    name: str           # e.g. "video"
    slot_key: str       # e.g. "kling:video_slots"
    max_slots: int      # e.g. 18

    @abstractmethod
    async def tick(self, tasks: list[Task], slots: SlotManager) -> list[StateChange]:
        """每个 Tick 执行一次，返回需要更新的状态变更列表"""
        pass
```

#### Video Handler 的 tick 逻辑

1. **查**：遍历所有 `submitted` 的 shots，批量并行查询 Kling 状态
2. **收**：
   - `succeed` → `release_slot` + `asyncio.create_task(download_and_upload(shot))` + 状态改为 `completed`
   - `failed` → `release_slot` + 状态改为 `failed`
3. **补**：计算空闲槽位数，从 `pending` 队列 FIFO 取出新 shot 提交给 Kling，直到槽满
4. **写**：更新 task 的聚合状态（completed/total/message）到 Redis

#### Avatar Handler 的 tick 逻辑

1. 检查当前 stage（如 `voice_submitted`），查询 Kling 状态
2. 若 `voice_succeed` → 释放 slot，推进到 `element_pending`，并在同一 tick 内尝试申请 slot 提交主体创建
3. 若 `element_succeed` → 释放 slot，状态改为 `completed`

---

## 4. API 层与数据流

### 4.1 创建任务（不变）

```python
@router.post("/{task_type}", response_model=TaskCreateResponse)
async def create_task(task_type: str, request: TaskCreateRequest):
    task_id = generate_task_id()

    # 1. 写入 PostgreSQL（持久化底单）
    await db_task.create(task_id=task_id, type=task_type, user_id=...)

    # 2. 写入 Redis（标记为 pending，供 Async Engine 消费）
    await redis_task.create(task_id=task_id, type=task_type, status="pending", ...)

    return TaskCreateResponse(task_id=task_id, status="pending")
```

### 4.2 查询状态（不变）

```python
@router.get("/{task_id}", response_model=TaskStatusResponse)
async def get_task_status(task_id: str):
    # 先读 Redis（热数据）
    task = await redis_task.get(task_id)
    if not task:
        # fallback 到 PostgreSQL（已完成的归档数据）
        task = await db_task.get(task_id)
    return task
```

### 4.3 前端兼容性

**前端 `useTask.ts` 的轮询逻辑完全不需要修改。** 这是渐进式迁移的关键——调度层的重构对上层透明。

---

## 5. 部署方案

### 5.1 Docker Compose

```yaml
services:
  api:
    build:
      context: ../python-api
      dockerfile: Dockerfile
    command: uvicorn app.main:app --host 0.0.0.0 --port 8000
    # ...

  scheduler:
    build:
      context: ../python-api
      dockerfile: Dockerfile
    container_name: meijiaka-scheduler
    command: python -m app.scheduler.main
    environment:
      - REDIS_HOST=redis
      - DATABASE_URL=postgresql+asyncpg://...
    depends_on:
      - redis
      - db
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 512M
```

### 5.2 Celery 的处置

- 立即下线 `worker-video`（不再消费 `video` 队列）
- Phase 2 下线 `worker-avatar`（Avatar Handler 迁入 Async Engine 后）
- 可选：暂时保留 Celery 跑 `subtitle`，待后续迁移
- 最终目标：所有"提交→轮询"类任务都迁入 Async Engine，Celery 整体移除

---

## 6. 迁移路径

| 阶段 | 时间 | 动作 | 风险 |
|------|------|------|------|
| **Phase 1** | 本周 | Async Engine 只接管 `video`（18 slots）；`avatar_clone` 仍由 Celery 运行 | 改动面最小，只验证 video 链路 |
| **Phase 2** | 1-2 周后 | Async Engine 新增 `avatar_clone` Handler（2 slots）；彻底下线 Celery 的 `worker-video` 和 `worker-avatar` | 验证 avatar 链路，解决资源饿死 |
| **Phase 3** | 未来 | `subtitle`、`image` 等陆续迁入 Async Engine；Celery 完全移除 | 统一所有第三方异步任务调度 |

---

## 7. 设计原则论证

### 7.1 主流（Mainstream）

- **Redis + PostgreSQL 双存储**：运行态在 Redis，持久态在 PostgreSQL。这是现代异步系统的事实标准，从 AWS Lambda 到 Vercel 再到国内云厂商均采用类似模式。
- **Python asyncio 轻量调度器**：不引入 Kafka、RabbitMQ 或 Airflow 等重型框架，利用原生异步能力构建。Prefect、Dagster 的底层 Scheduler 也采用类似思想。
- **Gateway + 独立 Scheduler 进程**：API 负责接入，Scheduler 负责推进，职责清晰。这是当前中小型 SaaS 的主流演进方向。

### 7.2 合理（Reasonable）

- **完全匹配项目定位**：项目定位是"轻量云账号 + 全本地业务数据"，不需要 Kubernetes 或复杂工作流引擎。Async Engine 只是一个额外的 Python 进程，资源占用 < 512MB。
- **渐进式迁移，契约不变**：前端轮询逻辑、API URL、响应 Schema 均不变。改动仅集中在后端任务分发层，业务代码零侵入。
- **资源隔离精确可控**：Video 18 slots + Avatar 2 slots = 20 slots，与 Kling 实际并发限制完全对齐。不会出现"形象克隆占满 Worker 导致视频饿死"的结构性问题。
- **开发体验优先**：本地开发时，scheduler 可以和 api 一起 `docker-compose up`，也可以单独 `python -m app.scheduler.main` 调试。不需要 ngrok，不需要把开发环境搬到云端。
- **幂等和可恢复**：每个 shot 的提交操作都是幂等的。Redis 记录了 `kling_task_id`，Scheduler 重启后从 Redis 恢复 running 任务，继续轮询，不会丢失状态。

### 7.3 长期稳定（Long-term Stable）

- **HA 预留，无单点故障**：`SlotManager` 基于 Redis Lua 脚本实现原子操作，天然支持多实例竞争。未来如需高可用，可启动第二个 Scheduler 实例，通过 Redis 分布式锁选举 Leader，实现秒级主备切换，无需重构。
- **Handler 插件化扩展**：未来接入即梦、Runway、Pika 或新的 AI 服务，只需实现新的 `AsyncHandler` 子类，配置 `slot_key` 和 `max_slots`。核心调度逻辑永远不需要改动。
- **数据一致性保障**：运行态在 Redis（崩溃恢复快），完成态在 PostgreSQL（数据不丢）。即使 Scheduler 挂掉 30 分钟，Kling 端的任务仍在运行，恢复后继续轮询即可。
- **第三方接口变更的防御性**：Handler 内部对 Kling API 的调用有统一的超时控制、重试策略和异常兜底。如果 Kling 某个接口升级，只改对应 Handler，不影响其他模块。
- **可观测性支撑长期运维**：通过 Prometheus 指标，可长期监控"视频生成成功率"、"平均生成耗时"、"槽位利用率"、"Kling API 延迟分布"，为后续扩容和成本优化提供数据支撑。

---

## 8. 关键文件位置（建议）

```
python-api/
├── app/
│   └── scheduler/
│       ├── __init__.py
│       ├── main.py              # Async Engine 入口（Tick 循环）
│       ├── engine.py            # AsyncEngine 核心调度器
│       ├── slot_manager.py      # 槽位管理器（Redis Lua）
│       ├── registry.py          # 任务注册表（Redis 读写）
│       ├── handlers/
│       │   ├── __init__.py
│       │   ├── base.py          # AsyncHandler 抽象基类
│       │   ├── video_handler.py # Video 任务处理器
│       │   └── avatar_handler.py # Avatar Clone 处理器
│       └── models.py            # Scheduler 内部数据模型
```

---

## 9. 结论

当前 Celery 在"提交→轮询→收尾"类任务中的角色是**结构性错位**的。它带来的任务风暴、队列拥堵和状态死锁不是可以通过调参修复的，而是其模型与问题本质不匹配的结果。

**统一异步调度方案**的核心决策：

1. **用 Async Engine（Slot-Based Scheduler）替代 Celery 管理所有第三方异步任务**
2. **Video 分配 18 槽，Avatar Clone 分配 2 槽，由唯一调度器全局管理**
3. **API 层和前端轮询逻辑完全不变，实现渐进式迁移**
4. **本地开发环境保持原样，无需引入 webhook 或云端部署**

这是根治"任务队列生成视频总会出问题"的唯一长期方案。
