2026-09-20 12:27:47 +03:00
"""Настройки и ротация журнала: удаление записей старше срока и самых старых сверх лимита."""
import asyncio
import logging
from datetime import datetime , timedelta , timezone
from sqlalchemy import delete , func , select
from sqlalchemy.orm import Session
from app.db import SessionLocal
2026-09-26 21:33:50 +03:00
from app.models import AppSetting , AuditLog , LoginAttempt
2026-09-20 12:27:47 +03:00
from app.services import SYSTEM , audit
log = logging . getLogger ( "ipam.rotation" )
DEFAULTS = { "retention_days" : 90 , "max_entries" : 100_000 }
LOCK_KEY = 703001 # advisory lock: одна ротация за раз, даже при нескольких репликах
INTERVAL_SECONDS = 3600
def get_settings ( db : Session ) -> dict :
row = db . get ( AppSetting , "journal" )
return { ** DEFAULTS , ** ( row . value if row else {})}
def save_settings ( db : Session , values : dict ) -> None :
row = db . get ( AppSetting , "journal" )
if row is None :
db . add ( AppSetting ( key = "journal" , value = values ))
else :
row . value = values
def rotate ( db : Session ) -> dict | None :
"""Возвращает {'by_age', 'by_count'} или None, если ротацию уже выполняет другой процесс."""
if not db . scalar ( select ( func . pg_try_advisory_xact_lock ( LOCK_KEY ))):
db . rollback ()
return None
cfg = get_settings ( db )
by_age = by_count = 0
if cfg [ "retention_days" ] > 0 :
cutoff = datetime . now ( timezone . utc ) - timedelta ( days = cfg [ "retention_days" ])
by_age = db . execute ( delete ( AuditLog ) . where ( AuditLog . ts < cutoff )) . rowcount
if cfg [ "max_entries" ] > 0 :
total = db . scalar ( select ( func . count ()) . select_from ( AuditLog )) or 0
if total > cfg [ "max_entries" ]:
# удаляем с запасом в одну запись — под сводную запись о ротации, чтобы итог не превышал лимит
n = total - cfg [ "max_entries" ] + 1
2026-09-26 21:33:50 +03:00
# сначала неудачные и заблокированные входы (их может нагенерировать кто угодно), затем самые старые записи остальных типов (изменение 024, находка №4)
noisy = db . execute ( delete ( AuditLog ) . where ( AuditLog . id . in_ (
select ( AuditLog . id ) . where ( AuditLog . entity_type == "session" , AuditLog . action . in_ (( "failed" , "locked" ))) . order_by ( AuditLog . id ) . limit ( n )))) . rowcount
rest = n - noisy
by_count = noisy + ( db . execute ( delete ( AuditLog ) . where ( AuditLog . id . in_ ( select ( AuditLog . id ) . order_by ( AuditLog . id ) . limit ( rest )))) . rowcount if rest > 0 else 0 )
db . execute ( delete ( LoginAttempt ) . where ( LoginAttempt . ts < datetime . now ( timezone . utc ) - timedelta ( days = 1 )))
2026-09-20 12:27:47 +03:00
if by_age or by_count :
parts = []
if by_age :
parts . append ( f "старше { cfg [ 'retention_days' ] } дн.: { by_age } " )
if by_count :
parts . append ( f "сверх лимита { cfg [ 'max_entries' ] } : { by_count } " )
audit ( db , SYSTEM , "journal" , None , "rotated" , "rotation" , { "by_age" : by_age , "by_count" : by_count },
message = "Ротация журнала: удалено " + "; " . join ( parts ))
db . commit ()
return { "by_age" : by_age , "by_count" : by_count }
def run_rotation () -> None :
try :
with SessionLocal () as db :
result = rotate ( db )
if result and ( result [ "by_age" ] or result [ "by_count" ]):
log . info ( "journal rotated: %s " , result )
except Exception : # фоновая задача не должна падать
log . exception ( "journal rotation failed" )
async def rotation_loop () -> None :
await asyncio . sleep ( 30 )
while True :
await asyncio . to_thread ( run_rotation )
await asyncio . sleep ( INTERVAL_SECONDS )