新增 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 目录。
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
"""同步型供应商的任务注册表:把「一次调用等到底」转成「提交 + 查询」。
|
||||
|
||||
## 为什么必须有它
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user