feat(scada): add project monitoring replay
Generic Container CI/CD / test-build-publish (push) Successful in 3m39s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m39s

This commit is contained in:
2026-11-04 14:27:21 +08:00
parent f25423dec4
commit 26fa5d1909
53 changed files with 5153 additions and 20 deletions
+188
View File
@@ -0,0 +1,188 @@
"""Short-transaction storage for replay control and resumable batches."""
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from fastapi import HTTPException
from psycopg.types.json import Jsonb
class ScadaReplayRepository:
def __init__(self, connection, project_id):
self.connection, self.project_id = connection, str(project_id)
self.lock_key = f"scada-replay:{self.project_id}"
async def available(self) -> bool:
async with self.connection.transaction():
cur = await self.connection.execute(
"SELECT to_regclass('peripheral_sample.scada_replays') IS NOT NULL "
"AND to_regclass('peripheral_sample.scada_replay_batches') IS NOT NULL AS ready"
)
return (await cur.fetchone())["ready"]
@asynccontextmanager
async def control(self):
async with self.connection.transaction():
await self.connection.execute("SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (self.lock_key,))
yield
@asynccontextmanager
async def processing(self):
# Session lock outlives short transactions, never an open SQL transaction
# across Timescale IO. A dead connection automatically releases ownership.
async with self.connection.transaction():
cur = await self.connection.execute("SELECT pg_try_advisory_lock(hashtextextended(%s,0)) AS owned", (self.lock_key,))
owned = (await cur.fetchone())["owned"]
try:
yield owned
finally:
if owned:
async with self.connection.transaction():
await self.connection.execute("SELECT pg_advisory_unlock(hashtextextended(%s,0))", (self.lock_key,))
async def get(self, replay_id) -> dict:
async with self.connection.transaction():
cur = await self.connection.execute(
"SELECT document FROM peripheral_sample.scada_replays WHERE project_id=%s AND replay_id=%s",
(self.project_id, str(replay_id)),
)
row = await cur.fetchone()
if not row:
raise HTTPException(404, "当前项目不存在此 SCADA 回放")
return row["document"]
async def current(self) -> dict | None:
async with self.connection.transaction():
cur = await self.connection.execute(
"SELECT document FROM peripheral_sample.scada_replays WHERE project_id=%s"
" ORDER BY created_at DESC LIMIT 1", (self.project_id,),
)
row = await cur.fetchone()
return row["document"] if row else None
async def by_request(self, request_id) -> dict | None:
async with self.connection.transaction():
cur = await self.connection.execute(
"SELECT document FROM peripheral_sample.scada_replays WHERE project_id=%s AND request_id=%s",
(self.project_id, str(request_id)),
)
row = await cur.fetchone()
return row["document"] if row else None
async def active_other(self, replay_id) -> bool:
async with self.connection.transaction():
cur = await self.connection.execute(
"SELECT 1 FROM peripheral_sample.scada_replays WHERE project_id=%s AND replay_id<>%s "
"AND status IN ('ready','paused') LIMIT 1",
(self.project_id, str(replay_id)),
)
return await cur.fetchone() is not None
async def insert(self, document: dict):
await self.connection.execute(
"INSERT INTO peripheral_sample.scada_replays(project_id,replay_id,request_id,status,document) "
"VALUES(%s,%s,%s,%s,%s)",
(self.project_id, document["replay_id"], document["config"]["request_id"], document["status"], Jsonb(document)),
)
async def save(self, document: dict):
document["updated_at"] = datetime.now(timezone.utc).isoformat()
await self.connection.execute(
"UPDATE peripheral_sample.scada_replays SET status=%s,document=%s,updated_at=now() "
"WHERE project_id=%s AND replay_id=%s",
(document["status"], Jsonb(document), self.project_id, document["replay_id"]),
)
async def pending_batch(self, replay_id) -> dict | None:
async with self.connection.transaction():
cur = await self.connection.execute(
"SELECT document FROM peripheral_sample.scada_replay_batches "
"WHERE project_id=%s AND replay_id=%s AND stage<>'detected' ORDER BY sequence LIMIT 1",
(self.project_id, str(replay_id)),
)
row = await cur.fetchone()
return row["document"] if row else None
async def save_batch(self, replay_id, batch: dict):
await self.connection.execute(
"INSERT INTO peripheral_sample.scada_replay_batches(project_id,replay_id,sequence,stage,document) "
"VALUES(%s,%s,%s,%s,%s) ON CONFLICT(project_id,replay_id,sequence) "
"DO UPDATE SET stage=excluded.stage,document=excluded.document",
(self.project_id, str(replay_id), batch["sequence"], batch["stage"], Jsonb(batch)),
)
async def events(self, replay_id, offset=0, limit=100) -> list[dict]:
async with self.connection.transaction():
cur = await self.connection.execute(
"SELECT e.value AS event FROM peripheral_sample.scada_replay_batches b "
"CROSS JOIN LATERAL jsonb_array_elements(COALESCE(b.document->'events','[]'::jsonb)) e "
"WHERE b.project_id=%s AND b.replay_id=%s ORDER BY b.sequence DESC LIMIT %s OFFSET %s",
(self.project_id, str(replay_id), limit, offset),
)
return [r["event"] for r in await cur.fetchall()]
async def monitoring_outputs(
self,
replay_id,
device_ids: list[str],
start: datetime,
end: datetime,
before_sequence: int,
) -> list[dict]:
"""Read only committed replay outputs available before the current batch."""
if not device_ids:
return []
async with self.connection.transaction():
cur = await self.connection.execute(
"SELECT r.value AS reading FROM peripheral_sample.scada_replay_batches b "
"CROSS JOIN LATERAL jsonb_array_elements(COALESCE(b.document->'outputs','[]'::jsonb)) r "
"WHERE b.project_id=%s AND b.replay_id=%s AND b.stage='detected' AND b.sequence<%s "
"AND r.value->>'device_id'=ANY(%s) "
"AND (r.value->>'time')::timestamptz>=%s AND (r.value->>'time')::timestamptz<=%s "
"ORDER BY (r.value->>'time')::timestamptz,r.value->>'device_id'",
(self.project_id, str(replay_id), before_sequence, device_ids, start, end),
)
return [row["reading"] for row in await cur.fetchall()]
async def detected_outputs(
self,
replay_id,
device_ids: list[str],
start: datetime,
end: datetime,
before_sequence: int,
*,
limit: int | None = None,
offset: int = 0,
) -> list[dict]:
"""Read immutable detector outputs available to a bounded replay investigation."""
statement = (
"SELECT r.value AS reading FROM peripheral_sample.scada_replay_batches b "
"CROSS JOIN LATERAL jsonb_array_elements(COALESCE(b.document->'outputs','[]'::jsonb)) r "
"WHERE b.project_id=%s AND b.replay_id=%s AND b.sequence<=%s AND b.stage='detected' "
"AND r.value->>'device_id'=ANY(%s) "
"AND (r.value->>'time')::timestamptz>=%s AND (r.value->>'time')::timestamptz<=%s "
"ORDER BY (r.value->>'time')::timestamptz,r.value->>'device_id'"
)
params: tuple = (
self.project_id, str(replay_id), before_sequence, device_ids, start, end,
)
if limit is not None:
statement += " LIMIT %s OFFSET %s"
params += (limit, offset)
async with self.connection.transaction():
cur = await self.connection.execute(statement, params)
return [row["reading"] for row in await cur.fetchall()]
async def latest_outputs(self, replay_id, through: datetime, limit: int = 500) -> list[dict]:
"""Return the latest committed cleaned output for each replay device."""
async with self.connection.transaction():
cur = await self.connection.execute(
"SELECT DISTINCT ON(r.value->>'device_id') r.value AS reading "
"FROM peripheral_sample.scada_replay_batches b "
"CROSS JOIN LATERAL jsonb_array_elements(COALESCE(b.document->'outputs','[]'::jsonb)) r "
"WHERE b.project_id=%s AND b.replay_id=%s AND b.stage IN ('cleaned','detected') "
"AND (r.value->>'time')::timestamptz<=%s "
"ORDER BY r.value->>'device_id',(r.value->>'time')::timestamptz DESC LIMIT %s",
(self.project_id, str(replay_id), through, limit),
)
return [row["reading"] for row in await cur.fetchall()]