Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/9470.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Implement prometheus query preset repository layer.
13 changes: 13 additions & 0 deletions src/ai/backend/common/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ class ErrorDomain(enum.StrEnum):
KEYPAIR_RESOURCE_POLICY = "keypair-resource-policy"
DATABASE = "database"
USER_RESOURCE_POLICY = "user-resource-policy"
PROMETHEUS_QUERY_PRESET = "prometheus-query-preset"

EXTERNAL_SYSTEM = "external-system" # Errors from external systems

Expand Down Expand Up @@ -1167,3 +1168,15 @@ def error_code(self) -> ErrorCode:
operation=ErrorOperation.GENERIC,
error_detail=ErrorDetail.INTERNAL_ERROR,
)


class PrometheusQueryPresetNotFound(BackendAIError, web.HTTPNotFound):
error_type = "https://api.backend.ai/probs/prometheus-query-preset-not-found"
error_title = "The prometheus query preset does not exist."

def error_code(self) -> ErrorCode:
return ErrorCode(
domain=ErrorDomain.PROMETHEUS_QUERY_PRESET,
operation=ErrorOperation.READ,
error_detail=ErrorDetail.NOT_FOUND,
)
Comment thread
seedspirit marked this conversation as resolved.
1 change: 1 addition & 0 deletions src/ai/backend/common/metrics/metric.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ class LayerType(enum.StrEnum):
NOTIFICATION_REPOSITORY = "notification_repository"
OBJECT_STORAGE_REPOSITORY = "object_storage_repository"
PERMISSION_CONTROLLER_REPOSITORY = "permission_controller_repository"
PROMETHEUS_QUERY_PRESET_REPOSITORY = "prometheus_query_preset_repository"
PROJECT_RESOURCE_POLICY_REPOSITORY = "project_resource_policy_repository"
RESERVOIR_REGISTRY_REPOSITORY = "reservoir_registry_repository"
RESOURCE_PRESET_REPOSITORY = "resource_preset_repository"
Expand Down
12 changes: 10 additions & 2 deletions src/ai/backend/manager/data/prometheus_query_preset/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
from .types import PrometheusQueryPresetData
from .types import (
PrometheusQueryPresetData,
PrometheusQueryPresetListResult,
PrometheusQueryPresetModifier,
)

__all__ = ("PrometheusQueryPresetData",)
__all__ = (
"PrometheusQueryPresetData",
"PrometheusQueryPresetListResult",
"PrometheusQueryPresetModifier",
)
38 changes: 37 additions & 1 deletion src/ai/backend/manager/data/prometheus_query_preset/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, override
from uuid import UUID

from ai.backend.manager.types import OptionalState, PartialModifier, TriState

@dataclass

@dataclass(frozen=True)
class PrometheusQueryPresetData:
"""Domain model data for prometheus query preset."""

Expand All @@ -18,3 +21,36 @@ class PrometheusQueryPresetData:
group_labels: list[str]
created_at: datetime = field(compare=False)
updated_at: datetime = field(compare=False)


@dataclass
class PrometheusQueryPresetModifier(PartialModifier):
"""Modifier for prometheus query preset."""

name: OptionalState[str] = field(default_factory=OptionalState[str].nop)
metric_name: OptionalState[str] = field(default_factory=OptionalState[str].nop)
query_template: OptionalState[str] = field(default_factory=OptionalState[str].nop)
time_window: TriState[str] = field(default_factory=TriState[str].nop)
filter_labels: OptionalState[list[str]] = field(default_factory=OptionalState[list[str]].nop)
group_labels: OptionalState[list[str]] = field(default_factory=OptionalState[list[str]].nop)

@override
def fields_to_update(self) -> dict[str, Any]:
to_update: dict[str, Any] = {}
self.name.update_dict(to_update, "name")
self.metric_name.update_dict(to_update, "metric_name")
self.query_template.update_dict(to_update, "query_template")
self.time_window.update_dict(to_update, "time_window")
self.filter_labels.update_dict(to_update, "filter_labels")
self.group_labels.update_dict(to_update, "group_labels")
Comment on lines +44 to +45
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PrometheusQueryPresetModifier.fields_to_update() populates keys "filter_labels" and "group_labels", but PrometheusQueryPresetRow does not have such columns (they are nested under the JSONB "options" column). If this modifier is used to build an UPDATE statement (as other *Modifier types are), it will generate invalid column updates. Consider either removing this modifier until it is wired correctly, or have it emit an "options" update (or provide a dedicated updater spec that correctly merges/updates PresetOptions).

Suggested change
self.filter_labels.update_dict(to_update, "filter_labels")
self.group_labels.update_dict(to_update, "group_labels")
# NOTE: filter_labels and group_labels are stored under a JSONB "options"
# column in the underlying row type, so they must be handled by a dedicated
# updater/merger rather than as top-level columns here.

Copilot uses AI. Check for mistakes.
return to_update


@dataclass
class PrometheusQueryPresetListResult:
"""Search result with total count for prometheus query presets."""

items: list[PrometheusQueryPresetData]
total_count: int
has_next_page: bool
has_previous_page: bool
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

from ai.backend.manager.data.prometheus_query_preset import (
PrometheusQueryPresetData,
PrometheusQueryPresetListResult,
PrometheusQueryPresetModifier,
)

from .creators import PrometheusQueryPresetCreatorSpec
from .repositories import PrometheusQueryPresetRepositories
from .repository import PrometheusQueryPresetRepository

