Skip to content

Commit 20d764d

Browse files
HyeockJinKimclaude
andauthored
feat(BA-6929): add DB record retention repository for simple/grouped categories (#12952)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e0cc59b commit 20d764d

13 files changed

Lines changed: 686 additions & 0 deletions

File tree

changes/12952.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add a DB record retention repository that purges accumulated log, reconcile-history, login, roles/invitation, and usage records past their configured age boundary.

src/ai/backend/manager/data/retention/types.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import enum
4+
from dataclasses import dataclass
45

56

67
class RetentionCategory(enum.StrEnum):
@@ -17,3 +18,15 @@ class RetentionCategory(enum.StrEnum):
1718
SESSIONS = "sessions"
1819
USAGE_RECORDS = "usage_records"
1920
USAGE_BUCKETS = "usage_buckets"
21+
22+
23+
@dataclass(frozen=True)
24+
class RetentionPurgeResult:
25+
"""Outcome of purging one category's older-than-threshold rows.
26+
27+
``deleted_count`` is the total rows removed across the category's tables,
28+
letting the sweep account the result against its per-tick budget.
29+
"""
30+
31+
category: RetentionCategory
32+
deleted_count: int
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Retention layer exceptions."""
2+
3+
from __future__ import annotations
4+
5+
from typing import override
6+
7+
from ai.backend.common.exception import (
8+
ErrorCode,
9+
ErrorDetail,
10+
ErrorDomain,
11+
ErrorOperation,
12+
)
13+
from ai.backend.manager.errors.repository import RepositoryError
14+
15+
16+
class RetentionCategoryNotSupportedError(RepositoryError):
17+
"""Raised when a retention category has no code-side cleanup wired yet.
18+
19+
The ordered-delete categories (``sessions``, ``deployments``,
20+
``usage_buckets``) are implemented separately; requesting one here fails
21+
loudly instead of silently deleting nothing.
22+
"""
23+
24+
error_type = "https://api.backend.ai/probs/retention-category-not-supported"
25+
error_title = "Retention category is not supported."
26+
27+
@override
28+
def error_code(self) -> ErrorCode:
29+
return ErrorCode(
30+
domain=ErrorDomain.BACKENDAI,
31+
operation=ErrorOperation.GENERIC,
32+
error_detail=ErrorDetail.INTERNAL_ERROR,
33+
)

src/ai/backend/manager/repositories/repositories.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
from ai.backend.manager.repositories.resource_usage_history.repositories import (
6565
ResourceUsageHistoryRepositories,
6666
)
67+
from ai.backend.manager.repositories.retention.repositories import RetentionRepositories
6768
from ai.backend.manager.repositories.role_preset.repositories import RolePresetRepositories
6869
from ai.backend.manager.repositories.runtime_variant.repositories import RuntimeVariantRepositories
6970
from ai.backend.manager.repositories.runtime_variant_preset.repositories import (
@@ -142,6 +143,7 @@ class Repositories:
142143
events: EventsRepositories
143144
storage_namespace: StorageNamespaceRepositories
144145
audit_log: AuditLogRepositories
146+
retention: RetentionRepositories
145147

146148
@classmethod
147149
def create(cls, args: RepositoryArgs) -> Self:
@@ -198,6 +200,7 @@ def create(cls, args: RepositoryArgs) -> Self:
198200
events_repositories = EventsRepositories.create(args)
199201
storage_namespace_repositories = StorageNamespaceRepositories.create(args)
200202
audit_log_repositories = AuditLogRepositories.create(args)
203+
retention_repositories = RetentionRepositories.create(args)
201204

202205
return cls(
203206
agent=agent_repositories,
@@ -251,4 +254,5 @@ def create(cls, args: RepositoryArgs) -> Self:
251254
events=events_repositories,
252255
storage_namespace=storage_namespace_repositories,
253256
audit_log=audit_log_repositories,
257+
retention=retention_repositories,
254258
)

src/ai/backend/manager/repositories/retention/__init__.py

Whitespace-only changes.

src/ai/backend/manager/repositories/retention/db_source/__init__.py

Whitespace-only changes.
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
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)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Batch-purge specs for retention cleanup.
2+
3+
A single :class:`TimestampBoundaryPurgerSpec` covers every retention target:
4+
common-column groups (``logs`` on ``created_at``, ``reconcile_history`` on
5+
``updated_at``) reuse one instance per table, simple categories reuse it
6+
directly, and lifecycle categories add a terminal-state filter through
7+
``extra_conditions``. Keeping the boundary logic in one spec is the shared
8+
"mixin" the design calls for — the ``category -> specs`` mapping lives in the
9+
DB source, not here.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from collections.abc import Sequence
15+
from dataclasses import dataclass, field
16+
from datetime import datetime
17+
from typing import Any, override
18+
19+
import sqlalchemy as sa
20+
from sqlalchemy.sql.elements import ColumnElement
21+
22+
from ai.backend.manager.models.base import Base
23+
from ai.backend.manager.repositories.base.purger import BatchPurgerSpec
24+
25+
26+
@dataclass
27+
class TimestampBoundaryPurgerSpec[TRow: Base](BatchPurgerSpec[TRow]):
28+
"""Selects rows older than ``threshold`` on a single boundary timestamp.
29+
30+
Rows whose ``boundary`` column is NULL are never selected, so a lifecycle
31+
record still lacking its terminal timestamp is preserved. ``extra_conditions``
32+
appends terminal-status / lifecycle filters (e.g. ``status == DELETED``).
33+
"""
34+
35+
row_class: type[TRow]
36+
# A mapped timestamp column expression. Any-typed because targets range over
37+
# declaratively-mapped attributes and imperatively-mapped ones (error_logs),
38+
# and over both non-null and nullable lifecycle columns.
39+
boundary: Any
40+
threshold: datetime
41+
extra_conditions: Sequence[ColumnElement[bool]] = field(default_factory=tuple)
42+
43+
@override
44+
def build_subquery(self) -> sa.sql.Select[tuple[TRow]]:
45+
stmt = sa.select(self.row_class).where(self.boundary < self.threshold)
46+
for condition in self.extra_conditions:
47+
stmt = stmt.where(condition)
48+
return stmt
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from dataclasses import dataclass
2+
from typing import Self
3+
4+
from ai.backend.manager.repositories.retention.repository import RetentionRepository
5+
from ai.backend.manager.repositories.types import RepositoryArgs
6+
7+
8+
@dataclass
9+
class RetentionRepositories:
10+
repository: RetentionRepository
11+
12+
@classmethod
13+
def create(cls, args: RepositoryArgs) -> Self:
14+
return cls(
15+
repository=RetentionRepository(args.ops_provider),
16+
)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from datetime import datetime
5+
6+
from ai.backend.logging import BraceStyleAdapter
7+
from ai.backend.manager.data.retention.types import RetentionCategory, RetentionPurgeResult
8+
from ai.backend.manager.repositories.ops import DBOpsProvider
9+
from ai.backend.manager.repositories.retention.db_source.db_source import RetentionDBSource
10+
11+
log = BraceStyleAdapter(logging.getLogger(__spec__.name))
12+
13+
14+
class RetentionRepository:
15+
"""Deletes accumulated DB records past their category's age boundary.
16+
17+
A single repository owns the ``category -> tables`` mapping and reuses the
18+
``BatchPurger`` framework for chunk-based (delete-and-advance) deletes. The
19+
leader sweep (separate task) computes ``threshold = now - retention_period``
20+
per policy and calls :meth:`purge_older_than`.
21+
"""
22+
23+
_db_source: RetentionDBSource
24+
25+
def __init__(self, ops_provider: DBOpsProvider) -> None:
26+
self._db_source = RetentionDBSource(ops_provider)
27+
28+
async def purge_older_than(
29+
self,
30+
category: RetentionCategory,
31+
threshold: datetime,
32+
batch_size: int,
33+
) -> RetentionPurgeResult:
34+
"""Purge rows of ``category`` older than ``threshold``, chunked per table."""
35+
return await self._db_source.purge_older_than(category, threshold, batch_size)

0 commit comments

Comments
 (0)