Skip to content

Commit d8805ea

Browse files
authored
feat: add RSS data models (SQLAlchemy + Pydantic) (#857)
* feat: add RSS data models (SQLAlchemy + Pydantic) * fix: remove deprecated tables and add FTS ResourceType * refactor: restructure to follow n-layer architecture * chore: remove unnecessary comments * fix: change map_status and clean up schema
1 parent ea405cc commit d8805ea

File tree

14 files changed

+2788
-1891
lines changed

14 files changed

+2788
-1891
lines changed

diracx-core/src/diracx/core/exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ def __init__(self, pfn: str, se_name: str, detail: str | None = None):
7070
)
7171

7272

73+
class ResourceNotFoundError(DiracError):
74+
def __init__(self, name: str, detail: str | None = None):
75+
self.name: str = name
76+
super().__init__(f"{name} not found" + (f" ({detail})" if detail else ""))
77+
78+
7379
class SandboxAlreadyAssignedError(DiracError):
7480
def __init__(self, pfn: str, se_name: str, detail: str | None = None):
7581
self.pfn: str = pfn
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from __future__ import annotations
2+
3+
from enum import StrEnum
4+
from typing import Annotated, Literal, Union
5+
6+
from pydantic import BaseModel, Field
7+
8+
9+
class AllowedStatus(BaseModel):
10+
allowed: Literal[True]
11+
warnings: str | None = None
12+
13+
def __bool__(self) -> bool:
14+
return True
15+
16+
17+
class BannedStatus(BaseModel):
18+
allowed: Literal[False]
19+
reason: str = "Unknown"
20+
21+
def __bool__(self) -> bool:
22+
return False
23+
24+
25+
ResourceStatus = Annotated[
26+
Union[AllowedStatus, BannedStatus],
27+
Field(discriminator="allowed"),
28+
]
29+
30+
31+
class ResourceType(StrEnum):
32+
Compute = "ComputeElement"
33+
Storage = "StorageElement"
34+
FTS = "FTS"
35+
36+
37+
class StorageElementStatus(BaseModel):
38+
read: ResourceStatus
39+
write: ResourceStatus
40+
check: ResourceStatus
41+
remove: ResourceStatus
42+
43+
44+
class ComputeElementStatus(BaseModel):
45+
all: ResourceStatus
46+
47+
48+
class FTSStatus(BaseModel):
49+
all: ResourceStatus
50+
51+
52+
class SiteStatus(BaseModel):
53+
all: ResourceStatus
54+
55+
56+
ALLOWED = {"Active", "Degraded"}
57+
BANNED = {"Banned", "Probing", "Error", "Unknown"}

diracx-db/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ AuthDB = "diracx.db.sql:AuthDB"
3131
JobDB = "diracx.db.sql:JobDB"
3232
JobLoggingDB = "diracx.db.sql:JobLoggingDB"
3333
PilotAgentsDB = "diracx.db.sql:PilotAgentsDB"
34+
ResourceStatusDB = "diracx.db.sql:ResourceStatusDB"
3435
SandboxMetadataDB = "diracx.db.sql:SandboxMetadataDB"
3536
TaskQueueDB = "diracx.db.sql:TaskQueueDB"
3637

diracx-db/src/diracx/db/sql/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"JobDB",
66
"JobLoggingDB",
77
"PilotAgentsDB",
8+
"ResourceStatusDB",
89
"SandboxMetadataDB",
910
"TaskQueueDB",
1011
)
@@ -13,5 +14,6 @@
1314
from .job.db import JobDB
1415
from .job_logging.db import JobLoggingDB
1516
from .pilot_agents.db import PilotAgentsDB
17+
from .rss.db import ResourceStatusDB
1618
from .sandbox_metadata.db import SandboxMetadataDB
1719
from .task_queue.db import TaskQueueDB

diracx-db/src/diracx/db/sql/rss/__init__.py