__all__ = (
"PrometheusQueryPresetCreatorSpec",
"PrometheusQueryPresetData",
"PrometheusQueryPresetListResult",
"PrometheusQueryPresetModifier",
"PrometheusQueryPresetRepositories",
"PrometheusQueryPresetRepository",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""CreatorSpec implementations for prometheus query preset repository."""

from __future__ import annotations

from dataclasses import dataclass
from typing import override

from ai.backend.manager.models.prometheus_query_preset.row import (
PresetOptions,
PrometheusQueryPresetRow,
)
from ai.backend.manager.repositories.base import CreatorSpec


@dataclass
class PrometheusQueryPresetCreatorSpec(CreatorSpec[PrometheusQueryPresetRow]):
"""CreatorSpec for prometheus query preset."""

name: str
metric_name: str
query_template: str
time_window: str | None
filter_labels: list[str]
group_labels: list[str]

@override
def build_row(self) -> PrometheusQueryPresetRow:
return PrometheusQueryPresetRow(
name=self.name,
metric_name=self.metric_name,
query_template=self.query_template,
time_window=self.time_window,
options=PresetOptions(
filter_labels=self.filter_labels,
group_labels=self.group_labels,
),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .db_source import PrometheusQueryPresetDBSource

__all__ = ("PrometheusQueryPresetDBSource",)
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""Database source for prometheus query preset repository operations."""

from __future__ import annotations

from typing import TYPE_CHECKING, Any, cast
from uuid import UUID

import sqlalchemy as sa
from sqlalchemy.engine import CursorResult

from ai.backend.common.exception import PrometheusQueryPresetNotFound
from ai.backend.manager.data.prometheus_query_preset import (
PrometheusQueryPresetData,
PrometheusQueryPresetListResult,
)
from ai.backend.manager.models.prometheus_query_preset import PrometheusQueryPresetRow
from ai.backend.manager.repositories.base import (
BatchQuerier,
Creator,
execute_batch_querier,
execute_creator,
)
from ai.backend.manager.repositories.base.updater import Updater, execute_updater
from ai.backend.manager.repositories.prometheus_query_preset.updaters import (
PrometheusQueryPresetUpdaterSpec,
)
from ai.backend.manager.types import OptionalState

if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession as SASession

from ai.backend.manager.models.utils import ExtendedAsyncSAEngine


__all__ = ("PrometheusQueryPresetDBSource",)


class PrometheusQueryPresetDBSource:
"""Database source for prometheus query preset operations."""

_db: ExtendedAsyncSAEngine

def __init__(self, db: ExtendedAsyncSAEngine) -> None:
self._db = db

async def create(
self,
creator: Creator[PrometheusQueryPresetRow],
) -> PrometheusQueryPresetData:
"""Creates a new prometheus query preset."""
async with self._db.begin_session() as db_sess:
result = await execute_creator(db_sess, creator)
return result.row.to_data()

async def _merge_partial_options(
self,
db_sess: SASession,
updater: Updater[PrometheusQueryPresetRow],
) -> Updater[PrometheusQueryPresetRow]:
"""When only one of filter_labels/group_labels is being updated,
fetch the current options to preserve the other field."""
updater.spec = cast(PrometheusQueryPresetUpdaterSpec, updater.spec)
filter_updating = updater.spec.filter_labels.optional_value() is not None
group_updating = updater.spec.group_labels.optional_value() is not None

# If both are being updated or both are not being updated, no need to merge
if filter_updating == group_updating:
return updater

stmt = sa.select(PrometheusQueryPresetRow.options).where(
PrometheusQueryPresetRow.id == updater.pk_value
)
current_options = (await db_sess.execute(stmt)).scalar_one_or_none()
if current_options is None:
raise PrometheusQueryPresetNotFound(
f"Prometheus query preset {updater.pk_value} not found"
)

if filter_updating:
updater.spec.group_labels = OptionalState[list[str]].update(
list(current_options.group_labels)
)
if group_updating:
updater.spec.filter_labels = OptionalState[list[str]].update(
list(current_options.filter_labels)
)
return updater

async def update(
self,
updater: Updater[PrometheusQueryPresetRow],
) -> PrometheusQueryPresetData:
"""Updates an existing prometheus query preset."""
async with self._db.begin_session() as db_sess:
updater = await self._merge_partial_options(db_sess, updater)
result = await execute_updater(db_sess, updater)
if result is None:
raise PrometheusQueryPresetNotFound(
f"Prometheus query preset {updater.pk_value} not found"
)
return result.row.to_data()

async def delete(self, preset_id: UUID) -> bool:
"""Deletes a prometheus query preset."""
async with self._db.begin_session() as db_sess:
stmt = sa.delete(PrometheusQueryPresetRow).where(
PrometheusQueryPresetRow.id == preset_id
)
result = await db_sess.execute(stmt)
return cast(CursorResult[Any], result).rowcount > 0

async def get_by_id(self, preset_id: UUID) -> PrometheusQueryPresetData:
"""Retrieves a prometheus query preset by ID."""
async with self._db.begin_readonly_session_read_committed() as db_sess:
row = await db_sess.get(PrometheusQueryPresetRow, preset_id)
if row is None:
raise PrometheusQueryPresetNotFound(
f"Prometheus query preset {preset_id} not found"
)
return row.to_data()

async def search(
self,
querier: BatchQuerier,
) -> PrometheusQueryPresetListResult:
"""Searches prometheus query presets with total count."""
async with self._db.begin_readonly_session() as db_sess:
query = sa.select(PrometheusQueryPresetRow)

result = await execute_batch_querier(
db_sess,
query,
querier,
)

items = [row.PrometheusQueryPresetRow.to_data() for row in result.rows]
Comment thread
seedspirit marked this conversation as resolved.

return PrometheusQueryPresetListResult(
items=items,
total_count=result.total_count,
has_next_page=result.has_next_page,
has_previous_page=result.has_previous_page,
)
Loading
Loading