Files
insar-management-system-v2/backend/app/services/pairing_state_service.py
T

380 lines
13 KiB
Python

from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, Optional, Sequence
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from .. import database
from ..models import (
PairingCacheStateORM,
PairingDirtySceneORM,
PairingMetricCacheORM,
PairingNetworkEdgeORM,
PairingNetworkRunORM,
RadarDataORM,
)
PAIRING_CACHE_SCOPE_GLOBAL = "global"
DEFAULT_PAIRING_METRIC_VERSION = "2026.05.raw.v1"
PAIRING_ORIENTATION_RULE_VERSION = "date_then_scene_uid_v1"
def _new_session() -> AsyncSession:
if database.AsyncSessionLocal is None:
raise RuntimeError("Database session factory is not initialized.")
return database.AsyncSessionLocal()
def _utcnow() -> datetime:
return datetime.utcnow()
def _normalize_scene_uid(
*,
scene_ref_id: Optional[int],
unique_id: Optional[str],
file_path: Optional[str],
) -> str:
unique_text = str(unique_id or "").strip()
if unique_text:
return unique_text
file_text = str(file_path or "").strip()
if file_text:
return file_text
if scene_ref_id is not None:
return f"scene:{int(scene_ref_id)}"
return "scene:unknown"
class PairingStateService:
metric_version = DEFAULT_PAIRING_METRIC_VERSION
orientation_rule_version = PAIRING_ORIENTATION_RULE_VERSION
async def _count_radar_scenes(self, db: AsyncSession) -> int:
result = await db.execute(select(func.count(RadarDataORM.id)))
return int(result.scalar_one() or 0)
async def _count_pending_dirty_scenes(self, db: AsyncSession) -> int:
result = await db.execute(
select(func.count(PairingDirtySceneORM.id)).where(
PairingDirtySceneORM.status == "PENDING"
)
)
return int(result.scalar_one() or 0)
async def _count_metric_cache_rows(self, db: AsyncSession) -> int:
result = await db.execute(
select(func.count(PairingMetricCacheORM.id)).where(
PairingMetricCacheORM.metric_version == self.metric_version
)
)
return int(result.scalar_one() or 0)
async def _get_global_state(self, db: AsyncSession) -> Optional[PairingCacheStateORM]:
result = await db.execute(
select(PairingCacheStateORM).where(
PairingCacheStateORM.cache_scope == PAIRING_CACHE_SCOPE_GLOBAL
)
)
return result.scalar_one_or_none()
def _build_state_payload(
self,
*,
state: Optional[PairingCacheStateORM],
scene_count: int,
dirty_scene_count: int,
metric_cache_count: int,
) -> Dict[str, Any]:
status = state.status if state is not None else "UNINITIALIZED"
last_error = state.last_error if state is not None else None
needs_rebuild = bool(
dirty_scene_count > 0
or status in {"DIRTY", "DEGRADED", "FAILED", "UNINITIALIZED"}
or (scene_count > 1 and metric_cache_count == 0)
)
cache_ready = not needs_rebuild and status == "READY"
state_dict = getattr(state, "__dict__", {}) if state is not None else {}
return {
"state_present": state is not None,
"cache_scope": state.cache_scope if state is not None else PAIRING_CACHE_SCOPE_GLOBAL,
"metric_version": (state.metric_version if state is not None else None) or self.metric_version,
"status": status,
"scene_count": int(scene_count),
"pair_count": int(metric_cache_count),
"dirty_scene_count": int(dirty_scene_count),
"needs_rebuild": needs_rebuild,
"cache_ready": cache_ready,
"last_full_rebuild_at": state_dict.get("last_full_rebuild_at"),
"last_incremental_reconcile_at": state_dict.get("last_incremental_reconcile_at"),
"last_error": last_error,
"updated_at": state_dict.get("updated_at"),
}
async def ensure_pairing_cache_state(
self,
db: AsyncSession,
*,
commit: bool = False,
) -> Dict[str, Any]:
scene_count = await self._count_radar_scenes(db)
dirty_scene_count = await self._count_pending_dirty_scenes(db)
metric_cache_count = await self._count_metric_cache_rows(db)
state = await self._get_global_state(db)
created = False
if state is None:
state = PairingCacheStateORM(
cache_scope=PAIRING_CACHE_SCOPE_GLOBAL,
metric_version=self.metric_version,
status="DIRTY" if scene_count > 1 else "READY",
scene_count=scene_count,
pair_count=metric_cache_count,
dirty_scene_count=dirty_scene_count,
)
db.add(state)
await db.flush()
created = True
if state.metric_version != self.metric_version:
state.metric_version = self.metric_version
state.status = "DIRTY"
state.last_error = None
else:
state.metric_version = state.metric_version or self.metric_version
state.scene_count = scene_count
state.pair_count = metric_cache_count
state.dirty_scene_count = dirty_scene_count
if state.status == "READY" and (
dirty_scene_count > 0 or (scene_count > 1 and metric_cache_count == 0)
):
state.status = "DIRTY"
elif state.status in {None, ""}:
state.status = "DIRTY" if scene_count > 1 else "READY"
if commit:
await db.commit()
await db.refresh(state)
else:
await db.flush()
payload = self._build_state_payload(
state=state,
scene_count=scene_count,
dirty_scene_count=dirty_scene_count,
metric_cache_count=metric_cache_count,
)
payload["created"] = created
return payload
async def bootstrap_pairing_cache_state(self) -> Dict[str, Any]:
async with _new_session() as db:
return await self.ensure_pairing_cache_state(db, commit=True)
async def mark_global_dirty(
self,
db: AsyncSession,
*,
reason: str = "manual",
commit: bool = False,
) -> Dict[str, Any]:
payload = await self.ensure_pairing_cache_state(db, commit=False)
state = await self._get_global_state(db)
if state is None:
raise RuntimeError("Pairing cache state row is missing after bootstrap.")
state.status = "DIRTY"
if state.last_error and reason != "error_recovery":
state.last_error = None
if commit:
await db.commit()
else:
await db.flush()
payload = await self.ensure_pairing_cache_state(db, commit=False)
payload["dirty_mark_mode"] = "global"
payload["reason"] = reason
return payload
async def mark_scenes_dirty(
self,
db: AsyncSession,
*,
scene_ids: Sequence[int],
reason: str = "scan",
commit: bool = False,
) -> Dict[str, Any]:
normalized_ids = sorted(
{
int(scene_id)
for scene_id in (scene_ids or [])
if scene_id is not None and int(scene_id) > 0
}
)
if not normalized_ids:
return await self.mark_global_dirty(db, reason=reason, commit=commit)
result = await db.execute(
select(RadarDataORM).where(RadarDataORM.id.in_(normalized_ids))
)
radar_rows = result.scalars().all()
if not radar_rows:
return await self.mark_global_dirty(db, reason=reason, commit=commit)
existing_result = await db.execute(
select(PairingDirtySceneORM.scene_ref_id).where(
PairingDirtySceneORM.scene_ref_id.in_([row.id for row in radar_rows]),
PairingDirtySceneORM.status == "PENDING",
)
)
existing_pending_ids = {int(value) for value in existing_result.scalars().all()}
created = 0
now = _utcnow()
for row in radar_rows:
if int(row.id) in existing_pending_ids:
continue
db.add(
PairingDirtySceneORM(
scene_ref_id=row.id,
scene_uid=_normalize_scene_uid(
scene_ref_id=row.id,
unique_id=row.unique_id,
file_path=row.file_path,
),
reason=reason,
status="PENDING",
marked_at=now,
)
)
created += 1
await db.flush()
state = await self._get_global_state(db)
if state is None:
await self.ensure_pairing_cache_state(db, commit=False)
state = await self._get_global_state(db)
if state is None:
raise RuntimeError("Pairing cache state row is missing after bootstrap.")
state.status = "DIRTY"
state.last_error = None
if commit:
await db.commit()
else:
await db.flush()
payload = await self.ensure_pairing_cache_state(db, commit=False)
payload["dirty_mark_mode"] = "scene"
payload["reason"] = reason
payload["requested_scene_count"] = len(normalized_ids)
payload["resolved_scene_count"] = len(radar_rows)
payload["created_dirty_rows"] = created
return payload
async def get_pairing_system_status(
self,
db: Optional[AsyncSession] = None,
) -> Dict[str, Any]:
own_session = db is None
session = db or _new_session()
try:
scene_count = await self._count_radar_scenes(session)
dirty_scene_count = await self._count_pending_dirty_scenes(session)
metric_cache_count = await self._count_metric_cache_rows(session)
state = await self._get_global_state(session)
run_count_result = await session.execute(select(func.count(PairingNetworkRunORM.id)))
network_run_count = int(run_count_result.scalar_one() or 0)
edge_count_result = await session.execute(select(func.count(PairingNetworkEdgeORM.id)))
network_edge_count = int(edge_count_result.scalar_one() or 0)
reverse_duplicate_result = await session.execute(
text(
"""
SELECT COUNT(*)
FROM pairing_metric_cache a
JOIN pairing_metric_cache b
ON a.master_scene_ref_id = b.slave_scene_ref_id
AND a.slave_scene_ref_id = b.master_scene_ref_id
AND a.metric_version = b.metric_version
AND a.id < b.id
"""
)
)
duplicate_reverse_pair_count = int(reverse_duplicate_result.scalar_one() or 0)
orphan_edge_result = await session.execute(
text(
"""
SELECT COUNT(*)
FROM pairing_network_edges e
LEFT JOIN pairing_network_runs r
ON r.id = e.network_run_ref_id
LEFT JOIN pairing_metric_cache m
ON m.id = e.metric_cache_ref_id
WHERE r.id IS NULL OR m.id IS NULL
"""
)
)
orphan_edge_count = int(orphan_edge_result.scalar_one() or 0)
payload = self._build_state_payload(
state=state,
scene_count=scene_count,
dirty_scene_count=dirty_scene_count,
metric_cache_count=metric_cache_count,
)
payload.update(
{
"ok": bool(
payload["status"] not in {"FAILED", "UNINITIALIZED", "ERROR"}
and orphan_edge_count == 0
and duplicate_reverse_pair_count == 0
),
"network_run_count": network_run_count,
"network_edge_count": network_edge_count,
"duplicate_reverse_pair_count": duplicate_reverse_pair_count,
"orphan_edge_count": orphan_edge_count,
"orientation_rule_version": self.orientation_rule_version,
"error": None,
}
)
return payload
except Exception as exc:
return {
"ok": False,
"state_present": False,
"cache_scope": PAIRING_CACHE_SCOPE_GLOBAL,
"metric_version": self.metric_version,
"status": "ERROR",
"scene_count": 0,
"pair_count": 0,
"dirty_scene_count": 0,
"needs_rebuild": True,
"cache_ready": False,
"last_full_rebuild_at": None,
"last_incremental_reconcile_at": None,
"last_error": None,
"updated_at": None,
"network_run_count": 0,
"network_edge_count": 0,
"duplicate_reverse_pair_count": 0,
"orphan_edge_count": 0,
"orientation_rule_version": self.orientation_rule_version,
"error": str(exc),
}
finally:
if own_session:
await session.close()
pairing_state_service = PairingStateService()