fix: universe build을 isolated thread로 분리하여 API 먹통 방지

BackgroundTask가 FastAPI event loop을 공유해서 deadlock 발생 →
별도 thread에서 새 asyncio event loop으로 실행하도록 변경.
- API event loop 완전 분리
- DB 커넥션 풀 독립적 사용 (per-batch factory session)
- 빌드 중 API 정상 응답 유지

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 5 months ago
parent 0e048f82ef
commit e51e045169

@ -181,14 +181,25 @@ async def discover_tickers(
async def _run_build_snapshots(
tickers, start_date: str, end_date: str, force_rebuild: bool
):
"""Background task wrapper for build_snapshots (needs its own DB session)."""
"""Background task wrapper for build_snapshots.
Spawns a completely separate thread with its own asyncio event loop so
the long-running build never touches the API's event loop or DB connection
pool, keeping the API responsive throughout.
"""
import asyncio as _asyncio
import threading
from app.core.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
def _thread_main():
loop = _asyncio.new_event_loop()
_asyncio.set_event_loop(loop)
async def _build():
svc = UniverseService()
try:
result = await svc.build_snapshots(
db,
AsyncSessionLocal,
tickers=tickers,
start_date=start_date,
end_date=end_date,
@ -198,6 +209,15 @@ async def _run_build_snapshots(
except Exception as e:
logger.error(f"Universe background build failed: {e}")
try:
loop.run_until_complete(_build())
finally:
loop.close()
t = threading.Thread(target=_thread_main, daemon=True, name="universe-build")
t.start()
logger.info(f"Universe build started in isolated thread (daemon)")
@router.post(
"/admin/build-snapshots",
@ -231,7 +251,7 @@ async def build_snapshots(
svc = UniverseService()
try:
result = await svc.build_snapshots(
db,
db, # existing session is fine for small quick jobs
tickers=tickers,
start_date=body.start_date,
end_date=body.end_date,

@ -234,7 +234,7 @@ class UniverseService:
async def build_snapshots(
self,
db: AsyncSession,
db_or_factory,
tickers: Optional[List[str]],
start_date: str,
end_date: str,
@ -243,15 +243,33 @@ class UniverseService:
"""
Build monthly market_cap snapshots for given tickers (or all registry).
For each ticker:
1. Fetch shares_outstanding history from SEC EDGAR companyfacts
2. Fetch monthly close prices via yfinance bulk download
3. Compute market_cap = close × shares per month
4. Upsert into universe_snapshot
db_or_factory: Either an AsyncSession (for small sync calls 20 tickers)
or an AsyncSessionLocal factory (for large background jobs).
Using a factory means each batch opens/closes its own DB
connection, preventing the long-running job from monopolising
the connection pool and blocking the API.
Returns: { tickers_processed, tickers_failed, snapshots_created }
"""
# Resolve ticker list
from app.core.database import AsyncSessionLocal
# Normalise: wrap a plain session in a no-op factory so the rest of the
# code can always call `async with _db_ctx() as db:`
from contextlib import asynccontextmanager
if callable(db_or_factory) and not isinstance(db_or_factory, AsyncSession):
# It's a factory (e.g. AsyncSessionLocal)
_db_ctx = db_or_factory
else:
# It's an existing session — wrap it so we never close it here
_session = db_or_factory
@asynccontextmanager
async def _db_ctx():
yield _session
# ---- Resolve ticker list ----
async with _db_ctx() as db:
if tickers:
ticker_list = [t.upper() for t in tickers]
else:
@ -272,36 +290,43 @@ class UniverseService:
)
if force_rebuild:
# Delete in chunks to avoid huge IN clauses
for i in range(0, len(ticker_list), _CHUNK):
chunk = ticker_list[i:i + _CHUNK]
await db.execute(
UniverseSnapshot.__table__.delete().where(
UniverseSnapshot.ticker.in_(ticker_list)
UniverseSnapshot.ticker.in_(chunk)
)
)
await db.flush()
await db.commit()
# Load registry metadata (sector/industry/exchange/CIK)
registry_map: Dict[str, UniverseTickerRegistry] = {}
for i in range(0, len(ticker_list), _CHUNK):
chunk = ticker_list[i:i + _CHUNK]
reg_result = await db.execute(
select(UniverseTickerRegistry).where(
UniverseTickerRegistry.ticker.in_(ticker_list)
UniverseTickerRegistry.ticker.in_(chunk)
)
)
registry_map: Dict[str, UniverseTickerRegistry] = {
r.ticker: r for r in reg_result.scalars().all()
}
for r in reg_result.scalars().all():
registry_map[r.ticker] = r
total_snapshots = 0
total_failed = 0
total_batches = (len(ticker_list) + _PRICE_BATCH - 1) // _PRICE_BATCH
# Process in batches of _PRICE_BATCH for yfinance bulk download
# ---- Process in batches — each batch uses its own DB session ----
for batch_start in range(0, len(ticker_list), _PRICE_BATCH):
batch = ticker_list[batch_start:batch_start + _PRICE_BATCH]
batch_num = batch_start // _PRICE_BATCH + 1
# ---- Fetch monthly prices (synchronous, in thread) ----
# Fetch monthly prices (synchronous yfinance, runs in thread)
price_data = await asyncio.to_thread(
self._fetch_bulk_monthly_prices, batch, start_date, end_date
)
# ---- Fetch shares_outstanding from SEC EDGAR (concurrent) ----
# Fetch shares_outstanding from SEC EDGAR (async, rate-limited)
sem = asyncio.Semaphore(_SEC_CONCURRENCY)
async def _fetch_one(tkr: str) -> Tuple[str, List]:
@ -321,7 +346,7 @@ class UniverseService:
tkr, history = item
shares_map[tkr] = history
# ---- Build snapshot rows ----
# Build snapshot rows
batch_rows = []
for ticker in batch:
shares_history = shares_map.get(ticker, [])
@ -335,8 +360,6 @@ class UniverseService:
# Use the LATEST available shares_outstanding (most recent SEC filing).
# yfinance returns split-adjusted prices retroactively, so using the
# post-split shares count gives correct market_cap across all periods.
# Small error (~10-20%) may occur for companies with large buyback programs,
# but this is acceptable for screening purposes.
latest_shares = shares_history[-1][1] if shares_history else None
for snap_date_key, close in ticker_prices.items():
@ -351,14 +374,13 @@ class UniverseService:
# Sanity checks: skip absurd values (data quality)
if close > 1_000_000 or close <= 0: # max BRK-A ~$600K
continue
if shares < 100_000: # too few shares for a real public co
if shares < 100_000: # too few shares for a public co
continue
market_cap = shares * close
if math.isnan(market_cap) or market_cap <= 0 or market_cap > 5e12:
continue
# Normalize to first of month
snapshot_dt = datetime(
snap_date.year, snap_date.month, 1, tzinfo=timezone.utc
)
@ -373,8 +395,9 @@ class UniverseService:
"exchange": reg.exchange if reg else None,
})
# ---- Batch upsert ----
# Upsert with a fresh short-lived DB session (releases connection immediately after)
if batch_rows:
async with _db_ctx() as db:
for i in range(0, len(batch_rows), _CHUNK):
chunk = batch_rows[i:i + _CHUNK]
stmt = pg_insert(UniverseSnapshot).values(chunk)
@ -393,14 +416,12 @@ class UniverseService:
total_snapshots += result.rowcount
await db.commit()
batch_num = batch_start // _PRICE_BATCH + 1
total_batches = (len(ticker_list) + _PRICE_BATCH - 1) // _PRICE_BATCH
logger.info(
f"Universe: batch {batch_num}/{total_batches}"
f"{len(batch)} tickers, {len(batch_rows)} snapshot rows"
)
# Yield to event loop between batches to keep API responsive
await asyncio.sleep(1)
await asyncio.sleep(0)
logger.info(
f"Universe: build complete — {len(ticker_list)} tickers, "

Loading…
Cancel
Save