o2o-site-AEO/solution/backend/services/rollback_service.py
Mina Choi b4085a0e0f [fix] solution: 온보딩 생성·크롤링 진단·장애 알림 묶음
운영 번들 자동 로그인 자격증명 유출, 온보딩 COPY 잡이 Gemini 429 로 죽던 것,
크롤링 실패가 로그에만 남던 것을 한 번에 정리한다. 실측(2026-09-15 밤, 킹서버):
사진분석 배치가 Gemini 분당 쿼터를 다 써서 같은 키를 쓰는 온보딩 COPY 잡도 같이
429 를 맞고 DEAD 로 갔다 — 확인된 fact 만으로도 편집·발행이 되는데 잡을 죽일
이유가 없었다.

- solution/frontend: `VITE_AUTO_LOGIN_ID`·`PW` 를 운영 진입점에 안 넘긴다(자동 로그인은
  dev 서버 전용) + `Step5Generating` 겉모습을 이전 카드 스타일로, 데이터는 실제 잡
  진행(useGenerationJob) 그대로
- solution/backend: copy_service — Gemini 호출 실패해도 잡을 안 죽이고 fact 만으로 계속.
  db_session_manager — 유니크 제약 충돌(정상 경로) 로그를 ERROR → WARN.
  worker/runner + alert_service + teams_webhook — 잡 dead-letter·발행 실패·큐 정체를
  Teams 로 알림(영구 저장 + 재시도 + dedupe). `/readyz` 추가.
  collect_diagnostics(신규) — 크롤링 채널별 실패를 jobs.result 에 구조화해서 싣는다.
- postgres-init: 0015(users token_version) · 0016(alert_outbox) 마이그레이션

검증: 백엔드 pytest 759 passed. tsc(solution/frontend) 통과. Teams 알림 실채널 수신 확인.
2026-09-16 16:25:02 +09:00

169 lines
7.9 KiB
Python

"""예전 버전으로 되돌리기 — ROLLBACK 잡이 하는 일.
대상 버전 스냅샷 로드 → payload 재조립 → 렌더 → 게이트 → 공개 주소 전환
★ 재수집·재생성을 하지 않는다. run_build 와 다른 점이 이것 하나다 — 대상 버전이
박제해 둔 snapshot(site_versions.snapshot)을 **그대로** 다시 payload 로 옮길 뿐이다.
사장님이 그때 이미 확인하고 발행했던 값이므로, 지금 fact 상태가 바뀌었어도 롤백은
그 시점의 값을 그대로 복원한다 — "롤백했는데 최신 수정이 섞여 있다"를 막는다.
★ 그래도 게이트는 다시 돈다. 스냅샷은 그대로지만 **렌더러는 그때와 다를 수 있다**
(그 사이 배포됐을 수 있다) — 새 렌더러가 옛 스냅샷을 어떻게 굽는지는 다시 확인해야 한다.
★ 재굽기가 필요 없을 수도 있다 — 대상 버전 디렉토리가 보관 기간 안에 아직 디스크에 있으면
렌더러가 그 사실을 알아서 이용한다(prerender.ts publishVersion 은 렌더 자체는 매번 다시
하지만, 결과가 같으면 파일 내용도 같다 — 멱등이다). 디스크에서 지워졌어도 문제없다 —
snapshot 이 있으니 처음부터 다시 굽는다.
★ 실패하면 지금 공개된 버전은 그대로다. render_service.render_site 가 실패를 던지거나
report.ok=False 면 심볼릭 링크를 아예 안 돌린다(prerender.ts publishVersion 은 성공한
렌더 결과에만 닿는다) — 발행 파이프라인과 같은 원자성 보장이다.
"""
import os
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 alert_service, azure_static, indexnow, publish_gate, render_service, site_payload
from services.build_service import 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 잡 핸들러. payload: {place_id, owner_user_id, target_version, requested_by?}"""
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),
}
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))
mismatches = list(report.get("mismatches") or [])
unique_count_raw = report.get("uniqueContentCount")
# ★ 렌더러가 그때와 달라졌을 수 있으므로 게이트를 다시 돈다(파일 머리주석 참조) —
# fact 검증 게이트(1차)는 건너뛴다. snapshot 은 이미 확인된 값만 담아 발행했던 것이라
# 다시 물을 것이 없다 — 여기서 또 물으면 "그때는 됐는데 지금은 안 된다"는 모순이 난다.
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
LOG.i(f"[rollback] place={place_id} v{target_version} 로 되돌림")
return result