Files
ecm_sync_system/sync_state_machine/common/persistence.py
T

227 lines
7.4 KiB
Python

from __future__ import annotations
import os
import sqlite3
from abc import ABC, abstractmethod
from typing import Any, Callable, ClassVar, Dict, List, Optional, TYPE_CHECKING
from ..config.persist_config import PersistConfig
if TYPE_CHECKING:
from .sync_node import SyncNode
class PersistenceBackend(ABC):
"""同步系统持久化契约。"""
backend_name: ClassVar[str] = "abstract"
@staticmethod
def _is_read_only_sql(sql: str) -> bool:
stmt = (sql or "").strip().lower()
return stmt.startswith("select") or stmt.startswith("pragma") or stmt.startswith("with")
@classmethod
def query_records(
cls,
*,
backend: str,
db_path: str,
sql: str,
limit: int = 200,
) -> tuple[List[str], List[Dict[str, Any]]]:
if backend != "sqlite":
raise NotImplementedError(f"records query backend not supported yet: {backend}")
if not cls._is_read_only_sql(sql):
raise ValueError("Only read-only SQL is allowed")
path = db_path
if not os.path.exists(path):
raise FileNotFoundError(f"DB not found: {path}")
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(sql)
rows = cursor.fetchmany(max(1, min(limit, 2000)))
columns = [item[0] for item in (cursor.description or [])]
data = [{k: row[k] for k in columns} for row in rows]
return columns, data
@classmethod
def records_summary(
cls,
*,
backend: str,
db_path: str,
) -> Dict[str, Any]:
summary: Dict[str, Any] = {
"backend": backend,
"db_path": db_path,
"db_exists": os.path.exists(db_path),
"tables": [],
"sample_bindings": [],
}
if not os.path.exists(db_path):
return summary
if backend != "sqlite":
summary["error"] = f"records summary backend not supported yet: {backend}"
return summary
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
tables = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
).fetchall()
table_names = [str(r["name"]) for r in tables]
for name in table_names:
count = conn.execute(f"SELECT COUNT(1) AS c FROM {name}").fetchone()["c"]
summary["tables"].append({"name": name, "count": int(count)})
if "bindings" in table_names:
sample = conn.execute(
"SELECT node_type, local_id, remote_id FROM bindings ORDER BY last_updated DESC LIMIT 50"
).fetchall()
summary["sample_bindings"] = [
{
"node_type": row["node_type"],
"local_id": row["local_id"],
"remote_id": row["remote_id"],
}
for row in sample
]
return summary
@abstractmethod
async def initialize(self) -> None:
raise NotImplementedError
@abstractmethod
async def save_node(self, collection_id: str, scope: str, node: "SyncNode") -> None:
raise NotImplementedError
@abstractmethod
async def save_nodes_bulk(self, collection_id: str, scope: str, nodes: List["SyncNode"]) -> None:
raise NotImplementedError
@abstractmethod
async def delete_node(self, collection_id: str, scope: str, node_id: str) -> None:
raise NotImplementedError
@abstractmethod
async def delete_nodes_bulk(self, collection_id: str, scope: str, node_ids: List[str]) -> None:
raise NotImplementedError
@abstractmethod
async def load_nodes(
self,
collection_id: str,
scope: str,
*,
exclude_node_types: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
raise NotImplementedError
@abstractmethod
async def save_binding(self, scope: str, node_type: str, local_id: str, payload_dict: Dict[str, Any]) -> None:
raise NotImplementedError
@abstractmethod
async def save_bindings_bulk(self, scope: str, node_type: str, bindings: Dict[str, Optional[str]]) -> None:
raise NotImplementedError
@abstractmethod
async def delete_binding(self, scope: str, node_type: str, local_id: str) -> None:
raise NotImplementedError
@abstractmethod
async def delete_bindings_bulk(self, scope: str, node_type: str, local_ids: List[str]) -> None:
raise NotImplementedError
@abstractmethod
async def load_bindings(self, scope: str, node_type: str) -> List[Dict[str, Any]]:
raise NotImplementedError
@abstractmethod
async def load_bind_data_id_overrides(self, node_type: str, scope: str) -> Dict[str, Optional[str]]:
raise NotImplementedError
@abstractmethod
async def delete_scope_node_types(self, *, scope: str, node_types: List[str]) -> None:
raise NotImplementedError
@abstractmethod
async def copy_nodes_between_scopes(
self,
*,
collection_id: str,
source_scope: str,
target_scope: str,
node_types: List[str],
) -> None:
raise NotImplementedError
@abstractmethod
async def copy_bindings_between_scopes(
self,
*,
source_scope: str,
target_scope: str,
node_types: List[str],
) -> None:
raise NotImplementedError
@abstractmethod
async def dump_to_runtime(self, filename: str) -> None:
raise NotImplementedError
@abstractmethod
async def close(self) -> None:
raise NotImplementedError
PersistenceBackendFactory = Callable[[PersistConfig], PersistenceBackend]
_PERSISTENCE_BACKEND_REGISTRY: dict[str, PersistenceBackendFactory] = {}
def register_persistence_backend(name: str, factory: PersistenceBackendFactory, *, override: bool = False) -> None:
backend_name = name.strip()
if not backend_name:
raise ValueError("backend name cannot be empty")
if not override and backend_name in _PERSISTENCE_BACKEND_REGISTRY:
raise ValueError(f"persistence backend already registered: {backend_name}")
_PERSISTENCE_BACKEND_REGISTRY[backend_name] = factory
def get_registered_persistence_backends() -> Dict[str, PersistenceBackendFactory]:
return dict(_PERSISTENCE_BACKEND_REGISTRY)
def create_persistence_backend(
persist_config: PersistConfig,
) -> PersistenceBackend:
backend_name = (persist_config.backend or "").strip()
if not backend_name:
raise ValueError("persist.backend cannot be empty")
if not _PERSISTENCE_BACKEND_REGISTRY:
register_persistence_backend(
"sqlite",
lambda config: SQLitePersistenceBackend(config),
)
factory = _PERSISTENCE_BACKEND_REGISTRY.get(backend_name)
if factory is None:
available = ", ".join(sorted(_PERSISTENCE_BACKEND_REGISTRY)) or "none"
raise NotImplementedError(f"Persistence backend not supported yet: {backend_name}. Available: {available}")
return factory(persist_config)
def _register_builtin_persistence_backends() -> None:
from .persistence_backends.sqlite_backend import SQLitePersistenceBackend
register_persistence_backend("sqlite", lambda config: SQLitePersistenceBackend(config), override=True)
_register_builtin_persistence_backends()