refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构

- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/
- 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层
- 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构
- 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations)
- 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image
- 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
This commit is contained in:
fawney19
2026-04-03 16:26:16 +08:00
parent 8f26e1a31f
commit 1d9c77522a
868 changed files with 1735 additions and 2433 deletions

View File

@@ -0,0 +1,262 @@
"""
Task poller (Phase2)
Provides a generic polling skeleton for async tasks.
Currently wired with a video poller adapter.
优化HTTP 请求期间不持有数据库连接,避免阻塞其他请求。
采用三阶段处理:准备数据 -> HTTP 请求 -> 更新数据库。
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from typing import Any, Protocol, runtime_checkable
from uuid import uuid4
from sqlalchemy.orm import Session
from src.core.api_format.conversion.internal_video import InternalVideoPollResult
from src.core.logger import logger
from src.database import create_session
from src.services.system.scheduler import get_scheduler
from src.services.task.video.poller_adapter import VideoPollContext, VideoTaskPollerAdapter
@runtime_checkable
class TaskPollerAdapter(Protocol):
task_type: str
# scheduler
job_id: str
job_name: str
interval_seconds: int
# distributed lock (optional, best-effort)
lock_key: str
lock_ttl: int
# execution
batch_size: int
concurrency: int
consecutive_failure_alert_threshold: int
def list_due_task_ids(self, db: Session, *, now: datetime, limit: int) -> list[str]: ...
def get_task(self, db: Session, task_id: str) -> Any | None: ...
# 分阶段处理方法(推荐使用)
async def prepare_poll_context(
self, db: Session, task: Any
) -> Any: ... # Returns context or error result
async def poll_task_http(self, ctx: Any) -> Any: ... # Returns poll result
async def update_task_after_poll(
self,
task_id: str,
result: Any,
ctx: Any,
redis_client: Any | None,
error_exception: Exception | None = None,
) -> None: ...
# 旧版方法(保留兼容性)
async def poll_single_task(
self, db: Session, task: Any, *, redis_client: Any | None
) -> None: ...
def sanitize_error_message(self, message: str) -> str: ...
class TaskPollerService:
"""Generic background poller for async tasks."""
def __init__(self, adapter: TaskPollerAdapter) -> None:
self.adapter = adapter
self._lock = asyncio.Lock()
self.redis: Any | None = None
self._semaphore: asyncio.Semaphore | None = None
self._consecutive_failures = 0
async def start(self) -> None:
if self._semaphore is None:
self._semaphore = asyncio.Semaphore(self.adapter.concurrency)
# lazy import to avoid redis hard dependency in local runs
from src.clients.redis_client import get_redis_client
if self.redis is None:
self.redis = await get_redis_client(require_redis=False)
scheduler = get_scheduler()
scheduler.add_interval_job(
self.poll_pending_tasks,
seconds=self.adapter.interval_seconds,
job_id=self.adapter.job_id,
name=self.adapter.job_name,
)
async def stop(self) -> None:
scheduler = get_scheduler()
scheduler.remove_job(self.adapter.job_id)
async def poll_pending_tasks(self) -> None:
try:
await self._do_poll()
except asyncio.CancelledError:
logger.debug("[{}] poll_pending_tasks cancelled (shutdown?)", self.adapter.task_type)
return
async def _do_poll(self) -> None:
async with self._lock:
token = await self._acquire_redis_lock()
if token is None:
return
try:
with create_session() as db:
now = datetime.now(timezone.utc)
task_ids = self.adapter.list_due_task_ids(
db, now=now, limit=self.adapter.batch_size
)
if not task_ids:
self._consecutive_failures = 0
return
poll_results: list[bool] = []
if self._semaphore is None:
self._semaphore = asyncio.Semaphore(self.adapter.concurrency)
semaphore = self._semaphore
async def poll_with_semaphore(task_id: str) -> None:
async with semaphore:
try:
# ========== 阶段 1准备数据短暂持有连接==========
with create_session() as task_db:
task_obj = self.adapter.get_task(task_db, task_id)
if not task_obj:
logger.warning(
"[{}] Task {} disappeared during poll",
self.adapter.task_type,
task_id,
)
poll_results.append(True)
return
ctx_or_result = await self.adapter.prepare_poll_context(
task_db, task_obj
)
# 检查是否是错误结果(而非上下文)
if isinstance(ctx_or_result, InternalVideoPollResult):
# 准备阶段就失败了,直接更新任务状态
await self.adapter.update_task_after_poll(
task_id=task_id,
result=ctx_or_result,
ctx=None, # type: ignore[arg-type]
redis_client=self.redis,
)
poll_results.append(True)
return
ctx: VideoPollContext = ctx_or_result
# ========== 阶段 2HTTP 请求(不持有数据库连接)==========
error_exception: Exception | None = None
try:
result = await self.adapter.poll_task_http(ctx)
except Exception as http_exc:
# HTTP 请求失败,记录异常以便后续处理
error_exception = http_exc
result = InternalVideoPollResult(
status=None, # type: ignore[arg-type]
error_message=str(http_exc),
)
# ========== 阶段 3更新数据库获取新连接==========
await self.adapter.update_task_after_poll(
task_id=task_id,
result=result,
ctx=ctx,
redis_client=self.redis,
error_exception=error_exception,
)
poll_results.append(True)
except Exception as exc:
logger.exception(
"[{}] Unexpected error polling task {}: {}",
self.adapter.task_type,
task_id,
self.adapter.sanitize_error_message(str(exc)),
)
poll_results.append(False)
async with asyncio.TaskGroup() as tg:
for tid in task_ids:
tg.create_task(poll_with_semaphore(tid))
batch_failures = sum(1 for r in poll_results if r is False)
if batch_failures == len(task_ids):
self._consecutive_failures += 1
if (
self._consecutive_failures
>= self.adapter.consecutive_failure_alert_threshold
):
logger.error(
"[ALERT] {} poller: {} consecutive batches failed.",
self.adapter.task_type,
self._consecutive_failures,
)
else:
self._consecutive_failures = 0
finally:
await self._release_redis_lock(token)
async def _acquire_redis_lock(self) -> str | None:
if not self.redis:
return "no_redis"
token = str(uuid4())
try:
acquired = await self.redis.set(
self.adapter.lock_key, token, nx=True, ex=self.adapter.lock_ttl
)
except Exception as exc:
logger.warning(
"[{}] Redis lock acquire failed (best-effort skip): {}",
self.adapter.task_type,
exc,
)
return "no_redis"
return token if acquired else None
async def _release_redis_lock(self, token: str) -> None:
if not self.redis or token == "no_redis":
return
script = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
"""
try:
await self.redis.eval(script, 1, self.adapter.lock_key, token)
except Exception as exc:
logger.warning(
"[{}] Redis lock release failed (will expire via TTL): {}",
self.adapter.task_type,
exc,
)
_task_poller: TaskPollerService | None = None
def get_task_poller() -> TaskPollerService:
global _task_poller
if _task_poller is None:
_task_poller = TaskPollerService(VideoTaskPollerAdapter())
return _task_poller