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
+3
View File
@@ -260,6 +260,9 @@ class Settings(BaseSettings):
# Admin audit log: how long to keep rows (days; 0 = keep forever) + how often the GC runs. # Admin audit log: how long to keep rows (days; 0 = keep forever) + how often the GC runs.
audit_retention_days: int = 180 audit_retention_days: int = 180
audit_gc_minutes: int = 1440 audit_gc_minutes: int = 1440
# YouTube quota history (quota_events): days kept (0 = forever). A year, so the admin usage view's
# longest window (365 days) stays whole; the same daily cleanup job prunes it.
quota_retention_days: int = 365
# On-disk layout: "plex" (Channel/Season YYYY/… [id].ext + .nfo/poster) or "flat". # On-disk layout: "plex" (Channel/Season YYYY/… [id].ext + .nfo/poster) or "flat".
download_layout: DownloadLayout = "plex" download_layout: DownloadLayout = "plex"
# Default SponsorBlock (skip sponsor segments) for new custom profiles. # Default SponsorBlock (skip sponsor segments) for new custom profiles.
+18 -2
View File
@@ -7,11 +7,13 @@ always be allowed; expensive backfill must yield to the remaining budget.
import contextlib import contextlib
import contextvars import contextvars
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Any, cast
from zoneinfo import ZoneInfo 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.dialects.postgresql import insert as pg_insert
from sqlalchemy.engine import CursorResult
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import sysconfig from app import sysconfig
@@ -96,6 +98,20 @@ def pacific_today():
return datetime.now(_PACIFIC).date() 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: def pacific_day_start_utc() -> datetime:
"""UTC instant of the current Pacific day's midnight — aligns 'today' with the budget reset.""" """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) start = datetime.now(_PACIFIC).replace(hour=0, minute=0, second=0, microsecond=0)
+6 -3
View File
@@ -425,12 +425,15 @@ def _people_enrich_job() -> None:
def _audit_gc_job() -> None: def _audit_gc_job() -> None:
# Prune audit-log rows older than the admin-set retention (audit_retention_days; 0 = keep # The history cleanup: audit-log rows past audit_retention_days and YouTube quota history past
# forever). Pure DB, no quota. # quota_retention_days (0 = keep forever, each). Pure DB, no quota.
def work(db): def work(db):
from app import sysconfig from app import sysconfig
return {"reaped": audit.gc(db, sysconfig.get_int(db, "audit_retention_days"))} reaped = audit.gc(db, sysconfig.get_int(db, "audit_retention_days"))
quota_reaped = quota.gc(db, sysconfig.get_int(db, "quota_retention_days"))
db.commit()
return {"reaped": reaped, "quota_reaped": quota_reaped}
_job("audit_gc", work) _job("audit_gc", work)
+1
View File
@@ -80,6 +80,7 @@ SPECS: tuple[ConfigSpec, ...] = (
), ),
# Live-search source: "scrape" (InnerTube, zero API quota) or "api" (search.list, 100u/page). # Live-search source: "scrape" (InnerTube, zero API quota) or "api" (search.list, 100u/page).
ConfigSpec("search_source", "choice", "quota", "search_source"), ConfigSpec("search_source", "choice", "quota", "search_source"),
ConfigSpec("quota_retention_days", "int", "quota", "quota_retention_days", min=0, max=3650),
# --- Backfill (recent-first first pass per channel) --- # --- Backfill (recent-first first pass per channel) ---
ConfigSpec( ConfigSpec(
"backfill_recent_max_videos", "backfill_recent_max_videos",
+46
View File
@@ -0,0 +1,46 @@
"""`quota_events` has a retention: the daily history-cleanup job (`audit_gc`) prunes rows older than
`quota_retention_days`, as it does the audit log. Without it the table only ever grew — one row per
API spend plus a zero-unit row per live search."""
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from app import scheduler, sysconfig
from app.models import QuotaEvent
def _event(days_ago: int) -> QuotaEvent:
return QuotaEvent(
user_id=None,
action="videos.list",
units=1,
created_at=datetime.now(timezone.utc) - timedelta(days=days_ago),
)
def _remaining(db) -> list[int]:
db.expire_all()
return sorted(e.units for e in db.execute(select(QuotaEvent)).scalars())
def test_the_history_cleanup_prunes_quota_events_past_the_retention(db):
old, recent = _event(400), _event(10)
recent.units = 7
db.add_all([old, recent])
db.commit()
scheduler.JOB_FUNCS["audit_gc"]()
assert _remaining(db) == [7] # the default keeps a year, which the admin usage view can show
def test_a_zero_retention_keeps_quota_history_forever(db):
db.add(_event(4000))
db.commit()
sysconfig.set_value(db, "quota_retention_days", 0)
db.commit()
scheduler.JOB_FUNCS["audit_gc"]()
assert _remaining(db) == [1]
+4
View File
@@ -62,6 +62,10 @@
"label": "Live search source", "label": "Live search source",
"hint": "Where live YouTube search results come from. “Website” reads YouTube's own search endpoint and spends no API quota (recommended). “Official API” uses search.list (100 units/page) — switch to it if the website source ever breaks or is blocked." "hint": "Where live YouTube search results come from. “Website” reads YouTube's own search endpoint and spends no API quota (recommended). “Official API” uses search.list (100 units/page) — switch to it if the website source ever breaks or is blocked."
}, },
"quota_retention_days": {
"label": "Quota history kept (days)",
"hint": "How long the per-action YouTube quota history (who spent what) is kept before the history cleanup job prunes it. 0 = keep forever. Default 365, so the usage view's longest window stays whole."
},
"backfill_recent_max_videos": { "backfill_recent_max_videos": {
"label": "Recent backfill — max videos", "label": "Recent backfill — max videos",
"hint": "Newest videos fetched per channel on the first pass." "hint": "Newest videos fetched per channel on the first pass."
+2 -2
View File
@@ -76,7 +76,7 @@
"plex_sync": "Mirrors the enabled Plex libraries into the app's catalogue — the metadata behind Plex browsing, search, filters and the info pages (genres, cast, ratings, artwork). If it stops, newly added or changed Plex titles don't appear until the next sync.", "plex_sync": "Mirrors the enabled Plex libraries into the app's catalogue — the metadata behind Plex browsing, search, filters and the info pages (genres, cast, ratings, artwork). If it stops, newly added or changed Plex titles don't appear until the next sync.",
"plex_watch_sync": "Pulls recent Plex watch changes (finished episodes, resume positions) into Siftlode and re-pushes any local watch changes that didn't reach Plex. The cheap two-way keep-in-sync between full reconciles. If it stops, watched/resume made in the Plex app takes longer to show here.", "plex_watch_sync": "Pulls recent Plex watch changes (finished episodes, resume positions) into Siftlode and re-pushes any local watch changes that didn't reach Plex. The cheap two-way keep-in-sync between full reconciles. If it stops, watched/resume made in the Plex app takes longer to show here.",
"plex_watch_reconcile": "A full watch-state reconcile that rescans every library — catches what the incremental pass can't, notably an episode un-marked as watched on the Plex side. Heavier, so it runs about once a day. If it stops, un-watches done in Plex aren't mirrored back.", "plex_watch_reconcile": "A full watch-state reconcile that rescans every library — catches what the incremental pass can't, notably an episode un-marked as watched on the Plex side. Heavier, so it runs about once a day. If it stops, un-watches done in Plex aren't mirrored back.",
"audit_gc": "Prunes admin audit-log entries older than the configured retention window (Configuration → Audit log). If it stops, the audit log just keeps growing — harmless but unbounded.", "audit_gc": "Prunes admin audit-log entries and YouTube quota history older than their retention windows (Configuration → Audit log, Quota). If it stops, both just keep growing — harmless but unbounded.",
"log_gc": "Deletes stored log events older than the retention window (Configuration → Diagnostics). Runs hourly so nothing outlives the promised limit by more than an hour.", "log_gc": "Deletes stored log events older than the retention window (Configuration → Diagnostics). Runs hourly so nothing outlives the promised limit by more than an hour.",
"tmdb_enrich": "Enriches the mirrored Plex catalogue from TMDB (keywords, crew, budget/revenue, TMDb score, recommendations, artwork), matched by each title's canonical id and served from the app's own database. Optional — a no-op until a TMDB API key is set. If it stops, new Plex titles just miss their TMDB extras until it runs again.", "tmdb_enrich": "Enriches the mirrored Plex catalogue from TMDB (keywords, crew, budget/revenue, TMDb score, recommendations, artwork), matched by each title's canonical id and served from the app's own database. Optional — a no-op until a TMDB API key is set. If it stops, new Plex titles just miss their TMDB extras until it runs again.",
"people_enrich": "Makes the people in the Plex library local: fetches each person's biography from TMDB and caches their portrait, resized through Plex, the most-credited people first. A person page and a cast strip then open from the app's own data. The TMDB half does nothing without a TMDB API key, the portrait half nothing without Plex. If it stops, a person page fetches its own bio when opened, a moment later." "people_enrich": "Makes the people in the Plex library local: fetches each person's biography from TMDB and caches their portrait, resized through Plex, the most-credited people first. A person page and a cast strip then open from the app's own data. The TMDB half does nothing without a TMDB API key, the portrait half nothing without Plex. If it stops, a person page fetches its own bio when opened, a moment later."
@@ -97,7 +97,7 @@
"plex_watch_sync": "Plex watch sync (incremental)", "plex_watch_sync": "Plex watch sync (incremental)",
"plex_watch_reconcile": "Plex watch reconcile (full)", "plex_watch_reconcile": "Plex watch reconcile (full)",
"tmdb_enrich": "TMDB enrichment (Plex)", "tmdb_enrich": "TMDB enrichment (Plex)",
"audit_gc": "Audit-log cleanup", "audit_gc": "Audit-log and quota-history cleanup",
"log_gc": "Log cleanup", "log_gc": "Log cleanup",
"people_enrich": "People details and portraits (Plex)" "people_enrich": "People details and portraits (Plex)"
}, },
+4
View File
@@ -62,6 +62,10 @@
"label": "Élő keresés forrása", "label": "Élő keresés forrása",
"hint": "Honnan jönnek az élő YouTube-keresés találatai. A „Weboldal” a YouTube saját keresővégpontját olvassa, és nem fogyaszt API-kvótát (ajánlott). A „Hivatalos API” a search.list-et használja (100 egység/oldal) — válts rá, ha a weboldalas forrás elromlik vagy letiltják." "hint": "Honnan jönnek az élő YouTube-keresés találatai. A „Weboldal” a YouTube saját keresővégpontját olvassa, és nem fogyaszt API-kvótát (ajánlott). A „Hivatalos API” a search.list-et használja (100 egység/oldal) — válts rá, ha a weboldalas forrás elromlik vagy letiltják."
}, },
"quota_retention_days": {
"label": "Kvótaelőzmények megőrzése (nap)",
"hint": "Meddig marad meg a YouTube-kvóta tételes előzménye (ki mit fogyasztott), mielőtt az előzmény-takarító feladat törli. 0 = örökre. Alapérték 365, így a használati nézet leghosszabb ablaka is teljes marad."
},
"backfill_recent_max_videos": { "backfill_recent_max_videos": {
"label": "Friss behúzás — max videó", "label": "Friss behúzás — max videó",
"hint": "Csatornánként az első körben behúzott legújabb videók száma." "hint": "Csatornánként az első körben behúzott legújabb videók száma."
+2 -2
View File
@@ -76,7 +76,7 @@
"plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infóoldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő szinkron után jelennek meg.", "plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infóoldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő szinkron után jelennek meg.",
"plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes egyeztetések között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.", "plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes egyeztetések között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.",
"plex_watch_reconcile": "A megtekintési állapot teljes egyeztetése, amely minden könyvtárat újraszken­nel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben levett megtekintve jel nem tükröződik vissza.", "plex_watch_reconcile": "A megtekintési állapot teljes egyeztetése, amely minden könyvtárat újraszken­nel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben levett megtekintve jel nem tükröződik vissza.",
"audit_gc": "Törli a beállított megőrzési időnél (Konfiguráció → Eseménynapló) régebbi admin-naplóbejegyzéseket. Ha leáll, az eseménynapló csak nő — ártalmatlan, de korlátlanul.", "audit_gc": "Törli a megőrzési időnél régebbi admin-naplóbejegyzéseket és YouTube-kvótaelőzményeket (Konfiguráció → Eseménynapló, Kvóta). Ha leáll, mindkettő csak nő — ártalmatlan, de korlátlanul.",
"log_gc": "Törli a megőrzési időnél (Konfiguráció → Diagnosztika) régebbi tárolt naplóeseményeket. Óránként fut, így semmi sem marad meg egy óránál tovább az ígért határidőn túl.", "log_gc": "Törli a megőrzési időnél (Konfiguráció → Diagnosztika) régebbi tárolt naplóeseményeket. Óránként fut, így semmi sem marad meg egy óránál tovább az ígért határidőn túl.",
"tmdb_enrich": "A tükrözött Plex-katalógust dúsítja a TMDB-ből (kulcsszavak, stáb, költségvetés/bevétel, TMDb-pontszám, ajánlások, artwork), a címek kanonikus azonosítója alapján párosítva, és az app saját adatbázisából szolgálja ki. Opcionális — TMDB API-kulcs nélkül nem csinál semmit. Ha leáll, az új Plex-címek csak a következő futásig maradnak TMDB-extrák nélkül.", "tmdb_enrich": "A tükrözött Plex-katalógust dúsítja a TMDB-ből (kulcsszavak, stáb, költségvetés/bevétel, TMDb-pontszám, ajánlások, artwork), a címek kanonikus azonosítója alapján párosítva, és az app saját adatbázisából szolgálja ki. Opcionális — TMDB API-kulcs nélkül nem csinál semmit. Ha leáll, az új Plex-címek csak a következő futásig maradnak TMDB-extrák nélkül.",
"people_enrich": "Helyivé teszi a Plex-könyvtár szereplőit: a TMDB-ből letölti az életrajzukat, a portréjukat pedig a Plexszel kicsinyítve elmenti, a legtöbbet szereplőkkel kezdve. A személyoldal és a szereplősáv így az app saját adataiból nyílik meg. A TMDB-rész TMDB API-kulcs nélkül nem csinál semmit, a portrérész Plex nélkül. Ha leáll, a személyoldal megnyitáskor maga kéri le az életrajzot, egy pillanattal később." "people_enrich": "Helyivé teszi a Plex-könyvtár szereplőit: a TMDB-ből letölti az életrajzukat, a portréjukat pedig a Plexszel kicsinyítve elmenti, a legtöbbet szereplőkkel kezdve. A személyoldal és a szereplősáv így az app saját adataiból nyílik meg. A TMDB-rész TMDB API-kulcs nélkül nem csinál semmit, a portrérész Plex nélkül. Ha leáll, a személyoldal megnyitáskor maga kéri le az életrajzot, egy pillanattal később."
@@ -97,7 +97,7 @@
"plex_watch_sync": "Plex megtekintés-szinkron (inkrementális)", "plex_watch_sync": "Plex megtekintés-szinkron (inkrementális)",
"plex_watch_reconcile": "Plex megtekintés-egyeztetés (teljes)", "plex_watch_reconcile": "Plex megtekintés-egyeztetés (teljes)",
"tmdb_enrich": "TMDB-dúsítás (Plex)", "tmdb_enrich": "TMDB-dúsítás (Plex)",
"audit_gc": "Eseménynapló-takarítás", "audit_gc": "Eseménynapló- és kvótaelőzmény-takarítás",
"log_gc": "Naplótakarítás", "log_gc": "Naplótakarítás",
"people_enrich": "Személyadatok és portrék (Plex)" "people_enrich": "Személyadatok és portrék (Plex)"
}, },