"""발행 워커 — publish_outbox drain → published 에 rev 부여 (docsrv-viewer-publish). APScheduler 1분(max_instances=1). pg_advisory_xact_lock 단일 라이터 → rev 커밋순 gapless (인플라이트 갭 차단: bigserial seq 폴링이 아니라 outbox id 순 + 단일 라이터 rev 부여). outbox 를 id(커밋순) 순으로 처리, (kind, source_id) 당 published upsert: - 기존 행과 (payload_hash, deleted) 동일 → no-op(디둡, rev 안 올림) + processed 마킹 - 그 외 → pub_id 재사용(기존)|신규 uuid, rev = MAX(rev)+1, payload/hash/deleted 갱신 tombstone(deleted=True)은 디둡 복합키라 안 삼켜짐. 배치 단일 트랜잭션. 배치 내 같은 (kind, source_id) 가 두 번 오면 flush 로 직전 반영을 다음 select 가 보게 함(최신 승). study_publish_enabled=False(기본) 면 no-op — 저자/4-A enqueue 결선(P0-1b) 전까지 inert. """ from __future__ import annotations import uuid from datetime import datetime, timezone from sqlalchemy import func, select, text from core.config import settings from core.database import async_session from core.utils import setup_logger from models.published import Published, PublishOutbox logger = setup_logger("study_publish_worker") BATCH_SIZE = 500 # pg_advisory_xact_lock 전역 단일 라이터 키(발행 워커 전용 임의 상수, 타 advisory 락과 비충돌). ADVISORY_LOCK_KEY = 838201 # 행별 격리 재시도 상한 — 초과 시 failed_at 스탬프(terminal)로 select 에서 제외. MAX_OUTBOX_ATTEMPTS = 5 async def consume_publish_outbox() -> None: """APScheduler 진입점. 미처리 outbox 를 rev 부여하며 published 로 반영.""" if not settings.study_publish_enabled: logger.debug("study_publish 비활성 (study_publish_enabled=false)") return async with async_session() as session: try: # 1) 전역 단일 라이터 락(트랜잭션 스코프 — commit/rollback 시 자동 해제). await session.execute( text("SELECT pg_advisory_xact_lock(:k)").bindparams(k=ADVISORY_LOCK_KEY) ) # 2) 현재 최대 rev. max_rev = int( (await session.execute(select(func.coalesce(func.max(Published.rev), 0)))).scalar() or 0 ) # 3) 미처리 outbox 를 커밋순(id)으로. failed_at(terminal) 은 제외 — poison 행이 # head-of-line 을 영구 점유하지 않게 함. rows = ( await session.execute( select(PublishOutbox) .where( PublishOutbox.processed_at.is_(None), PublishOutbox.failed_at.is_(None), ) .order_by(PublishOutbox.id.asc()) .limit(BATCH_SIZE) ) ).scalars().all() if not rows: return now = datetime.now(timezone.utc) published_count = 0 failed_count = 0 for ob in rows: try: # 행 단위 savepoint 격리 — 한 행의 예외가 배치 전체(앞 행 processed_at 포함)를 # 롤백해 poison 행이 다음 사이클에 다시 최저 id 로 선택되는 무한 재선택을 차단. async with session.begin_nested(): existing = ( await session.execute( select(Published).where( Published.kind == ob.kind, Published.source_id == ob.source_id, ) ) ).scalar_one_or_none() # (payload_hash, deleted) 디둡 — no-op 재투영은 rev 안 올림. is_noop = ( existing is not None and existing.payload_hash == ob.payload_hash and existing.deleted == ob.deleted ) if is_noop: ob.processed_at = now else: new_rev = max_rev + 1 if existing is None: session.add( Published( kind=ob.kind, source_id=ob.source_id, pub_id=uuid.uuid4().hex, payload=ob.payload, payload_hash=ob.payload_hash, schema_version=ob.schema_version, rev=new_rev, deleted=ob.deleted, created_at=now, updated_at=now, ) ) else: existing.payload = ob.payload existing.payload_hash = ob.payload_hash existing.schema_version = ob.schema_version existing.deleted = ob.deleted existing.rev = new_rev existing.updated_at = now ob.processed_at = now # 배치 내 동일 (kind, source_id) 후속 행이 직전 반영을 보도록 flush(최신 승). await session.flush() except Exception as row_err: # savepoint 롤백 = 이 행의 쓰기(processed_at 포함) 취소. attempts/failed_at 만 # 바깥 트랜잭션에 누적돼 최종 commit 으로 영속(영구 재선택 방지). ob.attempts = (ob.attempts or 0) + 1 if ob.attempts >= MAX_OUTBOX_ATTEMPTS: ob.failed_at = now failed_count += 1 logger.error( "publish_outbox_row_terminal id=%s kind=%s source_id=%s attempts=%s: %s", ob.id, ob.kind, ob.source_id, ob.attempts, row_err, ) else: logger.warning( "publish_outbox_row_retry id=%s kind=%s source_id=%s attempts=%s: %s", ob.id, ob.kind, ob.source_id, ob.attempts, row_err, ) continue else: # savepoint 커밋 성공 시에만 rev 카운터 전진(실패 행은 rev 미소모 → 드물게 gap, # 단일 라이터·커밋순 부여라 viewer since-rev 증분 동기 정합엔 무해). if not is_noop: max_rev = new_rev published_count += 1 await session.commit() logger.info( "publish_outbox_drained scanned=%s published=%s failed=%s max_rev=%s", len(rows), published_count, failed_count, max_rev, ) except Exception as e: await session.rollback() logger.exception("publish_outbox_drain_failed: %s", e)