Files
life-echo/api/app/features/story/post_commit.py
Sully 53e0065e3e refactor(api): TOML 配置 SSOT、统一错误契约、Auth/事务加固与可观测性 (#33)
配置 SSOT(TOML + .env)
统一错误契约
Auth 与事务边界
Redis / Celery 可靠性:业务 Redis(DB/0)与 Celery broker/backend(DB/1)显式拆分;连接池、sync client
可观测性(OpenTelemetry + LGTM)
2026-05-22 13:44:50 +08:00

229 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Story 写入提交后的统一异步调度:插图、章节物化、可选 memory compaction。
enqueue 失败不回滚已提交数据,仅记录日志;依赖后续触发或重试收敛。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, cast
from app.core.logging import get_logger
from app.core.memoir_pipeline_progress import merge_pipeline_run
from app.core.memory_compaction_schedule import schedule_memory_compaction_run
from app.core.redis_sync import get_sync_redis
from app.features.memoir.constants import memoir
from app.features.story.constants import story
logger = get_logger(__name__)
def _story_image_enqueue_key(story_id: str) -> str:
return f"enqueue:story-image:{story_id}"
def _get_redis():
return get_sync_redis(decode_responses=True)
@dataclass
class PostCommitResult:
enqueued_story_image_count: int = 0
enqueued_chapter_recompose_count: int = 0
compaction_scheduled: bool = False
quality_pass_scheduled: bool = False
errors: list[str] = field(default_factory=list)
def enqueue_story_post_commit_effects(
*,
user_id: str,
story_ids: set[str],
chapter_ids: set[str],
trigger_source: str,
need_image: bool = True,
need_recompose: bool = True,
need_compaction: bool = False,
need_quality_pass: bool = False,
compaction_extra: dict[str, Any] | None = None,
memoir_correlation_id: str | None = None,
) -> PostCommitResult:
"""
Unified post-commit fan-out: story images, chapter recompose, compaction,
and quality pass. story_ids 为空则跳过 imagechapter_ids 为空则跳过 recompose。
need_compaction=True 时仅按 user_id 调度 compaction不依赖 story/chapter 集合)。
"""
result = PostCommitResult()
r = _get_redis()
ttl = int(story.image_enqueue_dedup_ttl)
if need_image and story_ids:
from app.tasks.story_image_tasks import (
generate_story_image as gen_story_image_task,
)
for sid in sorted(story_ids):
key = _story_image_enqueue_key(sid)
try:
if not r.set(key, "1", nx=True, ex=ttl):
logger.debug(
"story_image enqueue skipped (dedup): story={} trigger={}",
sid,
trigger_source,
)
continue
except Exception as exc:
logger.warning(
"story_image enqueue dedup redis failed, allowing enqueue: "
"story={} err={}",
sid,
exc,
)
try:
img_ar = cast(Any, gen_story_image_task).delay(
sid, memoir_correlation_id=memoir_correlation_id
)
result.enqueued_story_image_count += 1
tid_img = getattr(img_ar, "id", None)
if memoir_correlation_id and tid_img:
merge_pipeline_run(
memoir_correlation_id,
{
"fanout": {
"story_images": [
{
"story_id": sid,
"task_id": str(tid_img),
"status": "enqueued",
}
]
},
},
)
except Exception as exc:
logger.warning(
"generate_story_image.delay failed story={} trigger={}: {}",
sid,
trigger_source,
exc,
)
result.errors.append(f"generate_story_image:{sid}:{exc}")
try:
r.delete(key)
except Exception as e:
logger.debug("Redis key 清理失败: {}", e)
if need_recompose and chapter_ids:
from app.tasks.chapter_compose_tasks import (
recompose_chapter as recompose_chapter_task,
)
cd = int(story.recompose_chapter_delay_seconds)
for cid in sorted(chapter_ids):
try:
rkwargs: dict[str, Any] = {}
if memoir_correlation_id:
rkwargs["memoir_correlation_id"] = memoir_correlation_id
rec_ar = cast(Any, recompose_chapter_task).apply_async(
args=[cid],
kwargs=rkwargs,
countdown=max(0, cd),
)
result.enqueued_chapter_recompose_count += 1
tid_rec = getattr(rec_ar, "id", None)
if memoir_correlation_id and tid_rec:
merge_pipeline_run(
memoir_correlation_id,
{
"fanout": {
"recompose_chapters": [
{
"chapter_id": cid,
"task_id": str(tid_rec),
"status": "enqueued",
}
]
},
},
)
except Exception as exc:
logger.warning(
"recompose_chapter.apply_async failed chapter={} trigger={}: {}",
cid,
trigger_source,
exc,
)
result.errors.append(f"recompose_chapter:{cid}:{exc}")
if need_compaction:
try:
ctx: dict[str, Any] = {
"trigger_source": trigger_source,
"trigger_time": datetime.now(timezone.utc).isoformat(),
"story_ids": sorted(story_ids),
"chapter_ids": sorted(chapter_ids),
}
if compaction_extra:
ctx.update(compaction_extra)
schedule_memory_compaction_run(user_id, ctx)
result.compaction_scheduled = True
if memoir_correlation_id:
merge_pipeline_run(
memoir_correlation_id,
{
"fanout": {
"compaction": {
"status": "scheduled",
"note": "debounce",
},
},
},
)
except Exception as exc:
logger.warning(
"schedule_memory_compaction_run failed user_id={} trigger={}: {}",
user_id,
trigger_source,
exc,
)
result.errors.append(f"compaction:{exc}")
if need_quality_pass and memoir.quality_pass_enabled and story_ids:
try:
from app.tasks.memoir_quality_pass_tasks import (
memoir_quality_pass as quality_pass_task,
)
cd = int(memoir.quality_pass_delay_seconds)
qp_ar = cast(Any, quality_pass_task).apply_async(
args=[user_id, sorted(story_ids), sorted(chapter_ids)],
kwargs={"memoir_correlation_id": memoir_correlation_id},
countdown=max(0, cd),
)
result.quality_pass_scheduled = True
tid_qp = getattr(qp_ar, "id", None)
if memoir_correlation_id and tid_qp:
merge_pipeline_run(
memoir_correlation_id,
{
"fanout": {
"quality_pass": {
"task_id": str(tid_qp),
"status": "enqueued",
},
},
},
)
except Exception as exc:
logger.warning(
"memoir_quality_pass enqueue failed user_id={} trigger={}: {}",
user_id,
trigger_source,
exc,
)
result.errors.append(f"quality_pass:{exc}")
return result