增加sqlalchemy后端,优化日志结构

This commit is contained in:
strepsiades
2026-04-07 20:22:25 +08:00
parent c377264610
commit ba73b30a04
23 changed files with 1586 additions and 308 deletions
+2 -1
View File
@@ -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 -2
View File
@@ -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:
+127 -41
View File
@@ -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"]
+60 -3
View File
@@ -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>"
+75 -7
View File
@@ -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
+2 -2
View File
@@ -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}]"
+24 -1
View File
@@ -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
+6 -4
View File
@@ -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,
)
+33 -22
View File
@@ -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)