|
| 1 | +"""Database source for retention cleanup. |
| 2 | +
|
| 3 | +Holds the ``category -> tables`` catalog (kept inside the repository, never a |
| 4 | +module global) and drains each table with chunk-based delete-and-advance via |
| 5 | +the shared ``batch_purge``. |
| 6 | +""" |
| 7 | + |
| 8 | +from __future__ import annotations |
| 9 | + |
| 10 | +import logging |
| 11 | +from collections.abc import Mapping, Sequence |
| 12 | +from dataclasses import dataclass, field |
| 13 | +from datetime import datetime |
| 14 | +from typing import Any |
| 15 | + |
| 16 | +from sqlalchemy.sql.elements import ColumnElement |
| 17 | + |
| 18 | +from ai.backend.logging.utils import BraceStyleAdapter |
| 19 | +from ai.backend.manager.data.auth.login_session_types import LoginSessionStatus |
| 20 | +from ai.backend.manager.data.permission.status import RoleStatus |
| 21 | +from ai.backend.manager.data.retention.types import RetentionCategory, RetentionPurgeResult |
| 22 | +from ai.backend.manager.data.role_invitation.types import RoleInvitationState |
| 23 | +from ai.backend.manager.data.vfolder.types import VFolderInvitationState |
| 24 | +from ai.backend.manager.errors.retention import RetentionCategoryNotSupportedError |
| 25 | +from ai.backend.manager.models.audit_log.row import AuditLogRow |
| 26 | +from ai.backend.manager.models.base import Base |
| 27 | +from ai.backend.manager.models.error_logs import ErrorLogRow |
| 28 | +from ai.backend.manager.models.event_log.row import EventLogRow |
| 29 | +from ai.backend.manager.models.login_session.row import LoginHistoryRow, LoginSessionRow |
| 30 | +from ai.backend.manager.models.rbac_models.role import RoleRow |
| 31 | +from ai.backend.manager.models.replica_group_history.row import ReplicaGroupHistoryRow |
| 32 | +from ai.backend.manager.models.resource_usage_history.row import KernelUsageRecordRow |
| 33 | +from ai.backend.manager.models.role_invitation.row import RoleInvitationRow |
| 34 | +from ai.backend.manager.models.scheduling_history.row import ( |
| 35 | + DeploymentHistoryRow, |
| 36 | + KernelSchedulingHistoryRow, |
| 37 | + RouteHistoryRow, |
| 38 | + SessionSchedulingHistoryRow, |
| 39 | +) |
| 40 | +from ai.backend.manager.models.vfolder.row import VFolderInvitationRow |
| 41 | +from ai.backend.manager.repositories.base import BatchPurger, BatchPurgerSpec |
| 42 | +from ai.backend.manager.repositories.ops import DBOpsProvider |
| 43 | +from ai.backend.manager.repositories.retention.purgers import TimestampBoundaryPurgerSpec |
| 44 | + |
| 45 | +log = BraceStyleAdapter(logging.getLogger(__spec__.name)) |
| 46 | + |
| 47 | +# Invitation states that are terminal (no further update expected), so their |
| 48 | +# proxy timestamp is a safe grace boundary. Only PENDING is non-terminal. |
| 49 | +_TERMINAL_ROLE_INVITATION_STATES = ( |
| 50 | + RoleInvitationState.ACCEPTED, |
| 51 | + RoleInvitationState.REJECTED, |
| 52 | + RoleInvitationState.CANCELED, |
| 53 | +) |
| 54 | +_TERMINAL_VFOLDER_INVITATION_STATES = ( |
| 55 | + VFolderInvitationState.ACCEPTED, |
| 56 | + VFolderInvitationState.REJECTED, |
| 57 | + VFolderInvitationState.CANCELED, |
| 58 | +) |
| 59 | + |
| 60 | + |
| 61 | +@dataclass(frozen=True) |
| 62 | +class _BoundaryTable: |
| 63 | + """One table's fixed cleanup definition; only ``threshold`` varies per sweep. |
| 64 | +
|
| 65 | + The boundary column is chosen by table nature: append-only logs use |
| 66 | + ``created_at``, in-place-merged history uses ``updated_at``, and lifecycle |
| 67 | + records use their terminal timestamp plus a terminal-status |
| 68 | + ``extra_conditions`` filter. |
| 69 | + """ |
| 70 | + |
| 71 | + row_class: type[Base] |
| 72 | + boundary: Any |
| 73 | + extra_conditions: Sequence[ColumnElement[bool]] = field(default_factory=tuple) |
| 74 | + |
| 75 | + |
| 76 | +class RetentionDBSource: |
| 77 | + _ops: DBOpsProvider |
| 78 | + _catalog: Mapping[RetentionCategory, Sequence[_BoundaryTable]] |
| 79 | + |
| 80 | + def __init__(self, ops_provider: DBOpsProvider) -> None: |
| 81 | + self._ops = ops_provider |
| 82 | + self._catalog = self._build_catalog() |
| 83 | + |
| 84 | + @staticmethod |
| 85 | + def _build_catalog() -> Mapping[RetentionCategory, Sequence[_BoundaryTable]]: |
| 86 | + """Build the fixed ``category -> tables`` catalog once (column refs are |
| 87 | + constant; only the threshold is applied per sweep). |
| 88 | +
|
| 89 | + Categories with a bespoke ordered delete (sessions, deployments, |
| 90 | + usage_buckets) are implemented separately and intentionally absent. |
| 91 | + """ |
| 92 | + return { |
| 93 | + RetentionCategory.LOGS: ( |
| 94 | + _BoundaryTable(EventLogRow, EventLogRow.created_at), |
| 95 | + _BoundaryTable(AuditLogRow, AuditLogRow.created_at), |
| 96 | + # error_logs purges purely on the boundary — is_read/is_cleared |
| 97 | + # flags are intentionally ignored (all rows past boundary go). |
| 98 | + _BoundaryTable(ErrorLogRow, ErrorLogRow.created_at), |
| 99 | + ), |
| 100 | + # updated_at (not created_at): attempts++ merges touch updated_at, |
| 101 | + # so a recently-retried row keeps an old created_at but survives. |
| 102 | + RetentionCategory.RECONCILE_HISTORY: ( |
| 103 | + _BoundaryTable(SessionSchedulingHistoryRow, SessionSchedulingHistoryRow.updated_at), |
| 104 | + _BoundaryTable(KernelSchedulingHistoryRow, KernelSchedulingHistoryRow.updated_at), |
| 105 | + _BoundaryTable(DeploymentHistoryRow, DeploymentHistoryRow.updated_at), |
| 106 | + _BoundaryTable(RouteHistoryRow, RouteHistoryRow.updated_at), |
| 107 | + _BoundaryTable(ReplicaGroupHistoryRow, ReplicaGroupHistoryRow.updated_at), |
| 108 | + ), |
| 109 | + RetentionCategory.LOGIN: ( |
| 110 | + _BoundaryTable(LoginHistoryRow, LoginHistoryRow.created_at), |
| 111 | + _BoundaryTable( |
| 112 | + LoginSessionRow, |
| 113 | + LoginSessionRow.invalidated_at, |
| 114 | + ( |
| 115 | + LoginSessionRow.status.in_(( |
| 116 | + LoginSessionStatus.INVALIDATED, |
| 117 | + LoginSessionStatus.REVOKED, |
| 118 | + )), |
| 119 | + ), |
| 120 | + ), |
| 121 | + ), |
| 122 | + RetentionCategory.ROLES_INVITATIONS: ( |
| 123 | + _BoundaryTable( |
| 124 | + RoleRow, |
| 125 | + RoleRow.deleted_at, |
| 126 | + (RoleRow.status == RoleStatus.DELETED,), |
| 127 | + ), |
| 128 | + _BoundaryTable( |
| 129 | + RoleInvitationRow, |
| 130 | + RoleInvitationRow.updated_at, |
| 131 | + (RoleInvitationRow.state.in_(_TERMINAL_ROLE_INVITATION_STATES),), |
| 132 | + ), |
| 133 | + _BoundaryTable( |
| 134 | + VFolderInvitationRow, |
| 135 | + VFolderInvitationRow.modified_at, |
| 136 | + (VFolderInvitationRow.state.in_(_TERMINAL_VFOLDER_INVITATION_STATES),), |
| 137 | + ), |
| 138 | + ), |
| 139 | + RetentionCategory.USAGE_RECORDS: ( |
| 140 | + _BoundaryTable(KernelUsageRecordRow, KernelUsageRecordRow.period_end), |
| 141 | + ), |
| 142 | + } |
| 143 | + |
| 144 | + def _purger_specs( |
| 145 | + self, |
| 146 | + category: RetentionCategory, |
| 147 | + threshold: datetime, |
| 148 | + ) -> list[BatchPurgerSpec[Any]]: |
| 149 | + """Look up the category's tables and bind ``threshold`` into each spec.""" |
| 150 | + tables = self._catalog.get(category) |
| 151 | + if tables is None: |
| 152 | + raise RetentionCategoryNotSupportedError( |
| 153 | + f"Retention category '{category.value}' has no simple/grouped " |
| 154 | + "cleanup wired in this repository." |
| 155 | + ) |
| 156 | + return [ |
| 157 | + TimestampBoundaryPurgerSpec( |
| 158 | + t.row_class, t.boundary, threshold, extra_conditions=t.extra_conditions |
| 159 | + ) |
| 160 | + for t in tables |
| 161 | + ] |
| 162 | + |
| 163 | + async def purge_older_than( |
| 164 | + self, |
| 165 | + category: RetentionCategory, |
| 166 | + threshold: datetime, |
| 167 | + batch_size: int, |
| 168 | + ) -> RetentionPurgeResult: |
| 169 | + """Delete every row of ``category`` older than ``threshold``. |
| 170 | +
|
| 171 | + Each of the category's tables is drained in its own transaction via |
| 172 | + ``batch_purge``, which deletes in ``batch_size`` chunks (delete-and- |
| 173 | + advance) so a large backlog never becomes a single huge DELETE. |
| 174 | + """ |
| 175 | + specs = self._purger_specs(category, threshold) |
| 176 | + total_deleted = 0 |
| 177 | + |
| 178 | + for spec in specs: |
| 179 | + async with self._ops.write_ops() as w: |
| 180 | + result = await w.batch_purge(BatchPurger(spec=spec, batch_size=batch_size)) |
| 181 | + total_deleted += result.deleted_count |
| 182 | + |
| 183 | + log.debug( |
| 184 | + "retention purge category={} threshold={} deleted={}", |
| 185 | + category.value, |
| 186 | + threshold, |
| 187 | + total_deleted, |
| 188 | + ) |
| 189 | + return RetentionPurgeResult(category=category, deleted_count=total_deleted) |
0 commit comments