여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - 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>
120 lines
4.7 KiB
Python
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("공개 버전 전환 실패")
|