增加sqlalchemy后端,优化日志结构
This commit is contained in:
@@ -6,7 +6,7 @@ from .persistence import (
|
||||
get_registered_persistence_backends,
|
||||
register_persistence_backend,
|
||||
)
|
||||
from .persistence_backends import SQLitePersistenceBackend
|
||||
from .persistence_backends import SQLAlchemyPersistenceBackend, SQLitePersistenceBackend
|
||||
from .collection import DataCollection
|
||||
from .binding import BindingManager
|
||||
|
||||
@@ -17,6 +17,7 @@ __all__ = [
|
||||
"SyncNode",
|
||||
"PersistenceBackend",
|
||||
"SQLitePersistenceBackend",
|
||||
"SQLAlchemyPersistenceBackend",
|
||||
"create_persistence_backend",
|
||||
"register_persistence_backend",
|
||||
"get_registered_persistence_backends",
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from ..logging import get_logger
|
||||
from ..logging import get_logger, get_scope_display_name
|
||||
from .persistence import PersistenceBackend
|
||||
from .persistence_service import ScopedPersistenceView
|
||||
from .types import BindingStatus, BindingRecord
|
||||
@@ -253,7 +253,7 @@ class BindingManager:
|
||||
|
||||
touched_text = ",".join(touched_types) if touched_types else "none"
|
||||
logger.info(
|
||||
f"🔍 [BindingManager.persist] 完成: scope={self.scope}, types={type_count}, saved={saved_total}, deleted={deleted_total}, touched=[{touched_text}]"
|
||||
f"🔍 [BindingManager.persist] 完成: scope={get_scope_display_name(self.scope)}, types={type_count}, saved={saved_total}, deleted={deleted_total}, touched=[{touched_text}]"
|
||||
)
|
||||
|
||||
async def unload(self, *, node_types: Optional[List[str]] = None) -> None:
|
||||
|
||||
@@ -5,6 +5,9 @@ import sqlite3
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Callable, ClassVar, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.engine import URL, make_url
|
||||
|
||||
from ..config.persist_config import PersistConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -26,72 +29,153 @@ class PersistenceBackend(ABC):
|
||||
cls,
|
||||
*,
|
||||
backend: str,
|
||||
db_path: str,
|
||||
db_path: str | None = None,
|
||||
url: str | None = None,
|
||||
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}")
|
||||
if backend == "sqlite":
|
||||
path = db_path
|
||||
if not path:
|
||||
raise ValueError("db_path is required for backend=sqlite")
|
||||
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
|
||||
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
|
||||
|
||||
if backend == "sqlalchemy":
|
||||
if not url:
|
||||
raise ValueError("url is required for backend=sqlalchemy")
|
||||
engine = create_engine(cls._to_sync_sqlalchemy_url(url))
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text(sql))
|
||||
rows = result.fetchmany(max(1, min(limit, 2000)))
|
||||
columns = list(result.keys())
|
||||
data = [{column: row[index] for index, column in enumerate(columns)} for row in rows]
|
||||
return columns, data
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
raise NotImplementedError(f"records query backend not supported yet: {backend}")
|
||||
|
||||
@classmethod
|
||||
def records_summary(
|
||||
cls,
|
||||
*,
|
||||
backend: str,
|
||||
db_path: str,
|
||||
db_path: str | None = None,
|
||||
url: str | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
db_exists = cls._target_exists(backend=backend, db_path=db_path, url=url)
|
||||
summary: Dict[str, Any] = {
|
||||
"backend": backend,
|
||||
"db_path": db_path,
|
||||
"db_exists": os.path.exists(db_path),
|
||||
"url": url,
|
||||
"db_exists": db_exists,
|
||||
"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}"
|
||||
if not db_exists:
|
||||
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"
|
||||
if backend == "sqlite":
|
||||
assert db_path is not None
|
||||
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()
|
||||
summary["sample_bindings"] = [
|
||||
{
|
||||
"node_type": row["node_type"],
|
||||
"local_id": row["local_id"],
|
||||
"remote_id": row["remote_id"],
|
||||
}
|
||||
for row in sample
|
||||
]
|
||||
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
|
||||
|
||||
if backend == "sqlalchemy":
|
||||
if not url:
|
||||
summary["error"] = "url is required for backend=sqlalchemy"
|
||||
return summary
|
||||
engine = create_engine(cls._to_sync_sqlalchemy_url(url))
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
inspector = inspect(conn)
|
||||
table_names = sorted(inspector.get_table_names())
|
||||
|
||||
for name in table_names:
|
||||
count = conn.execute(text(f"SELECT COUNT(1) AS c FROM {name}")).scalar_one()
|
||||
summary["tables"].append({"name": name, "count": int(count)})
|
||||
|
||||
if "bindings" in table_names:
|
||||
sample = conn.execute(
|
||||
text("SELECT node_type, local_id, remote_id FROM bindings ORDER BY last_updated DESC LIMIT 50")
|
||||
).fetchall()
|
||||
summary["sample_bindings"] = [
|
||||
{
|
||||
"node_type": row[0],
|
||||
"local_id": row[1],
|
||||
"remote_id": row[2],
|
||||
}
|
||||
for row in sample
|
||||
]
|
||||
finally:
|
||||
engine.dispose()
|
||||
return summary
|
||||
|
||||
summary["error"] = f"records summary backend not supported yet: {backend}"
|
||||
return summary
|
||||
|
||||
@staticmethod
|
||||
def _target_exists(*, backend: str, db_path: str | None, url: str | None) -> bool:
|
||||
if backend == "sqlite":
|
||||
return bool(db_path) and os.path.exists(db_path)
|
||||
if backend == "sqlalchemy":
|
||||
if not url:
|
||||
return False
|
||||
parsed = make_url(url)
|
||||
if parsed.get_backend_name() == "sqlite":
|
||||
database = parsed.database
|
||||
if not database or database == ":memory:":
|
||||
return True
|
||||
return os.path.exists(database)
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _to_sync_sqlalchemy_url(url: str) -> str:
|
||||
parsed = make_url(url)
|
||||
async_to_sync = {
|
||||
"sqlite+aiosqlite": "sqlite+pysqlite",
|
||||
"postgresql+asyncpg": "postgresql+psycopg",
|
||||
"mysql+aiomysql": "mysql+pymysql",
|
||||
}
|
||||
drivername = async_to_sync.get(parsed.drivername, parsed.drivername)
|
||||
sync_url: URL = parsed.set(drivername=drivername)
|
||||
return str(sync_url)
|
||||
|
||||
@abstractmethod
|
||||
async def initialize(self) -> None:
|
||||
raise NotImplementedError
|
||||
@@ -218,9 +302,11 @@ def create_persistence_backend(
|
||||
|
||||
|
||||
def _register_builtin_persistence_backends() -> None:
|
||||
from .persistence_backends.sqlalchemy_backend import SQLAlchemyPersistenceBackend
|
||||
from .persistence_backends.sqlite_backend import SQLitePersistenceBackend
|
||||
|
||||
register_persistence_backend("sqlite", lambda config: SQLitePersistenceBackend(config), override=True)
|
||||
register_persistence_backend("sqlalchemy", lambda config: SQLAlchemyPersistenceBackend(config), override=True)
|
||||
|
||||
|
||||
_register_builtin_persistence_backends()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .sqlalchemy_backend import SQLAlchemyPersistenceBackend
|
||||
from .sqlite_backend import SQLitePersistenceBackend
|
||||
|
||||
__all__ = ["SQLitePersistenceBackend"]
|
||||
__all__ = ["SQLitePersistenceBackend", "SQLAlchemyPersistenceBackend"]
|
||||
@@ -0,0 +1,428 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Column, Index, MetaData, PrimaryKeyConstraint, String, Table, Text, delete, insert, select
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncConnection, create_async_engine
|
||||
|
||||
from ...config.persist_config import PersistConfig
|
||||
from ..persistence import PersistenceBackend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..sync_node import SyncNode
|
||||
|
||||
|
||||
class SQLAlchemyPersistenceBackend(PersistenceBackend):
|
||||
"""Generic SQLAlchemy persistence backend with explicit schema bootstrap."""
|
||||
|
||||
backend_name = "sqlalchemy"
|
||||
bulk_delete_batch_size = 1000
|
||||
collection_id_length = 255
|
||||
scope_length = 255
|
||||
node_id_length = 255
|
||||
node_type_length = 128
|
||||
data_id_length = 255
|
||||
bind_data_id_length = 255
|
||||
action_length = 32
|
||||
status_length = 32
|
||||
binding_status_length = 32
|
||||
remote_id_length = 255
|
||||
last_updated_length = 64
|
||||
|
||||
def __init__(self, persist_config: PersistConfig | str | None = None, **_: Any) -> None:
|
||||
if isinstance(persist_config, PersistConfig):
|
||||
self.persist_config = persist_config
|
||||
self.database_url = persist_config.resolved_url()
|
||||
self.db_path = persist_config.resolved_db_path()
|
||||
self.backend_options = dict(persist_config.backend_options)
|
||||
elif isinstance(persist_config, str):
|
||||
self.persist_config = None
|
||||
self.database_url = persist_config
|
||||
parsed = make_url(persist_config)
|
||||
self.db_path = str(parsed.database) if parsed.get_backend_name() == "sqlite" and parsed.database else None
|
||||
self.backend_options = {}
|
||||
else:
|
||||
raise ValueError("SQLAlchemyPersistenceBackend requires PersistConfig or database URL")
|
||||
|
||||
self.engine: Optional[AsyncEngine] = None
|
||||
self.metadata = MetaData()
|
||||
self.nodes = Table(
|
||||
"nodes",
|
||||
self.metadata,
|
||||
Column("collection_id", String(self.collection_id_length), nullable=False),
|
||||
Column("scope", String(self.scope_length), nullable=False, default="global"),
|
||||
Column("node_id", String(self.node_id_length), nullable=False),
|
||||
Column("node_type", String(self.node_type_length), nullable=False),
|
||||
Column("data_id", String(self.data_id_length), nullable=True),
|
||||
Column("bind_data_id", String(self.bind_data_id_length), nullable=True),
|
||||
Column("data", Text, nullable=True),
|
||||
Column("origin_data", Text, nullable=True),
|
||||
Column("action", String(self.action_length), nullable=True),
|
||||
Column("status", String(self.status_length), nullable=True),
|
||||
Column("binding_status", String(self.binding_status_length), nullable=True),
|
||||
Column("error", Text, nullable=True),
|
||||
Column("sync_log", Text, nullable=True),
|
||||
Column("context", Text, nullable=True),
|
||||
PrimaryKeyConstraint("collection_id", "scope", "node_id", name="pk_nodes"),
|
||||
Index("idx_nodes_collection_scope_type", "collection_id", "scope", "node_type"),
|
||||
Index("idx_nodes_collection_scope_data_id", "collection_id", "scope", "data_id"),
|
||||
Index("idx_nodes_scope_type_node_id", "scope", "node_type", "node_id"),
|
||||
)
|
||||
self.bindings = Table(
|
||||
"bindings",
|
||||
self.metadata,
|
||||
Column("scope", String(self.scope_length), nullable=False, default="global"),
|
||||
Column("node_type", String(self.node_type_length), nullable=False),
|
||||
Column("local_id", String(self.node_id_length), nullable=False),
|
||||
Column("remote_id", String(self.remote_id_length), nullable=True),
|
||||
Column("last_updated", String(self.last_updated_length), nullable=True),
|
||||
PrimaryKeyConstraint("scope", "node_type", "local_id", name="pk_bindings"),
|
||||
Index("idx_bindings_scope_type_remote", "scope", "node_type", "remote_id"),
|
||||
)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
if self.db_path and self.db_path != ":memory:":
|
||||
dir_path = os.path.dirname(self.db_path)
|
||||
if dir_path:
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
|
||||
engine_options = {"future": True}
|
||||
engine_options.update(self.backend_options)
|
||||
self.engine = create_async_engine(self.database_url, **engine_options)
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(self.metadata.create_all)
|
||||
|
||||
async def save_node(self, collection_id: str, scope: str, node: "SyncNode") -> None:
|
||||
row = self._node_row(collection_id=collection_id, scope=scope, node=node)
|
||||
async with self._begin() as conn:
|
||||
await self._replace_node_rows(conn, collection_id=collection_id, scope=scope, node_ids=[node.node_id])
|
||||
await conn.execute(insert(self.nodes), [row])
|
||||
|
||||
async def save_nodes_bulk(self, collection_id: str, scope: str, nodes: List["SyncNode"]) -> None:
|
||||
if not nodes:
|
||||
return
|
||||
rows = [self._node_row(collection_id=collection_id, scope=scope, node=node) for node in nodes]
|
||||
node_ids = [node.node_id for node in nodes]
|
||||
async with self._begin() as conn:
|
||||
await self._replace_node_rows(conn, collection_id=collection_id, scope=scope, node_ids=node_ids)
|
||||
await conn.execute(insert(self.nodes), rows)
|
||||
|
||||
async def delete_node(self, collection_id: str, scope: str, node_id: str) -> None:
|
||||
async with self._begin() as conn:
|
||||
await conn.execute(
|
||||
delete(self.nodes).where(
|
||||
self.nodes.c.collection_id == collection_id,
|
||||
self.nodes.c.scope == scope,
|
||||
self.nodes.c.node_id == node_id,
|
||||
)
|
||||
)
|
||||
|
||||
async def delete_nodes_bulk(self, collection_id: str, scope: str, node_ids: List[str]) -> None:
|
||||
if not node_ids:
|
||||
return
|
||||
async with self._begin() as conn:
|
||||
for batch in self._chunked(node_ids):
|
||||
await conn.execute(
|
||||
delete(self.nodes).where(
|
||||
self.nodes.c.collection_id == collection_id,
|
||||
self.nodes.c.scope == scope,
|
||||
self.nodes.c.node_id.in_(batch),
|
||||
)
|
||||
)
|
||||
|
||||
async def load_nodes(
|
||||
self,
|
||||
collection_id: str,
|
||||
scope: str,
|
||||
*,
|
||||
exclude_node_types: Optional[List[str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
stmt = select(self.nodes).where(
|
||||
self.nodes.c.collection_id == collection_id,
|
||||
self.nodes.c.scope == scope,
|
||||
)
|
||||
filtered_types = [node_type for node_type in (exclude_node_types or []) if node_type]
|
||||
if filtered_types:
|
||||
stmt = stmt.where(self.nodes.c.node_type.not_in(filtered_types))
|
||||
|
||||
async with self._connect() as conn:
|
||||
rows = (await conn.execute(stmt)).mappings().all()
|
||||
return [self._deserialize_node_row(row) for row in rows]
|
||||
|
||||
async def save_binding(self, scope: str, node_type: str, local_id: str, payload_dict: Dict[str, Any]) -> None:
|
||||
row = self._binding_row(scope=scope, node_type=node_type, local_id=local_id, remote_id=payload_dict.get("remote_id") if payload_dict else None)
|
||||
async with self._begin() as conn:
|
||||
await self._replace_binding_rows(conn, scope=scope, node_type=node_type, local_ids=[local_id])
|
||||
await conn.execute(insert(self.bindings), [row])
|
||||
|
||||
async def save_bindings_bulk(self, scope: str, node_type: str, bindings: Dict[str, Optional[str]]) -> None:
|
||||
if not bindings:
|
||||
return
|
||||
rows = [
|
||||
self._binding_row(scope=scope, node_type=node_type, local_id=local_id, remote_id=remote_id)
|
||||
for local_id, remote_id in bindings.items()
|
||||
]
|
||||
async with self._begin() as conn:
|
||||
await self._replace_binding_rows(conn, scope=scope, node_type=node_type, local_ids=list(bindings.keys()))
|
||||
await conn.execute(insert(self.bindings), rows)
|
||||
|
||||
async def delete_binding(self, scope: str, node_type: str, local_id: str) -> None:
|
||||
async with self._begin() as conn:
|
||||
await conn.execute(
|
||||
delete(self.bindings).where(
|
||||
self.bindings.c.scope == scope,
|
||||
self.bindings.c.node_type == node_type,
|
||||
self.bindings.c.local_id == local_id,
|
||||
)
|
||||
)
|
||||
|
||||
async def delete_bindings_bulk(self, scope: str, node_type: str, local_ids: List[str]) -> None:
|
||||
if not local_ids:
|
||||
return
|
||||
async with self._begin() as conn:
|
||||
for batch in self._chunked(local_ids):
|
||||
await conn.execute(
|
||||
delete(self.bindings).where(
|
||||
self.bindings.c.scope == scope,
|
||||
self.bindings.c.node_type == node_type,
|
||||
self.bindings.c.local_id.in_(batch),
|
||||
)
|
||||
)
|
||||
|
||||
async def load_bindings(self, scope: str, node_type: str) -> List[Dict[str, Any]]:
|
||||
stmt = select(self.bindings.c.local_id, self.bindings.c.remote_id).where(
|
||||
self.bindings.c.scope == scope,
|
||||
self.bindings.c.node_type == node_type,
|
||||
)
|
||||
async with self._connect() as conn:
|
||||
rows = (await conn.execute(stmt)).mappings().all()
|
||||
return [{"local_id": row["local_id"], "payload": {"remote_id": row["remote_id"]}} for row in rows]
|
||||
|
||||
async def load_bind_data_id_overrides(self, node_type: str, scope: str) -> Dict[str, Optional[str]]:
|
||||
local_join = (
|
||||
select(
|
||||
self.bindings.c.local_id.label("src_node_id"),
|
||||
self.nodes.c.data_id.label("bind_data_id"),
|
||||
)
|
||||
.select_from(
|
||||
self.bindings.outerjoin(
|
||||
self.nodes,
|
||||
(self.nodes.c.node_id == self.bindings.c.remote_id)
|
||||
& (self.nodes.c.node_type == self.bindings.c.node_type)
|
||||
& (self.nodes.c.scope == self.bindings.c.scope),
|
||||
)
|
||||
)
|
||||
.where(
|
||||
self.bindings.c.node_type == node_type,
|
||||
self.bindings.c.scope == scope,
|
||||
)
|
||||
)
|
||||
remote_join = (
|
||||
select(
|
||||
self.bindings.c.remote_id.label("src_node_id"),
|
||||
self.nodes.c.data_id.label("bind_data_id"),
|
||||
)
|
||||
.select_from(
|
||||
self.bindings.outerjoin(
|
||||
self.nodes,
|
||||
(self.nodes.c.node_id == self.bindings.c.local_id)
|
||||
& (self.nodes.c.node_type == self.bindings.c.node_type)
|
||||
& (self.nodes.c.scope == self.bindings.c.scope),
|
||||
)
|
||||
)
|
||||
.where(
|
||||
self.bindings.c.node_type == node_type,
|
||||
self.bindings.c.scope == scope,
|
||||
self.bindings.c.remote_id.is_not(None),
|
||||
)
|
||||
)
|
||||
async with self._connect() as conn:
|
||||
rows = (await conn.execute(local_join.union_all(remote_join))).mappings().all()
|
||||
result: Dict[str, Optional[str]] = {}
|
||||
for row in rows:
|
||||
src_node_id = row["src_node_id"]
|
||||
if src_node_id:
|
||||
result[str(src_node_id)] = row["bind_data_id"]
|
||||
return result
|
||||
|
||||
async def delete_scope_node_types(self, *, scope: str, node_types: List[str]) -> None:
|
||||
filtered_types = [node_type for node_type in node_types if node_type]
|
||||
if not filtered_types:
|
||||
return
|
||||
async with self._begin() as conn:
|
||||
await conn.execute(delete(self.nodes).where(self.nodes.c.scope == scope, self.nodes.c.node_type.in_(filtered_types)))
|
||||
await conn.execute(delete(self.bindings).where(self.bindings.c.scope == scope, self.bindings.c.node_type.in_(filtered_types)))
|
||||
|
||||
async def copy_nodes_between_scopes(
|
||||
self,
|
||||
*,
|
||||
collection_id: str,
|
||||
source_scope: str,
|
||||
target_scope: str,
|
||||
node_types: List[str],
|
||||
) -> None:
|
||||
if source_scope == target_scope or not node_types:
|
||||
return
|
||||
stmt = select(self.nodes).where(
|
||||
self.nodes.c.collection_id == collection_id,
|
||||
self.nodes.c.scope == source_scope,
|
||||
self.nodes.c.node_type.in_(node_types),
|
||||
)
|
||||
async with self._begin() as conn:
|
||||
rows = (await conn.execute(stmt)).mappings().all()
|
||||
target_rows = [dict(row, scope=target_scope) for row in rows]
|
||||
if not target_rows:
|
||||
return
|
||||
await self._replace_node_rows(
|
||||
conn,
|
||||
collection_id=collection_id,
|
||||
scope=target_scope,
|
||||
node_ids=[str(row["node_id"]) for row in target_rows],
|
||||
)
|
||||
await conn.execute(insert(self.nodes), target_rows)
|
||||
|
||||
async def copy_bindings_between_scopes(
|
||||
self,
|
||||
*,
|
||||
source_scope: str,
|
||||
target_scope: str,
|
||||
node_types: List[str],
|
||||
) -> None:
|
||||
if source_scope == target_scope or not node_types:
|
||||
return
|
||||
stmt = select(self.bindings).where(
|
||||
self.bindings.c.scope == source_scope,
|
||||
self.bindings.c.node_type.in_(node_types),
|
||||
)
|
||||
async with self._begin() as conn:
|
||||
rows = (await conn.execute(stmt)).mappings().all()
|
||||
target_rows = [dict(row, scope=target_scope) for row in rows]
|
||||
if not target_rows:
|
||||
return
|
||||
by_type: Dict[str, List[str]] = {}
|
||||
for row in target_rows:
|
||||
by_type.setdefault(str(row["node_type"]), []).append(str(row["local_id"]))
|
||||
for node_type, local_ids in by_type.items():
|
||||
await self._replace_binding_rows(conn, scope=target_scope, node_type=node_type, local_ids=local_ids)
|
||||
await conn.execute(insert(self.bindings), target_rows)
|
||||
|
||||
async def dump_to_runtime(self, filename: str) -> None:
|
||||
raise NotImplementedError("dump_to_runtime is only supported by the sqlite backend")
|
||||
|
||||
async def close(self) -> None:
|
||||
if self.engine is None:
|
||||
return
|
||||
await self.engine.dispose()
|
||||
self.engine = None
|
||||
|
||||
async def _replace_node_rows(
|
||||
self,
|
||||
conn: AsyncConnection,
|
||||
*,
|
||||
collection_id: str,
|
||||
scope: str,
|
||||
node_ids: List[str],
|
||||
) -> None:
|
||||
if not node_ids:
|
||||
return
|
||||
for batch in self._chunked(node_ids):
|
||||
await conn.execute(
|
||||
delete(self.nodes).where(
|
||||
self.nodes.c.collection_id == collection_id,
|
||||
self.nodes.c.scope == scope,
|
||||
self.nodes.c.node_id.in_(batch),
|
||||
)
|
||||
)
|
||||
|
||||
async def _replace_binding_rows(
|
||||
self,
|
||||
conn: AsyncConnection,
|
||||
*,
|
||||
scope: str,
|
||||
node_type: str,
|
||||
local_ids: List[str],
|
||||
) -> None:
|
||||
if not local_ids:
|
||||
return
|
||||
for batch in self._chunked(local_ids):
|
||||
await conn.execute(
|
||||
delete(self.bindings).where(
|
||||
self.bindings.c.scope == scope,
|
||||
self.bindings.c.node_type == node_type,
|
||||
self.bindings.c.local_id.in_(batch),
|
||||
)
|
||||
)
|
||||
|
||||
def _chunked(self, values: List[str]) -> List[List[str]]:
|
||||
if not values:
|
||||
return []
|
||||
batch_size = max(1, int(self.bulk_delete_batch_size))
|
||||
return [values[index : index + batch_size] for index in range(0, len(values), batch_size)]
|
||||
|
||||
def _node_row(self, *, collection_id: str, scope: str, node: "SyncNode") -> Dict[str, Any]:
|
||||
bind_data_id = None
|
||||
if isinstance(node.context, dict):
|
||||
value = node.context.get("bind_data_id")
|
||||
if value is not None and value != "":
|
||||
bind_data_id = str(value)
|
||||
return {
|
||||
"collection_id": collection_id,
|
||||
"scope": scope,
|
||||
"node_id": node.node_id,
|
||||
"node_type": node.node_type,
|
||||
"data_id": node.data_id,
|
||||
"bind_data_id": bind_data_id,
|
||||
"data": json.dumps(node.data) if node.data else None,
|
||||
"origin_data": json.dumps(node.origin_data) if node.origin_data else None,
|
||||
"action": node.action.value,
|
||||
"status": node.status.value,
|
||||
"binding_status": node.binding_status.value,
|
||||
"error": node.error,
|
||||
"sync_log": node.sync_log,
|
||||
"context": json.dumps(node.context) if node.context else None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _binding_row(*, scope: str, node_type: str, local_id: str, remote_id: Optional[str]) -> Dict[str, Any]:
|
||||
return {
|
||||
"scope": scope,
|
||||
"node_type": node_type,
|
||||
"local_id": local_id,
|
||||
"remote_id": remote_id,
|
||||
"last_updated": None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _deserialize_node_row(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
item = {
|
||||
"node_id": row["node_id"],
|
||||
"node_type": row["node_type"],
|
||||
"data_id": row["data_id"],
|
||||
"bind_data_id": row["bind_data_id"],
|
||||
"depend_ids": [],
|
||||
"data": json.loads(row["data"]) if row["data"] else None,
|
||||
"origin_data": json.loads(row["origin_data"]) if row["origin_data"] else None,
|
||||
"action": row["action"],
|
||||
"status": row["status"],
|
||||
"binding_status": row["binding_status"],
|
||||
"error": row["error"],
|
||||
"sync_log": row["sync_log"],
|
||||
"context": json.loads(row["context"]) if row["context"] else {},
|
||||
}
|
||||
if item["bind_data_id"] and isinstance(item["context"], dict):
|
||||
item["context"]["bind_data_id"] = item["bind_data_id"]
|
||||
return item
|
||||
|
||||
def _require_engine(self) -> AsyncEngine:
|
||||
if self.engine is None:
|
||||
raise RuntimeError("SQLAlchemyPersistenceBackend is not initialized")
|
||||
return self.engine
|
||||
|
||||
def _connect(self):
|
||||
return self._require_engine().connect()
|
||||
|
||||
def _begin(self):
|
||||
return self._require_engine().begin()
|
||||
@@ -20,7 +20,10 @@ class SQLitePersistenceBackend(PersistenceBackend):
|
||||
def __init__(self, persist_config: PersistConfig | str | None = None, **_: Any) -> None:
|
||||
if isinstance(persist_config, PersistConfig):
|
||||
self.persist_config = persist_config
|
||||
self.db_path = persist_config.db_path
|
||||
resolved_db_path = persist_config.resolved_db_path()
|
||||
if not resolved_db_path:
|
||||
raise ValueError("sqlite persistence requires a resolvable db_path")
|
||||
self.db_path = resolved_db_path
|
||||
elif isinstance(persist_config, str):
|
||||
self.persist_config = None
|
||||
self.db_path = persist_config
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .persistence_backends.sqlalchemy_backend import SQLAlchemyPersistenceBackend
|
||||
|
||||
__all__ = ["SQLAlchemyPersistenceBackend"]
|
||||
@@ -1,13 +1,70 @@
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class PersistConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
||||
|
||||
backend: str = "sqlite"
|
||||
db_path: str
|
||||
db_path: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
backend_options: Dict[str, Any] = Field(default_factory=dict)
|
||||
enable: bool = True
|
||||
wipe_on_start: bool = False
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _validate_target(cls, data: Any) -> Any:
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
normalized = dict(data)
|
||||
normalized["backend"] = (str(normalized.get("backend") or "sqlite").strip().lower() or "sqlite")
|
||||
normalized["db_path"] = cls._normalize_optional_text(normalized.get("db_path"))
|
||||
normalized["url"] = cls._normalize_optional_text(normalized.get("url"))
|
||||
|
||||
if not normalized["db_path"] and not normalized["url"]:
|
||||
raise ValueError("persist requires either db_path or url")
|
||||
if normalized["backend"] != "sqlite" and not normalized["url"]:
|
||||
raise ValueError(f"persist.url is required for backend={normalized['backend']}")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize_optional_text(value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = str(value).strip()
|
||||
return normalized or None
|
||||
|
||||
def resolved_url(self) -> str:
|
||||
if self.url:
|
||||
return self.url
|
||||
if self.backend == "sqlite" and self.db_path:
|
||||
return f"sqlite+aiosqlite:///{self.db_path}"
|
||||
raise ValueError(f"persist.url is required for backend={self.backend}")
|
||||
|
||||
def resolved_db_path(self) -> Optional[str]:
|
||||
if self.db_path:
|
||||
return self.db_path
|
||||
if not self.url:
|
||||
return None
|
||||
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
parsed = make_url(self.url)
|
||||
if parsed.get_backend_name() != "sqlite":
|
||||
return None
|
||||
database = parsed.database
|
||||
if not database:
|
||||
return None
|
||||
return str(database)
|
||||
|
||||
def wipe_file_path(self) -> Optional[str]:
|
||||
db_path = self.resolved_db_path()
|
||||
if not db_path or db_path == ":memory:":
|
||||
return None
|
||||
return db_path
|
||||
|
||||
def display_target(self) -> str:
|
||||
return self.url or self.db_path or "<unset>"
|
||||
|
||||
@@ -23,7 +23,7 @@ from datetime import datetime
|
||||
import httpx
|
||||
|
||||
from schemas.common.push_system import PushIdsSchema
|
||||
from ...logging import get_logger
|
||||
from ...logging import get_logger, get_scope_display_name
|
||||
|
||||
|
||||
def _build_default_push_steps() -> List[Dict[str, str]]:
|
||||
@@ -43,6 +43,7 @@ class ApiClient:
|
||||
secret: str,
|
||||
debug: bool = False,
|
||||
log_file: Optional[Path] = None,
|
||||
session_label: Optional[str] = None,
|
||||
rate_limit_max_requests: int = 30,
|
||||
rate_limit_window_seconds: float = 10.0,
|
||||
):
|
||||
@@ -64,6 +65,8 @@ class ApiClient:
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
self._debug = debug
|
||||
self._log_file = log_file
|
||||
self._scope = ""
|
||||
self._session_label = (session_label or "").strip()
|
||||
self._trace_seq = 0
|
||||
self._trace_by_push_id: Dict[str, deque[Dict[str, Any]]] = defaultdict(deque)
|
||||
self._trace_by_biz_id: Dict[str, deque[Dict[str, Any]]] = defaultdict(deque)
|
||||
@@ -78,6 +81,43 @@ class ApiClient:
|
||||
self._request_success = 0
|
||||
self._request_failure = 0
|
||||
|
||||
def set_scope(self, scope: Optional[str]) -> None:
|
||||
self._scope = str(scope or "").strip()
|
||||
|
||||
def set_session_label(self, label: Optional[str]) -> None:
|
||||
self._session_label = str(label or "").strip()
|
||||
|
||||
def get_log_session_label(self) -> str:
|
||||
if self._session_label:
|
||||
return self._session_label
|
||||
scope_label = get_scope_display_name(self._scope)
|
||||
return scope_label or self._scope
|
||||
|
||||
def _project_name_from_request(self, payload: Optional[Dict[str, Any]]) -> str:
|
||||
if not isinstance(payload, dict):
|
||||
return ""
|
||||
project_id = str(payload.get("project_id") or "").strip()
|
||||
if not project_id:
|
||||
return ""
|
||||
display = get_scope_display_name(f"project_id:{project_id}")
|
||||
if not display or display == f"project_id:{project_id}":
|
||||
return ""
|
||||
return display
|
||||
|
||||
def _resolve_request_session_label(
|
||||
self,
|
||||
*,
|
||||
request_params: Optional[Dict[str, Any]] = None,
|
||||
request_payload: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
payload_label = self._project_name_from_request(request_payload)
|
||||
if payload_label:
|
||||
return payload_label
|
||||
params_label = self._project_name_from_request(request_params)
|
||||
if params_label:
|
||||
return params_label
|
||||
return self.get_log_session_label()
|
||||
|
||||
def _is_rate_limit_enabled(self) -> bool:
|
||||
return self._rate_limit_max_requests > 0 and self._rate_limit_window_seconds > 0
|
||||
|
||||
@@ -110,7 +150,7 @@ class ApiClient:
|
||||
|
||||
async def close(self):
|
||||
"""关闭 HTTP 客户端"""
|
||||
self.log_request_stats()
|
||||
self.log_request_stats(level="INFO")
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
@@ -126,8 +166,11 @@ class ApiClient:
|
||||
def format_request_stats(self) -> str:
|
||||
"""格式化当前客户端生命周期内的请求统计。"""
|
||||
stats = self.get_request_stats()
|
||||
session_text = self.get_log_session_label()
|
||||
session_prefix = f"session={session_text} " if session_text else ""
|
||||
return (
|
||||
f"{self._REQUEST_STATS_LOG_PREFIX}: "
|
||||
f"{session_prefix}"
|
||||
f"total={stats['total']}, success={stats['success']}, failure={stats['failure']}"
|
||||
)
|
||||
|
||||
@@ -135,12 +178,25 @@ class ApiClient:
|
||||
"""输出当前客户端生命周期内的请求统计。"""
|
||||
self._log(self.format_request_stats(), level=level)
|
||||
|
||||
def _log_request_summary(self, *, method: str, endpoint: str, elapsed_seconds: float) -> None:
|
||||
def _log_request_summary(
|
||||
self,
|
||||
*,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
elapsed_seconds: float,
|
||||
request_params: Optional[Dict[str, Any]] = None,
|
||||
request_payload: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
if not self._debug:
|
||||
return
|
||||
session_text = self._resolve_request_session_label(
|
||||
request_params=request_params,
|
||||
request_payload=request_payload,
|
||||
)
|
||||
session_prefix = f"session={session_text} " if session_text else ""
|
||||
self._log(
|
||||
f"🔎 ApiClient request: method={method.upper()} endpoint={endpoint} elapsed={elapsed_seconds:.3f}s",
|
||||
level="DEBUG",
|
||||
f"🔎 ApiClient request: {session_prefix}method={method.upper()} endpoint={endpoint} elapsed={elapsed_seconds:.3f}s",
|
||||
level="INFO",
|
||||
)
|
||||
|
||||
def _log(self, message: str, level: str = "DEBUG"):
|
||||
@@ -468,7 +524,13 @@ class ApiClient:
|
||||
|
||||
# 记录响应时间
|
||||
elapsed = time.time() - start_time
|
||||
self._log_request_summary(method=method, endpoint=endpoint, elapsed_seconds=elapsed)
|
||||
self._log_request_summary(
|
||||
method=method,
|
||||
endpoint=endpoint,
|
||||
elapsed_seconds=elapsed,
|
||||
request_params=params,
|
||||
request_payload=data if method.upper() in ['POST', 'PUT', 'DELETE'] else None,
|
||||
)
|
||||
self._log(f"Response Time: {elapsed:.3f}s", level="DEBUG")
|
||||
|
||||
# 检查 HTTP 状态
|
||||
@@ -499,7 +561,13 @@ class ApiClient:
|
||||
|
||||
# 记录错误
|
||||
elapsed = time.time() - start_time
|
||||
self._log_request_summary(method=method, endpoint=endpoint, elapsed_seconds=elapsed)
|
||||
self._log_request_summary(
|
||||
method=method,
|
||||
endpoint=endpoint,
|
||||
elapsed_seconds=elapsed,
|
||||
request_params=params,
|
||||
request_payload=data if method.upper() in ['POST', 'PUT', 'DELETE'] else None,
|
||||
)
|
||||
error_msg = "\n========== [ApiClient HTTP Error] ==========\n"
|
||||
error_msg += f"Request: {method} {url}\n"
|
||||
error_msg += f"Params: {params}\n"
|
||||
|
||||
@@ -15,6 +15,7 @@ from ..datasource import BaseDataSource
|
||||
from ..handler import NodeHandler
|
||||
from ..task_result import TaskResult
|
||||
from .client import ApiClient
|
||||
from ...logging import set_scope_display_name
|
||||
|
||||
|
||||
class ApiDataSource(BaseDataSource):
|
||||
@@ -56,6 +57,29 @@ class ApiDataSource(BaseDataSource):
|
||||
rate_limit_window_seconds=rate_limit_window_seconds,
|
||||
)
|
||||
self.poll_mode = poll_mode
|
||||
|
||||
def set_collection(self, collection) -> None:
|
||||
super().set_collection(collection)
|
||||
self.client.set_scope(getattr(collection, "scope", ""))
|
||||
|
||||
def _update_client_session_label_from_project_node(self, node) -> None:
|
||||
if self._collection is None or getattr(self._collection, "scope", "") == "global":
|
||||
return
|
||||
if getattr(node, "node_type", None) != "project":
|
||||
return
|
||||
if not hasattr(node, "get_data"):
|
||||
return
|
||||
|
||||
data = node.get_data()
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
|
||||
project_name = str(data.get("name") or "").strip()
|
||||
if not project_name:
|
||||
return
|
||||
|
||||
set_scope_display_name(self._collection.scope, f"project:{project_name}")
|
||||
self.client.set_session_label(f"project:{project_name}")
|
||||
|
||||
async def initialize(self):
|
||||
"""初始化数据源(初始化 HTTP 客户端)"""
|
||||
@@ -175,6 +199,7 @@ class ApiDataSource(BaseDataSource):
|
||||
return None
|
||||
|
||||
def _on_node_loaded(self, handler: NodeHandler, node) -> None:
|
||||
self._update_client_session_label_from_project_node(node)
|
||||
data_id = node.data_id or ""
|
||||
if not data_id:
|
||||
return
|
||||
|
||||
@@ -37,7 +37,7 @@ from ..engine import (
|
||||
StateMachineRuntime,
|
||||
e40_sync_execute,
|
||||
)
|
||||
from ..logging import get_logger
|
||||
from ..logging import get_logger, get_scope_display_name
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -95,7 +95,7 @@ class BaseDataSource(ABC):
|
||||
|
||||
def _datasource_log_prefix(self) -> str:
|
||||
scope = self._collection.scope if self._collection is not None else "unbound"
|
||||
return f"[{scope}][{self._endpoint_name}:{self._datasource_type}]"
|
||||
return f"[{get_scope_display_name(scope)}][{self._endpoint_name}:{self._datasource_type}]"
|
||||
|
||||
def _node_log_prefix(self, node_type: str) -> str:
|
||||
return f"{self._datasource_log_prefix()}[{node_type}]"
|
||||
|
||||
@@ -6,11 +6,34 @@ from typing import Optional
|
||||
|
||||
|
||||
APP_LOGGER_NAME = "sync_state_machine"
|
||||
_SCOPE_DISPLAY_NAMES: dict[str, str] = {}
|
||||
|
||||
|
||||
def set_scope_display_name(scope: str, display_name: str) -> None:
|
||||
normalized_scope = str(scope).strip()
|
||||
normalized_name = str(display_name).strip()
|
||||
if not normalized_scope:
|
||||
return
|
||||
if not normalized_name:
|
||||
_SCOPE_DISPLAY_NAMES.pop(normalized_scope, None)
|
||||
return
|
||||
_SCOPE_DISPLAY_NAMES[normalized_scope] = normalized_name
|
||||
|
||||
|
||||
def get_scope_display_name(scope: Optional[str]) -> str:
|
||||
normalized_scope = str(scope or "").strip()
|
||||
if not normalized_scope:
|
||||
return ""
|
||||
return _SCOPE_DISPLAY_NAMES.get(normalized_scope, normalized_scope)
|
||||
|
||||
|
||||
def clear_scope_display_names() -> None:
|
||||
_SCOPE_DISPLAY_NAMES.clear()
|
||||
|
||||
|
||||
class ScopeLoggerAdapter(logging.LoggerAdapter):
|
||||
def process(self, msg, kwargs):
|
||||
scope = self.extra.get("scope")
|
||||
scope = get_scope_display_name(self.extra.get("scope"))
|
||||
if scope and not str(msg).startswith("["):
|
||||
msg = f"[{scope}] {msg}"
|
||||
return msg, kwargs
|
||||
|
||||
@@ -197,7 +197,7 @@ def _log_pipeline_start(config: PipelineRunConfig, *, title: str) -> None:
|
||||
|
||||
def _log_pipeline_end(config: PipelineRunConfig, *, stats: Dict[str, Any]) -> None:
|
||||
logger.info("✅ Pipeline completed: stats=%s", stats)
|
||||
logger.info("🗃️ Persistence DB: %s", config.persist.db_path)
|
||||
logger.info("🗃️ Persistence Target: %s", config.persist.display_target())
|
||||
|
||||
ensure_builtin_datasource_types_registered()
|
||||
|
||||
@@ -428,15 +428,17 @@ async def create_scope_runtime_from_config(
|
||||
) -> ProjectScopeRuntime:
|
||||
ensure_app_logging(config.logging)
|
||||
|
||||
db_path = Path(config.persist.db_path)
|
||||
owns_persistence_service = persistence_service is None
|
||||
created_backend: Optional[PersistenceBackend] = None
|
||||
local_ds = None
|
||||
remote_ds = None
|
||||
try:
|
||||
if persistence_service is None:
|
||||
if config.persist.wipe_on_start and db_path.exists():
|
||||
db_path.unlink()
|
||||
wipe_file_path = config.persist.wipe_file_path()
|
||||
if config.persist.wipe_on_start and wipe_file_path:
|
||||
db_path = Path(wipe_file_path)
|
||||
if db_path.exists():
|
||||
db_path.unlink()
|
||||
created_backend = create_persistence_backend(config.persist)
|
||||
persistence_service = PersistenceService(created_backend)
|
||||
await persistence_service.initialize()
|
||||
|
||||
@@ -14,7 +14,7 @@ from ..config import (
|
||||
apply_config_overrides,
|
||||
build_config_from_file,
|
||||
)
|
||||
from ..logging import get_scope_logger
|
||||
from ..logging import get_scope_logger, get_scope_display_name
|
||||
from .factory import create_pipeline_from_config, create_scope_runtime_from_config
|
||||
|
||||
|
||||
@@ -337,7 +337,7 @@ class ProjectBatchPipeline:
|
||||
title = f"multi-project pipeline [{scope}]"
|
||||
logger.info(
|
||||
"🚀 Project pipeline start: scope=%s local_project_id=%s remote_project_id=%s",
|
||||
scope,
|
||||
get_scope_display_name(scope),
|
||||
item.local_project_id,
|
||||
item.remote_project_id,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
async def print_pipeline_summary(*, logger, node_types, binding_manager, local_datasource, remote_datasource, local_collection, remote_collection, consistency_by_type, stats) -> None:
|
||||
scoped_logger = logger
|
||||
plain_logger = logger.logger if isinstance(logger, logging.LoggerAdapter) else logger
|
||||
|
||||
def _log_header(message: str) -> None:
|
||||
scoped_logger.info(message)
|
||||
|
||||
def _log_body(message: str) -> None:
|
||||
plain_logger.info(message)
|
||||
|
||||
binding_counts = {}
|
||||
for node_type in node_types:
|
||||
records = await binding_manager.get_all_records(node_type)
|
||||
@@ -36,31 +47,31 @@ async def print_pipeline_summary(*, logger, node_types, binding_manager, local_d
|
||||
remote_text = _clip(repr(sample.get("remote")), 72)
|
||||
return f"{local_text}/{remote_text}"
|
||||
|
||||
logger.info(f"\n{'=' * line_width}")
|
||||
logger.info("🔎 Consistency Summary")
|
||||
logger.info(f"{'=' * line_width}")
|
||||
_log_header(f"\n{'=' * line_width}")
|
||||
_log_header("🔎 Consistency Summary")
|
||||
_log_header(f"{'=' * line_width}")
|
||||
|
||||
for node_type in node_types:
|
||||
item = consistency_by_type.get(node_type, {}) or {}
|
||||
checked_pairs = int(item.get("checked_pairs", 0))
|
||||
mismatch_count = int(item.get("mismatch_count", 0))
|
||||
logger.info(f"{node_type} (checked_pairs={checked_pairs}, mismatches={mismatch_count})")
|
||||
_log_body(f"{node_type} (checked_pairs={checked_pairs}, mismatches={mismatch_count})")
|
||||
|
||||
fields = item.get("fields", []) or []
|
||||
differing_fields = [field for field in fields if int(field.get("mismatch_count", 0)) > 0]
|
||||
if not differing_fields:
|
||||
logger.info(" pass")
|
||||
_log_body(" pass")
|
||||
continue
|
||||
|
||||
logger.info(" field mismatch/total samples")
|
||||
logger.info(" -----------------------------------------------------------")
|
||||
_log_body(" field mismatch/total samples")
|
||||
_log_body(" -----------------------------------------------------------")
|
||||
for field_report in differing_fields:
|
||||
field_name = str(field_report.get("field_name", ""))
|
||||
field_mismatch = int(field_report.get("mismatch_count", 0))
|
||||
field_total = int(field_report.get("total_count", 0))
|
||||
samples = field_report.get("samples", []) or []
|
||||
sample_text = _format_sample(samples[0]) if samples else "-"
|
||||
logger.info(
|
||||
_log_body(
|
||||
f" {_clip(field_name, 30):<30} {field_mismatch:>4}/{field_total:<4} "
|
||||
f"{sample_text}"
|
||||
)
|
||||
@@ -74,15 +85,15 @@ async def print_pipeline_summary(*, logger, node_types, binding_manager, local_d
|
||||
action_w = 16
|
||||
consistency_w = 18
|
||||
|
||||
logger.info(f"\n{'='*line_width}")
|
||||
logger.info(f"📊 {title} Summary")
|
||||
logger.info(f"{'='*line_width}")
|
||||
logger.info("说明: Create/Update/Delete = 成功/失败/总数 (失败=FAILED+SKIPPED)")
|
||||
logger.info("说明: Consistent = 一致对数/不一致对数/已检查绑定对数")
|
||||
logger.info(
|
||||
_log_header(f"\n{'='*line_width}")
|
||||
_log_header(f"📊 {title} Summary")
|
||||
_log_header(f"{'='*line_width}")
|
||||
_log_body("说明: Create/Update/Delete = 成功/失败/总数 (失败=FAILED+SKIPPED)")
|
||||
_log_body("说明: Consistent = 一致对数/不一致对数/已检查绑定对数")
|
||||
_log_body(
|
||||
f"{'Node Type':<{node_type_w}} {'Bound/Rec/Load':<{bound_w}} {'Create(S/F/T)':<{action_w}} {'Update(S/F/T)':<{action_w}} {'Delete(S/F/T)':<{action_w}} {'Consistent(S/F/T)':<{consistency_w}}"
|
||||
)
|
||||
logger.info(f"{'-'*line_width}")
|
||||
_log_body(f"{'-'*line_width}")
|
||||
|
||||
total_bound_normal = 0
|
||||
total_bindings = 0
|
||||
@@ -105,7 +116,7 @@ async def print_pipeline_summary(*, logger, node_types, binding_manager, local_d
|
||||
consistency_str = format_consistency_sft(node_type)
|
||||
|
||||
bound_str = f"{bound_normal_count}/{bindings}/{loaded_count}"
|
||||
logger.info(
|
||||
_log_body(
|
||||
f"{_clip(node_type, node_type_w):<{node_type_w}} "
|
||||
f"{_clip(bound_str, bound_w):<{bound_w}} "
|
||||
f"{_clip(create_str, action_w):<{action_w}} "
|
||||
@@ -131,14 +142,14 @@ async def print_pipeline_summary(*, logger, node_types, binding_manager, local_d
|
||||
total_consistency["checked"] += int(consistency.get("checked_pairs", 0))
|
||||
total_consistency["mismatch"] += int(consistency.get("mismatch_count", 0))
|
||||
|
||||
logger.info(f"{'-'*line_width}")
|
||||
_log_body(f"{'-'*line_width}")
|
||||
total_consistent = max(0, total_consistency["checked"] - total_consistency["mismatch"])
|
||||
total_create_sft = f"{total_create['success']}/{total_create['failed_effective']}/{total_create['total']}"
|
||||
total_update_sft = f"{total_update['success']}/{total_update['failed_effective']}/{total_update['total']}"
|
||||
total_delete_sft = f"{total_delete['success']}/{total_delete['failed_effective']}/{total_delete['total']}"
|
||||
total_consistency_sft = f"{total_consistent}/{total_consistency['mismatch']}/{total_consistency['checked']}"
|
||||
total_bound_str = f"{total_bound_normal}/{total_bindings}/{total_loaded}"
|
||||
logger.info(
|
||||
_log_body(
|
||||
f"{'TOTAL':<{node_type_w}} "
|
||||
f"{_clip(total_bound_str, bound_w):<{bound_w}} "
|
||||
f"{_clip(total_create_sft, action_w):<{action_w}} "
|
||||
@@ -158,13 +169,13 @@ async def print_pipeline_summary(*, logger, node_types, binding_manager, local_d
|
||||
failed = int(stats.get("failed", 0))
|
||||
post_check_passed = bool(stats.get("post_check_passed", True))
|
||||
|
||||
logger.info("\n" + "=" * 96)
|
||||
logger.info(
|
||||
_log_header("\n" + "=" * 96)
|
||||
_log_header(
|
||||
f"📈 Pipeline Stats: loaded={loaded}, synced={synced}, failed={failed}, post_check_passed={post_check_passed}, mismatch_types={len(mismatch_node_types)}"
|
||||
)
|
||||
if mismatch_node_types:
|
||||
logger.info(f"🔎 Mismatch Node Types: {', '.join(mismatch_node_types)}")
|
||||
logger.info("=" * 96)
|
||||
_log_body(f"🔎 Mismatch Node Types: {', '.join(mismatch_node_types)}")
|
||||
_log_header("=" * 96)
|
||||
|
||||
print_consistency_summary()
|
||||
print_collection_summary("Local", local_datasource, local_collection)
|
||||
|
||||
Reference in New Issue
Block a user