Files
ecm_sync_system/sync_state_machine/sync_system/strategy.py
T
2026-03-24 08:40:40 +08:00

333 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from typing import TypeVar, Generic, Type, Dict, List, Optional, Any
from pydantic import BaseModel
import logging
from pathlib import Path
from ..common.collection import DataCollection
from ..common.binding import BindingManager
from ..common.sync_node import SyncNode
from .config import StrategyConfig, OrphanAction, UpdateDirection, ConfigPresets
from .strategy_ops import (
get_phase1_reset_defaults,
run_bind,
run_create,
run_delete,
run_phase2_cleanup,
run_update,
)
from ..engine import (
StateMachineConfig,
StateMachineRuntime,
)
from ..validation import SchemaDiffValidator
from .strategy_ops.compare_ops import normalized_data_for_compare
T = TypeVar("T", bound=BaseModel)
logger = logging.getLogger(__name__)
class BaseSyncStrategy(Generic[T]):
"""
同步策略基类。
纯逻辑层,不负责 IO 执行。
子类可以通过覆盖 default_config 来设置默认配置:
```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)
"""
# 类变量:默认配置(子类可覆盖)
default_config: StrategyConfig = StrategyConfig()
# 类变量:schema 类型(子类必须设置)
schema: Type[T] = None # type: ignore
# 类变量:是否默认跳过同步(子类可覆盖)
default_skip_sync: bool = False
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.skip_sync = self.default_skip_sync
# 统一由 Pipeline 注入 runtime;未注入时在执行阶段显式报错
self.sm_runtime: Optional[StateMachineRuntime] = None
self._schema_diff_validator: Optional[SchemaDiffValidator] = None
# 配置逻辑校验
self._validate_config()
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 _validate_config(self) -> None:
"""
校验配置的逻辑一致性。
规则:
- PUSH 模式:local_orphan_action 应为 CREATE_REMOTEremote_orphan_action 应为 NONE
- PULL 模式:local_orphan_action 应为 NONEremote_orphan_action 应为 CREATE_LOCAL
- NONE 模式:两者都应为 NONE
"""
direction = self.config.update_direction
local_action = self.config.local_orphan_action
remote_action = self.config.remote_orphan_action
# PUSH 模式检查
if direction == UpdateDirection.PUSH:
if local_action == OrphanAction.CREATE_LOCAL:
logger.warning(
f"[{self.node_type}] Config inconsistency: "
f"update_direction=PUSH but local_orphan_action=CREATE_LOCAL (本地创建)。"
f"应使用 CREATE_REMOTE (推送到远程)"
)
if remote_action == OrphanAction.CREATE_LOCAL:
logger.warning(
f"[{self.node_type}] Config inconsistency: "
f"update_direction=PUSH but remote_orphan_action=CREATE_LOCAL (本地创建)。"
f"PUSH 模式应设为 NONE 或 DELETE_REMOTE"
)
# PULL 模式检查
elif direction == UpdateDirection.PULL:
if local_action == OrphanAction.CREATE_REMOTE:
logger.warning(
f"[{self.node_type}] Config inconsistency: "
f"update_direction=PULL but local_orphan_action=CREATE_REMOTE (推送到远程)。"
f"PULL 模式应设为 NONE 或 DELETE_LOCAL"
)
if remote_action in {OrphanAction.CREATE_REMOTE, OrphanAction.DELETE_LOCAL}:
logger.warning(
f"[{self.node_type}] Config inconsistency: "
f"update_direction=PULL but remote_orphan_action={remote_action}。"
f"PULL 模式建议使用 CREATE_LOCAL 或 NONE"
)
# NONE 模式检查
elif direction == UpdateDirection.NONE:
if local_action != OrphanAction.NONE:
logger.warning(
f"[{self.node_type}] Config inconsistency: "
f"update_direction=NONE but local_orphan_action={local_action}。"
f"应设为 NONE"
)
if remote_action != OrphanAction.NONE:
logger.warning(
f"[{self.node_type}] Config inconsistency: "
f"update_direction=NONE but remote_orphan_action={remote_action}。"
f"应设为 NONE"
)
def set_config(self, config: StrategyConfig):
"""完全替换配置对象"""
self.config = config
self._schema_diff_validator = None
self._validate_config()
def update_config(self, **kwargs):
"""
临时修改部分配置项。
Example:
strategy.update_config(
local_orphan_action=OrphanAction.NONE,
update_direction=UpdateDirection.PULL
)
"""
valid_fields = type(self.config).model_fields
for key, value in kwargs.items():
if key in valid_fields:
setattr(self.config, key, value)
else:
logger.warning(f"[{self.node_type}] Unknown config key: {key}")
self._schema_diff_validator = None
# 更新配置后也要校验
self._validate_config()
def apply_preset(self, preset_name: str):
"""
应用配置预设。
Args:
preset_name: 预设名称,可选:first_sync, daily_sync, repair_sync, pull_only, push_only
Example:
strategy.apply_preset("first_sync")
"""
preset_config = ConfigPresets.get_preset(preset_name)
self.config = preset_config
self._schema_diff_validator = None
self._validate_config()
logger.info(f"[{self.node_type}] Applied preset: {preset_name}")
def preprocess_compare_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
return payload
def normalize_compare_payload(
self,
data: Optional[Dict[str, Any]],
data_id_map: Optional[Dict[str, str]] = None,
*,
ignore_fields: Optional[set[str]] = None,
) -> Dict[str, Any]:
prepared = self.preprocess_compare_payload(dict(data or {}))
return normalized_data_for_compare(
prepared,
data_id_map,
ignore_fields=ignore_fields,
)
def get_schema_diff_validator(self) -> SchemaDiffValidator:
if self._schema_diff_validator is None:
self._schema_diff_validator = SchemaDiffValidator(
schema_name=self.schema.__name__,
ignore_fields=self.config.compare_ignore_fields,
)
return self._schema_diff_validator
def reset_schema_diff_validator(self) -> None:
self.get_schema_diff_validator().reset()
def emit_schema_diff_report(self) -> None:
validator = self.get_schema_diff_validator()
if not validator.has_records():
return
for line in validator.format_summary_lines():
logger.info(f"[{self.node_type}] validate {line}")
async def bind(self, **kwargs):
"""进行绑定逻辑判定,设置 node.binding_status 和 node.action"""
raise NotImplementedError
async def create(self) -> List[SyncNode]:
"""准备 CREATE 操作"""
raise NotImplementedError
async def update(self) -> List[SyncNode]:
"""准备 UPDATE 操作"""
raise NotImplementedError
def _needs_update(
self,
source_node: SyncNode,
target_node: SyncNode,
resolved_data: Optional[Dict[str, Any]] = None,
data_id_map: Optional[Dict[str, str]] = None,
) -> bool:
from .strategy_ops.update_ops import default_needs_update
return default_needs_update(self, source_node, target_node, resolved_data, data_id_map)
@classmethod
def get_phase1_reset_defaults(cls) -> Dict[str, Any]:
return get_phase1_reset_defaults()
@classmethod
async def run_reset(
cls,
*,
node_types: List[str],
local_collection: DataCollection,
remote_collection: DataCollection,
binding_manager: BindingManager,
runtime: StateMachineRuntime,
) -> int:
"""
执行加载后的重置清理(僵尸 CREATE 节点清理)。
说明:
- `DataCollection.load_from_persistence()` 仅恢复持久化状态;
- 这里触发 E01create_zombie -> S15(删除),其余 -> S00。
"""
return await run_phase2_cleanup(
node_types=node_types,
local_collection=local_collection,
remote_collection=remote_collection,
binding_manager=binding_manager,
runtime=runtime,
)
@classmethod
async def run_phase2_reset(
cls,
*,
node_types: List[str],
local_collection: DataCollection,
remote_collection: DataCollection,
binding_manager: BindingManager,
runtime: StateMachineRuntime,
) -> int:
"""兼容旧命名,等价于 `run_reset`。"""
return await cls.run_reset(
node_types=node_types,
local_collection=local_collection,
remote_collection=remote_collection,
binding_manager=binding_manager,
runtime=runtime,
)
class DefaultSyncStrategy(BaseSyncStrategy[T]):
"""
基于 STATE_DEFINITIONS.md 实现的标准化同步策略。
采用四阶段管线:核心状态 -> 依赖检查 -> 自动匹配 -> 动作映射。
"""
async def bind(self, **kwargs):
self.ensure_runtime()
await run_bind(self, **kwargs)
def _get_id_field_hints(self) -> Dict[str, str]:
"""
获取 ID 字段的 node_type 提示。
默认实现:从 depend_fields 中提取。
子类可以重写此方法提供更精确的映射。
Returns:
字段名到 node_type 的映射,例如 {"project_id": "project"}
"""
return dict(self.config.depend_fields) if self.config.depend_fields else {}
async def create(self) -> List[SyncNode]:
self.ensure_runtime()
return await run_create(self)
async def update(self) -> List[SyncNode]:
self.ensure_runtime()
return await run_update(self)
async def delete(self) -> List[SyncNode]:
self.ensure_runtime()
return await run_delete(self)