fix(quota): prune quota history past a retention window

quota_events had no retention: one row per API spend plus a zero-unit row per live search, kept
forever. The daily history cleanup (audit_gc) now also prunes it past quota_retention_days (a new
Quota setting, default 365 so the admin usage view's longest window stays whole; 0 = forever). The
floor of two days keeps today's rows, which the per-user search cap counts.
This commit is contained in:
2026-10-04 17:53:19 +02:00
parent 33dec56e45
commit ce8ccfc3c0
9 changed files with 86 additions and 9 deletions
+18 -2
View File
@@ -7,11 +7,13 @@ always be allowed; expensive backfill must yield to the remaining budget.
import contextlib
import contextvars
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Any, cast
from zoneinfo import ZoneInfo
from sqlalchemy import func, select
from sqlalchemy import delete, func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.engine import CursorResult
from sqlalchemy.orm import Session
from app import sysconfig
@@ -96,6 +98,20 @@ def pacific_today():
return datetime.now(_PACIFIC).date()
def gc(db: Session, retention_days: int) -> int:
"""Delete quota-history rows older than `retention_days` (<= 0 keeps them forever) and return how
many went. Flushes only; the cleanup job commits. Today's rows are never in reach: the per-user
search cap counts them, and the floor of two days outlasts any Pacific day (25 h at most)."""
if retention_days <= 0:
return 0
cutoff = datetime.now(timezone.utc) - timedelta(days=max(2, retention_days))
# A DELETE answers with a CursorResult; `execute` is typed as the general Result, without rowcount.
result = cast(
CursorResult[Any], db.execute(delete(QuotaEvent).where(QuotaEvent.created_at < cutoff))
)
return result.rowcount or 0
def pacific_day_start_utc() -> datetime:
"""UTC instant of the current Pacific day's midnight — aligns 'today' with the budget reset."""
start = datetime.now(_PACIFIC).replace(hour=0, minute=0, second=0, microsecond=0)