feat: add router

This commit is contained in:
itqop 2025-11-05 01:44:22 +03:00
parent 33d8f5ab8b
commit 18cbbe00d3
3 changed files with 197 additions and 70 deletions

View File

@ -1,14 +1,68 @@
"""Агрегатор v1-роутов. # src/dataloader/api/v1/router.py
from __future__ import annotations
Экспортирует готовый `router`, собранный из модульных роутеров в пакете `routes`. from collections.abc import AsyncGenerator
Оставлен как тонкий слой для обратной совместимости импортов `from dataloader.api.v1 import router`. from http import HTTPStatus
""" from typing import Annotated
from uuid import UUID
from fastapi import APIRouter from fastapi import APIRouter, Depends, HTTPException
from dataloader.api.v1.schemas import (
CancelJobResponse,
JobStatusResponse,
TriggerJobRequest,
TriggerJobResponse,
)
from dataloader.api.v1.service import JobsService
from dataloader.storage.db import session_scope
router = APIRouter() router = APIRouter(prefix="/api/v1/jobs", tags=["jobs"])
async def get_service() -> AsyncGenerator[JobsService, None]:
"""
Создаёт JobsService с новой сессией и корректно закрывает её после запроса.
"""
async for s in session_scope():
yield JobsService(s)
__all__ = ["router"]
@router.post("/trigger", response_model=TriggerJobResponse, status_code=HTTPStatus.OK)
async def trigger_job(
payload: TriggerJobRequest,
svc: Annotated[JobsService, Depends(get_service)],
) -> TriggerJobResponse:
"""
Создаёт или возвращает существующую задачу по idempotency_key.
"""
return await svc.trigger(payload)
@router.get("/{job_id}/status", response_model=JobStatusResponse, status_code=HTTPStatus.OK)
async def get_status(
job_id: UUID,
svc: Annotated[JobsService, Depends(get_service)],
) -> JobStatusResponse:
"""
Возвращает статус задачи по идентификатору.
"""
st = await svc.status(job_id)
if not st:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="job not found")
return st
@router.post("/{job_id}/cancel", response_model=CancelJobResponse, status_code=HTTPStatus.OK)
async def cancel_job(
job_id: UUID,
svc: Annotated[JobsService, Depends(get_service)],
) -> CancelJobResponse:
"""
Запрашивает отмену задачи.
"""
st = await svc.cancel(job_id)
if not st:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="job not found")
return st

View File

@ -1,49 +1,70 @@
"""Pydantic схемы для API v1: запросы и ответы.""" # src/dataloader/api/v1/schemas.py
from __future__ import annotations
from pydantic import BaseModel, Field from datetime import datetime, timezone
from typing import Optional, Dict, Any from typing import Any, Optional
from datetime import datetime from uuid import UUID, uuid4
from uuid import UUID
from pydantic import BaseModel, Field, field_validator
class JobTriggerRequest(BaseModel): class TriggerJobRequest(BaseModel):
"""Запрос на постановку задачи в очередь.""" """
queue: str = Field(..., description="Название очереди") Запрос на постановку задачи в очередь.
task: str = Field(..., description="Тип задачи") """
args: Optional[Dict[str, Any]] = Field(default={}, description="Аргументы задачи") queue: str = Field(...)
idempotency_key: Optional[str] = Field(None, description="Ключ идемпотентности") task: str = Field(...)
lock_key: str = Field(..., description="Ключ блокировки") args: dict[str, Any] = Field(default_factory=dict)
partition_key: Optional[str] = Field(default="", description="Ключ партиционирования") idempotency_key: Optional[str] = Field(default=None)
priority: Optional[int] = Field(default=100, description="Приоритет задачи") lock_key: str = Field(...)
available_at: Optional[datetime] = Field(None, description="Время доступности задачи (RFC3339)") partition_key: str = Field(default="")
priority: int = Field(default=100, ge=0)
available_at: Optional[datetime] = Field(default=None)
max_attempts: int = Field(default=5, ge=0)
lease_ttl_sec: int = Field(default=60, gt=0)
producer: Optional[str] = Field(default=None)
consumer_group: Optional[str] = Field(default=None)
@field_validator("available_at")
@classmethod
def _ensure_tz(cls, v: Optional[datetime]) -> Optional[datetime]:
if v is None:
return None
return v if v.tzinfo else v.replace(tzinfo=timezone.utc)
class JobTriggerResponse(BaseModel): class TriggerJobResponse(BaseModel):
"""Ответ на постановку задачи.""" """
job_id: UUID = Field(..., description="Идентификатор задачи") Ответ на постановку задачи.
status: str = Field(..., description="Статус задачи") """
job_id: UUID = Field(...)
status: str = Field(...)
class JobStatusResponse(BaseModel): class JobStatusResponse(BaseModel):
"""Ответ со статусом задачи.""" """
job_id: UUID Текущий статус задачи.
status: str """
attempt: int job_id: UUID = Field(...)
started_at: Optional[datetime] = None status: str = Field(...)
finished_at: Optional[datetime] = None attempt: int = Field(...)
heartbeat_at: Optional[datetime] = None started_at: Optional[datetime] = Field(default=None)
error: Optional[str] = None finished_at: Optional[datetime] = Field(default=None)
progress: Dict[str, Any] = Field(default_factory=dict) heartbeat_at: Optional[datetime] = Field(default=None)
error: Optional[str] = Field(default=None)
progress: dict[str, Any] = Field(default_factory=dict)
class JobCancelResponse(BaseModel): class CancelJobResponse(BaseModel):
"""Ответ на отмену задачи.""" """
job_id: UUID Ответ на запрос отмены задачи.
status: str """
attempt: int job_id: UUID = Field(...)
started_at: Optional[datetime] = None status: str = Field(...)
finished_at: Optional[datetime] = None
heartbeat_at: Optional[datetime] = None
error: Optional[str] = None
progress: Dict[str, Any] = Field(default_factory=dict)
def new_job_id() -> UUID:
"""
Возвращает новый UUID для идентификатора задачи.
"""
return uuid4()

