o2o-site-AEO/solution/backend/services/render_service.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

120 lines
4.7 KiB
Python

"""렌더러 실행 — 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("공개 버전 전환 실패")