feat: move unified generation to background worker

This commit is contained in:
2026-04-19 17:29:37 +08:00
parent 5318de670f
commit 6fb128955f
15 changed files with 632 additions and 285 deletions

View File

@@ -6,7 +6,7 @@ from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import desc, select
from sqlalchemy import desc, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
@@ -59,6 +59,7 @@ def _job_progress(job: GenerationJob) -> dict[str, Any]:
progress_map: dict[str, tuple[int, str]] = {
"request_accepted": (5, "已接收请求"),
"worker_started": (12, "后台任务已开始"),
"context_prepared": (20, "上下文已准备"),
"narrative_generated": (45, "正文已生成"),
"story_saved": (60, "主记录已保存"),
@@ -66,8 +67,18 @@ def _job_progress(job: GenerationJob) -> dict[str, Any]:
"provider_call_succeeded": (72, "Provider 调用成功"),
"provider_call_failed": (72, "Provider 调用失败,尝试恢复"),
"cover_image_started": (75, "封面生成中"),
"cover_image_succeeded": (88, "封面已生成"),
"cover_image_failed": (88, "封面生成失败"),
"storybook_images_started": (75, "绘本插图生成中"),
"storybook_cover_image_succeeded": (82, "绘本封面已生成"),
"storybook_cover_image_failed": (82, "绘本封面生成失败"),
"storybook_page_image_succeeded": (86, "分页插图已生成"),
"storybook_page_image_failed": (86, "分页插图生成失败"),
"storybook_images_completed": (92, "绘本插图已完成"),
"audio_started": (75, "音频生成中"),
"audio_cache_hit": (88, "音频缓存已复用"),
"audio_succeeded": (88, "音频已生成"),
"audio_failed": (88, "音频生成失败"),
"asset_retry_started": (25, "资源重试中"),
"postprocessing_queued": (90, "后处理已排队"),
"asset_generation_completed": (100, "资源已完成"),
@@ -155,6 +166,10 @@ async def record_generation_event(
) -> GenerationJobEvent:
"""Append one event to an existing generation job."""
job.current_step = event_type
if story_id is not None:
job.story_id = story_id
event = GenerationJobEvent(
job_id=job.id,
story_id=story_id if story_id is not None else job.story_id,
@@ -169,6 +184,42 @@ async def record_generation_event(
return event
async def claim_generation_job_for_worker(
db: AsyncSession,
*,
job_id: str,
) -> GenerationJob | None:
"""Claim one queued generation job for worker execution once."""
claim_result = await db.execute(
update(GenerationJob)
.where(
GenerationJob.id == job_id,
GenerationJob.status == "running",
GenerationJob.current_step == "request_accepted",
)
.values(current_step="worker_started")
)
await db.commit()
if not claim_result.rowcount:
return None
result = await db.execute(select(GenerationJob).where(GenerationJob.id == job_id))
job = result.scalar_one_or_none()
if job is None:
return None
await record_generation_event(
db,
job=job,
event_type="worker_started",
status="running",
message="Generation worker started processing the accepted request.",
)
return job
async def finish_generation_job(
db: AsyncSession,
*,

View File

@@ -12,7 +12,7 @@ from sqlalchemy.orm import joinedload
from app.core.config import settings
from app.core.logging import get_logger
from app.db.models import ChildProfile, Story, StoryUniverse
from app.db.models import ChildProfile, GenerationJob, Story, StoryUniverse
from app.schemas.story_schemas import (
AchievementItem,
FullStoryResponse,
@@ -33,6 +33,7 @@ from app.services.audio_storage import (
write_story_audio_cache,
)
from app.services.generation_jobs import (
claim_generation_job_for_worker,
create_generation_job,
ensure_no_active_story_generation_job,
finish_generation_job,
@@ -1113,7 +1114,7 @@ async def generate_generation_service(
user_id: str,
db: AsyncSession,
) -> GenerationResponse:
"""Unified generation workflow entry point for stories and storybooks."""
"""Queue one unified generation workflow for background execution."""
job = await create_generation_job(
db,
@@ -1124,7 +1125,65 @@ async def generate_generation_service(
)
try:
response = await _generate_generation_service_with_job(request, user_id, db, job=job)
from app.tasks.generation_workflow import run_generation_workflow_task
run_generation_workflow_task.delay(job.id)
except Exception as exc:
await finish_generation_job(
db,
job=job,
story=None,
status="failed",
current_step="generation_failed",
error_message="Background generation dispatch failed.",
message="Generation failed before the worker could start processing the job.",
metadata={"dispatch_error": str(exc)},
)
raise HTTPException(
status_code=503,
detail="后台生成任务派发失败,请确认 worker 可用后重试。",
) from exc
return _build_queued_generation_response(request, job_id=job.id)
def _build_queued_generation_response(
request: GenerationRequest,
*,
job_id: str,
) -> GenerationResponse:
"""Build the immediate API response after a generation job is accepted."""
return GenerationResponse(
id=None,
generation_job_id=job_id,
title="生成任务已提交",
mode="storybook" if request.output_mode == "storybook" else "generated",
generation_status="queued",
text_status="generating",
image_status="not_requested",
audio_status="not_requested",
last_error=None,
retryable_assets=[],
child_profile_id=request.child_profile_id,
universe_id=request.universe_id,
)
async def execute_generation_job_service(
job: GenerationJob,
db: AsyncSession,
) -> GenerationResponse:
"""Execute one previously accepted generation job inside the worker."""
try:
request = GenerationRequest.model_validate(job.request_payload or {})
response = await _generate_generation_service_with_job(
request,
job.user_id,
db,
job=job,
)
except HTTPException as exc:
await finish_generation_job(
db,
@@ -1151,6 +1210,21 @@ async def generate_generation_service(
return response
async def run_generation_job_service(
job_id: str,
db: AsyncSession,
) -> GenerationJob | None:
"""Claim and execute one generation job from the background queue."""
job = await claim_generation_job_for_worker(db, job_id=job_id)
if job is None:
logger.info("generation_job_execution_skipped", job_id=job_id)
return None
await execute_generation_job_service(job, db)
return job
async def _generate_generation_service_with_job(
request: GenerationRequest,
user_id: str,