"""백그라운드 스케줄러(크론) 패키지 — '언제'(when) 담당. router/(HTTP 진입점)와 동급의 '시간 진입점' 계층. APScheduler 수명주기와 잡 등록(타이밍)만 책임지고, 실제로 하는 일(what)은 scheduler/jobs.py 에 있다. - 다중 워커(운영)에서 잡이 워커마다 중복 실행되면 안 되므로 SCHEDULER_ENABLED=1 인 프로세스에서만 등록한다. (개발은 RELOAD=1 단일 워커라 docker-compose 에서 SCHEDULER_ENABLED=1 로 켠다.) - apscheduler import 는 start_scheduler() 안에서 한다 → 미설치(이미지 미재빌드) 상태라도 API 는 부팅된다. 잡 ① close_expired_quotations : 매일 UTC 00:10(KST 09:10) — 마감시각 지난 견적 마감 잡 ② complete_requote_quotations: 1시간마다 — 재견적 중 DONE 세션 있으면 즉시 마감 """ import os from common.logger import LOG from scheduler import jobs __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 에서 호출. SCHEDULER_ENABLED=1 일 때만 스케줄러를 띄운다.""" global _scheduler if not _is_enabled(): LOG.i("[scheduler] disabled (SCHEDULER_ENABLED != 1)") return if _scheduler is not None: return try: from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.interval import IntervalTrigger except ImportError: # 의존성 미설치(이미지 미재빌드) → API 는 살리고 스케줄러만 끈다. LOG.e_no_callstack("[scheduler] apscheduler 미설치 → 스케줄러 비활성. requirements 재설치(이미지 재빌드) 필요") return _scheduler = AsyncIOScheduler(timezone="UTC") # 잡 ① 마감시간 처리: 매일 UTC 00:10 _scheduler.add_job( jobs.close_expired_quotations, CronTrigger(hour=0, minute=10), id="close_expired_quotations", coalesce=True, # 밀린 실행이 여러 번 쌓여도 1번만 misfire_grace_time=3600, # 정시보다 늦게 깨어나도 1시간 내면 실행 max_instances=1, ) # 잡 ② 재견적 협상완료 처리: 1시간마다 _scheduler.add_job( jobs.complete_requote_quotations, IntervalTrigger(hours=1), id="complete_requote_quotations", coalesce=True, misfire_grace_time=600, max_instances=1, ) _scheduler.start() LOG.i("[scheduler] started (close_expired=daily 00:10 UTC, complete_requote=hourly)") def shutdown_scheduler(): """lifespan shutdown 에서 호출.""" global _scheduler if _scheduler is not None: _scheduler.shutdown(wait=False) _scheduler = None LOG.i("[scheduler] stopped")