first commit

This commit is contained in:
strepsiades
2026-03-09 16:31:42 +08:00
commit 4a9c444b10
486 changed files with 52479 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
"""
Pipeline Layer - Pipeline orchestration and workflow management
"""
from .copy_data_pipeline import CopyDataPipeline
from .full_sync_pipeline import FullSyncPipeline
from ..config import (
PipelineRunConfig,
ApiDataSourceConfig,
JsonlDataSourceConfig,
DataSourceConfig,
PersistConfig,
LoggingConfig,
build_default_config,
build_config,
apply_config_overrides,
apply_env_overrides,
config_snapshot,
)
from .factory import (
create_jsonl_to_api_pipeline,
create_api_to_jsonl_pipeline,
create_jsonl_to_jsonl_pipeline,
create_pipeline_from_config,
run_pipeline_from_config,
)
__all__ = [
"CopyDataPipeline",
"FullSyncPipeline",
"create_jsonl_to_api_pipeline",
"create_jsonl_to_jsonl_pipeline",
"create_api_to_jsonl_pipeline",
"create_pipeline_from_config",
"run_pipeline_from_config",
"PipelineRunConfig",
"ApiDataSourceConfig",
"JsonlDataSourceConfig",
"DataSourceConfig",
"PersistConfig",
"LoggingConfig",
"build_default_config",
"build_config",
"apply_config_overrides",
"apply_env_overrides",
"config_snapshot",
]
@@ -0,0 +1,247 @@
"""
Copy Data Pipeline - Simple data copying without strategy execution
"""
from pathlib import Path
from typing import List, Any, Optional
from ..common.collection import DataCollection
from ..common.types import SyncAction, SyncStatus
from ..common.registry import DomainRegistry
from ..datasource import JsonlDataSource
from ..engine import StateMachineConfig, StateMachineRuntime, apply_state_to_node
class CopyDataPipeline:
"""
通用数据复制 Pipeline,支持任意数据源之间的复制。
流程:
1. 从 source_datasource 加载所有数据到 source_collection
2. 将每个节点复制到 target_collection,设置 action=CREATE
3. 使用 target_datasource 保存所有节点
用途:
- 数据迁移 (JSONL → JSONL)
- API 数据下载 (API → JSONL)
- 测试数据准备
- 初始化同步
支持两种初始化方式:
1. 传入数据源实例 (推荐): source_datasource + target_datasource
2. 传入目录路径 (兼容): source_dir + target_dir (自动创建 JsonlDataSource)
"""
def __init__(
self,
source_datasource: Any = None,
target_datasource: Any = None,
source_dir: Optional[Path | str] = None,
target_dir: Optional[Path | str] = None,
node_types: List[str] | None = None,
load_order: List[str] | None = None,
save_order: List[str] | None = None,
):
"""
初始化 CopyDataPipeline
Args:
source_datasource: 源数据源实例 (ApiDataSource, JsonlDataSource 等)
target_datasource: 目标数据源实例
source_dir: 源数据目录 (兼容模式,会创建 JsonlDataSource)
target_dir: 目标数据目录 (兼容模式,会创建 JsonlDataSource)
node_types: 要复制的节点类型列表,None 表示复制所有注册的类型
load_order: 数据加载顺序,None 表示使用 node_types 顺序
save_order: 数据保存顺序,None 表示使用 node_types 顺序
"""
self._supported_types: Optional[List[str]] = None
# 数据源处理 - 优先使用实例,其次使用目录
if source_datasource is not None:
self.source_datasource = source_datasource
self._owns_source = False
elif source_dir is not None:
from ..domain.registry import JSONL_HANDLERS
self.source_datasource = JsonlDataSource(dir_path=Path(source_dir), read_only=True)
# 自动注册所有 JSONL handlers,并收集支持的类型
supported_types = []
for handler_class in JSONL_HANDLERS:
handler = handler_class(self.source_datasource)
self.source_datasource.register_handler(handler)
supported_types.append(handler.node_type)
self._owns_source = True
self._supported_types = supported_types
else:
raise ValueError("Must provide either source_datasource or source_dir")
if target_datasource is not None:
self.target_datasource = target_datasource
self._owns_target = False
elif target_dir is not None:
from ..domain.registry import JSONL_HANDLERS
self.target_datasource = JsonlDataSource(dir_path=Path(target_dir), read_only=False)
# 自动注册所有 JSONL handlers
for handler_class in JSONL_HANDLERS:
self.target_datasource.register_handler(handler_class(self.target_datasource))
self._owns_target = True
else:
raise ValueError("Must provide either target_datasource or target_dir")
# 当使用 source_dir 创建时,如果 node_types 未指定,只使用支持的类型
if node_types is None and self._supported_types is not None:
self.node_types = self._supported_types
else:
self.node_types = node_types or DomainRegistry.get_registered_types()
self.load_order = load_order or self.node_types
self.save_order = save_order or self.node_types
cfg_path = Path(__file__).resolve().parents[1] / "config" / "node_state_machine.yaml"
self._sm_runtime = StateMachineRuntime(StateMachineConfig.load(cfg_path))
# 统计信息
self.stats = {
"loaded": 0,
"copied": 0,
"saved": 0,
"failed": 0,
}
async def run(self):
"""
执行完整的复制流程
Returns:
dict: 统计信息 {"loaded": int, "copied": int, "saved": int, "failed": int}
"""
print(f"[CopyDataPipeline] Starting...")
print(f" Source: {self.source_datasource.__class__.__name__}")
print(f" Target: {self.target_datasource.__class__.__name__}")
print(f" Node types: {self.node_types}")
# Stage 1: 加载源数据
print("\n[Stage 1] Loading source data...")
source_collection = DataCollection(collection_id="source")
await self._load_source_data(source_collection)
# Stage 2: 复制节点
print("\n[Stage 2] Copying nodes...")
target_collection = DataCollection(collection_id="target")
await self._copy_nodes(source_collection, target_collection)
# Stage 3: 保存到目标
print("\n[Stage 3] Saving to target...")
await self._save_target_data(target_collection)
# 打印统计信息
self._print_summary()
return self.stats
async def _load_source_data(self, collection: DataCollection):
"""从源数据源加载所有节点"""
# 如果是 JsonlDataSource 且没有注册 handlers,自动注册
if isinstance(self.source_datasource, JsonlDataSource) and not self.source_datasource._handlers:
self._auto_register_jsonl_handlers(self.source_datasource)
self.source_datasource.set_collection(collection)
await self.source_datasource.load_all(order=self.load_order)
# 统计加载的节点数
for node_type in self.node_types:
count = len(collection.filter(node_type=node_type))
if count > 0:
print(f" [LOAD] {node_type}: {count} nodes")
self.stats["loaded"] += count
async def _copy_nodes(
self,
source_collection: DataCollection,
target_collection: DataCollection
):
"""
复制所有节点到目标 collection,设置 action=CREATE, status=PENDING
注意:
- 保持相同的 node_id(用于追踪)
- data_id 设为空字符串(待创建)
- 复制 data 和 depend_ids
"""
for node in source_collection.filter(): # filter() 不带参数返回所有节点
# 获取节点类
node_class = DomainRegistry.get_node_class(node.node_type)
# 创建新节点
new_node = node_class(
node_id=node.node_id, # 保持相同的 node_id
data_id="", # CREATE 节点的 data_id 初始为空
data=node.data, # 复制数据
depend_ids=node.depend_ids.copy() if node.depend_ids else [],
)
apply_state_to_node(
self._sm_runtime,
new_node,
state_id="S06",
reason="copy pipeline init",
fields={"binding_status", "action", "status"},
)
apply_state_to_node(
self._sm_runtime,
new_node,
state_id="S06",
reason="copy pipeline init",
fields={"data_id"},
data_id="",
)
await target_collection.add(new_node)
self.stats["copied"] += 1
print(f" Copied {self.stats['copied']} nodes")
async def _save_target_data(self, collection: DataCollection):
"""保存所有节点到目标数据源"""
# 如果是 JsonlDataSource 且没有注册 handlers,自动注册
if isinstance(self.target_datasource, JsonlDataSource) and not self.target_datasource._handlers:
self._auto_register_jsonl_handlers(self.target_datasource)
self.target_datasource.set_collection(collection)
await self.target_datasource.sync_all(order=self.save_order)
# 统计保存结果
for node_type in self.node_types:
nodes = collection.filter(node_type=node_type)
success_count = sum(1 for n in nodes if n.status == SyncStatus.SUCCESS)
failed_count = sum(1 for n in nodes if n.status == SyncStatus.FAILED)
if success_count > 0 or failed_count > 0:
print(f" [SAVE] {node_type}: {success_count} success, {failed_count} failed")
self.stats["saved"] += success_count
self.stats["failed"] += failed_count
def _auto_register_jsonl_handlers(self, datasource: JsonlDataSource):
"""自动为 JsonlDataSource 注册需要的 handlers(兼容模式)"""
for node_type in self.node_types:
if not DomainRegistry.is_registered(node_type):
continue
handler_class = DomainRegistry.get_jsonl_handler(node_type)
if handler_class:
handler = handler_class(datasource)
datasource.register_handler(handler)
def _print_summary(self):
"""打印执行摘要"""
print("\n" + "=" * 60)
print("[CopyDataPipeline] Execution Summary")
print("=" * 60)
print(f" Loaded: {self.stats['loaded']} nodes")
print(f" Copied: {self.stats['copied']} nodes")
print(f" Saved: {self.stats['saved']} nodes")
print(f" Failed: {self.stats['failed']} nodes")
print("=" * 60)
if self.stats["failed"] > 0:
print(f"\n⚠️ {self.stats['failed']} nodes failed to save")
else:
print("\n✅ All nodes saved successfully")
+578
View File
@@ -0,0 +1,578 @@
"""
Pipeline Factory - 工厂函数,负责初始化所有组件并创建 Pipeline
用户只需提供配置参数,工厂函数负责:
1. 初始化 Persistence
2. 初始化 DataSource(并注册 Handler
3. 初始化 Collection 和 BindingManager
4. 初始化 Strategy(并注入依赖)
5. 创建 Pipeline(注入所有组件)
6. 返回完全就绪的 Pipeline
"""
from __future__ import annotations
import asyncio
import json
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..common.binding import BindingManager
from ..common.collection import DataCollection
from ..common.persistence import PersistenceBackend
from ..common.registry import DomainRegistry
from ..datasource.api.datasource import ApiDataSource
from ..datasource.jsonl.datasource import JsonlDataSource
from ..config import (
PipelineRunConfig,
DataSourceConfig,
ApiDataSourceConfig,
JsonlDataSourceConfig,
PersistConfig,
LoggingConfig,
StrategyRuntimeConfig,
apply_config_overrides,
OrphanAction,
UpdateDirection,
resolve_log_file_path,
config_snapshot,
)
from ..sync_system.strategy import BaseSyncStrategy
from .run_logger import init_pipeline_logger
from .full_sync_pipeline import FullSyncPipeline
logger = logging.getLogger(__name__)
def _project_root() -> Path:
return Path(__file__).resolve().parents[2]
def _prepare_remote_jsonl_dir(remote_dir: Path, *, project_root: Optional[Path] = None) -> Path:
project_root_path = (project_root or _project_root()).resolve()
resolved_remote_dir = remote_dir.resolve(strict=False)
if resolved_remote_dir.exists():
if not resolved_remote_dir.is_dir():
raise FileNotFoundError(
f"remote_datasource.jsonl_dir exists but is not a directory: {resolved_remote_dir}"
)
return resolved_remote_dir
try:
resolved_remote_dir.relative_to(project_root_path)
except ValueError as exc:
raise FileNotFoundError(
"remote_datasource.jsonl_dir does not exist and auto-create is only allowed "
f"under project root: {resolved_remote_dir} (project_root={project_root_path})"
) from exc
resolved_remote_dir.mkdir(parents=True, exist_ok=True)
logger.info("Created missing remote JSONL directory: %s", resolved_remote_dir)
return resolved_remote_dir
def _ensure_pipeline_logger(
*,
initialize_logger: bool,
logger_file_path: Optional[str],
logger_level: int,
suppress_node_logs: bool,
) -> None:
if not initialize_logger:
return
app_logger = logging.getLogger("sync_state_machine")
if app_logger.handlers:
return
init_pipeline_logger(
log_file_path=logger_file_path,
level=logger_level,
mirror_console=True,
replace_handlers=False,
suppress_node_logs=suppress_node_logs,
)
def _api_handler_kwargs(
node_type: str,
ds_config: DataSourceConfig,
target_project_ids: List[str],
) -> Dict[str, Any]:
kwargs = dict(ds_config.handler_configs.get(node_type, {}))
if node_type == "project" and target_project_ids:
kwargs.setdefault("filter_project_id", target_project_ids)
return kwargs
def _resolve_datasource_target_project_ids(config: PipelineRunConfig) -> tuple[List[str], List[str]]:
fallback = [str(pid) for pid in config.target_project_ids if str(pid)]
local_cfg_ids = [str(pid) for pid in config.local_datasource.target_project_ids if str(pid)]
remote_cfg_ids = [str(pid) for pid in config.remote_datasource.target_project_ids if str(pid)]
local_target_ids = local_cfg_ids if local_cfg_ids else fallback
remote_target_ids = remote_cfg_ids if remote_cfg_ids else fallback
return local_target_ids, remote_target_ids
async def _create_datasources(config: PipelineRunConfig):
local_cfg = config.local_datasource
remote_cfg = config.remote_datasource
if isinstance(local_cfg, JsonlDataSourceConfig):
local_dir = Path(local_cfg.jsonl_dir)
if not local_dir.exists() or not local_dir.is_dir():
raise FileNotFoundError(
f"local_datasource.jsonl_dir does not exist or is not a directory: {local_dir}"
)
local_ds = JsonlDataSource(local_dir, read_only=local_cfg.read_only)
else:
local_ds = ApiDataSource(
base_url=local_cfg.api_base_url,
uid=local_cfg.api_uid,
secret=local_cfg.api_secret,
debug=local_cfg.api_debug,
poll_max_retries=local_cfg.poll_max_retries,
poll_interval=local_cfg.poll_interval,
rate_limit_max_requests=local_cfg.api_rate_limit_max_requests,
rate_limit_window_seconds=local_cfg.api_rate_limit_window_seconds,
)
await local_ds.initialize()
if isinstance(remote_cfg, JsonlDataSourceConfig):
remote_dir = Path(remote_cfg.jsonl_dir)
remote_dir = _prepare_remote_jsonl_dir(remote_dir)
remote_ds = JsonlDataSource(remote_dir, read_only=remote_cfg.read_only)
else:
remote_ds = ApiDataSource(
base_url=remote_cfg.api_base_url,
uid=remote_cfg.api_uid,
secret=remote_cfg.api_secret,
debug=remote_cfg.api_debug,
poll_max_retries=remote_cfg.poll_max_retries,
poll_interval=remote_cfg.poll_interval,
rate_limit_max_requests=remote_cfg.api_rate_limit_max_requests,
rate_limit_window_seconds=remote_cfg.api_rate_limit_window_seconds,
)
await remote_ds.initialize()
return local_ds, remote_ds
def _register_handlers(
config: PipelineRunConfig,
local_ds,
remote_ds,
all_types: List[str],
) -> None:
for node_type in all_types:
if isinstance(config.local_datasource, JsonlDataSourceConfig):
jsonl_handler_class = DomainRegistry.get_jsonl_handler(node_type)
if jsonl_handler_class is None:
raise RuntimeError(f"Missing JSONL handler for node_type: {node_type}")
local_ds.register_handler(jsonl_handler_class(local_ds))
else:
api_handler_class = DomainRegistry.get_api_handler(node_type)
if api_handler_class is None:
raise RuntimeError(f"Missing API handler for node_type: {node_type}")
local_ds.register_handler(
api_handler_class(
**_api_handler_kwargs(
node_type,
config.local_datasource,
config.target_project_ids,
)
)
)
if isinstance(config.remote_datasource, JsonlDataSourceConfig):
jsonl_handler_class = DomainRegistry.get_jsonl_handler(node_type)
if jsonl_handler_class is None:
raise RuntimeError(f"Missing JSONL handler for node_type: {node_type}")
remote_ds.register_handler(jsonl_handler_class(remote_ds))
else:
api_handler_class = DomainRegistry.get_api_handler(node_type)
if api_handler_class is None:
raise RuntimeError(f"Missing API handler for node_type: {node_type}")
remote_ds.register_handler(
api_handler_class(
**_api_handler_kwargs(
node_type,
config.remote_datasource,
config.target_project_ids,
)
)
)
def _resolve_strategy_map(config: PipelineRunConfig, node_types: List[str]) -> Dict[str, StrategyRuntimeConfig]:
if config.strategies:
return {item.node_type: item for item in config.strategies}
resolved: Dict[str, StrategyRuntimeConfig] = {}
for node_type in node_types:
strategy_class = DomainRegistry.get_strategy_class(node_type)
if strategy_class is None:
continue
resolved[node_type] = StrategyRuntimeConfig(
node_type=node_type,
config=strategy_class.default_config.model_copy(deep=True),
)
return resolved
def _build_strategies(
config: PipelineRunConfig,
node_types: List[str],
local_collection: DataCollection,
remote_collection: DataCollection,
binding_manager: BindingManager,
strategy_map: Dict[str, StrategyRuntimeConfig],
) -> List[BaseSyncStrategy[Any]]:
strategies: List[BaseSyncStrategy[Any]] = []
both_jsonl = isinstance(config.local_datasource, JsonlDataSourceConfig) and isinstance(config.remote_datasource, JsonlDataSourceConfig)
for node_type in node_types:
strategy_class = DomainRegistry.get_strategy_class(node_type)
if strategy_class is None:
raise RuntimeError(f"Missing strategy for node_type: {node_type}")
strategy = strategy_class(node_type, local_collection, remote_collection, binding_manager)
if both_jsonl:
strategy.update_config(
local_orphan_action=OrphanAction.CREATE_REMOTE,
remote_orphan_action=OrphanAction.NONE,
update_direction=UpdateDirection.PUSH,
)
if node_type == "contract":
strategy.update_config(depend_fields={"project_id": "project"})
runtime_cfg = strategy_map.get(node_type)
if runtime_cfg is not None:
strategy.set_config(runtime_cfg.config.model_copy(deep=True))
strategies.append(strategy)
return strategies
async def create_pipeline_from_config(config: PipelineRunConfig) -> FullSyncPipeline:
if config.logging.initialize and not config.logging.file_path:
config.logging.file_path = resolve_log_file_path(config.logging)
_ensure_pipeline_logger(
initialize_logger=config.logging.initialize,
logger_file_path=config.logging.file_path,
logger_level=config.logging.level,
suppress_node_logs=config.logging.suppress_node_logs,
)
all_types = list(config.node_types)
strategy_map = _resolve_strategy_map(config, all_types)
local_target_project_ids, remote_target_project_ids = _resolve_datasource_target_project_ids(config)
if config.persist.backend != "sqlite":
raise NotImplementedError(f"Persistence backend not supported yet: {config.persist.backend}")
db_path = Path(config.persist.db_path)
if config.persist.wipe_on_start and db_path.exists():
db_path.unlink()
persistence: Optional[PersistenceBackend] = None
local_ds = None
remote_ds = None
try:
persistence = PersistenceBackend(str(config.persist.db_path), backend=config.persist.backend)
await persistence.initialize()
local_ds, remote_ds = await _create_datasources(config)
local_cfg_with_target = config.local_datasource.model_copy(
update={"target_project_ids": list(local_target_project_ids)},
deep=True,
)
remote_cfg_with_target = config.remote_datasource.model_copy(
update={"target_project_ids": list(remote_target_project_ids)},
deep=True,
)
handler_target_config = config.model_copy(
update={
"local_datasource": local_cfg_with_target,
"remote_datasource": remote_cfg_with_target,
},
deep=True,
)
_register_handlers(handler_target_config, local_ds, remote_ds, all_types)
local_ds.set_target_project_ids(local_target_project_ids)
remote_ds.set_target_project_ids(remote_target_project_ids)
local_collection = DataCollection("local", persistence, auto_persist=False)
remote_collection = DataCollection("remote", persistence, auto_persist=False)
binding_manager = BindingManager(persistence, auto_persist=False)
strategies = _build_strategies(
config,
all_types,
local_collection,
remote_collection,
binding_manager,
strategy_map,
)
pipeline = FullSyncPipeline(
local_datasource=local_ds,
remote_datasource=remote_ds,
strategies=strategies,
node_types=all_types,
persistence=persistence,
local_collection=local_collection,
remote_collection=remote_collection,
binding_manager=binding_manager,
enable_persistence=config.persist.enable,
target_project_ids=config.target_project_ids,
local_target_project_ids=local_target_project_ids,
remote_target_project_ids=remote_target_project_ids,
)
return pipeline
except Exception:
async def _safe_close(name: str, resource: ApiDataSource | JsonlDataSource | PersistenceBackend | None) -> None:
if resource is None:
return
try:
await asyncio.wait_for(resource.close(), timeout=3.0)
except Exception as close_exc:
logger.warning("Failed to close %s during create cleanup: %s", name, close_exc)
await _safe_close("remote_datasource", remote_ds)
await _safe_close("local_datasource", local_ds)
await _safe_close("persistence", persistence)
raise
async def run_pipeline_from_config(
config: PipelineRunConfig,
*,
title: str = "sync_state_machine pipeline",
print_summary: bool = True,
) -> Dict[str, Any]:
if config.logging.initialize and not config.logging.file_path:
config.logging.file_path = resolve_log_file_path(config.logging)
_ensure_pipeline_logger(
initialize_logger=config.logging.initialize,
logger_file_path=config.logging.file_path,
logger_level=config.logging.level,
suppress_node_logs=config.logging.suppress_node_logs,
)
resolved_cfg = config_snapshot(config)
resolved_cfg_text = json.dumps(resolved_cfg, ensure_ascii=False, indent=2)
logger.info(
"Resolved Config Summary: node_types=%d local=%s remote=%s",
len(config.node_types),
config.local_datasource.type,
config.remote_datasource.type,
)
if config.logging.file_path:
try:
with open(config.logging.file_path, "a", encoding="utf-8") as fp:
fp.write("Resolved Config:\n")
fp.write(resolved_cfg_text)
fp.write("\n")
except Exception as exc:
logger.warning("Failed to write resolved config into log file: %s", exc)
pipeline: Optional[FullSyncPipeline] = None
try:
if print_summary:
print("\n" + "=" * 80)
print(f"🚀 Creating {title}")
print(f"📝 Log file: {config.logging.file_path or '-'}")
print(
f"🔧 Resolved Config Summary: node_types={len(config.node_types)} "
f"local={config.local_datasource.type} "
f"remote={config.remote_datasource.type}"
)
print(f"🔧 Resolved Config:\n{resolved_cfg_text}")
print("=" * 80)
pipeline = await create_pipeline_from_config(config)
stats = await pipeline.run()
if print_summary:
print(f"\n✅ Pipeline completed: stats={stats}")
print(f"🗃️ Persistence DB: {config.persist.db_path}")
return stats
finally:
if pipeline is not None:
await pipeline.close()
async def create_jsonl_to_api_pipeline(
*,
# 路径配置
local_jsonl_dir: str,
persistence_db: str,
# API 配置
api_base_url: str,
api_uid: str,
api_secret: str,
# 节点类型
node_types: List[str],
# 项目过滤
target_project_ids: Optional[List[str]] = None,
strategy_overrides: Optional[Dict[str, Dict[str, Any]]] = None,
# Pipeline 行为
enable_persistence: bool = True,
wipe_persistence_on_start: bool = False,
# API 配置
api_debug: bool = False,
poll_max_retries: int = 10,
poll_interval: float = 0.5,
api_rate_limit_max_requests: int = 30,
api_rate_limit_window_seconds: float = 10.0,
# Handler 自定义配置(可选,作用于 API 端)
handler_configs: Optional[Dict[str, Dict[str, Any]]] = None,
# Logger 配置
initialize_logger: bool = True,
logger_file_path: Optional[str] = None,
logger_level: int = logging.INFO,
) -> FullSyncPipeline:
cfg = PipelineRunConfig(
node_types=node_types,
target_project_ids=target_project_ids or [],
local_datasource=JsonlDataSourceConfig(type="jsonl", jsonl_dir=local_jsonl_dir, read_only=False),
remote_datasource=ApiDataSourceConfig(
type="api",
api_base_url=api_base_url,
api_uid=api_uid,
api_secret=api_secret,
api_debug=api_debug,
poll_max_retries=poll_max_retries,
poll_interval=poll_interval,
api_rate_limit_max_requests=api_rate_limit_max_requests,
api_rate_limit_window_seconds=api_rate_limit_window_seconds,
target_project_ids=target_project_ids or [],
handler_configs=handler_configs or {},
),
strategies=[],
persist=PersistConfig(
db_path=persistence_db,
enable=enable_persistence,
wipe_on_start=wipe_persistence_on_start,
),
logging=LoggingConfig(
initialize=initialize_logger,
file_path=logger_file_path,
level=logger_level,
),
)
if strategy_overrides:
cfg = apply_config_overrides(cfg, {"strategies": strategy_overrides})
return await create_pipeline_from_config(cfg)
async def create_api_to_jsonl_pipeline(
*,
# API 配置(本地)
api_base_url: str,
api_uid: str,
api_secret: str,
# 路径配置
remote_jsonl_dir: str,
persistence_db: str,
# 节点类型
node_types: List[str],
# 项目过滤
target_project_ids: Optional[List[str]] = None,
strategy_overrides: Optional[Dict[str, Dict[str, Any]]] = None,
# Pipeline 行为
enable_persistence: bool = True,
wipe_persistence_on_start: bool = False,
# API 配置
api_debug: bool = False,
poll_max_retries: int = 10,
poll_interval: float = 0.5,
api_rate_limit_max_requests: int = 30,
api_rate_limit_window_seconds: float = 10.0,
# Handler 自定义配置(可选,作用于 API 端)
handler_configs: Optional[Dict[str, Dict[str, Any]]] = None,
# Logger 配置
initialize_logger: bool = True,
logger_file_path: Optional[str] = None,
logger_level: int = logging.INFO,
) -> FullSyncPipeline:
cfg = PipelineRunConfig(
node_types=node_types,
target_project_ids=target_project_ids or [],
local_datasource=ApiDataSourceConfig(
type="api",
api_base_url=api_base_url,
api_uid=api_uid,
api_secret=api_secret,
api_debug=api_debug,
poll_max_retries=poll_max_retries,
poll_interval=poll_interval,
api_rate_limit_max_requests=api_rate_limit_max_requests,
api_rate_limit_window_seconds=api_rate_limit_window_seconds,
target_project_ids=target_project_ids or [],
handler_configs=handler_configs or {},
),
remote_datasource=JsonlDataSourceConfig(type="jsonl", jsonl_dir=remote_jsonl_dir, read_only=False),
strategies=[],
persist=PersistConfig(
db_path=persistence_db,
enable=enable_persistence,
wipe_on_start=wipe_persistence_on_start,
),
logging=LoggingConfig(
initialize=initialize_logger,
file_path=logger_file_path,
level=logger_level,
),
)
if strategy_overrides:
cfg = apply_config_overrides(cfg, {"strategies": strategy_overrides})
return await create_pipeline_from_config(cfg)
async def create_jsonl_to_jsonl_pipeline(
*,
local_jsonl_dir: str,
remote_jsonl_dir: str,
persistence_db: str,
node_types: List[str],
target_project_ids: Optional[List[str]] = None,
enable_persistence: bool = True,
wipe_persistence_on_start: bool = False,
strategy_overrides: Optional[Dict[str, Dict[str, Any]]] = None,
initialize_logger: bool = True,
logger_file_path: Optional[str] = None,
logger_level: int = logging.INFO,
) -> FullSyncPipeline:
cfg = PipelineRunConfig(
node_types=node_types,
target_project_ids=target_project_ids or [],
local_datasource=JsonlDataSourceConfig(type="jsonl", jsonl_dir=local_jsonl_dir, read_only=False),
remote_datasource=JsonlDataSourceConfig(type="jsonl", jsonl_dir=remote_jsonl_dir, read_only=False),
strategies=[],
persist=PersistConfig(
db_path=persistence_db,
enable=enable_persistence,
wipe_on_start=wipe_persistence_on_start,
),
logging=LoggingConfig(
initialize=initialize_logger,
file_path=logger_file_path,
level=logger_level,
),
)
if strategy_overrides:
cfg = apply_config_overrides(cfg, {"strategies": strategy_overrides})
return await create_pipeline_from_config(cfg)
@@ -0,0 +1,488 @@
"""
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 ..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,
node_types: Optional[List[str]] = None,
load_order: Optional[List[str]] = None,
sync_order: Optional[List[str]] = None,
enable_persistence: bool = True,
# 项目过滤
target_project_ids: Optional[List[str]] = None,
local_target_project_ids: Optional[List[str]] = None,
remote_target_project_ids: Optional[List[str]] = None,
):
# 验证必需参数(由工厂函数创建)
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.node_types = node_types or [s.node_type for s in self.strategies]
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.target_project_ids = target_project_ids or []
self.local_target_project_ids = (
list(local_target_project_ids)
if local_target_project_ids is not None
else list(self.target_project_ids)
)
self.remote_target_project_ids = (
list(remote_target_project_ids)
if remote_target_project_ids is not None
else list(self.target_project_ids)
)
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
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")
await self.phase_sync_by_node_type()
self._logger.info("✅ Phase sync-by-node-type completed")
post_ok = await self.phase_post_check()
self.stats["post_check_passed"] = post_ok
self._logger.info("✅ Phase post-check completed")
await self.phase_persistence()
self._logger.info("✅ Phase persistence completed")
return self.stats
finally:
await self.close()
async def phase_sync_by_node_type(self) -> None:
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 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
@staticmethod
def _is_noop_strategy(strategy: BaseSyncStrategy[Any]) -> bool:
cfg = strategy.config
return (
cfg.local_orphan_action == OrphanAction.NONE
and cfg.remote_orphan_action == OrphanAction.NONE
and cfg.update_direction == UpdateDirection.NONE
)
async def phase_bootstrap(self) -> None:
await self._load_persistence_state()
self._logger.info("✅ Bootstrap: persistence loaded")
await self._load_from_datasource()
self._logger.info("✅ Bootstrap: datasource fetched")
if self.local_target_project_ids or self.remote_target_project_ids:
local_deleted = await self.local_collection.filter_by_project_ids(self.local_target_project_ids)
remote_deleted = await self.remote_collection.filter_by_project_ids(self.remote_target_project_ids)
if local_deleted > 0 or remote_deleted > 0:
self._logger.info(f"🔍 Project filtering: deleted {local_deleted} local, {remote_deleted} remote nodes")
async def phase_bind(self) -> None:
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:
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:
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
# 加载持久化数据
await self.local_collection.load_from_persistence()
await self.remote_collection.load_from_persistence()
for node_type in self.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 _load_from_datasource(self) -> None:
"""从数据源加载数据"""
self.local_datasource.set_collection(self.local_collection)
await self.local_datasource.load_all(order=self.load_order)
self.remote_datasource.set_collection(self.remote_collection)
await self.remote_datasource.load_all(order=self.load_order)
for node_type in self.node_types:
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
async def _reload_node_type(self, node_type: str, *, reason: str) -> None:
self._logger.info(f"🔄 Reload after write: node_type={node_type}, reason={reason}")
await self.local_datasource.load_all(order=[node_type])
await self.remote_datasource.load_all(order=[node_type])
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_list_item_fields = dict(strategy.config.post_check_ignore_list_item_fields) if strategy else {}
# 从远端 handler 的 update_schemas 提取只读字段过滤白名单
post_check_fields: List[str] | None = None
try:
remote_handler = self.remote_datasource.get_handler(node_type)
fields = remote_handler.get_update_fields()
if fields:
post_check_fields = fields
except Exception:
pass
result = await evaluate_consistency_for_node_type(
node_type=node_type,
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_list_item_fields=ignore_list_item_fields,
post_check_fields=post_check_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 结果反映最终状态。
"""
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()
self._logger.info("🔍 Persisting remote collection...")
await self.remote_collection.persist()
self._logger.info("🔍 Persisting binding manager...")
await self.binding_manager.persist()
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,
)
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
import logging
from pathlib import Path
from typing import Optional
class _NodeLogFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
message = record.getMessage()
if "node=" in message:
return False
if "节点 " in message and "状态" in message:
return False
return True
def init_pipeline_logger(
*,
log_file_path: Optional[str] = None,
level: int = logging.INFO,
mirror_console: bool = True,
replace_handlers: bool = True,
suppress_node_logs: bool = True,
) -> logging.Logger:
logger = logging.getLogger("sync_state_machine")
logger.setLevel(level)
logger.propagate = False
if replace_handlers:
clear_pipeline_logger_handlers(logger)
formatter = logging.Formatter("%(message)s")
node_filter = _NodeLogFilter() if suppress_node_logs else None
if mirror_console:
console_handler = logging.StreamHandler()
console_handler.setLevel(level)
console_handler.setFormatter(formatter)
if node_filter is not None:
console_handler.addFilter(node_filter)
logger.addHandler(console_handler)
if log_file_path:
path = Path(log_file_path)
path.parent.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(path, mode="w", encoding="utf-8")
file_handler.setLevel(level)
file_handler.setFormatter(formatter)
if node_filter is not None:
file_handler.addFilter(node_filter)
logger.addHandler(file_handler)
return logger
def clear_pipeline_logger_handlers(logger: Optional[logging.Logger] = None) -> None:
target = logger or logging.getLogger("sync_state_machine")
handlers = list(target.handlers)
for handler in handlers:
target.removeHandler(handler)
try:
handler.close()
except Exception:
pass
@@ -0,0 +1,150 @@
from __future__ import annotations
async def print_pipeline_summary(*, logger, node_types, binding_manager, local_datasource, remote_datasource, local_collection, remote_collection, consistency_by_type, stats) -> None:
binding_counts = {}
for node_type in node_types:
records = await binding_manager.get_all_records(node_type)
binding_counts[node_type] = len(records)
def format_action_sft(summary: dict, key: str) -> str:
action = summary.get(key, {})
success = int(action.get("success", 0))
failed = int(action.get("failed", 0)) + int(action.get("skipped", 0))
total = int(action.get("total", 0))
return f"{success}/{failed}/{total}"
def format_consistency_sft(node_type: str) -> str:
consistency = consistency_by_type.get(node_type, {})
checked = int(consistency.get("checked_pairs", 0))
mismatch = int(consistency.get("mismatch_count", 0))
matched = max(0, checked - mismatch)
return f"{matched}/{mismatch}/{checked}"
def _clip(text: str, width: int) -> str:
if len(text) <= width:
return text
if width <= 1:
return text[:width]
return text[: width - 1] + ""
def print_collection_summary(title: str, datasource, collection) -> None:
action_summary = datasource.get_action_summary()
line_width = 133
node_type_w = 28
bound_w = 16
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(
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}")
total_bound_normal = 0
total_bindings = 0
total_loaded = 0
total_create = {"total": 0, "success": 0, "failed_effective": 0}
total_update = {"total": 0, "success": 0, "failed_effective": 0}
total_delete = {"total": 0, "success": 0, "failed_effective": 0}
total_consistency = {"checked": 0, "mismatch": 0}
for node_type in node_types:
summary = action_summary.get(node_type, {})
bindings = binding_counts.get(node_type, 0)
nodes = collection.filter(node_type=node_type)
loaded_count = len(nodes)
bound_normal_count = len([n for n in nodes if n.binding_status.value == "normal"])
create_str = format_action_sft(summary, "create")
update_str = format_action_sft(summary, "update")
delete_str = format_action_sft(summary, "delete")
consistency_str = format_consistency_sft(node_type)
bound_str = f"{bound_normal_count}/{bindings}/{loaded_count}"
logger.info(
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}} "
f"{_clip(update_str, action_w):<{action_w}} "
f"{_clip(delete_str, action_w):<{action_w}} "
f"{_clip(consistency_str, consistency_w):<{consistency_w}}"
)
total_bound_normal += bound_normal_count
total_bindings += bindings
total_loaded += loaded_count
for bucket, key in (
(total_create, "create"),
(total_update, "update"),
(total_delete, "delete"),
):
action = summary.get(key, {})
bucket["total"] += action.get("total", 0)
bucket["success"] += action.get("success", 0)
bucket["failed_effective"] += action.get("failed", 0) + action.get("skipped", 0)
consistency = consistency_by_type.get(node_type, {})
total_consistency["checked"] += int(consistency.get("checked_pairs", 0))
total_consistency["mismatch"] += int(consistency.get("mismatch_count", 0))
logger.info(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(
f"{'TOTAL':<{node_type_w}} "
f"{_clip(total_bound_str, bound_w):<{bound_w}} "
f"{_clip(total_create_sft, action_w):<{action_w}} "
f"{_clip(total_update_sft, action_w):<{action_w}} "
f"{_clip(total_delete_sft, action_w):<{action_w}} "
f"{_clip(total_consistency_sft, consistency_w):<{consistency_w}}"
)
print_collection_summary("Local", local_datasource, local_collection)
print_collection_summary("Remote", remote_datasource, remote_collection)
loaded = int(stats.get("loaded", 0))
synced = int(stats.get("synced", 0))
failed = int(stats.get("failed", 0))
post_check_passed = bool(stats.get("post_check_passed", True))
mismatch_node_types = [
node_type
for node_type, item in consistency_by_type.items()
if int(item.get("mismatch_count", 0)) > 0
]
logger.info("\n" + "=" * 96)
logger.info(
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)
sample_per_type = 3
has_mismatch_samples = any(int(item.get("mismatch_count", 0)) > 0 for item in consistency_by_type.values())
if has_mismatch_samples:
logger.info("\n" + "=" * 96)
logger.info("🧪 Inconsistency Samples (per type)")
logger.info("=" * 96)
for node_type in node_types:
item = consistency_by_type.get(node_type, {})
mismatch_count = int(item.get("mismatch_count", 0))
if mismatch_count <= 0:
continue
checked_pairs = int(item.get("checked_pairs", 0))
samples = item.get("samples", []) or []
logger.info(f"- {node_type}: mismatches={mismatch_count}, checked_pairs={checked_pairs}")
for sample in samples[:sample_per_type]:
logger.info(f"{sample}")