Whitespace-only changes.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from __future__ import annotations
2+
3+
from sqlalchemy import select
4+
from sqlalchemy.engine import Row
5+
6+
from diracx.core.exceptions import ResourceNotFoundError
7+
8+
from ..utils import BaseSQLDB
9+
from .schema import (
10+
ResourceStatus,
11+
RSSBase,
12+
SiteStatus,
13+
)
14+
15+
16+
class ResourceStatusDB(BaseSQLDB):
17+
"""Class that defines the interactions with the tables of the ResourceStatusDB."""
18+
19+
metadata = RSSBase.metadata
20+
21+
async def get_site_status(self, name: str, vo: str = "all") -> tuple[str, str]:
22+
stmt = select(SiteStatus.status, SiteStatus.reason).where(
23+
SiteStatus.name == name,
24+
SiteStatus.status_type == "all",
25+
SiteStatus.vo == vo,
26+
)
27+
result = await self.conn.execute(stmt)
28+
row = result.one_or_none()
29+
if not row:
30+
raise ResourceNotFoundError(name)
31+
32+
return row.Status, row.Reason
33+
34+
async def get_resource_status(
35+
self,
36+
name: str,
37+
status_types: list[str] | None = None,
38+
vo: str = "all",
39+
) -> dict[str, Row]:
40+
if not status_types:
41+
status_types = ["all"]
42+
stmt = select(
43+
ResourceStatus.status, ResourceStatus.reason, ResourceStatus.status_type
44+
).where(
45+
ResourceStatus.name == name,
46+
ResourceStatus.status_type.in_(status_types),
47+
ResourceStatus.vo == vo,
48+
)
49+
result = await self.conn.execute(stmt)
50+
rows = result.all()
51+
52+
if not rows:
53+
raise ResourceNotFoundError(name)
54+
return {row.StatusType: row for row in rows}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from __future__ import annotations
2+
3+
from datetime import datetime
4+
5+
from sqlalchemy import BigInteger, String
6+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
7+
8+
from diracx.db.sql.utils.types import SmarterDateTime
9+
10+
from ..utils import str32, str64, str128, str512
11+
12+
# Defining the tables
13+
14+
15+
class RSSBase(DeclarativeBase):
16+
type_annotation_map = {
17+
str32: String(32),
18+
str64: String(64),
19+
str128: String(128),
20+
str512: String(512),
21+
}
22+
23+
24+
class ElementStatusBase:
25+
name: Mapped[str64] = mapped_column("Name", primary_key=True)
26+
status_type: Mapped[str128] = mapped_column(
27+
"StatusType", server_default="all", primary_key=True
28+
)
29+
vo: Mapped[str64] = mapped_column("VO", primary_key=True, server_default="all")
30+
status: Mapped[str] = mapped_column("Status", String(8), server_default="")
31+
reason: Mapped[str512] = mapped_column("Reason", server_default="Unspecified")
32+
date_effective: Mapped[datetime] = mapped_column("DateEffective", SmarterDateTime())
33+
token_expiration: Mapped[datetime] = mapped_column(
34+
"TokenExpiration", SmarterDateTime(), server_default="9999-12-31 23:59:59"
35+
)
36+
element_type: Mapped[str32] = mapped_column("ElementType", server_default="")
37+
last_check_time: Mapped[datetime] = mapped_column(
38+
"LastCheckTime", SmarterDateTime(), server_default="1000-01-01 00:00:00"
39+
)
40+
token_owner: Mapped[str] = mapped_column(
41+
"TokenOwner", String(16), server_default="rs_svc"
42+
)
43+
44+
45+
class ElementStatusBaseWithID(ElementStatusBase):
46+
"""Almost the same as ElementStatusBase.
47+
48+
Differences:
49+
- there's an autoincrement ID column which is also the primary key
50+
- the name and statusType components are not part of the primary key
51+
"""
52+
53+
id: Mapped[int] = mapped_column(
54+
"ID", BigInteger, autoincrement=True, primary_key=True
55+
)
56+
name: Mapped[str64] = mapped_column("Name")
57+
status_type: Mapped[str128] = mapped_column("StatusType", server_default="all")
58+
vo: Mapped[str64] = mapped_column("VO", server_default="all")
59+
status: Mapped[str] = mapped_column("Status", String(8), server_default="")
60+
reason: Mapped[str512] = mapped_column("Reason", server_default="Unspecified")
61+
date_effective: Mapped[datetime] = mapped_column("DateEffective", SmarterDateTime())
62+
token_expiration: Mapped[datetime] = mapped_column(
63+
"TokenExpiration", SmarterDateTime(), server_default="9999-12-31 23:59:59"
64+
)
65+
element_type: Mapped[str32] = mapped_column("ElementType", server_default="")
66+
last_check_time: Mapped[datetime] = mapped_column(
67+
"LastCheckTime", SmarterDateTime(), server_default="1000-01-01 00:00:00"
68+
)
69+
token_owner: Mapped[str] = mapped_column(
70+
"TokenOwner", String(16), server_default="rs_svc"
71+
)
72+
73+
74+
# tables with schema defined in ElementStatusBase
75+
76+
77+
class SiteStatus(ElementStatusBase, RSSBase):
78+
__tablename__ = "SiteStatus"
79+
80+
81+
class ResourceStatus(ElementStatusBase, RSSBase):
82+
__tablename__ = "ResourceStatus"
83+
84+
85+
# tables with schema defined in ElementStatusBaseWithID
86+
87+
88+
class SiteLog(ElementStatusBaseWithID, RSSBase):
89+
__tablename__ = "SiteLog"
90+
91+
92+
class SiteHistory(ElementStatusBaseWithID, RSSBase):
93+
__tablename__ = "SiteHistory"
94+
95+
96+
class ResourceLog(ElementStatusBaseWithID, RSSBase):
97+
__tablename__ = "ResourceLog"
98+
99+
100+
class ResourceHistory(ElementStatusBaseWithID, RSSBase):
101+
__tablename__ = "ResourceHistory"

