문제: 빌드/기동은 성공했는데 종목 검색 결과 빈 화면. 원인은 시드 트랜잭션
구조 — `_fetch_market_listing(KRX)` 가 `with engine.begin()` 블록 *밖*에서
호출되고, 컨테이너에서 KRX 서버 접근이 실패하거나 단일 ticker 처리 중
한 곳이라도 예외가 나면 전체 트랜잭션이 롤백되어 SEED_TICKERS 10개조차
들어가지 않음.
수정:
- `_upsert_seed_tickers()` 분리. SEED 10종목 전용 트랜잭션. 네트워크 무관.
- `seed_symbols()` 가 (1) SEED 먼저 → (2) KRX 시장별 fetch (try-per-market)
→ (3) 전 종목 bulk upsert (별도 트랜잭션) 순서로 동작. KRX fetch 실패해도
SEED 는 살아남음.
- `main.py` lifespan bootstrap 이 SEED upsert 를 매 부팅마다 무조건 실행
(10회, ms 단위, idempotent). count <= 10 일 때만 KRX 전 종목 fetch.
- 새 admin 엔드포인트 `POST /api/refresh/seed/symbols` — 컨테이너 재기동
없이 시드 재시도 가능.
이번 사례 (사금향 PC) 의 복구 절차:
curl -X POST http://localhost:8000/api/refresh/seed/symbols
이후 검색에 SEED 10 종목은 즉시 떠야 함. KRX 가 막힌 환경이면 markets={0,0}
가 응답에 찍히고, 그래도 SEED 만으로 핵심 동작은 가능.
135 lines
4.5 KiB
Python
135 lines
4.5 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from sqlalchemy import text
|
|
|
|
from app.api.chart import router as chart_router
|
|
from app.api.metrics import router as metrics_router
|
|
from app.api.news import router as news_router
|
|
from app.api.predict import router as predict_router
|
|
from app.api.refresh import router as refresh_router
|
|
from app.api.symbols import router as symbols_router
|
|
from app.config import settings
|
|
from app.db.connection import get_engine, ping as db_ping
|
|
from app.fetch import dart as dart_mod
|
|
from app.fetch import kis as kis_mod
|
|
from app.pipelines.scheduler import shutdown_scheduler, start_scheduler
|
|
|
|
logging.basicConfig(
|
|
level=settings.log_level,
|
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _bootstrap_db() -> None:
|
|
"""첫 부팅 자동화:
|
|
1) migrations/*.sql idempotent 적용 (timescale/pgvector 확장 + 스키마)
|
|
2) symbols 테이블 비어있으면 pykrx 로 전 종목 시드 (SEED 10 마크 포함)
|
|
|
|
BOOTSTRAP_DISABLED=1 이면 스킵 (테스트/CI 용). 어떤 단계든 실패해도 서버는
|
|
뜬다 — /health/db 가 진단을 알려준다.
|
|
"""
|
|
if os.environ.get("BOOTSTRAP_DISABLED") == "1":
|
|
logger.info("bootstrap skipped (BOOTSTRAP_DISABLED=1)")
|
|
return
|
|
|
|
# 1) migrations
|
|
try:
|
|
from app.db.migrate import apply_all
|
|
res = apply_all()
|
|
logger.info("bootstrap migrate: %s", res)
|
|
except Exception: # noqa: BLE001
|
|
logger.exception("bootstrap migrate failed")
|
|
return # 스키마 없으면 시드 불가
|
|
|
|
# 2) symbols 시드
|
|
# - SEED 10종목은 매 부팅마다 무조건 upsert (10회 upsert, ms 단위, 네트워크 무관)
|
|
# → KRX 접근 실패한 환경에서도 최소 10종목 검색 보장
|
|
# - KRX 전 종목 fetch 는 symbols 가 비어있을 때만 (호출 비용 큼)
|
|
try:
|
|
from app.fetch.symbols_seed import _upsert_seed_tickers, seed_symbols
|
|
n_seed = _upsert_seed_tickers()
|
|
logger.info("bootstrap seed-tickers ensured (%d)", n_seed)
|
|
|
|
eng = get_engine()
|
|
with eng.connect() as conn:
|
|
row = conn.execute(text("SELECT COUNT(*) FROM symbols")).first()
|
|
count = int(row[0]) if row else 0
|
|
if count <= n_seed:
|
|
# symbols 가 SEED 만큼 또는 그 이하 → KRX 전 종목 fetch 시도
|
|
logger.info("symbols sparse (count=%d) — running KRX listing seed", count)
|
|
report = seed_symbols()
|
|
logger.info("bootstrap seed_symbols: %s", report)
|
|
else:
|
|
logger.info("symbols already populated (count=%d) — skip KRX listing seed", count)
|
|
except Exception: # noqa: BLE001
|
|
logger.exception("bootstrap seed_symbols failed")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_: FastAPI):
|
|
_bootstrap_db()
|
|
# 스케줄러는 옵션. CI/테스트에서 disable 하고 싶으면 SCHEDULER_DISABLED 같은 env 추가 가능.
|
|
if os.environ.get("SCHEDULER_DISABLED") == "1":
|
|
logger.info("scheduler skipped (SCHEDULER_DISABLED=1)")
|
|
else:
|
|
try:
|
|
start_scheduler()
|
|
except Exception: # noqa: BLE001
|
|
logger.exception("scheduler start failed")
|
|
yield
|
|
shutdown_scheduler()
|
|
|
|
|
|
app = FastAPI(title="stock_chart_site", version="0.1.0", lifespan=lifespan)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
app.include_router(refresh_router)
|
|
app.include_router(symbols_router)
|
|
app.include_router(chart_router)
|
|
app.include_router(predict_router)
|
|
app.include_router(metrics_router)
|
|
app.include_router(news_router)
|
|
|
|
|
|
def _resolved_device() -> str:
|
|
if settings.model_device != "auto":
|
|
return settings.model_device
|
|
try:
|
|
import torch # noqa: WPS433
|
|
return "cuda" if torch.cuda.is_available() else "cpu"
|
|
except Exception: # noqa: BLE001
|
|
return "cpu"
|
|
|
|
|
|
@app.get("/health")
|
|
def health() -> dict[str, object]:
|
|
return {"ok": True, "device": _resolved_device(), "version": "0.1.0"}
|
|
|
|
|
|
@app.get("/health/db")
|
|
def health_db() -> dict[str, object]:
|
|
return {"ok": True, **db_ping()}
|
|
|
|
|
|
@app.get("/health/keys")
|
|
def health_keys() -> dict[str, object]:
|
|
"""등록된 외부 키들 ping (key 값은 노출하지 않음)."""
|
|
return {
|
|
"kis": kis_mod.ping(),
|
|
"dart": dart_mod.ping(),
|
|
# huggingface 는 모델 다운로드 시점에 확인 (별도 ping 호출 비용 회피)
|
|
}
|