8275a04903
CI / ai-gates (push) Successful in 38s
新增 app/providers/openai/:client 传输(JSON + multipart)、errors 错误映射、 models 厂商 DTO、capabilities 尺寸算术约束、tasks 同步→异步桥、image 图片业务。 装配与配置同步接入,缺 OPENAI_API_KEY 时跳过装配、服务仍可启动。 与火山视频的三处根本差异及其处置: - 接口同步(无任务 ID、无查询端点)→ SyncTaskRegistry 在本服务内部吸收, 对外仍是 ADR-023 §4 的两段式提交/轮询。代价:本服务从此持有状态,进程重启 丢在途任务,且横向扩容前须先解决任务 ID 只在签发进程内有效的问题。 - 产物是 base64 字节而非 URL → 无过期问题,但字节只在内存里。asset_url 放 data URI 仅为满足领域不变式,绝不能原样写进 media.assets.external_url。 - 图生图走 multipart → 先下载参考图再上传;下载失败与超限按参数错误同步拒绝, 不变成注定失败的后台任务。 两处需要记住的判断: - 尺寸上限「3840x2160」文档未说清是逐边还是按像素。逐边理解会禁掉所有高于 2160 的竖图,而 1024x1536 是文档明示的标准竖图,故必然过严。采用最长边 ≤3840 且总像素 ≤3840×2160,已在 capabilities.py 与取证页标为推断。 - 两份文档都没有错误码表,故 errors.py 以 HTTP 状态码为主、type/code 为辅, 顶部标注「未经文档证实」。429 无线索时按限流而非配额耗尽(猜成配额会把可 重试的限流判死);400 无线索时归服务端错误而非内容被拒(错报会让用户去改 一个其实没问题的提示词)。 一处死锁已修并回归钉住:SyncTaskRegistry 原用 add_done_callback 记录完成时刻, 而快速完成的任务在注册时已 done,回调便同步地在持有非可重入锁的线程里执行。 改为首次被观察到时惰性记录。 测试 166 → 271(+105)。四道门禁逐条读退出码,全绿。 产物转存仍阻塞于存储决策(ADR-023 §7),图片模型因此未播种进 V7 目录。
225 lines
10 KiB
Python
225 lines
10 KiB
Python
"""同步型供应商的任务注册表:把「一次调用等到底」转成「提交 + 查询」。
|
||
|
||
## 为什么必须有它
|
||
|
||
ADR-023 §4 定的是「Java↔Python 边界一律异步,同步型供应商由其适配层在内部消化
|
||
这个差异」。火山的视频接口本身异步,所以视频适配器不需要这一层。**OpenAI 的图片
|
||
接口是同步的**——`POST /images/generations` 在一次请求里等到图片生成完才返回,
|
||
没有任务 ID,也没有查询接口。差异必须在这里被吸收,否则就会渗透到客户端契约、
|
||
等待态设计和 E2E,那是 §4 明确要避免的。
|
||
|
||
## 这与「Python 不持有状态」的张力,以及取舍
|
||
|
||
Seedance 那条路径上,Python 确实无状态:任务状态的唯一事实来源是 Java 的
|
||
`generation_jobs`,Python 只是个转发器。**图片这条路径做不到**——同步接口没有可
|
||
供事后查询的厂商 ID,所以「在途任务」这件事只能记在本进程内存里。
|
||
|
||
代价与处置如实登记:
|
||
|
||
1. **进程重启会丢失在途任务。** 之后 Java 轮询会得到 `task_not_found`,任务被判
|
||
终态失败,而**那次调用可能已经完成并计费**。OpenAI 的图片接口没有任务 ID 可供
|
||
事后查询,所以这个窗口**无法靠"再查一次"弥补**,只能由 `max_attempts` 兜住。
|
||
这与火山那边「提交超时不知道有没有扣费」是同一类风险,但触发面更大(那边只有
|
||
提交超时,这边还包括进程重启)。
|
||
2. **产物是 base64 字节,只在内存里。** 完成的任务必须在 TTL 内被取走,否则丢失。
|
||
而转存阻塞于对象存储决策(ADR-023 §7),所以**图片链路在存储决策落地前比视频
|
||
更受限**:视频至少有个 24 小时的厂商 URL 可以先看效果,图片的字节一旦被清理
|
||
就什么都没有了。
|
||
3. 注册表**有界且有 TTL**,否则它就是个内存泄漏。
|
||
|
||
## 为什么用线程而不是 asyncio
|
||
|
||
FastAPI 的端点在本服务里是同步函数(`def` 而非 `async def`),且 `httpx.Client`
|
||
是同步客户端。用线程池承载那个长调用与现有代码一致;改成 asyncio 需要把传输层、
|
||
端点、测试全部改成异步,收益只是少几个线程——本服务的并发量级(一台轻量服务器上
|
||
的少量生成请求)用不上那个收益。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from collections.abc import Callable
|
||
from concurrent.futures import Future, ThreadPoolExecutor
|
||
from dataclasses import dataclass, field
|
||
|
||
from app.domain.generation import Failure, GenerationResult, TaskState
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
#: 本地任务 ID 的前缀,便于在日志里与厂商 ID 区分开。
|
||
LOCAL_TASK_PREFIX = "local-"
|
||
|
||
|
||
@dataclass
|
||
class _Entry:
|
||
"""一条在途或已完成的任务。"""
|
||
|
||
future: Future
|
||
created_at: float = field(default_factory=time.monotonic)
|
||
#: **首次观察到**完成的时刻;未完成为 None。TTL 从这里起算而不是从创建起算——
|
||
#: 否则一个耗时接近 TTL 的任务会在刚完成时就被清理掉,结果还没被取走。
|
||
#:
|
||
#: 为什么是「首次观察到」而不是「实际完成」:实际完成时刻需要一个
|
||
#: `add_done_callback` 才能捕捉,而那会**死锁**——快速完成的任务在
|
||
#: `add_done_callback` 时已经 done,回调便**同步地在调用线程执行**,而该线程
|
||
#: 此刻正持有本注册表的锁(非可重入)。改用惰性记录既无此风险,又只会让结果
|
||
#: 多留一会儿(保守方向),不会让它提前消失。
|
||
finished_at: float | None = None
|
||
|
||
|
||
class SyncTaskRegistry:
|
||
"""把同步调用包装成可轮询的任务。
|
||
|
||
线程安全:`submit`/`fetch`/清理可能来自不同请求线程。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
max_workers: int = 4,
|
||
completed_ttl_sec: float = 900.0,
|
||
max_entries: int = 256,
|
||
):
|
||
"""
|
||
:param max_workers: 并发执行的同步调用数上限。**同时也是对服务商并发的
|
||
自我限制**:超出的提交会排队而不是一起打过去,既避免把自己的 RPM 打爆,
|
||
也让「同时有多少钱在花」有个上界。
|
||
:param completed_ttl_sec: 已完成任务的保留时长。需大于 Java 侧最长的轮询
|
||
间隔(当前阶梯最长 30 秒),否则结果会在被取走之前被清理。默认 15
|
||
分钟留足余量。
|
||
:param max_entries: 注册表容量上限。满了先清理过期项,仍满则拒绝新提交
|
||
(而不是无声挤掉某个在途任务——那会让一个已经在计费的调用变成孤儿)。
|
||
"""
|
||
self._executor = ThreadPoolExecutor(
|
||
max_workers=max_workers, thread_name_prefix="sync-gen"
|
||
)
|
||
self._completed_ttl_sec = completed_ttl_sec
|
||
self._max_entries = max_entries
|
||
self._entries: dict[str, _Entry] = {}
|
||
self._lock = threading.Lock()
|
||
|
||
def submit(self, work: Callable[[], GenerationResult]) -> str:
|
||
"""安排一次同步调用在后台执行,立刻返回本地任务 ID。
|
||
|
||
:raises RuntimeError: 注册表已满(并发在途任务过多)
|
||
"""
|
||
with self._lock:
|
||
self._evict_expired_locked()
|
||
if len(self._entries) >= self._max_entries:
|
||
raise RuntimeError(
|
||
f"在途生成任务已达上限 {self._max_entries},请稍后重试"
|
||
)
|
||
task_id = LOCAL_TASK_PREFIX + uuid.uuid4().hex
|
||
# 刻意不注册 done_callback,原因见 _Entry.finished_at 的说明(会死锁)。
|
||
self._entries[task_id] = _Entry(future=self._executor.submit(work))
|
||
log.info("已受理同步生成任务 %s(在途 %d)", task_id, len(self._entries))
|
||
return task_id
|
||
|
||
def fetch(self, task_id: str) -> GenerationResult | None:
|
||
"""查任务;未知 ID 返回 None。
|
||
|
||
未知 ID 的两种成因**无法区分**:从未存在过,或进程重启后丢失。两者对调用
|
||
方的处置相同(停止轮询、任务判失败),故不强行区分——区分不了的事不要假装
|
||
区分。
|
||
"""
|
||
with self._lock:
|
||
entry = self._entries.get(task_id)
|
||
if entry is not None and entry.finished_at is None and entry.future.done():
|
||
# 首次观察到完成:记下时刻,TTL 由此起算。
|
||
entry.finished_at = time.monotonic()
|
||
if entry is None:
|
||
return None
|
||
|
||
if not entry.future.done():
|
||
# 同步接口不提供进度,故只能报 0。客户端契约本就不承诺平滑进度
|
||
# (ADR-023 §10),一个诚实的 0 不该被读成「卡住」。
|
||
return GenerationResult(
|
||
provider_request_id=task_id, state=TaskState.RUNNING
|
||
)
|
||
|
||
try:
|
||
result = entry.future.result()
|
||
except Exception as exc: # noqa: BLE001 - 兜底转成领域失败
|
||
return self._failure_result(task_id, exc)
|
||
# 用真实的任务 ID 覆盖工作函数里可能留空的那个字段。
|
||
return GenerationResult(
|
||
provider_request_id=task_id,
|
||
state=result.state,
|
||
progress=result.progress,
|
||
asset_url=result.asset_url,
|
||
asset_url_expires_in_sec=result.asset_url_expires_in_sec,
|
||
cover_url=result.cover_url,
|
||
ratio=result.ratio,
|
||
resolution=result.resolution,
|
||
width_px=result.width_px,
|
||
height_px=result.height_px,
|
||
duration_ms=result.duration_ms,
|
||
output_format=result.output_format,
|
||
failure=result.failure,
|
||
billing_tokens=result.billing_tokens,
|
||
)
|
||
|
||
@staticmethod
|
||
def _failure_result(task_id: str, exc: BaseException) -> GenerationResult:
|
||
"""工作函数抛出的异常转成领域失败。
|
||
|
||
已分类的服务商异常保留其分类;其余按可重试的上游问题处理,并把类型名带进
|
||
`provider_code`——否则排查时只有一句空洞的错误消息。
|
||
"""
|
||
from app.domain.generation import Retry, UserFacing
|
||
|
||
from .client import OpenAiError
|
||
|
||
if isinstance(exc, OpenAiError):
|
||
failure = exc.failure
|
||
else:
|
||
log.exception("同步生成任务 %s 抛出未分类异常", task_id)
|
||
failure = Failure(
|
||
Retry.BACKOFF,
|
||
UserFacing.UPSTREAM_UNAVAILABLE,
|
||
type(exc).__name__,
|
||
str(exc),
|
||
)
|
||
return GenerationResult(
|
||
provider_request_id=task_id, state=TaskState.FAILED, failure=failure
|
||
)
|
||
|
||
def _evict_expired_locked(self) -> None:
|
||
"""清理已完成且超过 TTL 的条目。调用方必须已持锁。
|
||
|
||
**只清已完成的**:在途任务无论多久都不清——它可能正在花钱,清掉会让它
|
||
变成一个无人认领的开销。真正卡死的在途任务由线程池的上限兜住。
|
||
"""
|
||
now = time.monotonic()
|
||
# 顺带给「已完成但从未被 fetch 过」的条目补上观察时刻,否则它们的
|
||
# finished_at 永远是 None,就永远不会过期——那正是内存泄漏。
|
||
for entry in self._entries.values():
|
||
if entry.finished_at is None and entry.future.done():
|
||
entry.finished_at = now
|
||
expired = [
|
||
task_id
|
||
for task_id, entry in self._entries.items()
|
||
if entry.finished_at is not None
|
||
and now - entry.finished_at > self._completed_ttl_sec
|
||
]
|
||
for task_id in expired:
|
||
del self._entries[task_id]
|
||
if expired:
|
||
log.debug("清理了 %d 个已完成的同步任务条目", len(expired))
|
||
|
||
@property
|
||
def size(self) -> int:
|
||
with self._lock:
|
||
return len(self._entries)
|
||
|
||
def shutdown(self) -> None:
|
||
"""停止接受新任务。
|
||
|
||
`wait=False`:在途的同步调用可能还要跑几分钟,进程退出时不等它们——结果
|
||
反正已经无处可交付了。它们会随进程一起消失,这正是上面第 1 点登记的风险。
|
||
"""
|
||
self._executor.shutdown(wait=False, cancel_futures=True)
|