Files
life-echo/api/app/features/story/post_commit.py

229 lines
8.2 KiB
Python
Raw Normal View History

"""
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