"""study_card_enqueue — 버전키 폴러 (공부 암기노트 Phase 1). ready ai_explanation 인데 '현재 버전' card_extract job 이 없는 question 을 enqueue. 버전 멱등(핵심): NOT EXISTS(job WHERE source_kind='question' AND source_id=q.id AND source_version=q.ai_explanation_generated_at) - 같은 버전 재추출 차단 — completed/all_dropped job 도 현 버전에 존재하면 재enqueue 0(livelock 방지). - explanation 재생성(새 generated_at)이면 새 버전 job 부재 → 자동 재추출(정정-stale 해소). NULL 가드: ai_explanation_generated_at IS NOT NULL 전제 — NULL 이면 NULL=NULL=UNKNOWN 으로 NOT EXISTS 가 항상 참이 되어 매 폴 재enqueue 폭주. ready 전이 직후 race 를 이 가드가 막는다. thundering-herd: per-poll LIMIT(CARD_ENQUEUE_BATCH) + 최근(generated_at desc) 우선으로 backfill 완만. """ from __future__ import annotations import logging from sqlalchemy import select from core.config import settings from core.database import async_session from models.study_memo_card_job import StudyMemoCardJob, enqueue_study_memo_card_job from models.study_question import StudyQuestion logger = logging.getLogger("study_card_enqueue") CARD_ENQUEUE_BATCH = 20 SOURCE_KIND_QUESTION = "question" async def run() -> None: """APScheduler 진입점. ready & 현 버전 job 부재 question 을 BATCH 만큼 enqueue.""" if not getattr(settings, "study_card_extract_enabled", True): return async with async_session() as session: # 현재 ai_explanation_generated_at 버전에 대한 job 이 이미 있는지 (correlated NOT EXISTS). job_exists = ( select(StudyMemoCardJob.id) .where( StudyMemoCardJob.source_kind == SOURCE_KIND_QUESTION, StudyMemoCardJob.source_id == StudyQuestion.id, StudyMemoCardJob.source_version == StudyQuestion.ai_explanation_generated_at, ) .exists() ) rows = ( await session.execute( select( StudyQuestion.id, StudyQuestion.user_id, StudyQuestion.ai_explanation_generated_at, ) .where( StudyQuestion.deleted_at.is_(None), StudyQuestion.ai_explanation_status == "ready", StudyQuestion.ai_explanation_generated_at.is_not(None), ~job_exists, ) .order_by(StudyQuestion.ai_explanation_generated_at.desc()) .limit(CARD_ENQUEUE_BATCH) ) ).all() if not rows: return enqueued = 0 for r in rows: ok = await enqueue_study_memo_card_job( session, user_id=r.user_id, source_kind=SOURCE_KIND_QUESTION, source_id=r.id, source_version=r.ai_explanation_generated_at, kind="card_extract", ) if ok: enqueued += 1 await session.commit() if enqueued: logger.info("study_card_enqueue candidates=%d enqueued=%d", len(rows), enqueued)