Files
ecm_sync_system/sync_state_machine/sync_system/strategy.py
T
2026-04-01 16:23:43 +08:00

373 lines
13 KiB
Python

from abc import ABC, abstractmethod
from typing import TypeVar, Generic, Type, Dict, List, Optional, Any
from pydantic import BaseModel
from pathlib import Path
from ..common.collection import DataCollection
from ..common.binding import BindingManager
from ..common.sync_node import SyncNode
from ..logging import get_logger
from .config import StrategyConfig, OrphanAction, UpdateDirection
from ..config.strategy_config import resolve_domain_option_config
from .strategy_ops import (
run_delete,
)
from .strategy_ops.bind_ops import (
apply_auto_bind_updates,
collect_auto_bind_ready_nodes,
collect_bind_entry_nodes,
plan_auto_bind_updates,
phase_core_state,
phase_dependency_check,
refresh_bind_data_id,
)
from .strategy_ops.create_ops import add_create_action
from .strategy_ops.update_ops import (
BoundNodePair,
_build_schema_diff_validator,
collect_bound_node_pairs,
default_get_node_update_payload,
default_needs_update,
default_update_pair,
emit_schema_diff_report,
prepare_directional_update,
)
from ..engine import (
StateMachineConfig,
StateMachineRuntime,
)
from .strategy_ops.compare_ops import build_data_id_normalization_map, normalized_data_for_compare
T = TypeVar("T", bound=BaseModel)
logger = get_logger(__name__)
class BaseSyncStrategy(ABC, Generic[T]):
"""
同步策略抽象接口。
子类可以通过覆盖以下类变量提供静态配置:
```python
class ProjectStrategy(DefaultSyncStrategy):
default_config = StrategyConfig(
auto_bind_fields=["name", "code"],
local_orphan_action=OrphanAction.CREATE_REMOTE
)
schema = ProjectResponse
```
跳过同步控制:
skip_sync: bool - 如果为 True,则跳过该类型的 bind/create/update/sync 阶段
数据仍会被加载,但不会执行任何同步操作
适用于只需要加载数据供其他类型引用的场景(如 supplier)
skip_post_check: bool - 如果为 True,则跳过该类型最终 post-check 的 reload 与一致性校验
"""
default_config: StrategyConfig
domain_option_model: Type[BaseModel] | None
schema: Type[T]
node_type: str
config: StrategyConfig
sm_runtime: Optional[StateMachineRuntime]
@abstractmethod
def __init__(
self,
node_type: str,
local_collection: DataCollection,
remote_collection: DataCollection,
binding_manager: BindingManager,
):
pass
@abstractmethod
async def bind(self):
"""进行绑定逻辑判定,设置 node.binding_status 和 node.action"""
pass
@abstractmethod
async def create(self) -> List[SyncNode]:
"""准备 CREATE 操作"""
pass
@abstractmethod
async def update(self) -> List[SyncNode]:
"""准备 UPDATE 操作"""
pass
@abstractmethod
async def delete(self) -> List[SyncNode]:
pass
@property
@abstractmethod
def skip_sync(self) -> bool:
pass
@property
@abstractmethod
def skip_post_check(self) -> bool:
pass
@abstractmethod
def normalize_compare_payload(
self,
data: Optional[Dict[str, Any]],
data_id_map: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
pass
class DefaultSyncStrategy(BaseSyncStrategy[T]):
"""
基于 STATE_DEFINITIONS.md 实现的标准化同步策略。
采用四阶段管线:核心状态 -> 依赖检查 -> 自动匹配 -> 动作映射。
"""
default_config: StrategyConfig = StrategyConfig()
domain_option_model: Type[BaseModel] | None = None
schema: Type[T] = None # type: ignore
def __init__(
self,
node_type: str,
local_collection: DataCollection,
remote_collection: DataCollection,
binding_manager: BindingManager,
):
if self.schema is None:
raise ValueError(f"{self.__class__.__name__} must define schema as a class variable")
self.node_type = node_type
self.local_collection = local_collection
self.remote_collection = remote_collection
self.binding_manager = binding_manager
self.config = self.default_config.model_copy(deep=True)
self.sm_runtime: Optional[StateMachineRuntime] = None
self.config.domain_option = resolve_domain_option_config(
self.domain_option_model,
self.config.domain_option,
)
self.config.log_logic_warnings(node_type=self.node_type, logger=logger)
@classmethod
def get_default_domain_option(cls) -> Dict[str, Any]:
return resolve_domain_option_config(cls.domain_option_model, None)
def _validate_domain_option(self, domain_option: Optional[Dict[str, Any]]) -> BaseModel | Dict[str, Any]:
if self.domain_option_model is None:
return dict(domain_option or {})
return self.domain_option_model.model_validate(domain_option or {})
@property
def domain_option(self) -> BaseModel | Dict[str, Any]:
return self._validate_domain_option(self.config.domain_option)
@property
def skip_sync(self) -> bool:
return bool(self.config.skip_sync)
@skip_sync.setter
def skip_sync(self, value: bool) -> None:
self.config.skip_sync = bool(value)
@property
def skip_post_check(self) -> bool:
return bool(self.config.skip_post_check)
@skip_post_check.setter
def skip_post_check(self, value: bool) -> None:
self.config.skip_post_check = bool(value)
def ensure_runtime(self) -> StateMachineRuntime:
runtime = self.sm_runtime
if runtime is None:
cfg_path = Path(__file__).resolve().parents[1] / "config" / "node_state_machine.yaml"
runtime = StateMachineRuntime(StateMachineConfig.load(cfg_path))
self.sm_runtime = runtime
self.local_collection.set_state_machine_runtime(runtime)
self.remote_collection.set_state_machine_runtime(runtime)
return runtime
def normalize_compare_payload(
self,
data: Optional[Dict[str, Any]],
data_id_map: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
normalized = normalized_data_for_compare(
data,
data_id_map,
ignore_fields=set(self.config.compare_ignore_fields),
ignore_list_item_fields=self.config.post_check_ignore_list_item_fields,
)
return normalized
async def bind(self):
runtime = self.ensure_runtime()
bind_local_nodes, bind_remote_nodes = collect_bind_entry_nodes(
node_type=self.node_type,
local_collection=self.local_collection,
remote_collection=self.remote_collection,
)
await phase_core_state(
node_type=self.node_type,
runtime=runtime,
binding_manager=self.binding_manager,
local_nodes=bind_local_nodes,
remote_nodes=bind_remote_nodes,
)
await phase_dependency_check(
node_type=self.node_type,
runtime=runtime,
depend_fields=dict(self.config.depend_fields),
local_nodes=bind_local_nodes,
remote_nodes=bind_remote_nodes,
local_collection=self.local_collection,
remote_collection=self.remote_collection,
)
auto_bind_local_nodes, auto_bind_remote_nodes = collect_auto_bind_ready_nodes(
node_type=self.node_type,
local_collection=self.local_collection,
remote_collection=self.remote_collection,
)
local_create_enabled = self.config.local_orphan_action == OrphanAction.CREATE_REMOTE
remote_create_enabled = self.config.remote_orphan_action == OrphanAction.CREATE_LOCAL
local_orphan_create_enabled_by_node = {
node.node_id: local_create_enabled for node in auto_bind_local_nodes
}
remote_orphan_create_enabled_by_node = {
node.node_id: remote_create_enabled for node in auto_bind_remote_nodes
}
pending_updates = await plan_auto_bind_updates(
node_type=self.node_type,
config=self.config,
local_collection=self.local_collection,
remote_collection=self.remote_collection,
binding_manager=self.binding_manager,
local_nodes=auto_bind_local_nodes,
remote_nodes=auto_bind_remote_nodes,
id_field_hints=dict(self.config.depend_fields),
local_orphan_create_enabled_by_node=local_orphan_create_enabled_by_node,
remote_orphan_create_enabled_by_node=remote_orphan_create_enabled_by_node,
)
await apply_auto_bind_updates(
node_type=self.node_type,
runtime=runtime,
binding_manager=self.binding_manager,
pending_auto_bind_updates=pending_updates,
)
await refresh_bind_data_id(
node_type=self.node_type,
local_collection=self.local_collection,
remote_collection=self.remote_collection,
binding_manager=self.binding_manager,
local_node_ids={
*(node.node_id for node in bind_local_nodes),
*(node.node_id for node in auto_bind_local_nodes),
},
remote_node_ids={
*(node.node_id for node in bind_remote_nodes),
*(node.node_id for node in auto_bind_remote_nodes),
},
)
async def create(self) -> List[SyncNode]:
self.ensure_runtime()
created_nodes: List[SyncNode] = []
created_nodes.extend(await self.create_for_direction(source_is_local=True))
created_nodes.extend(await self.create_for_direction(source_is_local=False))
return created_nodes
async def create_for_direction(self, *, source_is_local: bool) -> List[SyncNode]:
from_collection = self.local_collection if source_is_local else self.remote_collection
to_collection = self.remote_collection if source_is_local else self.local_collection
return await add_create_action(
self,
from_collection=from_collection,
to_collection=to_collection,
source_is_local=source_is_local,
)
async def update(self) -> List[SyncNode]:
self.ensure_runtime()
validator = _build_schema_diff_validator(self)
self._active_schema_diff_validator = validator
data_id_map = await self.build_update_data_id_map()
try:
pairs = await self.collect_update_pairs()
updated_by_id = await self.process_update_pairs(pairs, data_id_map)
finally:
self._active_schema_diff_validator = None
emit_schema_diff_report(self, validator)
return list(updated_by_id.values())
async def build_update_data_id_map(self) -> Dict[str, str]:
return await build_data_id_normalization_map(
node_type=self.node_type,
local_collection=self.local_collection,
remote_collection=self.remote_collection,
binding_manager=self.binding_manager,
)
async def process_update_pairs(
self,
pairs: List[BoundNodePair],
data_id_map: Optional[Dict[str, str]] = None,
) -> Dict[str, SyncNode]:
updated_by_id: Dict[str, SyncNode] = {}
for pair in pairs:
for updated_node in await self.update_pair(pair.local_node, pair.remote_node, data_id_map):
updated_by_id[updated_node.node_id] = updated_node
return updated_by_id
def get_node_update_payload(self, node: SyncNode) -> Dict[str, Any]:
return default_get_node_update_payload(node)
def should_update_pair(
self,
source_node: SyncNode,
target_node: SyncNode,
*,
source_data: Dict[str, Any],
target_data: Dict[str, Any],
data_id_map: Optional[Dict[str, str]] = None,
) -> bool:
return default_needs_update(self, source_data, target_data, data_id_map)
async def collect_update_pairs(self):
return await collect_bound_node_pairs(self)
async def prepare_update_for_direction(
self,
local_node: SyncNode,
remote_node: SyncNode,
direction: UpdateDirection,
data_id_map: Optional[Dict[str, str]] = None,
*,
include_fields: Optional[List[str]] = None,
exclude_fields: Optional[List[str]] = None,
) -> Optional[SyncNode]:
return await prepare_directional_update(
self,
local_node=local_node,
remote_node=remote_node,
direction=direction,
data_id_map=data_id_map,
include_fields=include_fields,
exclude_fields=exclude_fields,
)
async def update_pair(
self,
local_node: SyncNode,
remote_node: SyncNode,
data_id_map: Optional[Dict[str, str]] = None,
) -> List[SyncNode]:
return await default_update_pair(self, local_node, remote_node, data_id_map)
async def delete(self) -> List[SyncNode]:
self.ensure_runtime()
return await run_delete(self)