549 lines
24 KiB
Python
549 lines
24 KiB
Python
"""
|
||
Full Sync Pipeline - Implements document-specified end-to-end workflow.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||
from pathlib import Path
|
||
|
||
if TYPE_CHECKING:
|
||
from ..datasource.datasource import BaseDataSource
|
||
|
||
from ..datasource.jsonl import JsonlDataSource
|
||
from ..common.collection import DataCollection
|
||
from ..common.binding import BindingManager
|
||
from ..common.persistence import PersistenceBackend
|
||
from ..sync_system.strategy import BaseSyncStrategy
|
||
from ..sync_system.config import OrphanAction, UpdateDirection
|
||
from ..engine import StateMachineConfig, StateMachineRuntime
|
||
from ..engine import e41_post_create_ready
|
||
from ..sync_system.strategy_ops import finalize_peer_creating_sources
|
||
from ..sync_system.strategy_ops.post_check_ops import evaluate_consistency_for_node_type
|
||
from .summary_report import print_pipeline_summary
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class FullSyncPipeline:
|
||
"""
|
||
Full pipeline phases:
|
||
1) bootstrap
|
||
2) bind
|
||
3) create
|
||
4) update
|
||
5) post-check
|
||
6) persistence
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
local_datasource: "BaseDataSource",
|
||
remote_datasource: "BaseDataSource",
|
||
strategies: List[BaseSyncStrategy],
|
||
persistence: PersistenceBackend,
|
||
local_collection: DataCollection,
|
||
remote_collection: DataCollection,
|
||
binding_manager: BindingManager,
|
||
scope: str = "global",
|
||
pipeline_name: str | None = None,
|
||
bootstrap_binding_node_types: Optional[List[str]] = None,
|
||
ephemeral_node_types: Optional[List[str]] = None,
|
||
node_types: Optional[List[str]] = None,
|
||
load_order: Optional[List[str]] = None,
|
||
sync_order: Optional[List[str]] = None,
|
||
enable_persistence: bool = True,
|
||
):
|
||
# 验证必需参数(由工厂函数创建)
|
||
if strategies is None:
|
||
raise ValueError("strategies is required (use factory function to create pipeline)")
|
||
if local_collection is None or remote_collection is None or binding_manager is None:
|
||
raise ValueError("collections and binding_manager are required (use factory function to create pipeline)")
|
||
|
||
self.local_datasource = local_datasource
|
||
self.remote_datasource = remote_datasource
|
||
self.strategies = strategies
|
||
self.scope = scope
|
||
self.pipeline_name = pipeline_name or f"pipeline[{scope}]"
|
||
self.node_types = node_types or [s.node_type for s in self.strategies]
|
||
self.bootstrap_binding_node_types = list(dict.fromkeys(bootstrap_binding_node_types or []))
|
||
self.ephemeral_node_types = list(dict.fromkeys(ephemeral_node_types or []))
|
||
self.load_order = load_order or self.node_types
|
||
self.sync_order = sync_order or self.node_types
|
||
self.persistence = persistence
|
||
self.enable_persistence = enable_persistence
|
||
|
||
self.local_collection = local_collection
|
||
self.remote_collection = remote_collection
|
||
self.binding_manager = binding_manager
|
||
|
||
# 统一状态机 runtime 注入(唯一入口)
|
||
runtime = self._resolve_pipeline_runtime()
|
||
self.sm_runtime = runtime
|
||
self.local_collection.set_state_machine_runtime(runtime)
|
||
self.remote_collection.set_state_machine_runtime(runtime)
|
||
self.local_datasource.set_state_machine_runtime(runtime)
|
||
self.remote_datasource.set_state_machine_runtime(runtime)
|
||
for strategy in self.strategies:
|
||
strategy.sm_runtime = runtime
|
||
|
||
self.stats: Dict[str, Any] = {
|
||
"loaded": 0,
|
||
"synced": 0,
|
||
"failed": 0,
|
||
"post_check_passed": True,
|
||
}
|
||
self._consistency_by_type: Dict[str, Dict[str, Any]] = {}
|
||
self._logger = logger
|
||
self._closed = False
|
||
self._loaded_node_types: set[str] = set()
|
||
self._preloaded_local_nodes: List[Any] = []
|
||
self._preloaded_remote_nodes: List[Any] = []
|
||
self._preloaded_bindings: Dict[str, Dict[str, Optional[str]]] = {}
|
||
|
||
def _resolve_pipeline_runtime(self) -> StateMachineRuntime:
|
||
if self.strategies:
|
||
runtime = self.strategies[0].sm_runtime
|
||
if runtime is not None:
|
||
return runtime
|
||
|
||
cfg_path = Path(__file__).resolve().parents[1] / "config" / "node_state_machine.yaml"
|
||
return StateMachineRuntime(StateMachineConfig.load(cfg_path))
|
||
|
||
async def run(self) -> Dict[str, Any]:
|
||
try:
|
||
await self.phase_bootstrap()
|
||
self._logger.info("✅ Phase bootstrap completed (%s)", self.pipeline_name)
|
||
|
||
await self.phase_sync_by_node_type()
|
||
self._logger.info("✅ Phase sync-by-node-type completed (%s)", self.pipeline_name)
|
||
|
||
post_ok = await self.phase_post_check()
|
||
self.stats["post_check_passed"] = post_ok
|
||
self._logger.info("✅ Phase post-check completed (%s)", self.pipeline_name)
|
||
|
||
await self.phase_persistence()
|
||
self._logger.info("✅ Phase persistence completed (%s)", self.pipeline_name)
|
||
return self.stats
|
||
finally:
|
||
await self.close()
|
||
|
||
async def phase_sync_by_node_type(self) -> None:
|
||
self._prepare_datasources()
|
||
strategy_map = {s.node_type: s for s in self.strategies}
|
||
for node_type in self.sync_order:
|
||
strategy = strategy_map.get(node_type)
|
||
if strategy is None:
|
||
continue
|
||
try:
|
||
section_width = 96
|
||
self._logger.info("\n" + "-" * section_width)
|
||
self._logger.info(f"🧩 Strategy start: {node_type}")
|
||
self._logger.info("-" * section_width)
|
||
|
||
await self._load_node_type(node_type, reason="pre-strategy refresh")
|
||
|
||
await strategy.bind()
|
||
self._logger.info(f"✅ Strategy bind: {node_type}")
|
||
|
||
if strategy.skip_sync:
|
||
self._logger.info(f"⏭️ Skipping create/update for {node_type} (skip_sync=True)")
|
||
continue
|
||
|
||
created = await strategy.create()
|
||
self._logger.info(f"✅ Strategy create: {node_type} (created={len(created)})")
|
||
create_written = await self.commit_creates(node_type)
|
||
await self.normalize_create_success_to_update_entry(node_type)
|
||
if create_written:
|
||
await self._reload_node_type(node_type, reason="create wrote data")
|
||
|
||
updated = await strategy.update()
|
||
self._logger.info(f"✅ Strategy update: {node_type} (updated={len(updated)})")
|
||
update_written = await self.commit_updates(node_type)
|
||
if update_written:
|
||
await self._reload_node_type(node_type, reason="update wrote data")
|
||
|
||
self._logger.info(f"✅ Strategy done: {node_type}")
|
||
except Exception as exc:
|
||
self.stats["failed"] += 1
|
||
self._logger.error(f"❌ Strategy failed but pipeline continues: {node_type} | {type(exc).__name__}: {exc}")
|
||
continue
|
||
|
||
async def phase_bootstrap(self) -> None:
|
||
await self._load_persistence_state()
|
||
self._logger.info("✅ Bootstrap: persistence loaded")
|
||
|
||
await self._apply_preloaded_state()
|
||
self._logger.info("✅ Bootstrap: shared preload applied")
|
||
|
||
self._prepare_datasources()
|
||
self._logger.info("✅ Bootstrap: datasource prepared")
|
||
|
||
await self._apply_project_scope_cleanup()
|
||
|
||
async def phase_bind(self) -> None:
|
||
self._prepare_datasources()
|
||
strategy_map = {s.node_type: s for s in self.strategies}
|
||
for node_type in self.sync_order:
|
||
strategy = strategy_map.get(node_type)
|
||
if strategy is None:
|
||
continue
|
||
try:
|
||
await strategy.bind()
|
||
except Exception as exc:
|
||
self.stats["failed"] += 1
|
||
self._logger.error(f"❌ Strategy bind failed but pipeline continues: {node_type} | {type(exc).__name__}: {exc}")
|
||
continue
|
||
|
||
async def phase_create(self) -> None:
|
||
self._prepare_datasources()
|
||
strategy_map = {s.node_type: s for s in self.strategies}
|
||
for node_type in self.sync_order:
|
||
strategy = strategy_map.get(node_type)
|
||
if strategy is None:
|
||
continue
|
||
if strategy.skip_sync:
|
||
self._logger.info(f"⏭️ Skipping create for {node_type} (skip_sync=True)")
|
||
continue
|
||
try:
|
||
created = await strategy.create()
|
||
self._logger.info(f"✅ Strategy create: {node_type} (created={len(created)})")
|
||
create_written = await self.commit_creates(node_type)
|
||
await self.normalize_create_success_to_update_entry(node_type)
|
||
if create_written:
|
||
await self._reload_node_type(node_type, reason="create wrote data")
|
||
except Exception as exc:
|
||
self.stats["failed"] += 1
|
||
self._logger.error(f"❌ Strategy create failed but pipeline continues: {node_type} | {type(exc).__name__}: {exc}")
|
||
continue
|
||
|
||
async def phase_update(self) -> None:
|
||
self._prepare_datasources()
|
||
strategy_map = {s.node_type: s for s in self.strategies}
|
||
for node_type in self.sync_order:
|
||
strategy = strategy_map.get(node_type)
|
||
if strategy is None:
|
||
continue
|
||
if strategy.skip_sync:
|
||
self._logger.info(f"⏭️ Skipping update for {node_type} (skip_sync=True)")
|
||
continue
|
||
try:
|
||
updated = await strategy.update()
|
||
self._logger.info(f"✅ Strategy update: {node_type} (updated={len(updated)})")
|
||
update_written = await self.commit_updates(node_type)
|
||
if update_written:
|
||
await self._reload_node_type(node_type, reason="update wrote data")
|
||
await self._evaluate_consistency_for_node_type(node_type)
|
||
except Exception as exc:
|
||
self.stats["failed"] += 1
|
||
self._logger.error(f"❌ Strategy update failed but pipeline continues: {node_type} | {type(exc).__name__}: {exc}")
|
||
continue
|
||
|
||
async def close(self) -> None:
|
||
"""关闭所有资源(DataSource、Persistence)"""
|
||
if self._closed:
|
||
return # 已经关闭过,避免重复关闭
|
||
self._closed = True
|
||
|
||
async def _safe_close(name: str, close_coro, timeout: float = 3.0) -> None:
|
||
try:
|
||
await asyncio.wait_for(close_coro, timeout=timeout)
|
||
except asyncio.TimeoutError:
|
||
self._logger.warning("⚠️ close timeout: %s (>%ss)", name, timeout)
|
||
except Exception as exc:
|
||
self._logger.warning("⚠️ close failed: %s (%s)", name, exc)
|
||
|
||
await _safe_close("remote_datasource", self.remote_datasource.close())
|
||
await _safe_close("local_datasource", self.local_datasource.close())
|
||
await _safe_close("persistence", self.persistence.close())
|
||
|
||
async def _load_persistence_state(self) -> None:
|
||
"""加载持久化数据并清理僵尸节点"""
|
||
if not self.enable_persistence:
|
||
return
|
||
|
||
excluded_node_types = self.ephemeral_node_types or None
|
||
|
||
# 加载持久化数据
|
||
await self.local_collection.load_from_persistence_excluding(excluded_node_types)
|
||
await self.remote_collection.load_from_persistence_excluding(excluded_node_types)
|
||
binding_node_types = [
|
||
node_type
|
||
for node_type in dict.fromkeys([*self.node_types, *self.bootstrap_binding_node_types])
|
||
if node_type not in set(self.ephemeral_node_types)
|
||
]
|
||
for node_type in binding_node_types:
|
||
await self.binding_manager.load_from_persistence(node_type)
|
||
self._logger.info("🔄 Persistence loaded (state preserved; pending E01 normalization)")
|
||
|
||
# 加载后 reset 清理:清理 CREATE 失败的僵尸绑定
|
||
if not self.strategies:
|
||
raise RuntimeError("no strategies configured: cannot run bootstrap event E01")
|
||
total_cleaned = await BaseSyncStrategy.run_reset(
|
||
node_types=self.node_types,
|
||
local_collection=self.local_collection,
|
||
remote_collection=self.remote_collection,
|
||
binding_manager=self.binding_manager,
|
||
runtime=self.sm_runtime,
|
||
)
|
||
if total_cleaned > 0:
|
||
self._logger.info(f"🧹 Reset cleanup: {total_cleaned} CREATE-failed zombies cleaned")
|
||
self._logger.info("🔄 Post-load reset cleanup completed")
|
||
|
||
async def _apply_preloaded_state(self) -> None:
|
||
if self._preloaded_local_nodes:
|
||
await self.local_collection.import_nodes(self._preloaded_local_nodes)
|
||
if self._preloaded_remote_nodes:
|
||
await self.remote_collection.import_nodes(self._preloaded_remote_nodes)
|
||
for node_type, bindings in self._preloaded_bindings.items():
|
||
self.binding_manager.import_bindings(node_type, bindings)
|
||
|
||
def preload_shared_state(
|
||
self,
|
||
*,
|
||
local_nodes: List[Any],
|
||
remote_nodes: List[Any],
|
||
bindings_by_type: Dict[str, Dict[str, Optional[str]]],
|
||
) -> None:
|
||
self._preloaded_local_nodes = list(local_nodes)
|
||
self._preloaded_remote_nodes = list(remote_nodes)
|
||
self._preloaded_bindings = {
|
||
node_type: dict(bindings)
|
||
for node_type, bindings in bindings_by_type.items()
|
||
}
|
||
|
||
def _prepare_datasources(self) -> None:
|
||
"""为后续按 node_type 加载准备 collection 绑定。"""
|
||
self.local_datasource.set_collection(self.local_collection)
|
||
self.remote_datasource.set_collection(self.remote_collection)
|
||
|
||
def _get_project_scope_filter_ids(self, datasource: "BaseDataSource") -> List[str]:
|
||
try:
|
||
handler = datasource.get_handler("project")
|
||
get_data_id_filter = getattr(handler, "get_data_id_filter", None)
|
||
if callable(get_data_id_filter):
|
||
raw_filter = get_data_id_filter()
|
||
if isinstance(raw_filter, list):
|
||
return list(dict.fromkeys(raw_filter))
|
||
except Exception:
|
||
pass
|
||
return []
|
||
|
||
async def _apply_project_scope_cleanup(self) -> None:
|
||
local_project_ids = self._get_project_scope_filter_ids(self.local_datasource)
|
||
remote_project_ids = self._get_project_scope_filter_ids(self.remote_datasource)
|
||
if not local_project_ids and not remote_project_ids:
|
||
return
|
||
|
||
deleted_local = await self.local_collection.filter_by_project_ids(local_project_ids)
|
||
deleted_remote = await self.remote_collection.filter_by_project_ids(remote_project_ids)
|
||
deleted_bindings = await self.binding_manager.prune_missing_nodes(
|
||
local_collection=self.local_collection,
|
||
remote_collection=self.remote_collection,
|
||
node_types=list(dict.fromkeys([*self.node_types, *self.bootstrap_binding_node_types])),
|
||
)
|
||
|
||
if deleted_local or deleted_remote or deleted_bindings:
|
||
self._logger.info(
|
||
"🧹 Project-scope cleanup: local_deleted=%d remote_deleted=%d bindings_deleted=%d",
|
||
deleted_local,
|
||
deleted_remote,
|
||
deleted_bindings,
|
||
)
|
||
|
||
async def _load_node_type(self, node_type: str, *, reason: str) -> None:
|
||
self._logger.info(f"🔄 Load node_type={node_type}, reason={reason}")
|
||
await self.local_datasource.load_all(order=[node_type])
|
||
await self.remote_datasource.load_all(order=[node_type])
|
||
|
||
if node_type in self._loaded_node_types:
|
||
return
|
||
|
||
self._loaded_node_types.add(node_type)
|
||
self.stats["loaded"] += len(self.local_collection.filter(node_type=node_type))
|
||
self.stats["loaded"] += len(self.remote_collection.filter(node_type=node_type))
|
||
|
||
def _get_action_success_count(self, datasource: "BaseDataSource", node_type: str, action_key: str) -> int:
|
||
summary = datasource.get_action_summary().get(node_type, {})
|
||
action_summary = summary.get(action_key, {}) if isinstance(summary, dict) else {}
|
||
return int(action_summary.get("success", 0)) if isinstance(action_summary, dict) else 0
|
||
|
||
def _should_skip_reload(self) -> bool:
|
||
return isinstance(self.local_datasource, JsonlDataSource) and isinstance(self.remote_datasource, JsonlDataSource)
|
||
|
||
async def _reload_node_type(self, node_type: str, *, reason: str) -> None:
|
||
if self._should_skip_reload():
|
||
self._logger.info("⏭️ Reload skipped for JSONL pipeline: node_type=%s reason=%s", node_type, reason)
|
||
return
|
||
await self._load_node_type(node_type, reason=reason)
|
||
|
||
async def _evaluate_consistency_for_node_type(self, node_type: str) -> Dict[str, Any]:
|
||
strategy = next((item for item in self.strategies if item.node_type == node_type), None)
|
||
depend_fields = dict(strategy.config.depend_fields) if strategy and strategy.config.depend_fields else {}
|
||
compare_to_remote = True
|
||
if strategy and strategy.config.update_direction == UpdateDirection.PULL:
|
||
compare_to_remote = False
|
||
ignore_fields = list(strategy.config.post_check_ignore_fields) if strategy else []
|
||
|
||
result = await evaluate_consistency_for_node_type(
|
||
node_type=node_type,
|
||
strategy=strategy,
|
||
local_collection=self.local_collection,
|
||
remote_collection=self.remote_collection,
|
||
binding_manager=self.binding_manager,
|
||
logger=self._logger,
|
||
depend_fields=depend_fields,
|
||
compare_to_remote=compare_to_remote,
|
||
ignore_fields=ignore_fields,
|
||
)
|
||
self._consistency_by_type[node_type] = result
|
||
return result
|
||
|
||
async def commit_creates(self, node_type: str) -> bool:
|
||
"""提交 CREATE 操作(包含异步轮询)"""
|
||
# 执行同步(sync_all 内部会根据 action 筛选节点)
|
||
await self.local_datasource.sync_all(
|
||
order=[node_type],
|
||
poll_async_tasks=True
|
||
)
|
||
|
||
await self.remote_datasource.sync_all(
|
||
order=[node_type],
|
||
poll_async_tasks=True
|
||
)
|
||
|
||
await self._finalize_peer_creating_sources(node_type)
|
||
|
||
# 更新统计
|
||
local_nodes = self.local_collection.filter(node_type=node_type)
|
||
remote_nodes = self.remote_collection.filter(node_type=node_type)
|
||
self.stats["synced"] += len([n for n in local_nodes if n.action.value == "CREATE"])
|
||
self.stats["synced"] += len([n for n in remote_nodes if n.action.value == "CREATE"])
|
||
self.stats["failed"] += len([n for n in local_nodes if n.status.value == "FAILED"])
|
||
self.stats["failed"] += len([n for n in remote_nodes if n.status.value == "FAILED"])
|
||
|
||
local_success = self._get_action_success_count(self.local_datasource, node_type, "create")
|
||
remote_success = self._get_action_success_count(self.remote_datasource, node_type, "create")
|
||
wrote = (local_success + remote_success) > 0
|
||
return wrote
|
||
|
||
async def _finalize_peer_creating_sources(self, node_type: str) -> None:
|
||
finalized_success, finalized_failed = await finalize_peer_creating_sources(
|
||
node_type=node_type,
|
||
local_collection=self.local_collection,
|
||
remote_collection=self.remote_collection,
|
||
binding_manager=self.binding_manager,
|
||
runtime=self.sm_runtime,
|
||
)
|
||
|
||
if finalized_success or finalized_failed:
|
||
self._logger.info(
|
||
f"🔁 Peer-creating finalize: node_type={node_type}, success={finalized_success}, failed={finalized_failed}"
|
||
)
|
||
|
||
async def normalize_create_success_to_update_entry(self, node_type: str) -> None:
|
||
"""将 CREATE 成功节点从 S11C 归并到 S01,作为 update 起点。"""
|
||
normalized = 0
|
||
for collection in (self.local_collection, self.remote_collection):
|
||
candidates = collection.filter_by_state_ids(
|
||
node_type=node_type,
|
||
state_ids=["S11C"],
|
||
)
|
||
for node in candidates:
|
||
if node.action.value != "CREATE":
|
||
continue
|
||
decision = e41_post_create_ready(self.sm_runtime, node=node, execute_action="CREATE")
|
||
if decision.to_state != "S01":
|
||
raise RuntimeError(
|
||
f"[{node_type}] E41 expected S01, got {decision.to_state} for node={node.node_id}"
|
||
)
|
||
normalized += 1
|
||
|
||
if normalized > 0:
|
||
self._logger.info(f"🔁 Post-create normalization: {normalized} nodes moved S11C->S01 for {node_type}")
|
||
|
||
async def commit_updates(self, node_type: str) -> bool:
|
||
"""提交 UPDATE 操作(包含异步轮询)"""
|
||
# 执行同步(sync_all 内部会根据 action 筛选节点)
|
||
await self.local_datasource.sync_all(
|
||
order=[node_type],
|
||
poll_async_tasks=True
|
||
)
|
||
|
||
await self.remote_datasource.sync_all(
|
||
order=[node_type],
|
||
poll_async_tasks=True
|
||
)
|
||
|
||
# 更新统计
|
||
local_nodes = self.local_collection.filter(node_type=node_type)
|
||
remote_nodes = self.remote_collection.filter(node_type=node_type)
|
||
self.stats["synced"] += len([n for n in local_nodes if n.action.value == "UPDATE"])
|
||
self.stats["synced"] += len([n for n in remote_nodes if n.action.value == "UPDATE"])
|
||
self.stats["failed"] += len([n for n in local_nodes if n.status.value == "FAILED"])
|
||
self.stats["failed"] += len([n for n in remote_nodes if n.status.value == "FAILED"])
|
||
|
||
local_success = self._get_action_success_count(self.local_datasource, node_type, "update")
|
||
remote_success = self._get_action_success_count(self.remote_datasource, node_type, "update")
|
||
wrote = (local_success + remote_success) > 0
|
||
return wrote
|
||
|
||
async def phase_post_check(self) -> bool:
|
||
"""同步结果报告。
|
||
|
||
所有类型均已同步完毕后,统一做一次全量 reload(触发后端派生字段计算),
|
||
再对所有类型做一致性评估,确保 post-check 结果反映最终状态。
|
||
"""
|
||
if self._should_skip_reload():
|
||
self._logger.info("⏭️ Post-check reload skipped for JSONL pipeline")
|
||
else:
|
||
self._logger.info("🔄 Post-check: reloading all node types for final consistency evaluation...")
|
||
for node_type in self.node_types:
|
||
await self._reload_node_type(node_type, reason="post-check final reload")
|
||
for node_type in self.node_types:
|
||
await self._evaluate_consistency_for_node_type(node_type)
|
||
|
||
self.stats["consistency"] = {
|
||
node_type: {
|
||
"ok": item.get("ok", True),
|
||
"checked_pairs": item.get("checked_pairs", 0),
|
||
"mismatch_count": item.get("mismatch_count", 0),
|
||
}
|
||
for node_type, item in self._consistency_by_type.items()
|
||
}
|
||
await self.print_summary()
|
||
return True # 总是返回 True,不阻断流程
|
||
|
||
async def phase_persistence(self) -> None:
|
||
"""持久化所有数据。"""
|
||
if not self.enable_persistence:
|
||
self._logger.info("⏭️ Persistence disabled, skipping...")
|
||
return
|
||
|
||
self._logger.info("🔍 Persisting local collection...")
|
||
await self.local_collection.persist(exclude_node_types=self.ephemeral_node_types)
|
||
self._logger.info("🔍 Persisting remote collection...")
|
||
await self.remote_collection.persist(exclude_node_types=self.ephemeral_node_types)
|
||
self._logger.info("🔍 Persisting binding manager...")
|
||
await self.binding_manager.persist(exclude_node_types=self.ephemeral_node_types)
|
||
self._logger.info("✅ All data persisted successfully")
|
||
|
||
# ========== Summary & Reporting ==========
|
||
|
||
async def print_summary(self) -> None:
|
||
"""打印同步结果摘要"""
|
||
await print_pipeline_summary(
|
||
logger=self._logger,
|
||
node_types=self.node_types,
|
||
binding_manager=self.binding_manager,
|
||
local_datasource=self.local_datasource,
|
||
remote_datasource=self.remote_datasource,
|
||
local_collection=self.local_collection,
|
||
remote_collection=self.remote_collection,
|
||
consistency_by_type=self._consistency_by_type,
|
||
stats=self.stats,
|
||
)
|