kcg-ai-monitoring/prediction/tests/test_stats_aggregator_hour_boundary.py
htlee 7ab6baeed2 fix(prediction): stats_aggregator hour 경계 silent 누락 복구
배경: prediction 5분 interval 이지만 한 사이클 평균 13분 소요라
사이클이 hour 경계를 넘나드는 경우(12:55 시작 → 13:08 완료)가 흔하다.
이 때 사이클 내 생성된 이벤트(occurred_at=12:57)가 aggregate_hourly
호출 시점(now_kst=13:08) 기준 현재 hour=13:00 만 UPSERT 되어
12:00 hour 는 이전 사이클 snapshot 으로 stale 유지되는 silent drop.

실제 포착: 2026-04-20 12:50 CRITICAL GEAR_IDENTITY_COLLISION 이벤트가
prediction_stats_hourly.by_category 12:00 slot 에서 누락. Phase 1-2
snapshot 의 C1 drift 섹션이 only_in_events=GEAR_IDENTITY_COLLISION 으로 탐지.

수정:
- _aggregate_one_hour(conn, hour_start, updated_at): 단일 hour UPSERT 추출
- aggregate_hourly(): 호출 시마다 previous→current 순서로 2번 집계
  · UPSERT 라 idempotent
  · 반환값은 현재 hour (하위 호환)
  · target_hour 지정 케이스도 ±1h 재집계

검증:
- 3 유닛테스트 (경계 호출 2건 / 반환값 / 일 경계) 전수 통과
- 운영 수동 재집계로 12:00 slot GEAR_IDENTITY_COLLISION: 1 복구
- snapshot 재실행 시 C1 drift 0 확인

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:32:44 +09:00

130 lines
5.4 KiB
Python

"""stats_aggregator.aggregate_hourly 가 현재 + 이전 hour 를 모두 UPSERT 하는지 검증.
배경: prediction 사이클이 hour 경계를 넘나들 때 (사이클 시작 12:55, 완료 13:08),
stats_aggregate_hourly 가 13:08 기준으로 한 hour 만 재집계하면 12:00 hour 는
이전 사이클 snapshot 으로 stale — 이 사이클 내 생성된 이벤트(occurred_at=12:57)
가 누락되는 silent bug.
해결: aggregate_hourly 를 호출할 때마다 current_hour + previous_hour 를 동시 UPSERT.
이 테스트는 DB 없이 _aggregate_one_hour 호출 인자로 두 시각이 전달되는지만 검증.
"""
from __future__ import annotations
import sys
import types
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
if 'pydantic_settings' not in sys.modules:
stub = types.ModuleType('pydantic_settings')
class _S:
def __init__(self, **kw):
for name, value in self.__class__.__dict__.items():
if name.isupper():
setattr(self, name, kw.get(name, value))
stub.BaseSettings = _S
sys.modules['pydantic_settings'] = stub
# psycopg2 + db.kcgdb stub (로컬 테스트 환경)
if 'psycopg2' not in sys.modules:
pg = types.ModuleType('psycopg2')
pg.pool = types.ModuleType('psycopg2.pool')
pg.pool.ThreadedConnectionPool = object
pg.extras = types.ModuleType('psycopg2.extras')
pg.extras.execute_values = lambda *a, **k: None
sys.modules['psycopg2'] = pg
sys.modules['psycopg2.pool'] = pg.pool
sys.modules['psycopg2.extras'] = pg.extras
if 'db' not in sys.modules:
db_pkg = types.ModuleType('db')
db_pkg.__path__ = []
sys.modules['db'] = db_pkg
if 'db.kcgdb' not in sys.modules:
kcgdb_stub = types.ModuleType('db.kcgdb')
kcgdb_stub.get_conn = lambda: None
sys.modules['db.kcgdb'] = kcgdb_stub
from output import stats_aggregator as sa
class AggregateHourlyBoundaryTest(unittest.TestCase):
def _mock_conn(self):
conn = MagicMock()
conn.cursor.return_value.__enter__ = MagicMock(return_value=conn.cursor.return_value)
conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
return conn
def test_aggregates_both_current_and_previous_hour(self):
"""target_hour=13:08 KST 일 때 _aggregate_one_hour 가 12:00 과 13:00 두 번 호출."""
captured_hours: list[datetime] = []
def fake_one_hour(conn, hour_start, updated_at):
captured_hours.append(hour_start)
return {'hour': hour_start.isoformat(), 'detections': 0, 'events': 0,
'critical': 0, 'categories': 0, 'zones': 0}
target = datetime(2026, 4, 20, 13, 8, 0, tzinfo=sa._KST)
with patch.object(sa, '_aggregate_one_hour', side_effect=fake_one_hour):
with patch.object(sa, 'get_conn') as gc:
cm = MagicMock()
cm.__enter__.return_value = MagicMock()
cm.__exit__.return_value = False
gc.return_value = cm
sa.aggregate_hourly(target_hour=target)
self.assertEqual(len(captured_hours), 2)
# 이전 hour 가 먼저 (복구 목적) → 현재 hour
self.assertEqual(captured_hours[0], datetime(2026, 4, 20, 12, 0, 0, tzinfo=sa._KST))
self.assertEqual(captured_hours[1], datetime(2026, 4, 20, 13, 0, 0, tzinfo=sa._KST))
def test_return_value_reflects_current_hour_only(self):
"""하위 호환: 반환값은 현재 hour 만 (이전 hour 는 부수효과)."""
def fake_one_hour(conn, hour_start, updated_at):
return {'hour': hour_start.isoformat(), 'detections': hour_start.hour * 100,
'events': 0, 'critical': 0, 'categories': 0, 'zones': 0}
target = datetime(2026, 4, 20, 13, 8, 0, tzinfo=sa._KST)
with patch.object(sa, '_aggregate_one_hour', side_effect=fake_one_hour):
with patch.object(sa, 'get_conn') as gc:
cm = MagicMock()
cm.__enter__.return_value = MagicMock()
cm.__exit__.return_value = False
gc.return_value = cm
result = sa.aggregate_hourly(target_hour=target)
# 현재 hour = 13:00 → detections=1300
self.assertEqual(result['detections'], 1300)
self.assertTrue(result['hour'].startswith('2026-04-20T13:00'))
def test_handles_day_boundary(self):
"""target=00:05 이면 previous=전날 23:00 로 정확히 재집계."""
captured_hours: list[datetime] = []
def fake_one_hour(conn, hour_start, updated_at):
captured_hours.append(hour_start)
return {'hour': hour_start.isoformat(), 'detections': 0, 'events': 0,
'critical': 0, 'categories': 0, 'zones': 0}
target = datetime(2026, 4, 21, 0, 5, 0, tzinfo=sa._KST)
with patch.object(sa, '_aggregate_one_hour', side_effect=fake_one_hour):
with patch.object(sa, 'get_conn') as gc:
cm = MagicMock()
cm.__enter__.return_value = MagicMock()
cm.__exit__.return_value = False
gc.return_value = cm
sa.aggregate_hourly(target_hour=target)
self.assertEqual(captured_hours[0], datetime(2026, 4, 20, 23, 0, 0, tzinfo=sa._KST))
self.assertEqual(captured_hours[1], datetime(2026, 4, 21, 0, 0, 0, tzinfo=sa._KST))
if __name__ == '__main__':
unittest.main()