feat(domains): identity, billing, task services and domain placeholders
Billing implements reserve -> commit -> refund against bl_quota and bl_ledger with row-level locking and idempotency keys; the refund commits on its own transaction so a failed request cannot silently keep a user's quota. Task implements the unified task centre over the state machine. Identity issues and resolves users. Remaining domains are placeholders whose READMEs state what belongs there and what must not.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# `packages/task/` — 统一任务中心
|
||||
|
||||
**全系统唯一的长耗时任务视图**(架构 §6.2、ADR-14)。报告、换装、发型、抠图、
|
||||
渲染全部走这里,前端任务中心直接读 `tk_task`。
|
||||
|
||||
## 铁律
|
||||
|
||||
**不要在业务域里自建任务状态字段。** 每个功能各自实现一遍任务状态、进度、
|
||||
幂等、重试,集成时会全部返工 —— 这正是 M1 存在的理由。
|
||||
|
||||
## 已实现
|
||||
|
||||
- `tk_task` 表与状态机(`packages/domain/task/state.py`,纯逻辑可单测)
|
||||
- 幂等提交(相同 `idempotency_key` 返回同一任务,不重复扣点,FR-TASK-05)
|
||||
- 归属校验(架构 §11.1)
|
||||
- Celery 四队列(gpu/cpu/io/beat)与一个贯通的 `ping` 任务
|
||||
|
||||
## 待实现
|
||||
|
||||
| 需求 | 内容 |
|
||||
| --- | --- |
|
||||
| FR-TASK-02 | 进度与预估时间(阶段文案 + 排队位次 + 基于历史 p50 的预估) |
|
||||
| FR-TASK-06 | 任务取消:排队中可取消并退点;执行中标记取消不再返回结果 |
|
||||
| FR-TASK-07 | 死信队列与告警:连续失败进死信并告警,**不静默丢弃** |
|
||||
| — | beat 防重(单实例);自动重试与上限 |
|
||||
|
||||
## 状态机
|
||||
|
||||
```
|
||||
pending → queued → running → succeeded
|
||||
↓ ↓ ↓ ↘ failed → queued(重试)
|
||||
failed failed canceled canceled
|
||||
```
|
||||
|
||||
终态是 `succeeded` / `failed` / `canceled`;只有 `failed` 可以重新入队。
|
||||
非成功终态**必须触发配额退还**(`needs_refund()`)。
|
||||
|
||||
## 排队时间与执行时间必须分开统计
|
||||
|
||||
架构 §12.1:混在一起看会得出错误结论 —— 明明是 GPU 容量不足(该扩容),
|
||||
却被误判为推理慢(去优化模型)。
|
||||
@@ -0,0 +1 @@
|
||||
"""task 域:统一任务中心。"""
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
"""Data access for the task domain. **Only this module may touch tk_task.**"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from packages.shared.models import Task
|
||||
|
||||
|
||||
async def get(session: AsyncSession, task_id: int) -> Task | None:
|
||||
return await session.get(Task, task_id)
|
||||
|
||||
|
||||
async def get_for_update(session: AsyncSession, task_id: int) -> Task | None:
|
||||
result = await session.execute(select(Task).where(Task.task_id == task_id).with_for_update())
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def find_by_idempotency_key(session: AsyncSession, key: str) -> Task | None:
|
||||
result = await session.execute(select(Task).where(Task.idempotency_key == key))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_for_user(session: AsyncSession, user_id: int, limit: int = 20) -> list[Task]:
|
||||
result = await session.execute(
|
||||
select(Task)
|
||||
.where(Task.user_id == user_id)
|
||||
.order_by(Task.submitted_at.desc(), Task.task_id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
def add(session: AsyncSession, task: Task) -> Task:
|
||||
session.add(task)
|
||||
return task
|
||||
Executable
+136
@@ -0,0 +1,136 @@
|
||||
"""统一任务中心 (架构文档 §6.2 task 域, FR-TASK-01/02/05).
|
||||
|
||||
**全系统唯一的长耗时任务视图** —— 报告、换装、发型、抠图、渲染都走这里,前端
|
||||
任务中心直接读 ``tk_task``。State transitions are validated against the pure
|
||||
state machine in :mod:`packages.domain.task.state`, so an illegal transition
|
||||
fails loudly instead of corrupting the task list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from packages.domain.errors import InvalidTaskTransitionError
|
||||
from packages.domain.ids import next_id
|
||||
from packages.domain.task.state import (
|
||||
TaskStatus,
|
||||
TaskType,
|
||||
assert_transition,
|
||||
is_terminal,
|
||||
progress_for,
|
||||
)
|
||||
from packages.shared.errors import AppError, ErrorCode, ForbiddenError, NotFoundError
|
||||
from packages.shared.logging import get_logger
|
||||
from packages.shared.models import Task
|
||||
from packages.shared.observability import TASK_TRANSITIONS
|
||||
from packages.task import repo
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class TaskService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def submit(
|
||||
self,
|
||||
*,
|
||||
user_id: int,
|
||||
task_type: TaskType,
|
||||
idempotency_key: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
points_reserved: int = 0,
|
||||
reserve_tx_id: int | None = None,
|
||||
eta_seconds: int | None = None,
|
||||
) -> Task:
|
||||
"""Create a task. FR-TASK-05: 相同幂等键返回同一任务,不重复扣点。"""
|
||||
existing = await repo.find_by_idempotency_key(self._session, idempotency_key)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
task = repo.add(
|
||||
self._session,
|
||||
Task(
|
||||
task_id=next_id(),
|
||||
user_id=user_id,
|
||||
task_type=task_type.value,
|
||||
status=TaskStatus.PENDING.value,
|
||||
progress=progress_for(TaskStatus.PENDING),
|
||||
idempotency_key=idempotency_key,
|
||||
payload=payload or {},
|
||||
points_reserved=points_reserved,
|
||||
reserve_tx_id=reserve_tx_id,
|
||||
eta_seconds=eta_seconds,
|
||||
),
|
||||
)
|
||||
await self._session.flush()
|
||||
TASK_TRANSITIONS.labels(task_type.value, TaskStatus.PENDING.value).inc()
|
||||
return task
|
||||
|
||||
async def get(self, *, task_id: int, user_id: int) -> Task:
|
||||
task = await repo.get(self._session, task_id)
|
||||
if task is None:
|
||||
raise NotFoundError("任务不存在")
|
||||
# 架构文档 §11.1: 所有资源访问必须校验归属,不能只靠 ID 不可猜。
|
||||
if task.user_id != user_id:
|
||||
raise ForbiddenError()
|
||||
return task
|
||||
|
||||
async def list_for_user(self, *, user_id: int, limit: int = 20) -> list[Task]:
|
||||
return await repo.list_for_user(self._session, user_id, limit)
|
||||
|
||||
async def transition(
|
||||
self,
|
||||
*,
|
||||
task_id: int,
|
||||
target: TaskStatus,
|
||||
stage_text: str | None = None,
|
||||
progress: int | None = None,
|
||||
result: dict[str, Any] | None = None,
|
||||
error_code: int | None = None,
|
||||
error_message: str | None = None,
|
||||
) -> Task:
|
||||
task = await repo.get_for_update(self._session, task_id)
|
||||
if task is None:
|
||||
raise NotFoundError("任务不存在")
|
||||
|
||||
current = TaskStatus(task.status)
|
||||
try:
|
||||
assert_transition(current, target)
|
||||
except InvalidTaskTransitionError as exc:
|
||||
raise AppError(
|
||||
ErrorCode.TASK_STATE_INVALID,
|
||||
data={"current": exc.current, "target": exc.target},
|
||||
) from exc
|
||||
|
||||
now = datetime.now(UTC)
|
||||
task.status = target.value
|
||||
task.progress = progress if progress is not None else progress_for(target)
|
||||
if stage_text is not None:
|
||||
task.stage_text = stage_text
|
||||
if result is not None:
|
||||
task.result = result
|
||||
if error_code is not None:
|
||||
task.error_code = error_code
|
||||
if error_message is not None:
|
||||
task.error_message = error_message[:512]
|
||||
if target is TaskStatus.RUNNING and task.started_at is None:
|
||||
task.started_at = now
|
||||
if target is TaskStatus.QUEUED and current is TaskStatus.FAILED:
|
||||
task.retry_count += 1
|
||||
if is_terminal(target):
|
||||
task.finished_at = now
|
||||
|
||||
await self._session.flush()
|
||||
TASK_TRANSITIONS.labels(task.task_type, target.value).inc()
|
||||
logger.info(
|
||||
"task_transition",
|
||||
task_id=task_id,
|
||||
task_type=task.task_type,
|
||||
from_status=current.value,
|
||||
to_status=target.value,
|
||||
)
|
||||
return task
|
||||
Reference in New Issue
Block a user