컨테이너를 재생성하면 docker logs 가 사라져 크롤링이 잘 됐는지 확인할 방법이 없었다. 실측(09-30 버터브루): 수집 도중 배포로 API 가 80초 끊기자 화면이 사진 분석 폴링을 3회 실패 후 포기해 사진 10장이 DB 에 있는데도 0장으로 보였다. - services/activity_feed.py: 기존 alert_outbox·장애 채널로 kind=activity 이벤트 적재(중복 억제 없음) - collect_service: 수집 시작 · 채널 크롤링 실패 즉시 · 완료 요약(채널별·fact·사진·누락 필수항목·소요초) · 실패 - vision_service: 사진 분석 결과 - build_service · rollback_service: 첫 발행/재발행/빌드만/되돌리기 시작·끝 — URL · 굽기 사진 미러링 수 - site/prerender.ts: 렌더 보고서에 사진 미러링 수(media) 추가 - frontend pollJob: 연속 3회 실패여도 3분간 무응답일 때만 unreachable - frontend collectJobs: 사진 분석 폴링을 못 끝내도 사진 목록을 다시 읽고 경고 - test_build_publish: 게이트 반려 검사에서 activity 이벤트는 제외 관련 테스트 17개 파일 295 passed · 2 failed(test_search_console_service — 변경 전에도 실패) frontend tsc·eslint, site tsc·eslint 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
171 lines
7.1 KiB
Python
171 lines
7.1 KiB
Python
"""대상 버전 스냅샷 로드 → payload 재조립 → 렌더 → 게이트 → 공개 주소 전환"""
|
|
import os
|
|
import time
|
|
import uuid
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.database.model.models import places, site_publish_logs, site_versions, sites
|
|
from common.enums import (
|
|
BuildStatus,
|
|
DBWRType,
|
|
ErrorType,
|
|
PlaceCategory,
|
|
PublishAction,
|
|
PublishResult,
|
|
SiteStatus,
|
|
)
|
|
from common.logger import LOG
|
|
from common.utils.gtime import GTime
|
|
from crud.place_crud import PlaceCRUD
|
|
from crud.site_crud import SiteCRUD
|
|
from services import activity_feed, alert_service, azure_static, indexnow, publish_gate, render_service, site_payload
|
|
from services.build_service import post_build_summary, ensure_site, load_channel_links
|
|
|
|
_site_crud = SiteCRUD()
|
|
_place_crud = PlaceCRUD()
|
|
|
|
RENDER_TIMEOUT_SEC_ENV = "RENDER_TIMEOUT_SEC"
|
|
|
|
|
|
class RollbackAborted(RuntimeError):
|
|
"""재시도해도 소용없는 중단 — 잡의 last_error 로 남는다."""
|
|
|
|
|
|
async def _log(site_id, version_id, result: PublishResult, gate=None, actor=None):
|
|
row = site_publish_logs(
|
|
site_id=site_id,
|
|
site_version_id=version_id,
|
|
action=PublishAction.ROLLBACK.value,
|
|
result=result.value,
|
|
reject_reason=(gate.reason.value if gate is not None and gate.reason else None),
|
|
detail=(gate.as_log() if gate is not None and not gate.passed else None),
|
|
actor_user_id=uuid.UUID(actor) if actor else None,
|
|
)
|
|
await DB_SESSION_MNG.execute_lambda_run([site_publish_logs.DBType()], [lambda s: _site_crud.add_log(s, row)])
|
|
|
|
|
|
async def run_rollback(job: dict) -> dict:
|
|
"""ROLLBACK 잡 핸들러."""
|
|
started = time.monotonic()
|
|
try:
|
|
result = await _run_rollback(job)
|
|
except Exception as ex:
|
|
await activity_feed.post(
|
|
"❌ 되돌리기 실패",
|
|
[f"place_id={job['payload'].get('place_id')} → v{job['payload'].get('target_version')}",
|
|
f"{type(ex).__name__}: {ex}", f"{time.monotonic() - started:.0f}초"],
|
|
)
|
|
raise
|
|
await post_build_summary(
|
|
{**result, "version": result.get("target_version"), "mode": "되돌리기",
|
|
"build_status": "BUILT" if result.get("rolled_back") else "FAILED"},
|
|
time.monotonic() - started,
|
|
)
|
|
return result
|
|
|
|
|
|
async def _run_rollback(job: dict) -> dict:
|
|
payload = job["payload"]
|
|
place_id = payload["place_id"]
|
|
owner_user_id = payload["owner_user_id"]
|
|
target_version = int(payload["target_version"])
|
|
render_timeout_sec = float(os.environ.get(RENDER_TIMEOUT_SEC_ENV) or 180)
|
|
|
|
err, place = await DB_SESSION_MNG.execute_lambda(
|
|
places.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)),
|
|
)
|
|
if err != ErrorType.SUCCESS or place is None:
|
|
raise RollbackAborted(f"사업장을 찾을 수 없다: {place_id}")
|
|
|
|
site = await ensure_site(place_id)
|
|
|
|
v_err, version = await DB_SESSION_MNG.execute_lambda(
|
|
site_versions.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: _site_crud.get_version_by_number(s, site.site_id, target_version),
|
|
)
|
|
if v_err != ErrorType.SUCCESS or version is None:
|
|
raise RollbackAborted(f"버전 {target_version} 을 찾을 수 없다")
|
|
if version.build_status != BuildStatus.BUILT.value or not version.snapshot:
|
|
raise RollbackAborted(f"버전 {target_version} 은 성공적으로 빌드된 적이 없어 되돌릴 수 없다")
|
|
if site.current_version_id == version.site_version_id:
|
|
raise RollbackAborted(f"이미 지금 공개 중인 버전이다(v{target_version})")
|
|
|
|
result = {
|
|
"place_id": place_id, "site_id": str(site.site_id),
|
|
"target_version": target_version, "site_version_id": str(version.site_version_id),
|
|
"label": activity_feed.place_label(place),
|
|
}
|
|
await activity_feed.post("🏗 되돌리기 시작", [f"{result['label']} → v{target_version}"])
|
|
|
|
async def _fail(reason: str, gate: publish_gate.GateResult | None = None):
|
|
await _log(site.site_id, version.site_version_id, PublishResult.REJECTED if gate else PublishResult.FAILED,
|
|
gate, payload.get("requested_by"))
|
|
result["rolled_back"] = False
|
|
result["error"] = reason
|
|
LOG.w(f"[rollback] place={place_id} v{target_version} 실패: {reason}")
|
|
# 게이트 반려는 알리지 않는다 — build_service._fail 과 같은 규칙(운영자를 부를 일이 아니다).
|
|
if gate is None:
|
|
await alert_service.send_alert(
|
|
kind="build_failed",
|
|
title=f"롤백 실패 — {place_id} → v{target_version}",
|
|
detail=f"place_id={place_id} target_version={target_version}\n{reason}",
|
|
dedupe_key=f"build_failed:{place_id}",
|
|
)
|
|
return result
|
|
|
|
# snapshot 을 그대로 옮긴다 — 재수집하지 않는다(파일 머리주석 참조).
|
|
links = await load_channel_links(place_id)
|
|
payload_dict = await site_payload.prepare_site_payload(
|
|
place, version.snapshot, site, version, links, publish=True
|
|
)
|
|
payload_path = site_payload.write_payload(payload_dict)
|
|
result["payload_path"] = payload_path
|
|
|
|
try:
|
|
report = await render_service.render_site(payload_path, target_version, render_timeout_sec)
|
|
except render_service.RenderFailed as ex:
|
|
return await _fail(str(ex))
|
|
|
|
if report.get("media"):
|
|
result["render_media"] = report["media"]
|
|
mismatches = list(report.get("mismatches") or [])
|
|
unique_count_raw = report.get("uniqueContentCount")
|
|
|
|
# 렌더러가 그때와 달라졌을 수 있으므로 게이트를 다시 돈다(파일 머리주석 참조) — fact 검증 게이트(1차)는 건너뛴다.
|
|
gate = publish_gate.evaluate(
|
|
PlaceCategory(place.category), version.snapshot.get("facts") or [], unique_count_raw, mismatches
|
|
)
|
|
result["gate"] = {"passed": gate.passed, **gate.as_log()}
|
|
if not gate.passed:
|
|
return await _fail(f"{gate.reason.name}: {gate.as_log()}", gate)
|
|
if not report.get("ok"):
|
|
return await _fail(str(report.get("error") or "렌더 실패"))
|
|
|
|
slug = site_payload.publish_slug(place, site)
|
|
try:
|
|
await render_service.activate_site(slug, target_version)
|
|
await azure_static.publish(slug)
|
|
except (render_service.RenderFailed, azure_static.AzurePublishError) as ex:
|
|
return await _fail(str(ex))
|
|
await indexnow.submit(slug)
|
|
|
|
now = GTime.UTC()
|
|
site_update = {
|
|
"status": SiteStatus.PUBLISHED.value,
|
|
"current_version_id": version.site_version_id,
|
|
"published_at": now,
|
|
}
|
|
await DB_SESSION_MNG.execute_lambda_claim(
|
|
sites.DBType(), lambda s: _site_crud.update_site(s, site.site_id, site_update)
|
|
)
|
|
await _log(site.site_id, version.site_version_id, PublishResult.SUCCESS, None, payload.get("requested_by"))
|
|
await alert_service.resolve_alert(f"build_failed:{place_id}", f"롤백 성공 — {place_id} → v{target_version}")
|
|
|
|
result["rolled_back"] = True
|
|
result["url"] = site_payload.publish_url(place, site)
|
|
LOG.i(f"[rollback] place={place_id} v{target_version} 로 되돌림")
|
|
return result
|