Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


backend/retention.py (2958 bytes)

1 """What the server keeps, and for how long.
2 
3 A server job brings an audiobook and a book onto this machine. They are the
4 visitor's files, often bought ones, and the disk is small. So nothing stays
5 longer than it is of use:
6 
7   uploaded files   deleted when the job succeeds (runner.py). A job that was
8                    never started, failed or was cancelled keeps them for
9                    `input_retention_hours`, so the visitor can fix the language
10                    and try again; then they go too.
11   results          subtitles and videos stay for `artifact_retention_days`,
12                    then they are deleted. The row of the job stays, so the list
13                    still says what was converted.
14 
15 Jobs that ran in the visitor's browser have no files here, only the subtitles
16 the browser sent back; those follow the rule for results.
17 """
18 
19 from __future__ import annotations
20 
21 import logging
22 import shutil
23 from datetime import timedelta
24 
25 from sqlalchemy.orm import Session
26 
27 from .db import Artifact, Job, JobStatus, utcnow
28 from .runner import Paths
29 from .settings import settings
30 from .storage import storage
31 
32 log = logging.getLogger("subplz-web.retention")
33 
34 _IDLE = [JobStatus.draft, JobStatus.failed, JobStatus.canceled]
35 
36 
37 def sweep(session: Session) -> dict[str, int]:
38     """Delete what is past its time. Safe to run at any moment, and again."""
39     now = utcnow()
40     done = {"inputs": 0, "drafts": 0, "results": 0}
41 
42     old_idle = (
43         session.query(Job)
44         .filter(Job.local == 0, Job.status.in_(_IDLE),
45                 Job.created_at < now - timedelta(hours=settings.input_retention_hours))
46         .all()
47     )
48     for job in old_idle:
49         root = Paths.for_job(job.id).root
50         if root.exists():
51             shutil.rmtree(root, ignore_errors=True)
52             done["inputs"] += 1
53         _delete_stored(job.id)
54         if job.status == JobStatus.draft:
55             # Never started: nothing to show for it.
56             session.delete(job)
57             done["drafts"] += 1
58 
59     old_results = (
60         session.query(Job)
61         .join(Artifact, Artifact.job_id == Job.id)
62         .filter(Job.status == JobStatus.succeeded,
63                 Job.finished_at < now - timedelta(days=settings.artifact_retention_days))
64         .distinct()
65         .all()
66     )
67     for job in old_results:
68         _delete_stored(job.id)
69         shutil.rmtree(Paths.for_job(job.id).root, ignore_errors=True)
70         session.query(Artifact).filter(Artifact.job_id == job.id).delete()
71         job.stage = f"Files deleted after {settings.artifact_retention_days} days"
72         done["results"] += 1
73 
74     session.commit()
75     if any(done.values()):
76         log.info("retention sweep: %s", done)
77     return done
78 
79 
80 def _delete_stored(job_id: str) -> None:
81     try:
82         storage.delete_prefix(job_id)
83     except Exception as exc:  # noqa: BLE001 - the next sweep tries again
84         log.warning("could not delete stored files of %s: %s", job_id, exc)