View File

@ -1,31 +1,83 @@
"""Бизнес-логика для API v1.""" # src/dataloader/api/v1/service.py
from __future__ import annotations
from typing import Optional from datetime import datetime, timezone
from typing import Any, Optional
from uuid import UUID from uuid import UUID
from datetime import datetime
from .schemas import JobTriggerRequest, JobStatusResponse, JobCancelResponse from sqlalchemy.ext.asyncio import AsyncSession
from ...storage.repositories import JobRepository
from dataloader.api.v1.schemas import (
CancelJobResponse,
JobStatusResponse,
TriggerJobRequest,
TriggerJobResponse,
new_job_id,
)
from dataloader.storage.repositories import (
CreateJobRequest,
QueueRepository,
)
from dataloader.context import APP_CTX
class JobService: class JobsService:
"""Сервис для работы с задачами.""" """
Бизнес-логика работы с очередью задач.
"""
def __init__(self, session: AsyncSession):
self._s = session
self._repo = QueueRepository(self._s)
self._log = APP_CTX.get_logger()
def __init__(self, job_repo: JobRepository): async def trigger(self, req: TriggerJobRequest) -> TriggerJobResponse:
self.job_repo = job_repo """
Идемпотентно ставит задачу в очередь и возвращает её идентификатор и статус.
"""
job_uuid: UUID = new_job_id()
dt = req.available_at or datetime.now(timezone.utc)
creq = CreateJobRequest(
job_id=str(job_uuid),
queue=req.queue,
task=req.task,
args=req.args or {},
idempotency_key=req.idempotency_key,
lock_key=req.lock_key,
partition_key=req.partition_key or "",
priority=int(req.priority),
available_at=dt,
max_attempts=int(req.max_attempts),
lease_ttl_sec=int(req.lease_ttl_sec),
producer=req.producer,
consumer_group=req.consumer_group,
)
job_id, status = await self._repo.create_or_get(creq)
return TriggerJobResponse(job_id=UUID(job_id), status=status)
async def trigger_job(self, request: JobTriggerRequest) -> JobTriggerResponse: async def status(self, job_id: UUID) -> Optional[JobStatusResponse]:
"""Постановка задачи в очередь.""" """
# TODO: реализовать идемпотентную постановку через репозиторий Возвращает статус задачи.
raise NotImplementedError """
st = await self._repo.get_status(str(job_id))
async def get_job_status(self, job_id: UUID) -> Optional[JobStatusResponse]: if not st:
"""Получение статуса задачи.""" return None
# TODO: реализовать через репозиторий return JobStatusResponse(
raise NotImplementedError job_id=UUID(st.job_id),
status=st.status,
async def cancel_job(self, job_id: UUID) -> Optional[JobCancelResponse]: attempt=st.attempt,
"""Отмена задачи.""" started_at=st.started_at,
# TODO: реализовать через репозиторий finished_at=st.finished_at,
raise NotImplementedError heartbeat_at=st.heartbeat_at,
error=st.error,
progress=st.progress or {},
)
async def cancel(self, job_id: UUID) -> Optional[CancelJobResponse]:
"""
Запрашивает отмену задачи и возвращает её текущее состояние.
"""
await self._repo.cancel(str(job_id))
st = await self._repo.get_status(str(job_id))
if not st:
return None
return CancelJobResponse(job_id=UUID(st.job_id), status=st.status)