증상은 둘 다 "메일이 안 온다" / "승인했는데 홈페이지가 그대로" 로만 보인다. 서버는 정상으로
뜨고 로그에도 에러가 없어서 눈으로 원인을 못 찾는 종류다.
① 재고 채우기가 스케줄러에 **등록돼 있지 않았다.** `scheduler/jobs.py` 에 함수는 있고
`__init__.py` 주석도 "새벽에 재고를 채운다" 라고 말하는데 add_job 한 줄이 없어 한 번도
돈 적이 없다. 09:00 발송만 돌고 보낼 글은 0건이었다 — 지금 DB 의 254건은 전부 화면의
[지금 생성하기] 로 손으로 만든 것이다.
→ 04:10 KST 등록(발송보다 앞서야 그날 아침에 나갈 재고가 있다)
② 승인 뒤 재발행이 죽는다. `post_service._enqueue_build` 가 requested_by 에
"blog-approval" 이라는 **라벨**을 넣었고 `build_service._log` 가 그걸 uuid.UUID() 에
넣다 ValueError 를 던졌다. 하필 _log 는 사이트를 다 구운 **뒤**, sites.status 를
PUBLISHED 로 찍기 **전**에 불린다 — 굽기는 끝났는데 발행만 안 된 채 3회 재시도 후 DEAD.
실측: BUILD 잡 5건(2026-09-23~09-30)이 전부 이 원인이고 전부 미니블로그 승인분이었다.
→ 호출부는 사장님 ID 를 넣고, 파서(_actor_uuid)는 모양이 틀리면 기록만 비우고 진행한다.
감사 기록 한 줄이 발행을 막는 것은 순서가 뒤집힌 것이다. rollback_service 도 같은 파서.
- scheduler/__init__: blog-drafts 등록 + 왜 빠져 있었는지
- services/build_service: _actor_uuid 신설, _log 가 그것만 쓴다
- services/post_service: requested_by = str(owner_user_id)
- services/rollback_service: 같은 파서 재사용
테스트 7건 추가(build 4 · scheduler 3), 전부 통과.
test_build_publish.py 의 기존 실패 13건은 변동 없음 — 원본으로 되돌려 측정해 확인했다
(원본 13 failed/2 passed, 변경 후 13 failed/6 passed). 그 13건은 컨테이너 테스트 DB
쪽 문제다(로그: lease 갱신 실패 InvalidCatalogNameError) — 이 변경과 무관하고 미해결로 남긴다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
65 lines
2.9 KiB
Python
65 lines
2.9 KiB
Python
"""백그라운드 스케줄러(크론) 패키지 — '언제'(when) 담당."""
|
|
import os
|
|
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
|
|
from common.logger import LOG
|
|
|
|
__all__ = ["start_scheduler", "shutdown_scheduler"]
|
|
|
|
_scheduler = None # AsyncIOScheduler | None
|
|
|
|
|
|
def _is_enabled() -> bool:
|
|
return os.environ.get("SCHEDULER_ENABLED", "0") == "1"
|
|
|
|
|
|
def start_scheduler():
|
|
"""lifespan startup 에서 호출."""
|
|
global _scheduler
|
|
if not _is_enabled():
|
|
LOG.i("[scheduler] disabled (SCHEDULER_ENABLED != 1)")
|
|
return
|
|
if _scheduler is not None:
|
|
return
|
|
|
|
# 한국시간 기준.
|
|
_scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
|
# 1분이 아니라 5분이다.
|
|
from scheduler.jobs import sweep_social_posts
|
|
_scheduler.add_job(sweep_social_posts, 'interval', minutes=5, max_instances=1, coalesce=True)
|
|
if os.environ.get("GSC_ENABLED") == "1":
|
|
from services.search_console_service import run_scheduled_check
|
|
_scheduler.add_job(run_scheduled_check, "interval", minutes=10,
|
|
id="search-console", max_instances=1, coalesce=True)
|
|
|
|
from scheduler.jobs import (
|
|
sweep_alert_outbox, sweep_blog_drafts, sweep_blog_mail, sweep_queue_health,
|
|
)
|
|
_scheduler.add_job(sweep_alert_outbox, "interval", minutes=1,
|
|
id="alert-outbox", max_instances=1, coalesce=True)
|
|
_scheduler.add_job(sweep_queue_health, "interval", minutes=5,
|
|
id="queue-health", max_instances=1, coalesce=True)
|
|
# 미니 블로그 — 새벽에 재고를 채우고, 아침에 검수 통과분을 보낸다(docs/MINI_BLOG.md).
|
|
# ★★ 재고 채우기가 **등록돼 있지 않았다**(실측 2026-09-30). 주석만 "새벽에 채운다" 라고
|
|
# 말하고 `sweep_blog_drafts` 는 어디에서도 불리지 않아, 자동 생성이 한 번도 돈 적이 없다.
|
|
# 증상이 "메일이 안 온다" 로만 보여서 발송 쪽을 보게 된다 — 실제로는 **보낼 글이
|
|
# 0건**이었고, 지금 쌓여 있는 글은 전부 화면의 [지금 생성하기] 로 손으로 만든 것이다.
|
|
# 발송(09:00)보다 앞서야 그날 아침에 나갈 재고가 있다.
|
|
_scheduler.add_job(sweep_blog_drafts, "cron", hour=4, minute=10,
|
|
id="blog-drafts", max_instances=1, coalesce=True)
|
|
_scheduler.add_job(sweep_blog_mail, "cron", hour=9, minute=0,
|
|
id="blog-mail", max_instances=1, coalesce=True)
|
|
_scheduler.start()
|
|
LOG.i("[scheduler] started (KST: SNS 승인 만료·중단 복구)")
|
|
LOG.i(f"[scheduler] started (KST: {len(_scheduler.get_jobs())}개 잡)")
|
|
|
|
|
|
def shutdown_scheduler():
|
|
"""lifespan shutdown 에서 호출."""
|
|
global _scheduler
|
|
if _scheduler is not None:
|
|
_scheduler.shutdown(wait=False)
|
|
_scheduler = None
|
|
LOG.i("[scheduler] stopped")
|