refactor(db)!: finalize pooled WNDB v2 migration
This commit is contained in:
+250
-181
@@ -1,40 +1,29 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict
|
||||
from uuid import UUID
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
from psycopg.rows import dict_row
|
||||
from sqlalchemy.engine.url import make_url
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_check_async_connection = AsyncConnectionPool.check_connection
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PgEngineEntry:
|
||||
engine: AsyncEngine
|
||||
sessionmaker: async_sessionmaker[AsyncSession]
|
||||
connection_url: str
|
||||
pool_min_size: int
|
||||
pool_max_size: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@dataclass
|
||||
class PoolEntry:
|
||||
pool: AsyncConnectionPool
|
||||
connection_url: str
|
||||
pool_min_size: int
|
||||
pool_max_size: int
|
||||
borrow_count: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -45,186 +34,203 @@ class CacheKey:
|
||||
|
||||
class ProjectConnectionManager:
|
||||
def __init__(self) -> None:
|
||||
self._pg_cache: Dict[CacheKey, PgEngineEntry] = OrderedDict()
|
||||
self._ts_cache: Dict[CacheKey, PoolEntry] = OrderedDict()
|
||||
self._pg_raw_cache: Dict[CacheKey, PoolEntry] = OrderedDict()
|
||||
self._pg_lock = asyncio.Lock()
|
||||
self._retired_ts: list[tuple[CacheKey, PoolEntry]] = []
|
||||
self._retired_pg: list[tuple[CacheKey, PoolEntry]] = []
|
||||
self._ts_lock = asyncio.Lock()
|
||||
self._pg_raw_lock = asyncio.Lock()
|
||||
|
||||
def _normalize_pg_url(self, url: str) -> str:
|
||||
parsed = make_url(url)
|
||||
if parsed.drivername in {"postgresql", "postgres"}:
|
||||
parsed = parsed.set(drivername="postgresql+psycopg")
|
||||
return parsed.render_as_string(hide_password=False)
|
||||
|
||||
async def get_pg_sessionmaker(
|
||||
async def _get_timescale_pool_locked(
|
||||
self,
|
||||
project_id: UUID,
|
||||
db_role: str,
|
||||
connection_url: str,
|
||||
pool_min_size: int,
|
||||
pool_max_size: int,
|
||||
) -> async_sessionmaker[AsyncSession]:
|
||||
async with self._pg_lock:
|
||||
normalized_url = self._normalize_pg_url(connection_url)
|
||||
pool_min_size = max(1, pool_min_size)
|
||||
pool_max_size = max(pool_min_size, pool_max_size)
|
||||
|
||||
key = CacheKey(project_id=project_id, db_role=db_role)
|
||||
entry = self._pg_cache.get(key)
|
||||
if entry:
|
||||
if (
|
||||
entry.connection_url == normalized_url
|
||||
and entry.pool_min_size == pool_min_size
|
||||
and entry.pool_max_size == pool_max_size
|
||||
):
|
||||
self._pg_cache.move_to_end(key)
|
||||
return entry.sessionmaker
|
||||
|
||||
await entry.engine.dispose()
|
||||
logger.info(
|
||||
"Rebuilding PostgreSQL engine for project %s (%s) due to config change",
|
||||
project_id,
|
||||
db_role,
|
||||
)
|
||||
self._pg_cache.pop(key, None)
|
||||
|
||||
engine = create_async_engine(
|
||||
normalized_url,
|
||||
pool_size=pool_min_size,
|
||||
max_overflow=max(0, pool_max_size - pool_min_size),
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
sessionmaker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
self._pg_cache[key] = PgEngineEntry(
|
||||
engine=engine,
|
||||
sessionmaker=sessionmaker,
|
||||
connection_url=normalized_url,
|
||||
pool_min_size=pool_min_size,
|
||||
pool_max_size=pool_max_size,
|
||||
)
|
||||
await self._evict_pg_if_needed()
|
||||
) -> tuple[CacheKey, AsyncConnectionPool]:
|
||||
pool_min_size = max(0, pool_min_size)
|
||||
pool_max_size = max(1, pool_min_size, pool_max_size)
|
||||
key = CacheKey(project_id=project_id, db_role=db_role)
|
||||
entry = self._ts_cache.get(key)
|
||||
if entry:
|
||||
if (
|
||||
entry.connection_url == connection_url
|
||||
and entry.pool_min_size == pool_min_size
|
||||
and entry.pool_max_size == pool_max_size
|
||||
):
|
||||
self._ts_cache.move_to_end(key)
|
||||
return key, entry.pool
|
||||
logger.info(
|
||||
"Created PostgreSQL engine for project %s (%s)", project_id, db_role
|
||||
"Rebuilding TimescaleDB pool for project %s (%s) due to config change",
|
||||
project_id,
|
||||
db_role,
|
||||
)
|
||||
return sessionmaker
|
||||
|
||||
async def get_timescale_pool(
|
||||
self,
|
||||
project_id: UUID,
|
||||
db_role: str,
|
||||
connection_url: str,
|
||||
pool_min_size: int,
|
||||
pool_max_size: int,
|
||||
) -> AsyncConnectionPool:
|
||||
async with self._ts_lock:
|
||||
pool_min_size = max(1, pool_min_size)
|
||||
pool_max_size = max(pool_min_size, pool_max_size)
|
||||
|
||||
key = CacheKey(project_id=project_id, db_role=db_role)
|
||||
entry = self._ts_cache.get(key)
|
||||
if entry:
|
||||
if (
|
||||
entry.connection_url == connection_url
|
||||
and entry.pool_min_size == pool_min_size
|
||||
and entry.pool_max_size == pool_max_size
|
||||
):
|
||||
self._ts_cache.move_to_end(key)
|
||||
return entry.pool
|
||||
|
||||
pool = AsyncConnectionPool(
|
||||
conninfo=connection_url,
|
||||
min_size=pool_min_size,
|
||||
max_size=pool_max_size,
|
||||
open=False,
|
||||
kwargs={"row_factory": dict_row},
|
||||
check=_check_async_connection,
|
||||
)
|
||||
await pool.open()
|
||||
if entry is not None:
|
||||
if entry.borrow_count:
|
||||
self._retired_ts.append((key, entry))
|
||||
else:
|
||||
await entry.pool.close()
|
||||
logger.info(
|
||||
"Rebuilding TimescaleDB pool for project %s (%s) due to config change",
|
||||
project_id,
|
||||
db_role,
|
||||
)
|
||||
self._ts_cache.pop(key, None)
|
||||
self._ts_cache[key] = PoolEntry(
|
||||
pool=pool,
|
||||
connection_url=connection_url,
|
||||
pool_min_size=pool_min_size,
|
||||
pool_max_size=pool_max_size,
|
||||
)
|
||||
logger.info("Created TimescaleDB pool for project %s (%s)", project_id, db_role)
|
||||
return key, pool
|
||||
|
||||
pool = AsyncConnectionPool(
|
||||
conninfo=connection_url,
|
||||
min_size=pool_min_size,
|
||||
max_size=pool_max_size,
|
||||
open=False,
|
||||
kwargs={"row_factory": dict_row},
|
||||
)
|
||||
await pool.open()
|
||||
self._ts_cache[key] = PoolEntry(
|
||||
pool=pool,
|
||||
connection_url=connection_url,
|
||||
pool_min_size=pool_min_size,
|
||||
pool_max_size=pool_max_size,
|
||||
)
|
||||
await self._evict_ts_if_needed()
|
||||
logger.info(
|
||||
"Created TimescaleDB pool for project %s (%s)", project_id, db_role
|
||||
)
|
||||
return pool
|
||||
|
||||
async def get_pg_pool(
|
||||
async def _get_pg_pool_locked(
|
||||
self,
|
||||
project_id: UUID,
|
||||
db_role: str,
|
||||
connection_url: str,
|
||||
pool_min_size: int,
|
||||
pool_max_size: int,
|
||||
) -> AsyncConnectionPool:
|
||||
) -> tuple[CacheKey, AsyncConnectionPool]:
|
||||
pool_min_size = max(0, pool_min_size)
|
||||
pool_max_size = max(1, pool_min_size, pool_max_size)
|
||||
key = CacheKey(project_id=project_id, db_role=db_role)
|
||||
entry = self._pg_raw_cache.get(key)
|
||||
if entry:
|
||||
if (
|
||||
entry.connection_url == connection_url
|
||||
and entry.pool_min_size == pool_min_size
|
||||
and entry.pool_max_size == pool_max_size
|
||||
):
|
||||
self._pg_raw_cache.move_to_end(key)
|
||||
return key, entry.pool
|
||||
logger.info(
|
||||
"Rebuilding PostgreSQL pool for project %s (%s) due to config change",
|
||||
project_id,
|
||||
db_role,
|
||||
)
|
||||
|
||||
pool = AsyncConnectionPool(
|
||||
conninfo=connection_url,
|
||||
min_size=pool_min_size,
|
||||
max_size=pool_max_size,
|
||||
open=False,
|
||||
kwargs={"row_factory": dict_row},
|
||||
check=_check_async_connection,
|
||||
)
|
||||
await pool.open()
|
||||
if entry is not None:
|
||||
if entry.borrow_count:
|
||||
self._retired_pg.append((key, entry))
|
||||
else:
|
||||
await entry.pool.close()
|
||||
self._pg_raw_cache[key] = PoolEntry(
|
||||
pool=pool,
|
||||
connection_url=connection_url,
|
||||
pool_min_size=pool_min_size,
|
||||
pool_max_size=pool_max_size,
|
||||
)
|
||||
logger.info("Created PostgreSQL pool for project %s (%s)", project_id, db_role)
|
||||
return key, pool
|
||||
|
||||
@asynccontextmanager
|
||||
async def pg_connection(
|
||||
self,
|
||||
project_id: UUID,
|
||||
db_role: str,
|
||||
connection_url: str,
|
||||
pool_min_size: int,
|
||||
pool_max_size: int,
|
||||
) -> AsyncIterator[AsyncConnection]:
|
||||
async with self._pg_raw_lock:
|
||||
pool_min_size = max(1, pool_min_size)
|
||||
pool_max_size = max(pool_min_size, pool_max_size)
|
||||
|
||||
key = CacheKey(project_id=project_id, db_role=db_role)
|
||||
entry = self._pg_raw_cache.get(key)
|
||||
if entry:
|
||||
if (
|
||||
entry.connection_url == connection_url
|
||||
and entry.pool_min_size == pool_min_size
|
||||
and entry.pool_max_size == pool_max_size
|
||||
):
|
||||
self._pg_raw_cache.move_to_end(key)
|
||||
return entry.pool
|
||||
|
||||
await entry.pool.close()
|
||||
logger.info(
|
||||
"Rebuilding PostgreSQL pool for project %s (%s) due to config change",
|
||||
project_id,
|
||||
db_role,
|
||||
)
|
||||
self._pg_raw_cache.pop(key, None)
|
||||
|
||||
pool = AsyncConnectionPool(
|
||||
conninfo=connection_url,
|
||||
min_size=pool_min_size,
|
||||
max_size=pool_max_size,
|
||||
open=False,
|
||||
kwargs={"row_factory": dict_row},
|
||||
)
|
||||
await pool.open()
|
||||
self._pg_raw_cache[key] = PoolEntry(
|
||||
pool=pool,
|
||||
connection_url=connection_url,
|
||||
pool_min_size=pool_min_size,
|
||||
pool_max_size=pool_max_size,
|
||||
key, pool = await self._get_pg_pool_locked(
|
||||
project_id,
|
||||
db_role,
|
||||
connection_url,
|
||||
pool_min_size,
|
||||
pool_max_size,
|
||||
)
|
||||
borrowed_entry = self._pg_raw_cache[key]
|
||||
borrowed_entry.borrow_count += 1
|
||||
await self._evict_pg_raw_if_needed()
|
||||
logger.info(
|
||||
"Created PostgreSQL pool for project %s (%s)", project_id, db_role
|
||||
)
|
||||
return pool
|
||||
|
||||
async def _evict_pg_if_needed(self) -> None:
|
||||
while len(self._pg_cache) > settings.PROJECT_PG_CACHE_SIZE:
|
||||
key, entry = self._pg_cache.popitem(last=False)
|
||||
await entry.engine.dispose()
|
||||
logger.info(
|
||||
"Evicted PostgreSQL engine for project %s (%s)",
|
||||
key.project_id,
|
||||
key.db_role,
|
||||
)
|
||||
try:
|
||||
async with pool.connection() as conn:
|
||||
yield conn
|
||||
finally:
|
||||
async with self._pg_raw_lock:
|
||||
borrowed_entry.borrow_count -= 1
|
||||
if borrowed_entry.borrow_count == 0 and borrowed_entry.pool is not (
|
||||
self._pg_raw_cache.get(key).pool
|
||||
if key in self._pg_raw_cache
|
||||
else None
|
||||
):
|
||||
self._retired_pg = [
|
||||
item for item in self._retired_pg if item[1] is not borrowed_entry
|
||||
]
|
||||
await borrowed_entry.pool.close()
|
||||
await self._evict_pg_raw_if_needed()
|
||||
|
||||
async def _evict_ts_if_needed(self) -> None:
|
||||
@asynccontextmanager
|
||||
async def timescale_connection(
|
||||
self,
|
||||
project_id: UUID,
|
||||
db_role: str,
|
||||
connection_url: str,
|
||||
pool_min_size: int,
|
||||
pool_max_size: int,
|
||||
) -> AsyncIterator[AsyncConnection]:
|
||||
async with self._ts_lock:
|
||||
key, pool = await self._get_timescale_pool_locked(
|
||||
project_id,
|
||||
db_role,
|
||||
connection_url,
|
||||
pool_min_size,
|
||||
pool_max_size,
|
||||
)
|
||||
borrowed_entry = self._ts_cache[key]
|
||||
borrowed_entry.borrow_count += 1
|
||||
await self._evict_ts_if_needed()
|
||||
|
||||
try:
|
||||
async with pool.connection() as conn:
|
||||
yield conn
|
||||
finally:
|
||||
async with self._ts_lock:
|
||||
borrowed_entry.borrow_count -= 1
|
||||
if borrowed_entry.borrow_count == 0 and borrowed_entry.pool is not (
|
||||
self._ts_cache.get(key).pool
|
||||
if key in self._ts_cache
|
||||
else None
|
||||
):
|
||||
self._retired_ts = [
|
||||
item for item in self._retired_ts if item[1] is not borrowed_entry
|
||||
]
|
||||
await borrowed_entry.pool.close()
|
||||
await self._evict_ts_if_needed()
|
||||
|
||||
async def _evict_ts_if_needed(
|
||||
self, protected_key: CacheKey | None = None
|
||||
) -> None:
|
||||
while len(self._ts_cache) > settings.PROJECT_TS_CACHE_SIZE:
|
||||
key, entry = self._ts_cache.popitem(last=False)
|
||||
idle = next(
|
||||
(
|
||||
(key, entry)
|
||||
for key, entry in self._ts_cache.items()
|
||||
if entry.borrow_count == 0 and key != protected_key
|
||||
),
|
||||
None,
|
||||
)
|
||||
if idle is None:
|
||||
return
|
||||
key, entry = idle
|
||||
self._ts_cache.pop(key)
|
||||
await entry.pool.close()
|
||||
logger.info(
|
||||
"Evicted TimescaleDB pool for project %s (%s)",
|
||||
@@ -232,9 +238,22 @@ class ProjectConnectionManager:
|
||||
key.db_role,
|
||||
)
|
||||
|
||||
async def _evict_pg_raw_if_needed(self) -> None:
|
||||
async def _evict_pg_raw_if_needed(
|
||||
self, protected_key: CacheKey | None = None
|
||||
) -> None:
|
||||
while len(self._pg_raw_cache) > settings.PROJECT_PG_CACHE_SIZE:
|
||||
key, entry = self._pg_raw_cache.popitem(last=False)
|
||||
idle = next(
|
||||
(
|
||||
(key, entry)
|
||||
for key, entry in self._pg_raw_cache.items()
|
||||
if entry.borrow_count == 0 and key != protected_key
|
||||
),
|
||||
None,
|
||||
)
|
||||
if idle is None:
|
||||
return
|
||||
key, entry = idle
|
||||
self._pg_raw_cache.pop(key)
|
||||
await entry.pool.close()
|
||||
logger.info(
|
||||
"Evicted PostgreSQL pool for project %s (%s)",
|
||||
@@ -242,17 +261,61 @@ class ProjectConnectionManager:
|
||||
key.db_role,
|
||||
)
|
||||
|
||||
async def close_all(self) -> None:
|
||||
async with self._pg_lock:
|
||||
for key, entry in list(self._pg_cache.items()):
|
||||
await entry.engine.dispose()
|
||||
logger.info(
|
||||
"Closed PostgreSQL engine for project %s (%s)",
|
||||
key.project_id,
|
||||
key.db_role,
|
||||
)
|
||||
self._pg_cache.clear()
|
||||
async def close_project(
|
||||
self, project_id: UUID, db_role: str | None = None
|
||||
) -> bool:
|
||||
"""Close this worker's idle pools for a project.
|
||||
|
||||
Returns ``False`` without interrupting requests when any matching pool is
|
||||
currently borrowed. Callers may retry after those requests complete.
|
||||
"""
|
||||
closed = True
|
||||
for cache, lock, label in (
|
||||
(self._ts_cache, self._ts_lock, "TimescaleDB"),
|
||||
(self._pg_raw_cache, self._pg_raw_lock, "PostgreSQL"),
|
||||
):
|
||||
async with lock:
|
||||
keys = [
|
||||
key
|
||||
for key in cache
|
||||
if key.project_id == project_id
|
||||
and (db_role is None or key.db_role == db_role)
|
||||
]
|
||||
for key in keys:
|
||||
entry = cache[key]
|
||||
if entry.borrow_count:
|
||||
closed = False
|
||||
continue
|
||||
cache.pop(key)
|
||||
await entry.pool.close()
|
||||
logger.info(
|
||||
"Closed %s pool for project %s (%s)",
|
||||
label,
|
||||
key.project_id,
|
||||
key.db_role,
|
||||
)
|
||||
for retired, lock in (
|
||||
(self._retired_ts, self._ts_lock),
|
||||
(self._retired_pg, self._pg_raw_lock),
|
||||
):
|
||||
async with lock:
|
||||
matches = [
|
||||
item
|
||||
for item in retired
|
||||
if item[0].project_id == project_id
|
||||
and (db_role is None or item[0].db_role == db_role)
|
||||
]
|
||||
if any(entry.borrow_count for _key, entry in matches):
|
||||
closed = False
|
||||
for item in matches:
|
||||
key, entry = item
|
||||
if entry.borrow_count:
|
||||
continue
|
||||
retired.remove(item)
|
||||
await entry.pool.close()
|
||||
return closed
|
||||
|
||||
async def close_all(self) -> None:
|
||||
async with self._ts_lock:
|
||||
for key, entry in list(self._ts_cache.items()):
|
||||
await entry.pool.close()
|
||||
@@ -262,6 +325,9 @@ class ProjectConnectionManager:
|
||||
key.db_role,
|
||||
)
|
||||
self._ts_cache.clear()
|
||||
for _key, entry in self._retired_ts:
|
||||
await entry.pool.close()
|
||||
self._retired_ts.clear()
|
||||
|
||||
async with self._pg_raw_lock:
|
||||
for key, entry in list(self._pg_raw_cache.items()):
|
||||
@@ -272,6 +338,9 @@ class ProjectConnectionManager:
|
||||
key.db_role,
|
||||
)
|
||||
self._pg_raw_cache.clear()
|
||||
for _key, entry in self._retired_pg:
|
||||
await entry.pool.close()
|
||||
self._retired_pg.clear()
|
||||
|
||||
|
||||
project_connection_manager = ProjectConnectionManager()
|
||||
|
||||
@@ -5,9 +5,9 @@ from contextvars import ContextVar, Token
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterator
|
||||
|
||||
from psycopg.conninfo import make_conninfo
|
||||
from psycopg.conninfo import conninfo_to_dict, make_conninfo
|
||||
|
||||
from app.core.config import get_pgconn_string, get_timescaledb_pgconn_string
|
||||
from app.core.config import get_pgconn_string, get_timescaledb_pgconn_string, settings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -16,6 +16,17 @@ class ActiveProjectRouting:
|
||||
business_dsn: str
|
||||
timescale_dsn: str | None = None
|
||||
|
||||
@property
|
||||
def business_database_name(self) -> str:
|
||||
"""Return the physical BizDB name selected by metadata routing."""
|
||||
database_name = conninfo_to_dict(self.business_dsn).get("dbname")
|
||||
if not database_name:
|
||||
raise RuntimeError(
|
||||
f"Business database routing for project {self.project_code!r} "
|
||||
"does not contain a database name"
|
||||
)
|
||||
return database_name
|
||||
|
||||
|
||||
_active_project_routing: ContextVar[ActiveProjectRouting | None] = ContextVar(
|
||||
"active_project_routing",
|
||||
@@ -42,6 +53,23 @@ def _dsn_for_database(dsn: str, database_name: str) -> str:
|
||||
return make_conninfo(dsn, dbname=database_name)
|
||||
|
||||
|
||||
def get_project_database_name(name: str) -> str:
|
||||
"""Resolve a logical project code to its routed physical BizDB name."""
|
||||
routing = get_active_project_routing()
|
||||
if routing is not None and name == routing.project_code:
|
||||
return routing.business_database_name
|
||||
return name
|
||||
|
||||
|
||||
def get_project_template_database_name(name: str | None = None) -> str:
|
||||
"""Return the configured immutable template for the WNDB schema version.
|
||||
|
||||
The template belongs to the database schema version, not to an individual
|
||||
logical project or its temporary physical database name.
|
||||
"""
|
||||
return settings.WNDB_TEMPLATE_DB_NAME
|
||||
|
||||
|
||||
def get_project_pgconn_string(db_name: str | None = None) -> str:
|
||||
routing = get_active_project_routing()
|
||||
if routing is None:
|
||||
|
||||
@@ -56,38 +56,40 @@ class CompositeQueries:
|
||||
Raises:
|
||||
ValueError: 当 SCADA 设备未找到或字段无效时
|
||||
"""
|
||||
result = {}
|
||||
scada_by_id = await CompositeQueries._get_project_scada_index(postgres_conn)
|
||||
|
||||
link_devices: dict[str, str] = {}
|
||||
node_devices: dict[str, str] = {}
|
||||
for device_id in device_ids:
|
||||
target_scada = scada_by_id.get(device_id)
|
||||
if not target_scada:
|
||||
raise ValueError(f"SCADA device {device_id} not found")
|
||||
|
||||
scada_type = target_scada["device_type"]
|
||||
element_id = (
|
||||
target_scada["link_id"]
|
||||
if scada_type in {"pipe_flow", "flow"}
|
||||
else target_scada["node_id"]
|
||||
)
|
||||
|
||||
if scada_type == "pipe_flow":
|
||||
# 查询 link 模拟数据
|
||||
res = await RealtimeRepository.get_link_field_by_time_range(
|
||||
timescale_conn, start_time, end_time, element_id, "flow"
|
||||
)
|
||||
if scada_type in {"pipe_flow", "flow"}:
|
||||
link_devices[device_id] = target_scada["link_id"]
|
||||
elif scada_type == "pressure":
|
||||
# 查询 node 模拟数据
|
||||
res = await RealtimeRepository.get_node_field_by_time_range(
|
||||
timescale_conn, start_time, end_time, element_id, "pressure"
|
||||
)
|
||||
node_devices[device_id] = target_scada["node_id"]
|
||||
else:
|
||||
raise ValueError(f"Unknown SCADA type: {scada_type}")
|
||||
# 添加 scada_id 到每个数据项
|
||||
for item in res:
|
||||
item["scada_id"] = device_id
|
||||
result[device_id] = res
|
||||
return result
|
||||
|
||||
link_series = await RealtimeRepository.get_link_fields_by_ids_time_range(
|
||||
timescale_conn, start_time, end_time,
|
||||
list(dict.fromkeys(link_devices.values())), "flow",
|
||||
)
|
||||
node_series = await RealtimeRepository.get_node_fields_by_ids_time_range(
|
||||
timescale_conn, start_time, end_time,
|
||||
list(dict.fromkeys(node_devices.values())), "pressure",
|
||||
)
|
||||
return {
|
||||
device_id: [
|
||||
{**item, "scada_id": device_id}
|
||||
for item in (
|
||||
link_series.get(element_id, [])
|
||||
if device_id in link_devices
|
||||
else node_series.get(element_id, [])
|
||||
)
|
||||
]
|
||||
for device_id, element_id in (link_devices | node_devices).items()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_scada_associated_analysis_simulation_data(
|
||||
@@ -117,38 +119,41 @@ class CompositeQueries:
|
||||
Raises:
|
||||
ValueError: 当 SCADA 设备未找到或字段无效时
|
||||
"""
|
||||
result = {}
|
||||
scada_by_id = await CompositeQueries._get_project_scada_index(postgres_conn)
|
||||
|
||||
link_devices: dict[str, str] = {}
|
||||
node_devices: dict[str, str] = {}
|
||||
for device_id in device_ids:
|
||||
target_scada = scada_by_id.get(device_id)
|
||||
if not target_scada:
|
||||
raise ValueError(f"SCADA device {device_id} not found")
|
||||
|
||||
scada_type = target_scada["device_type"]
|
||||
element_id = (
|
||||
target_scada["link_id"]
|
||||
if scada_type in {"pipe_flow", "flow"}
|
||||
else target_scada["node_id"]
|
||||
)
|
||||
|
||||
if scada_type == "pipe_flow":
|
||||
# 查询 link 模拟数据
|
||||
res = await AnalysisResultsRepository.get_link_series(
|
||||
timescale_conn, run_id, element_id, start_time, end_time, "flow"
|
||||
)
|
||||
if scada_type in {"pipe_flow", "flow"}:
|
||||
link_devices[device_id] = target_scada["link_id"]
|
||||
elif scada_type == "pressure":
|
||||
# 查询 node 模拟数据
|
||||
res = await AnalysisResultsRepository.get_node_series(
|
||||
timescale_conn, run_id, element_id, start_time, end_time, "pressure"
|
||||
)
|
||||
node_devices[device_id] = target_scada["node_id"]
|
||||
else:
|
||||
raise ValueError(f"Unknown SCADA type: {scada_type}")
|
||||
# 添加 scada_id 到每个数据项
|
||||
for item in res:
|
||||
item["scada_id"] = device_id
|
||||
result[device_id] = res
|
||||
return result
|
||||
|
||||
link_series = await AnalysisResultsRepository.get_series_by_ids(
|
||||
timescale_conn, run_id, "link",
|
||||
list(dict.fromkeys(link_devices.values())), start_time, end_time, "flow",
|
||||
)
|
||||
node_series = await AnalysisResultsRepository.get_series_by_ids(
|
||||
timescale_conn, run_id, "node",
|
||||
list(dict.fromkeys(node_devices.values())), start_time, end_time, "pressure",
|
||||
)
|
||||
return {
|
||||
device_id: [
|
||||
{**item, "scada_id": device_id}
|
||||
for item in (
|
||||
link_series.get(element_id, [])
|
||||
if device_id in link_devices
|
||||
else node_series.get(element_id, [])
|
||||
)
|
||||
]
|
||||
for device_id, element_id in (link_devices | node_devices).items()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_realtime_simulation_data(
|
||||
@@ -175,26 +180,33 @@ class CompositeQueries:
|
||||
Raises:
|
||||
ValueError: 当 SCADA 设备未找到或字段无效时
|
||||
"""
|
||||
result = {}
|
||||
pipe_ids: list[str] = []
|
||||
junction_ids: list[str] = []
|
||||
for feature_id, feature_type in feature_infos:
|
||||
|
||||
if feature_type.lower() == "pipe":
|
||||
# 查询 link 模拟数据
|
||||
res = await RealtimeRepository.get_link_field_by_time_range(
|
||||
timescale_conn, start_time, end_time, feature_id, "flow"
|
||||
)
|
||||
pipe_ids.append(feature_id)
|
||||
elif feature_type.lower() == "junction":
|
||||
# 查询 node 模拟数据
|
||||
res = await RealtimeRepository.get_node_field_by_time_range(
|
||||
timescale_conn, start_time, end_time, feature_id, "pressure"
|
||||
)
|
||||
junction_ids.append(feature_id)
|
||||
else:
|
||||
raise ValueError(f"Unknown type: {feature_type}")
|
||||
# 添加 scada_id 到每个数据项
|
||||
for item in res:
|
||||
item["feature_id"] = feature_id
|
||||
result[feature_id] = res
|
||||
return result
|
||||
link_series = await RealtimeRepository.get_link_fields_by_ids_time_range(
|
||||
timescale_conn, start_time, end_time, list(dict.fromkeys(pipe_ids)), "flow"
|
||||
)
|
||||
node_series = await RealtimeRepository.get_node_fields_by_ids_time_range(
|
||||
timescale_conn, start_time, end_time,
|
||||
list(dict.fromkeys(junction_ids)), "pressure",
|
||||
)
|
||||
return {
|
||||
feature_id: [
|
||||
{**item, "feature_id": feature_id}
|
||||
for item in (
|
||||
link_series.get(feature_id, [])
|
||||
if feature_type.lower() == "pipe"
|
||||
else node_series.get(feature_id, [])
|
||||
)
|
||||
]
|
||||
for feature_id, feature_type in feature_infos
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_analysis_simulation_data(
|
||||
@@ -223,25 +235,34 @@ class CompositeQueries:
|
||||
Raises:
|
||||
ValueError: 当类型无效时
|
||||
"""
|
||||
result = {}
|
||||
pipe_ids: list[str] = []
|
||||
junction_ids: list[str] = []
|
||||
for feature_id, feature_type in feature_infos:
|
||||
if feature_type.lower() == "pipe":
|
||||
# 查询 link 模拟数据
|
||||
res = await AnalysisResultsRepository.get_link_series(
|
||||
timescale_conn, run_id, feature_id, start_time, end_time, "flow"
|
||||
)
|
||||
pipe_ids.append(feature_id)
|
||||
elif feature_type.lower() == "junction":
|
||||
# 查询 node 模拟数据
|
||||
res = await AnalysisResultsRepository.get_node_series(
|
||||
timescale_conn, run_id, feature_id, start_time, end_time, "pressure"
|
||||
)
|
||||
junction_ids.append(feature_id)
|
||||
else:
|
||||
raise ValueError(f"Unknown type: {feature_type}")
|
||||
# 添加 feature_id 到每个数据项
|
||||
for item in res:
|
||||
item["feature_id"] = feature_id
|
||||
result[feature_id] = res
|
||||
return result
|
||||
link_series = await AnalysisResultsRepository.get_series_by_ids(
|
||||
timescale_conn, run_id, "link", list(dict.fromkeys(pipe_ids)),
|
||||
start_time, end_time, "flow",
|
||||
)
|
||||
node_series = await AnalysisResultsRepository.get_series_by_ids(
|
||||
timescale_conn, run_id, "node", list(dict.fromkeys(junction_ids)),
|
||||
start_time, end_time, "pressure",
|
||||
)
|
||||
return {
|
||||
feature_id: [
|
||||
{**item, "feature_id": feature_id}
|
||||
for item in (
|
||||
link_series.get(feature_id, [])
|
||||
if feature_type.lower() == "pipe"
|
||||
else node_series.get(feature_id, [])
|
||||
)
|
||||
]
|
||||
for feature_id, feature_type in feature_infos
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_element_associated_scada_data(
|
||||
@@ -399,7 +420,7 @@ class CompositeQueries:
|
||||
if scada_by_id[device_id]["device_type"] in {"pipe_flow", "flow"}
|
||||
]
|
||||
|
||||
updated_rows = 0
|
||||
cleaned_rows: list[tuple[datetime, str, float | None]] = []
|
||||
for grouped_ids, cleaning_function in (
|
||||
(pressure_ids, clean_pressure_data_df_km),
|
||||
(flow_ids, clean_flow_data_df_kf),
|
||||
@@ -422,18 +443,25 @@ class CompositeQueries:
|
||||
if isinstance(time_value, datetime)
|
||||
else datetime.fromisoformat(str(time_value))
|
||||
)
|
||||
await ScadaRepository.update_scada_field(
|
||||
timescale_conn,
|
||||
time_dt,
|
||||
device_id,
|
||||
"cleaned_value",
|
||||
value,
|
||||
cleaned_rows.append(
|
||||
(
|
||||
time_dt,
|
||||
device_id,
|
||||
None if pd.isna(value) else float(value),
|
||||
)
|
||||
)
|
||||
updated_rows += 1
|
||||
|
||||
if updated_rows == 0:
|
||||
if not cleaned_rows:
|
||||
raise ValueError("SCADA 数据清洗未产生任何数据库更新")
|
||||
|
||||
updated_rows = await ScadaRepository.update_scada_field_batch(
|
||||
timescale_conn,
|
||||
cleaned_rows,
|
||||
"cleaned_value",
|
||||
)
|
||||
if updated_rows == 0:
|
||||
raise ValueError("SCADA 清洗结果未匹配任何已有监测数据")
|
||||
|
||||
return "success"
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -259,7 +259,7 @@ class InternalQueries:
|
||||
query = sql.SQL(
|
||||
"SELECT btrim({}::text) AS id, time, {} FROM {}.{} "
|
||||
"WHERE run_id = %s AND time >= %s AND time <= %s "
|
||||
"AND btrim({}::text) = ANY(%s)"
|
||||
"AND btrim({}::text) = ANY(%s) ORDER BY id, time"
|
||||
).format(
|
||||
sql.Identifier(id_column),
|
||||
sql.Identifier(field),
|
||||
@@ -274,7 +274,8 @@ class InternalQueries:
|
||||
else:
|
||||
query = sql.SQL(
|
||||
"SELECT btrim({}::text) AS id, time, {} FROM {}.{} "
|
||||
"WHERE time >= %s AND time <= %s AND btrim({}::text) = ANY(%s)"
|
||||
"WHERE time >= %s AND time <= %s "
|
||||
"AND btrim({}::text) = ANY(%s) ORDER BY id, time"
|
||||
).format(
|
||||
sql.Identifier(id_column),
|
||||
sql.Identifier(field),
|
||||
|
||||
@@ -175,6 +175,53 @@ class AnalysisResultsRepository:
|
||||
await cur.execute(query, (run_id, link_id, start_time, end_time))
|
||||
return await cur.fetchall()
|
||||
|
||||
@staticmethod
|
||||
async def get_series_by_ids(
|
||||
conn: AsyncConnection,
|
||||
run_id: UUID,
|
||||
element_type: str,
|
||||
element_ids: list[str],
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
field: str,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
if element_type == "node":
|
||||
table_name, id_column, valid_fields = (
|
||||
"node_results", "node_id", AnalysisResultsRepository.NODE_FIELDS
|
||||
)
|
||||
elif element_type == "link":
|
||||
table_name, id_column, valid_fields = (
|
||||
"link_results", "link_id", AnalysisResultsRepository.LINK_FIELDS
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"invalid analysis element type: {element_type}")
|
||||
if field not in valid_fields:
|
||||
raise ValueError(f"invalid {element_type} result field: {field}")
|
||||
|
||||
result: dict[str, list[dict[str, Any]]] = {
|
||||
element_id: [] for element_id in element_ids
|
||||
}
|
||||
if not element_ids:
|
||||
return result
|
||||
query = sql.SQL(
|
||||
"SELECT {} AS element_id, time, {} AS value FROM analysis.{} "
|
||||
"WHERE run_id = %s AND {} = ANY(%s) AND time BETWEEN %s AND %s "
|
||||
"ORDER BY {}, time"
|
||||
).format(
|
||||
sql.Identifier(id_column),
|
||||
sql.Identifier(field),
|
||||
sql.Identifier(table_name),
|
||||
sql.Identifier(id_column),
|
||||
sql.Identifier(id_column),
|
||||
)
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, (run_id, element_ids, start_time, end_time))
|
||||
for row in await cur.fetchall():
|
||||
result.setdefault(str(row["element_id"]), []).append(
|
||||
{"time": row["time"], "value": row["value"]}
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def get_values_at_time(
|
||||
conn: AsyncConnection,
|
||||
|
||||
@@ -7,6 +7,19 @@ from app.services.time_api import parse_utc_time
|
||||
|
||||
class RealtimeRepository:
|
||||
|
||||
@staticmethod
|
||||
def _batch_time(data: List[dict]) -> datetime:
|
||||
"""Return one normalized timestamp shared by every row in a snapshot."""
|
||||
if not data:
|
||||
raise ValueError("Realtime batch must not be empty")
|
||||
times = {
|
||||
parse_utc_time(item["time"], field_name="time")
|
||||
for item in data
|
||||
}
|
||||
if len(times) != 1:
|
||||
raise ValueError("Realtime batch must contain exactly one timestamp")
|
||||
return times.pop()
|
||||
|
||||
# --- Link Simulation ---
|
||||
|
||||
@staticmethod
|
||||
@@ -15,8 +28,7 @@ class RealtimeRepository:
|
||||
if not data:
|
||||
return
|
||||
|
||||
# 假设同一批次的数据时间是相同的
|
||||
target_time = data[0]["time"]
|
||||
target_time = RealtimeRepository._batch_time(data)
|
||||
|
||||
# 使用事务确保原子性
|
||||
async with conn.transaction():
|
||||
@@ -38,7 +50,7 @@ class RealtimeRepository:
|
||||
for item in data:
|
||||
await copy.write_row(
|
||||
(
|
||||
item["time"],
|
||||
target_time,
|
||||
item["id"],
|
||||
item.get("flow"),
|
||||
item.get("friction"),
|
||||
@@ -57,8 +69,7 @@ class RealtimeRepository:
|
||||
if not data:
|
||||
return
|
||||
|
||||
# 假设同一批次的数据时间是相同的
|
||||
target_time = data[0]["time"]
|
||||
target_time = RealtimeRepository._batch_time(data)
|
||||
|
||||
# 使用事务确保原子性
|
||||
with conn.transaction():
|
||||
@@ -80,7 +91,7 @@ class RealtimeRepository:
|
||||
for item in data:
|
||||
copy.write_row(
|
||||
(
|
||||
item["time"],
|
||||
target_time,
|
||||
item["id"],
|
||||
item.get("flow"),
|
||||
item.get("friction"),
|
||||
@@ -99,7 +110,8 @@ class RealtimeRepository:
|
||||
) -> List[dict]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT * FROM realtime.link_results WHERE time >= %s AND time <= %s AND link_id = %s",
|
||||
"SELECT * FROM realtime.link_results WHERE time >= %s AND time <= %s "
|
||||
"AND link_id = %s ORDER BY time",
|
||||
(start_time, end_time, link_id),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
@@ -112,7 +124,8 @@ class RealtimeRepository:
|
||||
normalized_end_time = parse_utc_time(end_time, field_name="end_time")
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT * FROM realtime.link_results WHERE time >= %s AND time <= %s",
|
||||
"SELECT * FROM realtime.link_results WHERE time >= %s AND time <= %s "
|
||||
"ORDER BY time, link_id",
|
||||
(normalized_start_time, normalized_end_time),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
@@ -140,7 +153,8 @@ class RealtimeRepository:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
"SELECT time, {} FROM realtime.link_results WHERE time >= %s AND time <= %s AND link_id = %s"
|
||||
"SELECT time, {} FROM realtime.link_results WHERE time >= %s "
|
||||
"AND time <= %s AND link_id = %s ORDER BY time"
|
||||
).format(sql.Identifier(field))
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
@@ -150,6 +164,36 @@ class RealtimeRepository:
|
||||
{"time": row["time"].isoformat(), "value": row[field]} for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def get_link_fields_by_ids_time_range(
|
||||
conn: AsyncConnection,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
link_ids: list[str],
|
||||
field: str,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
valid_fields = {
|
||||
"flow", "friction", "headloss", "quality", "reaction",
|
||||
"setting", "status", "velocity",
|
||||
}
|
||||
if field not in valid_fields:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
result = {link_id: [] for link_id in link_ids}
|
||||
if not link_ids:
|
||||
return result
|
||||
query = sql.SQL(
|
||||
"SELECT link_id, time, {} FROM realtime.link_results "
|
||||
"WHERE time BETWEEN %s AND %s AND link_id = ANY(%s) "
|
||||
"ORDER BY link_id, time"
|
||||
).format(sql.Identifier(field))
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, (start_time, end_time, link_ids))
|
||||
for row in await cur.fetchall():
|
||||
result.setdefault(str(row["link_id"]), []).append(
|
||||
{"time": row["time"].isoformat(), "value": row[field]}
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def get_links_field_by_time_range(
|
||||
conn: AsyncConnection,
|
||||
@@ -172,7 +216,8 @@ class RealtimeRepository:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
"SELECT link_id, time, {} FROM realtime.link_results WHERE time >= %s AND time <= %s"
|
||||
"SELECT link_id, time, {} FROM realtime.link_results "
|
||||
"WHERE time >= %s AND time <= %s ORDER BY link_id, time"
|
||||
).format(sql.Identifier(field))
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
@@ -230,8 +275,7 @@ class RealtimeRepository:
|
||||
if not data:
|
||||
return
|
||||
|
||||
# 假设同一批次的数据时间是相同的
|
||||
target_time = data[0]["time"]
|
||||
target_time = RealtimeRepository._batch_time(data)
|
||||
|
||||
# 使用事务确保原子性
|
||||
async with conn.transaction():
|
||||
@@ -253,7 +297,7 @@ class RealtimeRepository:
|
||||
for item in data:
|
||||
await copy.write_row(
|
||||
(
|
||||
item["time"],
|
||||
target_time,
|
||||
item["id"],
|
||||
item.get("actual_demand"),
|
||||
item.get("total_head"),
|
||||
@@ -267,8 +311,7 @@ class RealtimeRepository:
|
||||
if not data:
|
||||
return
|
||||
|
||||
# 假设同一批次的数据时间是相同的
|
||||
target_time = data[0]["time"]
|
||||
target_time = RealtimeRepository._batch_time(data)
|
||||
|
||||
# 使用事务确保原子性
|
||||
with conn.transaction():
|
||||
@@ -290,7 +333,7 @@ class RealtimeRepository:
|
||||
for item in data:
|
||||
copy.write_row(
|
||||
(
|
||||
item["time"],
|
||||
target_time,
|
||||
item["id"],
|
||||
item.get("actual_demand"),
|
||||
item.get("total_head"),
|
||||
@@ -305,7 +348,8 @@ class RealtimeRepository:
|
||||
) -> List[dict]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT * FROM realtime.node_results WHERE time >= %s AND time <= %s AND node_id = %s",
|
||||
"SELECT * FROM realtime.node_results WHERE time >= %s AND time <= %s "
|
||||
"AND node_id = %s ORDER BY time",
|
||||
(start_time, end_time, node_id),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
@@ -318,7 +362,8 @@ class RealtimeRepository:
|
||||
normalized_end_time = parse_utc_time(end_time, field_name="end_time")
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT * FROM realtime.node_results WHERE time >= %s AND time <= %s",
|
||||
"SELECT * FROM realtime.node_results WHERE time >= %s AND time <= %s "
|
||||
"ORDER BY time, node_id",
|
||||
(normalized_start_time, normalized_end_time),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
@@ -336,7 +381,8 @@ class RealtimeRepository:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
"SELECT time, {} FROM realtime.node_results WHERE time >= %s AND time <= %s AND node_id = %s"
|
||||
"SELECT time, {} FROM realtime.node_results WHERE time >= %s "
|
||||
"AND time <= %s AND node_id = %s ORDER BY time"
|
||||
).format(sql.Identifier(field))
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
@@ -346,6 +392,33 @@ class RealtimeRepository:
|
||||
{"time": row["time"].isoformat(), "value": row[field]} for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def get_node_fields_by_ids_time_range(
|
||||
conn: AsyncConnection,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
node_ids: list[str],
|
||||
field: str,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
valid_fields = {"actual_demand", "total_head", "pressure", "quality"}
|
||||
if field not in valid_fields:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
result = {node_id: [] for node_id in node_ids}
|
||||
if not node_ids:
|
||||
return result
|
||||
query = sql.SQL(
|
||||
"SELECT node_id, time, {} FROM realtime.node_results "
|
||||
"WHERE time BETWEEN %s AND %s AND node_id = ANY(%s) "
|
||||
"ORDER BY node_id, time"
|
||||
).format(sql.Identifier(field))
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, (start_time, end_time, node_ids))
|
||||
for row in await cur.fetchall():
|
||||
result.setdefault(str(row["node_id"]), []).append(
|
||||
{"time": row["time"].isoformat(), "value": row[field]}
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def get_nodes_field_by_time_range(
|
||||
conn: AsyncConnection, start_time: datetime, end_time: datetime, field: str
|
||||
@@ -355,7 +428,8 @@ class RealtimeRepository:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
"SELECT node_id, time, {} FROM realtime.node_results WHERE time >= %s AND time <= %s"
|
||||
"SELECT node_id, time, {} FROM realtime.node_results "
|
||||
"WHERE time >= %s AND time <= %s ORDER BY node_id, time"
|
||||
).format(sql.Identifier(field))
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
@@ -459,6 +533,19 @@ class RealtimeRepository:
|
||||
# transactions (savepoints), while this outer transaction guarantees
|
||||
# that a link write failure also rolls back the node replacement.
|
||||
async with conn.transaction():
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
|
||||
(simulation_time,),
|
||||
)
|
||||
await cur.execute(
|
||||
"DELETE FROM realtime.node_results WHERE time = %s",
|
||||
(simulation_time,),
|
||||
)
|
||||
await cur.execute(
|
||||
"DELETE FROM realtime.link_results WHERE time = %s",
|
||||
(simulation_time,),
|
||||
)
|
||||
if node_data:
|
||||
await RealtimeRepository.insert_nodes_batch(conn, node_data)
|
||||
|
||||
@@ -525,6 +612,19 @@ class RealtimeRepository:
|
||||
# transactions (savepoints), while this outer transaction guarantees
|
||||
# that a link write failure also rolls back the node replacement.
|
||||
with conn.transaction():
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
|
||||
(simulation_time,),
|
||||
)
|
||||
cur.execute(
|
||||
"DELETE FROM realtime.node_results WHERE time = %s",
|
||||
(simulation_time,),
|
||||
)
|
||||
cur.execute(
|
||||
"DELETE FROM realtime.link_results WHERE time = %s",
|
||||
(simulation_time,),
|
||||
)
|
||||
if node_data:
|
||||
RealtimeRepository.insert_nodes_batch_sync(conn, node_data)
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@ class ScadaRepository:
|
||||
) -> List[dict]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT * FROM scada.measurements WHERE device_id = ANY(%s) AND time >= %s AND time <= %s",
|
||||
"SELECT * FROM scada.measurements WHERE device_id = ANY(%s) "
|
||||
"AND time >= %s AND time <= %s ORDER BY device_id, time",
|
||||
(device_ids, start_time, end_time),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
@@ -49,7 +50,8 @@ class ScadaRepository:
|
||||
) -> List[dict]:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"SELECT * FROM scada.measurements WHERE device_id = ANY(%s) AND time >= %s AND time <= %s",
|
||||
"SELECT * FROM scada.measurements WHERE device_id = ANY(%s) "
|
||||
"AND time >= %s AND time <= %s ORDER BY device_id, time",
|
||||
(device_ids, start_time, end_time),
|
||||
)
|
||||
return cur.fetchall()
|
||||
@@ -88,7 +90,9 @@ class ScadaRepository:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
"SELECT device_id, time, {} FROM scada.measurements WHERE time >= %s AND time <= %s AND device_id = ANY(%s)"
|
||||
"SELECT device_id, time, {} FROM scada.measurements "
|
||||
"WHERE time >= %s AND time <= %s AND device_id = ANY(%s) "
|
||||
"ORDER BY device_id, time"
|
||||
).format(sql.Identifier(field))
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
@@ -122,6 +126,31 @@ class ScadaRepository:
|
||||
if cur.rowcount == 0:
|
||||
await cur.execute(insert_query, (time, device_id, value))
|
||||
|
||||
@staticmethod
|
||||
async def update_scada_field_batch(
|
||||
conn: AsyncConnection,
|
||||
rows: list[tuple[datetime, str, float | None]],
|
||||
field: str,
|
||||
) -> int:
|
||||
"""Update existing SCADA samples in one set-based statement."""
|
||||
valid_fields = {"monitored_value", "cleaned_value"}
|
||||
if field not in valid_fields:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
query = sql.SQL(
|
||||
"UPDATE scada.measurements AS measurement SET {} = batch.value "
|
||||
"FROM unnest(%s::timestamptz[], %s::text[], %s::double precision[]) "
|
||||
"AS batch(time, device_id, value) "
|
||||
"WHERE measurement.time = batch.time "
|
||||
"AND measurement.device_id = batch.device_id"
|
||||
).format(sql.Identifier(field))
|
||||
times, device_ids, values = zip(*rows)
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, (list(times), list(device_ids), list(values)))
|
||||
return cur.rowcount
|
||||
|
||||
@staticmethod
|
||||
async def delete_scada_by_id_time_range(
|
||||
conn: AsyncConnection, device_id: str, start_time: datetime, end_time: datetime
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.core.config import settings
|
||||
from app.infra.db.project_routing import get_project_timescale_pgconn_string
|
||||
|
||||
|
||||
_check_connection = ConnectionPool.check_connection
|
||||
_pools: OrderedDict[str, ConnectionPool] = OrderedDict()
|
||||
_pool_conninfo: dict[str, str] = {}
|
||||
_pool_borrows: dict[str, int] = {}
|
||||
@@ -49,6 +50,7 @@ def get_timescale_pool(db_name: str) -> ConnectionPool:
|
||||
min_size=settings.PROJECT_TS_POOL_MIN_SIZE,
|
||||
max_size=settings.PROJECT_TS_POOL_MAX_SIZE,
|
||||
kwargs={"row_factory": dict_row},
|
||||
check=_check_connection,
|
||||
open=True,
|
||||
)
|
||||
_pools[db_name] = pool
|
||||
|
||||
Reference in New Issue
Block a user