"""시스템 리소스 계측 및 로그 관리. - 2분(config 설정) 간격으로 CPU/메모리/디스크 사용률을 계측한다. - 계측값을 `system_resources` DB 테이블과 루트 `log/system_resources.log`에 함께 기록한다. - 로그 파일과 DB 이력은 항상 최근 1개월치만 유지한다(오래된 줄 삭제 + 새 줄 추가). """ from __future__ import annotations import asyncio import logging import shutil from datetime import datetime, timedelta, timezone from pathlib import Path import psutil from config.config_db import get_db_pool from config.config_system import ( RESOURCE_LOG_PATH, RESOURCE_LOG_RETENTION_DAYS, RESOURCE_SAMPLE_INTERVAL_SEC, ) logger = logging.getLogger(__name__) def _sample() -> dict[str, float | int]: """현재 시점 리소스 사용률을 계측한다.""" total, used, _free = shutil.disk_usage(".") return { "cpu_usage_percent": round(psutil.cpu_percent(interval=0.1), 2), "memory_usage_percent": round(psutil.virtual_memory().percent, 2), "disk_usage_percent": round(used / total * 100 if total else 0, 2), "total_storage_mb": round(used / 1024 / 1024, 2), } def _trim_log_file(retention_days: int) -> list[str]: """로그 파일에서 보관 기간이 지난 줄을 제거하고 남은 줄을 반환한다.""" path = Path(RESOURCE_LOG_PATH) if not path.exists(): return [] cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days) kept: list[str] = [] for line in path.read_text(encoding="utf-8").splitlines(): if not line.strip(): continue try: ts = datetime.fromisoformat(line.split("\t", 1)[0]) except ValueError: continue if ts >= cutoff: kept.append(line) return kept def _write_log(sample: dict[str, float | int], now: datetime) -> None: """최근 1개월치만 유지하며 새 계측 줄을 로그 파일에 기록한다.""" path = Path(RESOURCE_LOG_PATH) path.parent.mkdir(parents=True, exist_ok=True) lines = _trim_log_file(RESOURCE_LOG_RETENTION_DAYS) new_line = ( f"{now.isoformat()}\t" f"cpu={sample['cpu_usage_percent']}\t" f"mem={sample['memory_usage_percent']}\t" f"disk={sample['disk_usage_percent']}\t" f"storage_mb={sample['total_storage_mb']}" ) lines.append(new_line) path.write_text("\n".join(lines) + "\n", encoding="utf-8") async def _write_db(sample: dict[str, float | int]) -> None: """DB에 계측값을 저장하고 보관 기간이 지난 행을 삭제한다.""" pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: await cursor.execute( """SELECT (SELECT COUNT(*) FROM sessions WHERE expires_at > CURRENT_TIMESTAMP), (SELECT COUNT(*) FROM projects WHERE deleted_at IS NULL)""" ) active_users, active_projects = await cursor.fetchone() await cursor.execute( """INSERT INTO system_resources (cpu_usage_percent, memory_usage_percent, disk_usage_percent, active_user_count, active_project_count, total_storage_mb) VALUES (%s, %s, %s, %s, %s, %s)""", ( sample["cpu_usage_percent"], sample["memory_usage_percent"], sample["disk_usage_percent"], active_users, active_projects, sample["total_storage_mb"], ), ) await cursor.execute( "DELETE FROM system_resources WHERE timestamp < %s", (datetime.utcnow() - timedelta(days=RESOURCE_LOG_RETENTION_DAYS),), ) await connection.commit() async def sample_resources_loop() -> None: """앱 라이프사이클 동안 주기적으로 리소스를 계측·기록하는 백그라운드 태스크.""" while True: try: sample = _sample() _write_log(sample, datetime.now(timezone.utc)) await _write_db(sample) except Exception as exc: # noqa: BLE001 — 계측 실패로 앱이 죽지 않게 방어 logger.warning("리소스 계측 실패: %s", exc) await asyncio.sleep(RESOURCE_SAMPLE_INTERVAL_SEC)