테스트 강화 및 코드 품질 개선
This commit is contained in:
@@ -1,13 +1,88 @@
|
||||
import os
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError, as_completed
|
||||
from typing import Any
|
||||
|
||||
from .common import logger
|
||||
from .config import RuntimeConfig
|
||||
from .constants import THREADPOOL_MAX_WORKERS_CAP
|
||||
from .notifications import send_telegram_with_retry
|
||||
from .signals import process_symbol
|
||||
|
||||
# ============================================================================
|
||||
# MEDIUM-004: Graceful Shutdown 지원
|
||||
# ============================================================================
|
||||
_shutdown_requested = False
|
||||
_shutdown_lock = threading.Lock()
|
||||
|
||||
|
||||
def _signal_handler(signum, frame):
|
||||
"""
|
||||
SIGTERM/SIGINT 신호 수신 시 graceful shutdown 시작
|
||||
|
||||
Args:
|
||||
signum: 신호 번호 (SIGTERM=15, SIGINT=2)
|
||||
frame: 현재 스택 프레임
|
||||
"""
|
||||
global _shutdown_requested
|
||||
with _shutdown_lock:
|
||||
if not _shutdown_requested:
|
||||
_shutdown_requested = True
|
||||
logger.warning(
|
||||
"[Graceful Shutdown] 종료 신호 수신 (signal=%d). 진행 중인 작업 완료 후 종료합니다...", signum
|
||||
)
|
||||
|
||||
|
||||
def request_shutdown():
|
||||
"""프로그래밍 방식으로 shutdown 요청 (테스트용)"""
|
||||
global _shutdown_requested
|
||||
with _shutdown_lock:
|
||||
_shutdown_requested = True
|
||||
logger.info("[Graceful Shutdown] 프로그래밍 방식 종료 요청")
|
||||
|
||||
|
||||
def is_shutdown_requested() -> bool:
|
||||
"""Shutdown 요청 상태 확인"""
|
||||
with _shutdown_lock:
|
||||
return _shutdown_requested
|
||||
|
||||
|
||||
# Signal handler 등록 (프로그램 시작 시 자동 등록)
|
||||
try:
|
||||
signal.signal(signal.SIGTERM, _signal_handler)
|
||||
signal.signal(signal.SIGINT, _signal_handler)
|
||||
logger.debug("[Graceful Shutdown] Signal handler 등록 완료 (SIGTERM, SIGINT)")
|
||||
except (ValueError, OSError) as e:
|
||||
# Windows에서 SIGTERM이 없거나, 메인 스레드가 아닌 경우 무시
|
||||
logger.debug("[Graceful Shutdown] Signal handler 등록 실패 (무시): %s", e)
|
||||
|
||||
|
||||
def _get_optimal_thread_count(max_threads: int | None) -> int:
|
||||
"""CPU 코어 수 기반으로 최적 스레드 수 계산.
|
||||
|
||||
I/O bound 작업이므로 CPU 코어 수 * 2를 기본값으로 사용합니다.
|
||||
사용자가 명시적으로 값을 설정한 경우 해당 값을 사용합니다.
|
||||
|
||||
Args:
|
||||
max_threads: 사용자 지정 스레드 수 (None이면 자동 계산)
|
||||
|
||||
Returns:
|
||||
최적 스레드 수 (최대 8개로 제한)
|
||||
"""
|
||||
cap = int(os.getenv("THREADPOOL_MAX_WORKERS_CAP", THREADPOOL_MAX_WORKERS_CAP))
|
||||
|
||||
if max_threads is not None and max_threads > 0:
|
||||
return min(max_threads, cap)
|
||||
|
||||
# I/O bound 작업이므로 CPU 코어 수 * 2
|
||||
cpu_count = os.cpu_count() or 4
|
||||
optimal = cpu_count * 2
|
||||
|
||||
# 최대 8개로 제한 (너무 많은 스레드는 오히려 성능 저하)
|
||||
return min(optimal, cap)
|
||||
|
||||
|
||||
def _process_result_and_notify(
|
||||
symbol: str, res: dict[str, Any], cfg: RuntimeConfig, alerts: list[dict[str, str]]
|
||||
@@ -110,22 +185,44 @@ def run_sequential(symbols: list[str], cfg: RuntimeConfig, aggregate_enabled: bo
|
||||
|
||||
|
||||
def run_with_threads(symbols: list[str], cfg: RuntimeConfig, aggregate_enabled: bool = False):
|
||||
"""
|
||||
병렬 처리로 여러 심볼 분석 (MEDIUM-004: Graceful Shutdown 지원)
|
||||
|
||||
Args:
|
||||
symbols: 처리할 심볼 리스트
|
||||
cfg: RuntimeConfig 객체
|
||||
aggregate_enabled: 집계 알림 활성화 여부
|
||||
|
||||
Returns:
|
||||
매수 신호 발생 횟수
|
||||
"""
|
||||
global _shutdown_requested
|
||||
|
||||
max_workers = _get_optimal_thread_count(cfg.max_threads)
|
||||
cpu_cores = os.cpu_count() or 4
|
||||
|
||||
logger.info(
|
||||
"병렬 처리 시작 (심볼 수=%d, 스레드 수=%d, 심볼 간 지연=%.2f초)",
|
||||
"병렬 처리 시작 (심볼 수=%d, 스레드 수=%d [CPU 코어: %d], 심볼 간 지연=%.2f초)",
|
||||
len(symbols),
|
||||
cfg.max_threads or 0,
|
||||
max_workers,
|
||||
cpu_cores,
|
||||
cfg.symbol_delay or 0.0,
|
||||
)
|
||||
|
||||
alerts = []
|
||||
buy_signal_count = 0
|
||||
max_workers = cfg.max_threads or 4
|
||||
|
||||
# Throttle control
|
||||
last_request_time = [0.0]
|
||||
throttle_lock = threading.Lock()
|
||||
|
||||
def worker(symbol: str):
|
||||
"""워커 함수 (조기 종료 지원)"""
|
||||
# 종료 요청 확인
|
||||
if is_shutdown_requested():
|
||||
logger.info("[%s] 종료 요청으로 스킵", symbol)
|
||||
return symbol, None
|
||||
|
||||
try:
|
||||
with throttle_lock:
|
||||
elapsed = time.time() - last_request_time[0]
|
||||
@@ -141,20 +238,64 @@ def run_with_threads(symbols: list[str], cfg: RuntimeConfig, aggregate_enabled:
|
||||
return symbol, None
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_to_symbol = {executor.submit(worker, sym): sym for sym in symbols}
|
||||
future_to_symbol = {}
|
||||
|
||||
# Collect results as they complete
|
||||
# 심볼 제출 (조기 종료 지원)
|
||||
for sym in symbols:
|
||||
if is_shutdown_requested():
|
||||
logger.warning(
|
||||
"[Graceful Shutdown] 종료 요청으로 나머지 심볼 제출 중단 (%d/%d 제출 완료)",
|
||||
len(future_to_symbol),
|
||||
len(symbols),
|
||||
)
|
||||
break
|
||||
future = executor.submit(worker, sym)
|
||||
future_to_symbol[future] = sym
|
||||
|
||||
# 결과 수집 (타임아웃 적용)
|
||||
results = {}
|
||||
for future in as_completed(future_to_symbol):
|
||||
sym = future_to_symbol[future]
|
||||
try:
|
||||
symbol, res = future.result()
|
||||
results[symbol] = res
|
||||
except Exception as e:
|
||||
logger.exception("[%s] Future 결과 조회 오류: %s", sym, e)
|
||||
timeout_seconds = 90 # 전체 작업 타임아웃 90초
|
||||
individual_timeout = 15 # 개별 결과 조회 타임아웃 15초
|
||||
|
||||
# Process results in original order to maintain consistent log/alert order if desired,
|
||||
# or just process as is. Here we process in original symbol order.
|
||||
try:
|
||||
for future in as_completed(future_to_symbol, timeout=timeout_seconds):
|
||||
# 종료 요청 시 즉시 중단
|
||||
if is_shutdown_requested():
|
||||
logger.warning(
|
||||
"[Graceful Shutdown] 종료 요청으로 결과 수집 중단 (%d/%d 수집 완료)",
|
||||
len(results),
|
||||
len(future_to_symbol),
|
||||
)
|
||||
break
|
||||
|
||||
sym = future_to_symbol[future]
|
||||
try:
|
||||
symbol, res = future.result(timeout=individual_timeout)
|
||||
results[symbol] = res
|
||||
except TimeoutError:
|
||||
logger.warning("[%s] 결과 조회 타임아웃 (%d초 초과), 건너뜀", sym, individual_timeout)
|
||||
except Exception as e:
|
||||
logger.exception("[%s] Future 결과 조회 오류: %s", sym, e)
|
||||
|
||||
except TimeoutError:
|
||||
logger.error(
|
||||
"[경고] 전체 작업 타임아웃 (%d초 초과). 진행 중인 작업 강제 종료 중... (%d/%d 완료)",
|
||||
timeout_seconds,
|
||||
len(results),
|
||||
len(future_to_symbol),
|
||||
)
|
||||
|
||||
# Graceful shutdown 완료 체크
|
||||
if is_shutdown_requested():
|
||||
logger.warning(
|
||||
"[Graceful Shutdown] 병렬 처리 조기 종료 완료 (처리 심볼: %d/%d, 매수 신호: %d)",
|
||||
len(results),
|
||||
len(symbols),
|
||||
buy_signal_count,
|
||||
)
|
||||
return buy_signal_count
|
||||
|
||||
# 결과 처리 (원래 순서대로)
|
||||
for sym in symbols:
|
||||
res = results.get(sym)
|
||||
if res:
|
||||
@@ -166,5 +307,5 @@ def run_with_threads(symbols: list[str], cfg: RuntimeConfig, aggregate_enabled:
|
||||
|
||||
_notify_no_signals(alerts, cfg)
|
||||
|
||||
logger.info("병렬 처리 완료")
|
||||
logger.info("병렬 처리 완료 (처리 심볼: %d, 매수 신호: %d)", len(results), buy_signal_count)
|
||||
return buy_signal_count
|
||||
|
||||
Reference in New Issue
Block a user