120 lines
4.1 KiB
Python
120 lines
4.1 KiB
Python
|
|
"""Download retention garbage collector (a scheduler job).
|
||
|
|
|
||
|
|
Three passes each run:
|
||
|
|
1. Pre-expiry warning — for ready assets expiring within the grace window, notify each holder
|
||
|
|
once (gc_notified flag) so a download isn't silently deleted.
|
||
|
|
2. Expiry deletion — delete assets past their TTL (files + row). The FK SET NULL nulls the
|
||
|
|
holders' jobs' asset_id, so those jobs surface as "expired" (re-downloadable) in the UI.
|
||
|
|
3. LRU eviction — if a total cache cap is set and exceeded, delete least-recently-accessed
|
||
|
|
ready assets until back under it.
|
||
|
|
|
||
|
|
Runs in the API process (which mounts DOWNLOAD_ROOT). Pure disk/DB work — no YouTube quota.
|
||
|
|
"""
|
||
|
|
import logging
|
||
|
|
from datetime import datetime, timedelta, timezone
|
||
|
|
|
||
|
|
from sqlalchemy import func, select
|
||
|
|
from sqlalchemy.orm import Session
|
||
|
|
|
||
|
|
from app import sysconfig
|
||
|
|
from app.config import settings
|
||
|
|
from app.downloads import storage
|
||
|
|
from app.models import DownloadJob, MediaAsset
|
||
|
|
from app.notifications import create_notification
|
||
|
|
|
||
|
|
log = logging.getLogger("siftlode.downloads.gc")
|
||
|
|
|
||
|
|
|
||
|
|
def _holders(db: Session, asset_id: int) -> list[int]:
|
||
|
|
return list(
|
||
|
|
db.execute(
|
||
|
|
select(DownloadJob.user_id)
|
||
|
|
.where(DownloadJob.asset_id == asset_id)
|
||
|
|
.distinct()
|
||
|
|
).scalars()
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _notify_holders(db: Session, asset: MediaAsset, ntype: str) -> None:
|
||
|
|
title = asset.title or "download"
|
||
|
|
for uid in _holders(db, asset.id):
|
||
|
|
create_notification(
|
||
|
|
db,
|
||
|
|
user_id=uid,
|
||
|
|
type=ntype,
|
||
|
|
title=title, # English fallback; the client renders a translated message from data
|
||
|
|
data={"kind": ntype, "title": asset.title, "video_id": asset.source_ref},
|
||
|
|
commit=False,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _delete_asset(db: Session, asset: MediaAsset, ntype: str) -> None:
|
||
|
|
if asset.rel_path:
|
||
|
|
storage.delete_asset_files(settings.download_root, asset.rel_path)
|
||
|
|
_notify_holders(db, asset, ntype)
|
||
|
|
db.delete(asset) # jobs.asset_id -> NULL via FK ON DELETE SET NULL
|
||
|
|
|
||
|
|
|
||
|
|
def run_download_gc(db: Session) -> dict:
|
||
|
|
now = datetime.now(timezone.utc)
|
||
|
|
grace_days = sysconfig.get_int(db, "download_gc_grace_days")
|
||
|
|
warned = expired = evicted = 0
|
||
|
|
|
||
|
|
# 1. Pre-expiry warnings.
|
||
|
|
warn_before = now + timedelta(days=grace_days)
|
||
|
|
soon = db.execute(
|
||
|
|
select(MediaAsset).where(
|
||
|
|
MediaAsset.status == "ready",
|
||
|
|
MediaAsset.gc_notified.is_(False),
|
||
|
|
MediaAsset.expires_at.is_not(None),
|
||
|
|
MediaAsset.expires_at > now,
|
||
|
|
MediaAsset.expires_at <= warn_before,
|
||
|
|
)
|
||
|
|
).scalars().all()
|
||
|
|
for asset in soon:
|
||
|
|
_notify_holders(db, asset, "download_expiring")
|
||
|
|
asset.gc_notified = True
|
||
|
|
warned += 1
|
||
|
|
if soon:
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
# 2. Expiry deletion (ready assets past TTL, and any errored leftovers with a TTL).
|
||
|
|
dead = db.execute(
|
||
|
|
select(MediaAsset).where(
|
||
|
|
MediaAsset.status.in_(("ready", "error")),
|
||
|
|
MediaAsset.expires_at.is_not(None),
|
||
|
|
MediaAsset.expires_at <= now,
|
||
|
|
)
|
||
|
|
).scalars().all()
|
||
|
|
for asset in dead:
|
||
|
|
_delete_asset(db, asset, "download_expired")
|
||
|
|
expired += 1
|
||
|
|
if dead:
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
# 3. LRU eviction over the total cache cap.
|
||
|
|
cap = sysconfig.get_int(db, "download_total_max_bytes")
|
||
|
|
if cap > 0:
|
||
|
|
total = db.execute(
|
||
|
|
select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(
|
||
|
|
MediaAsset.status == "ready"
|
||
|
|
)
|
||
|
|
).scalar() or 0
|
||
|
|
if total > cap:
|
||
|
|
candidates = db.execute(
|
||
|
|
select(MediaAsset)
|
||
|
|
.where(MediaAsset.status == "ready")
|
||
|
|
.order_by(MediaAsset.last_access_at.asc().nulls_first())
|
||
|
|
).scalars().all()
|
||
|
|
for asset in candidates:
|
||
|
|
if total <= cap:
|
||
|
|
break
|
||
|
|
total -= asset.size_bytes or 0
|
||
|
|
_delete_asset(db, asset, "download_evicted")
|
||
|
|
evicted += 1
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
result = {"warned": warned, "expired": expired, "evicted": evicted}
|
||
|
|
log.info("download_gc %s", result)
|
||
|
|
return result
|