"""렌더러 실행 — Python 워커가 미리 컴파일된 Node 렌더러를 subprocess 로 직접 돌린다.""" import asyncio import fcntl import os from pathlib import Path from common.logger import LOG from services import render_report # 컴파일된 렌더러 진입점. RENDERER_ENTRY_ENV = "RENDERER_ENTRY" DEFAULT_RENDERER_ENTRY = "/app/solution/site/dist/prerender/prerender.js" NODE_BIN_ENV = "NODE_BIN" DEFAULT_NODE_BIN = "node" # 렌더러가 굽는 산출물 루트(out/). OUTPUT_DIR_ENV = "SITE_OUTPUT_DIR" DEFAULT_OUTPUT_DIR = "/app/solution/site/out" class RenderFailed(RuntimeError): """게이트 반려(구조화 데이터 불일치·고유 콘텐츠 0건)는 여기 안 걸린다 — 그건 exit code 1 이어도 보고서가 정상적으로 남으므로 호출측이 report.ok 로 판단한다.""" def renderer_entry() -> str: return os.environ.get(RENDERER_ENTRY_ENV) or DEFAULT_RENDERER_ENTRY def output_dir() -> str: return os.environ.get(OUTPUT_DIR_ENV) or DEFAULT_OUTPUT_DIR async def _run_subprocess(args: list[str], timeout_sec: float) -> tuple[int | None, str]: """같은 볼륨의 자산 대장·사이트맵은 한 프로세스만 갱신한다.""" root = Path(output_dir()) root.mkdir(parents=True, exist_ok=True) with (root / ".render.lock").open("a") as lock: try: async with asyncio.timeout(timeout_sec): while True: try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) break except BlockingIOError: await asyncio.sleep(0.1) return await _run_unlocked(args, timeout_sec) except TimeoutError: return None, "렌더 대기 또는 실행 시간 초과" finally: fcntl.flock(lock, fcntl.LOCK_UN) async def _run_unlocked(args: list[str], timeout_sec: float) -> tuple[int | None, str]: """렌더러 프로세스를 돌리고 (exit_code, stderr 일부) 를 돌려준다.""" proc = await asyncio.create_subprocess_exec( *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) try: _stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec) except (asyncio.TimeoutError, TimeoutError): proc.kill() # kill 후에도 반드시 wait 한다 — 안 하면 좀비 프로세스로 남는다. await proc.wait() return None, f"{timeout_sec:.0f}초 안에 끝나지 않아 강제 종료했다" except asyncio.CancelledError: if proc.returncode is None: proc.kill() await proc.wait() raise # 사유는 앞부분에 나온다(watch-payloads.mjs 의 run() 과 같은 규칙) — 스택 전체를 들 이유가 없다. return proc.returncode, stderr.decode("utf-8", errors="replace")[:4000] async def render_site(payload_path: str, site_version: int, timeout_sec: float) -> dict: """payload 파일 하나를 굽는다.""" slug = Path(payload_path).stem # 재시도에서 같은 버전의 옛 성공 보고서를 이번 실행 결과로 읽지 않는다. render_report.report_path(slug).unlink(missing_ok=True) args = [ os.environ.get(NODE_BIN_ENV) or DEFAULT_NODE_BIN, renderer_entry(), f"--payload={payload_path}", f"--out={output_dir()}", "--stage-only", ] try: code, stderr = await _run_subprocess(args, timeout_sec) except OSError as ex: raise RenderFailed(f"렌더러 실행 실패: {type(ex).__name__}") from ex report = render_report.read_report(slug) if code in (0, 1) and report is not None and report.get("siteVersion") == site_version: return report reason = ( f"렌더러가 {timeout_sec:.0f}초 안에 끝나지 않았다" if code is None else f"렌더러가 비정상 종료했다(exit {code})" if code != 0 else "보고서를 남기지 못했다(버전 불일치 포함)" ) detail = f"{reason}" + (f" — {stderr.strip()}" if stderr.strip() else "") LOG.w(f"[render] {slug} v{site_version}: {detail}") raise RenderFailed(detail) async def activate_site(slug: str, site_version: int) -> None: """두 게이트를 통과한 뒤 Node가 공개 링크만 전환한다.""" args = [ os.environ.get(NODE_BIN_ENV) or DEFAULT_NODE_BIN, renderer_entry(), f"--out={output_dir()}", f"--activate={slug}:{site_version}", ] try: code, _stderr = await _run_subprocess(args, 30) except OSError as ex: raise RenderFailed(f"공개 전환 실행 실패: {type(ex).__name__}") from ex if code != 0: raise RenderFailed("공개 버전 전환 실패")