"""렌더 보고서 — SSG 가 실제로 페이지를 구웠는지 확인하는 창구. ★ 왜 필요한가 발행 잡은 payload JSON 을 쓰는 것까지가 끝이고, 방문자가 볼 HTML 은 프론트의 SSG 가 굽는다. 그 사이가 끊겨 있었다. 프리렌더가 깨져도 DB 에는 "발행됨"으로 남고, 페이지는 없는데 아무도 그걸 모른다 — 사장님이 [사이트 열기] 를 눌러야 404 로 발견된다. 이제 프리렌더가 사이트마다 결과를 `/.status/.json` 에 쓴다. 백엔드에 마운트된 디렉토리가 payload 디렉토리뿐이라 보고서도 그 안에 둔다. ★ 여기서 하는 두 가지 1. 렌더 상태 — 구워졌는가, 언제, 실패했으면 왜 (사이트 조회 API 가 그대로 내보낸다) 2. 발행 대기 — BUILD 잡이 이 버전의 보고서를 기다렸다가, 그 결과로 발행 여부를 정한다 (wait_for). 게이트는 **실제로 나갈 HTML** 을 보고 판정해야 한다. ★ 읽기 전용이다. 보고서가 없거나 깨져 있어도 예외를 밖으로 내보내지 않는다 — 사이트 조회가 부수 산출물 때문에 실패하면 안 된다. """ import asyncio import json from pathlib import Path from common.logger import LOG from services.site_payload import payload_dir # 프리렌더가 쓰는 보고서 스키마 버전. 모양이 바뀌면 prerender.ts 와 같이 올린다. REPORT_SCHEMA_VERSION = 1 # 보고서 디렉토리 이름. prerender.ts 의 writeReport 와 맞춰야 한다. STATUS_DIR = ".status" def report_path(slug: str) -> Path: """`/.status/.json`.""" return payload_dir() / STATUS_DIR / f"{slug}.json" def read_report(slug: str) -> dict | None: """렌더 보고서를 읽는다. 없거나 깨졌으면 None. ★ 없는 것과 깨진 것을 구분하지 않는다. 호출측이 할 일은 같다 — '아직 못 구웠다'로 본다.""" if not slug: return None path = report_path(slug) try: if not path.is_file(): return None report = json.loads(path.read_text(encoding="utf-8")) except Exception as ex: # noqa: BLE001 — 부수 산출물이 사이트 조회를 깨뜨리면 안 된다 LOG.w(f"[render] 보고서 읽기 실패 slug={slug}: {type(ex).__name__}: {ex}") return None if not isinstance(report, dict): return None if report.get("schemaVersion") != REPORT_SCHEMA_VERSION: # 모르는 버전을 반쯤 해석해 "정상"이라고 답하는 게 최악이다. LOG.w(f"[render] 모르는 보고서 버전 slug={slug}: {report.get('schemaVersion')}") return None return report async def wait_for(slug: str, site_version: int, timeout_sec: float, poll_sec: float = 1.0) -> dict | None: """이 버전의 렌더 보고서가 나올 때까지 기다린다. 시간 안에 안 나오면 None. ★ 왜 기다리나 발행 게이트는 **실제로 나갈 HTML** 을 보고 판정해야 한다. 그 HTML 은 프론트 렌더러가 굽고, 렌더러는 payload 파일을 보고 도는 별도 프로세스다. 그래서 잡이 payload 를 쓴 뒤 보고서가 올 때까지 기다렸다가, 그 결과로 발행 여부를 정한다. ★ 버전이 맞는 보고서만 받는다. 직전 버전 보고서를 보고 통과시키면 이번에 바뀐 값은 아무도 검사하지 않은 채로 발행된다. ★ BUILD 잡은 heartbeat 로 lease 를 갱신하므로 여기서 기다려도 reaper 에 회수되지 않는다.""" deadline = asyncio.get_running_loop().time() + timeout_sec while True: report = read_report(slug) if report is not None and report.get("siteVersion") == site_version: return report if asyncio.get_running_loop().time() >= deadline: LOG.w(f"[render] 보고서 대기 시간 초과 slug={slug} v{site_version} ({timeout_sec}초)") return None await asyncio.sleep(poll_sec) def render_status(slug: str, site_version: int | None = None) -> dict: """사이트 조회 API 가 그대로 내보내는 렌더 상태. state PENDING 아직 안 구웠다(보고서 없음) STALE 구웠지만 지금 버전이 아니다(발행 후 프리렌더 대기 중) OK 현재 버전이 구워져 있다 FAILED 프리렌더가 실패했다 — 페이지가 없거나 낡았다 """ report = read_report(slug) if report is None: return {"state": "PENDING", "rendered_at": None, "error": None, "rendered_version": None} rendered_version = report.get("siteVersion") base = { "rendered_at": report.get("renderedAt"), "error": report.get("error"), "rendered_version": rendered_version, } if not report.get("ok"): return {"state": "FAILED", **base} if site_version is not None and rendered_version != site_version: return {"state": "STALE", **base} return {"state": "OK", **base}