배경: 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>
130 lines
5.4 KiB
Python
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()
|