diracx-db/tests/rss/__init__.py

Whitespace-only changes.

diracx-db/tests/rss/test_rss_db.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
from __future__ import annotations
2+
3+
from datetime import datetime, timezone
4+
5+
import pytest
6+
from sqlalchemy import insert
7+
8+
from diracx.core.exceptions import ResourceNotFoundError
9+
from diracx.db.sql.rss.db import ResourceStatusDB
10+
11+
_NOW = datetime(2024, 1, 1, tzinfo=timezone.utc)
12+
_FAR = datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)
13+
14+
15+
@pytest.fixture
16+
async def rss_db(tmp_path):
17+
rss_db = ResourceStatusDB("sqlite+aiosqlite:///:memory:")
18+
async with rss_db.engine_context():
19+
async with rss_db.engine.begin() as conn:
20+
await conn.run_sync(rss_db.metadata.create_all)
21+
yield rss_db
22+
23+
24+
async def test_site_status(rss_db: ResourceStatusDB):
25+
# Insert a test Site
26+
async with rss_db.engine.begin() as conn:
27+
await conn.execute(
28+
insert(rss_db.metadata.tables["SiteStatus"]).values(
29+
Name="TestSite",
30+
StatusType="all",
31+
VO="all",
32+
Status="Active",
33+
Reason="All good",
34+
DateEffective=_NOW,
35+
TokenExpiration=_FAR,
36+
LastCheckTime=_NOW,
37+
ElementType="Site",
38+
TokenOwner="test",
39+
)
40+
)
41+
42+
# Test with the test Site (should be found)
43+
async with rss_db as db:
44+
status, reason = await db.get_site_status("TestSite")
45+
assert status == "Active"
46+
assert reason == "All good"
47+
48+
# Test with an unknow Site (should not be found)
49+
with pytest.raises(ResourceNotFoundError):
50+
async with rss_db as db:
51+
await db.get_site_status("Unknown")
52+
53+
54+
async def test_resource_status(rss_db: ResourceStatusDB):
55+
async with rss_db.engine.begin() as conn:
56+
# Insert a test Compute Element
57+
await conn.execute(
58+
insert(rss_db.metadata.tables["ResourceStatus"]).values(
59+
Name="TestCompute",
60+
StatusType="all",
61+
VO="all",
62+
Status="Active",
63+
Reason="All good",
64+
DateEffective=_NOW,
65+
TokenExpiration=_FAR,
66+
LastCheckTime=_NOW,
67+
ElementType="ComputeElement",
68+
TokenOwner="test",
69+
)
70+
)
71+
# Insert a test FTS
72+
await conn.execute(
73+
insert(rss_db.metadata.tables["ResourceStatus"]).values(
74+
Name="TestFTS",
75+
StatusType="all",
76+
VO="all",
77+
Status="Active",
78+
Reason="All good",
79+
DateEffective=_NOW,
80+
TokenExpiration=_FAR,
81+
LastCheckTime=_NOW,
82+
ElementType="FTS",
83+
TokenOwner="test",
84+
)
85+
)
86+
# Insert a test Storage Element with all StatusType
87+
for statustype in ["ReadAccess", "WriteAccess", "CheckAccess", "RemoveAccess"]:
88+
await conn.execute(
89+
insert(rss_db.metadata.tables["ResourceStatus"]).values(
90+
Name="TestStorage",
91+
StatusType=statustype,
92+
VO="all",
93+
Status="Active",
94+
Reason="All good",
95+
DateEffective=_NOW,
96+
TokenExpiration=_FAR,
97+
LastCheckTime=_NOW,
98+
ElementType="StorageElement",
99+
TokenOwner="test",
100+
)
101+
)
102+
103+
# Test with the test Compute Element (should be found)
104+
async with rss_db as db:
105+
result = await db.get_resource_status("TestCompute")
106+
assert "all" in result
107+
assert result["all"].Status == "Active"
108+
assert result["all"].Reason == "All good"
109+
110+
# Test with the test FTS (should be found)
111+
async with rss_db as db:
112+
result = await db.get_resource_status("TestFTS")
113+
assert "all" in result
114+
assert result["all"].Status == "Active"
115+
assert result["all"].Reason == "All good"
116+
117+
# Test with the test Storage Element (should be found)
118+
async with rss_db as db:
119+
result = await db.get_resource_status(
120+
"TestStorage", ["ReadAccess", "WriteAccess", "CheckAccess", "RemoveAccess"]
121+
)
122+
assert set(result.keys()) == {
123+
"ReadAccess",
124+
"WriteAccess",
125+
"CheckAccess",
126+
"RemoveAccess",
127+
}
128+
for row in result.values():
129+
assert row.Status == "Active"
130+
assert row.Reason == "All good"
131+
132+
# Test with an unknow Resource (should not be found)
133+
with pytest.raises(ResourceNotFoundError):
134+
async with rss_db as db:
135+
await db.get_resource_status("Unknown")

diracx-logic/src/diracx/logic/rss/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)