08c5213168
DS 가 가진 카드 SR progress row 를 발행(kind=study_card_progress) = read model.
viewer C-4 복습큐/미확인 set-difference 재료. plan study-viewer-port S-4.
- projection: KIND_CARD_PROGRESS + project_card_progress(card_id·topic_id·last_outcome·
last_reviewed_at·due_at·review_stage). ★ALL row(due_at NULL sentinel=암-on-new·terminal
포함) — due-only 발행 금지(sentinel 누락→viewer 미확인 오분류).
- enqueue: enqueue_card_progress_publish + backfill_publish_card_progress(필터 없음).
- 훅: /study-cards/{id}/rate 의 rate_card 직후(같은 tx·flag 게이트). 단일 write 사이트.
SR 계산=DS(sr_schedule 무변경), 발행=결과만.
- 카드 삭제 시 progress tombstone 안 함 = DS SR 보존(재승인 복원), orphan 은 viewer C-4 가 로컬 드롭.
- scripts/backfill_publish_card_progress.py.
py_compile PASS · project_card_progress 단위검증(sentinel due_at=None 보존).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
175 lines
6.8 KiB
Python
175 lines
6.8 KiB
Python
"""발행 outbox enqueue + 초기 백필 (docsrv-viewer-publish).
|
|
|
|
enqueue_publish: 저작/4-A 트랜잭션이 같은 session(=같은 Postgres tx)에서 호출 → caller commit
|
|
(P0-1 규율: 콘텐츠 변경과 outbox INSERT 원자성, dual-write 회피). payload/hash 스냅샷.
|
|
enqueue_question_publish: 문항 + (ready면)해설을 함께 적재. 저작 쓰기/4-A 완료/백필 공용.
|
|
backfill_publish_questions: 기존 active 문항을 bounded 로 1회 outbox 적재(초기 백필, P2-1 bounded page).
|
|
멱등 = 발행 워커의 (payload_hash, deleted) 디둡이 no-op 재투영 흡수(중복 enqueue 무해).
|
|
|
|
★주의: 저작 엔드포인트(study_questions create/update)·4-A 워커에서의 enqueue 결선은 P0-1b
|
|
(기존 hot 파일 수정이라 별 increment). 본 모듈은 호출 라이브러리 + 수동/백필 진입점.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from models.published import PublishOutbox
|
|
from models.study_memo_card import StudyMemoCard
|
|
from models.study_memo_card_progress import StudyMemoCardProgress
|
|
from models.study_question import StudyQuestion
|
|
from models.study_topic import StudyTopic
|
|
from services.study.publish_projection import (
|
|
KIND_CARD,
|
|
KIND_CARD_PROGRESS,
|
|
KIND_EXPLANATION,
|
|
KIND_QUESTION,
|
|
KIND_TOPIC,
|
|
SCHEMA_VERSION,
|
|
payload_hash,
|
|
project_card,
|
|
project_card_progress,
|
|
project_explanation,
|
|
project_question,
|
|
project_topic,
|
|
)
|
|
|
|
|
|
async def enqueue_publish(
|
|
session: AsyncSession,
|
|
*,
|
|
kind: str,
|
|
source_id: int,
|
|
payload: dict[str, Any] | None,
|
|
deleted: bool = False,
|
|
) -> None:
|
|
"""outbox 1행 INSERT. caller 가 commit (저자 tx 동봉). deleted=True 면 tombstone(payload={})."""
|
|
body: dict[str, Any] = payload if payload is not None else {}
|
|
session.add(
|
|
PublishOutbox(
|
|
kind=kind,
|
|
source_id=source_id,
|
|
payload=body,
|
|
payload_hash=payload_hash(body),
|
|
schema_version=SCHEMA_VERSION,
|
|
deleted=deleted,
|
|
)
|
|
)
|
|
|
|
|
|
async def enqueue_question_publish(session: AsyncSession, q: Any) -> None:
|
|
"""문항 + (ready면)해설을 outbox 적재. caller commit."""
|
|
await enqueue_publish(session, kind=KIND_QUESTION, source_id=q.id, payload=project_question(q))
|
|
expl = project_explanation(q)
|
|
if expl is not None:
|
|
await enqueue_publish(session, kind=KIND_EXPLANATION, source_id=q.id, payload=expl)
|
|
|
|
|
|
async def backfill_publish_questions(session: AsyncSession, *, after_id: int = 0, limit: int = 200) -> int:
|
|
"""active(미삭제) 문항을 id>after_id 부터 bounded 로 outbox 적재.
|
|
|
|
반환 = enqueue 한 문항 수(0 이면 끝). 큰 셋은 마지막 id 로 페이지 반복. caller commit.
|
|
"""
|
|
rows = (
|
|
await session.execute(
|
|
select(StudyQuestion)
|
|
.where(StudyQuestion.deleted_at.is_(None), StudyQuestion.id > after_id)
|
|
.order_by(StudyQuestion.id.asc())
|
|
.limit(limit)
|
|
)
|
|
).scalars().all()
|
|
for q in rows:
|
|
await enqueue_question_publish(session, q)
|
|
return len(rows)
|
|
|
|
|
|
async def enqueue_topic_publish(session: AsyncSession, topic: Any) -> None:
|
|
"""주제 메타를 outbox 적재(S-1). caller commit. 저작 create/update 결선 + 백필 공용."""
|
|
await enqueue_publish(session, kind=KIND_TOPIC, source_id=topic.id, payload=project_topic(topic))
|
|
|
|
|
|
async def backfill_publish_topics(session: AsyncSession, *, after_id: int = 0, limit: int = 200) -> int:
|
|
"""active(미삭제) 주제를 id>after_id 부터 bounded 로 outbox 적재(S-1 초기 백필).
|
|
|
|
반환 = enqueue 한 주제 수(0 이면 끝). 큰 셋은 마지막 id 로 페이지 반복. caller commit.
|
|
멱등 = 발행 워커의 (payload_hash, deleted) 디둡이 no-op 재투영 흡수(중복 enqueue 무해).
|
|
"""
|
|
rows = (
|
|
await session.execute(
|
|
select(StudyTopic)
|
|
.where(StudyTopic.deleted_at.is_(None), StudyTopic.id > after_id)
|
|
.order_by(StudyTopic.id.asc())
|
|
.limit(limit)
|
|
)
|
|
).scalars().all()
|
|
for t in rows:
|
|
await enqueue_topic_publish(session, t)
|
|
return len(rows)
|
|
|
|
|
|
async def enqueue_card_publish(session: AsyncSession, card: Any) -> None:
|
|
"""카드 상태 기반 발행/tombstone (S-2). caller commit.
|
|
|
|
검수완료(needs_review=False) & 미삭제 만 발행 — 그 외(검수대기 복귀·삭제·retire)는
|
|
tombstone(feed 1급 삭제 이벤트). 발행 자격이 카드 상태에 매여 있어 호출측은 '카드를
|
|
건드렸다'만 알면 되고 publish/tombstone 분기는 여기 단일화(경로별 가드 기억 회피).
|
|
"""
|
|
if card.deleted_at is not None or card.needs_review:
|
|
await enqueue_publish(session, kind=KIND_CARD, source_id=card.id, payload=None, deleted=True)
|
|
else:
|
|
await enqueue_publish(session, kind=KIND_CARD, source_id=card.id, payload=project_card(card))
|
|
|
|
|
|
async def backfill_publish_cards(session: AsyncSession, *, after_id: int = 0, limit: int = 200) -> int:
|
|
"""검수완료(needs_review=False)·미삭제 카드를 id>after_id 부터 bounded 로 outbox 적재(S-2 초기 백필).
|
|
|
|
반환 = enqueue 한 카드 수(0 이면 끝). 멱등 = 워커 (payload_hash, deleted) 디둡. caller commit.
|
|
"""
|
|
rows = (
|
|
await session.execute(
|
|
select(StudyMemoCard)
|
|
.where(
|
|
StudyMemoCard.deleted_at.is_(None),
|
|
StudyMemoCard.needs_review.is_(False),
|
|
StudyMemoCard.id > after_id,
|
|
)
|
|
.order_by(StudyMemoCard.id.asc())
|
|
.limit(limit)
|
|
)
|
|
).scalars().all()
|
|
for c in rows:
|
|
await enqueue_card_publish(session, c)
|
|
return len(rows)
|
|
|
|
|
|
async def enqueue_card_progress_publish(session: AsyncSession, progress: Any) -> None:
|
|
"""카드 SR progress row 발행(S-4). caller commit. rate_card 결과(ALL row, sentinel/terminal 포함)."""
|
|
await enqueue_publish(
|
|
session,
|
|
kind=KIND_CARD_PROGRESS,
|
|
source_id=progress.id,
|
|
payload=project_card_progress(progress),
|
|
)
|
|
|
|
|
|
async def backfill_publish_card_progress(session: AsyncSession, *, after_id: int = 0, limit: int = 200) -> int:
|
|
"""모든 card progress row 를 id>after_id 부터 bounded 로 outbox 적재(S-4 초기 백필).
|
|
|
|
★필터 없음 = ALL row(due_at NULL sentinel·terminal 포함) — due-only 백필은 sentinel 누락.
|
|
반환 = enqueue 한 row 수(0 이면 끝). 멱등 = 워커 디둡. caller commit.
|
|
"""
|
|
rows = (
|
|
await session.execute(
|
|
select(StudyMemoCardProgress)
|
|
.where(StudyMemoCardProgress.id > after_id)
|
|
.order_by(StudyMemoCardProgress.id.asc())
|
|
.limit(limit)
|
|
)
|
|
).scalars().all()
|
|
for p in rows:
|
|
await enqueue_card_progress_publish(session, p)
|
|
return len(rows)
|