373 lines
13 KiB
Python
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)
|
|
|
|
|