Files
ecm_sync_system/sync_state_machine/common/collection.py
T
2026-03-20 14:38:56 +08:00

541 lines
22 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.
import uuid
from enum import Enum
from typing import Dict, List, Optional, Any, Set, Callable, Literal
from .sync_node import SyncNode
from .types import SyncStatus
from .persistence import PersistenceBackend
class DataCollection:
"""
DataCollection 管理一组同步节点的内存集合。
支持快速遍历、CRUD 以及依赖检查。
唯一性保证:
- node_id: 全局唯一,由 Collection.generate_node_id() 生成
- data_id: 业务主键,允许空字符串但不允许重复(非空时)
node_id 生成策略:
- 由 Collection.generate_node_id() 统一生成
- Collection.add() 会检查 node_id 唯一性,重复时抛出异常
"""
def __init__(
self,
collection_id: str,
persistence: Optional[PersistenceBackend] = None,
auto_persist: bool = False,
sm_runtime: Optional[Any] = None,
):
self.collection_id = collection_id
self.persistence = persistence
self.auto_persist = auto_persist
self._sm_runtime = sm_runtime
self._nodes: Dict[str, SyncNode] = {} # 内存缓存
# data_id 全局唯一(按 collection 维度),用于 O(1) 反查 node_id
self._data_id_to_node_id: Dict[str, str] = {}
# auto_persist=False 时,delete() 不会立即落库;这里记录删除集合,persist() 时统一写回
self._deleted_node_ids: set[str] = set()
@staticmethod
def is_bootstrap_blocked(node: SyncNode) -> bool:
"""是否为加载异常隔离节点(不参与后续自动流程)。"""
context = node.context
if not isinstance(context, dict):
return False
return bool(context.get("bootstrap_blocked"))
def set_state_machine_runtime(self, runtime: Any) -> None:
"""设置 Collection 默认状态机 runtime。"""
self._sm_runtime = runtime
async def add(self, node: SyncNode):
# 检查 node_id 唯一性
if node.node_id in self._nodes:
raise ValueError(
f"Duplicate node_id detected: {node.node_id} already exists in collection"
)
# 检查 data_id 唯一性(允许空字符串)
if node.data_id and node.data_id != "":
existing_node_id = self._data_id_to_node_id.get(node.data_id)
if existing_node_id is not None:
raise ValueError(
f"Duplicate data_id detected: {node.data_id} already bound to node_id={existing_node_id}"
)
self._data_id_to_node_id[node.data_id] = node.node_id
self._nodes[node.node_id] = node
self._deleted_node_ids.discard(node.node_id)
if self.persistence and self.auto_persist:
await self.persistence.save_node(self.collection_id, node)
async def update(self, node: SyncNode):
old_node = self._nodes.get(node.node_id)
if old_node and old_node.data_id and old_node.data_id != "" and old_node.data_id != node.data_id:
# node_id 不变但 data_id 变更,清理旧索引
self._data_id_to_node_id.pop(old_node.data_id, None)
if node.data_id and node.data_id != "":
existing_node_id = self._data_id_to_node_id.get(node.data_id)
if existing_node_id is not None and existing_node_id != node.node_id:
raise ValueError(
f"Duplicate data_id detected: {node.data_id} already bound to node_id={existing_node_id}"
)
self._data_id_to_node_id[node.data_id] = node.node_id
self._nodes[node.node_id] = node
self._deleted_node_ids.discard(node.node_id)
if self.persistence and self.auto_persist:
await self.persistence.save_node(self.collection_id, node)
async def delete(self, node_id: str):
old_node = self._nodes.get(node_id)
if old_node and old_node.data_id and old_node.data_id != "":
self._data_id_to_node_id.pop(old_node.data_id, None)
if node_id in self._nodes:
del self._nodes[node_id]
if self.persistence:
if self.auto_persist:
await self.persistence.delete_node(self.collection_id, node_id)
else:
self._deleted_node_ids.add(node_id)
def get(self, node_id: str) -> Optional[SyncNode]:
return self._nodes.get(node_id)
def get_by_node_id(self, node_id: str) -> Optional[SyncNode]:
"""别名方法,与 get() 功能相同"""
return self.get(node_id)
def generate_node_id(self) -> str:
"""生成唯一的 node_id"""
return str(uuid.uuid4())
def get_node_id_by_data_id(self, data_id: str) -> Optional[str]:
"""按 data_id 反查 node_idO(1))。"""
return self._data_id_to_node_id.get(data_id)
def get_by_data_id_any(self, data_id: str) -> Optional[SyncNode]:
"""按 data_id 查找节点(不要求 node_type)(O(1))。"""
node_id = self._data_id_to_node_id.get(data_id)
if not node_id:
return None
return self._nodes.get(node_id)
def get_by_data_id(self, node_type: str, data_id: str) -> Optional[SyncNode]:
"""按 node_type 和 data_id 查找节点"""
node = self.get_by_data_id_any(data_id)
if not node:
return None
return node if node.node_type == node_type else None
def filter(
self,
node_type: Optional[str] = None,
filter: Optional[Dict[str, Any]] = None,
node_filter: Optional[
Dict[
Literal[
"node_id",
"data_id",
"depend_ids",
"origin_data",
"action",
"status",
"binding_status",
"error",
"context",
"sync_log",
"node_type",
],
Any,
]
| Callable[[SyncNode], bool]
] = None
) -> List[SyncNode]:
"""
获取节点列表。
- node_type: 按 node_type 过滤。
- filter: 按 node.data 中的属性过滤 (业务数据)。
- node_filter: 按 SyncNode 顶级属性过滤
- 如果是字典:按属性匹配 (如 {"status": SyncStatus.PENDING})
- 如果是函数:自定义过滤函数 (如 lambda n: n.status == SyncStatus.PENDING)
"""
results = self._nodes.values()
# 1. 按 node_type 过滤
if node_type:
results = [n for n in results if n.node_type == node_type]
else:
results = list(results)
# 2. 按 node_filter 过滤 (顶级属性)
if node_filter:
if callable(node_filter):
# 函数过滤器
results = [n for n in results if node_filter(n)]
else:
filtered = []
for node in results:
match = True
for k, v in node_filter.items():
if k == "node_id":
actual = node.node_id
elif k == "data_id":
actual = node.data_id
elif k == "depend_ids":
actual = node.depend_ids
elif k == "origin_data":
actual = node.origin_data
elif k == "action":
actual = node.action
elif k == "status":
actual = node.status
elif k == "binding_status":
actual = node.binding_status
elif k == "error":
actual = node.error
elif k == "context":
actual = node.context
elif k == "sync_log":
actual = node.sync_log
elif k == "node_type":
actual = node.node_type
else:
match = False
break
if actual != v:
match = False
break
if match:
filtered.append(node)
results = filtered
# 3. 按 filter 过滤 (data 内部)
if filter:
filtered = []
for node in results:
node_data = node.get_data()
if not node_data:
match = False
else:
match = True
for k, v in filter.items():
actual_val = node_data.get(k)
if actual_val != v:
match = False
break
if match:
filtered.append(node)
results = filtered
return results
@staticmethod
def _expected_matches_actual(expected: Any, actual: Any) -> bool:
if isinstance(expected, list):
return any(DataCollection._expected_matches_actual(item, actual) for item in expected)
if isinstance(expected, str):
actual_candidates = set()
if isinstance(actual, Enum):
actual_candidates.update({str(actual.name), str(actual.value)})
else:
actual_candidates.add(str(actual))
expected_lower = expected.lower()
return any(str(candidate).lower() == expected_lower for candidate in actual_candidates)
return expected == actual
@classmethod
def _node_matches_state_id(
cls,
runtime,
*,
node: SyncNode,
state_id: str,
action_hint: Optional[str] = None,
match_fields: Optional[Set[str]] = None,
) -> bool:
states = runtime._config.raw.get("states")
pseudo_states = runtime._config.raw.get("pseudo_states")
if not isinstance(states, dict):
raise RuntimeError("invalid state-machine config: missing states")
if pseudo_states is not None and not isinstance(pseudo_states, dict):
raise RuntimeError("invalid state-machine config: pseudo_states must be mapping")
state_def = states.get(state_id)
if state_def is None and isinstance(pseudo_states, dict):
state_def = pseudo_states.get(state_id)
if not isinstance(state_def, dict):
return False
snapshot = {
"binding_status": node.binding_status,
"action": action_hint if action_hint is not None else node.action,
"status": node.status,
"data_id_exist": bool(node.data_id),
}
selected_fields = match_fields or set(snapshot.keys())
for field, actual in snapshot.items():
if field not in selected_fields:
continue
if field not in state_def:
continue
expected = state_def[field]
if not cls._expected_matches_actual(expected, actual):
return False
return True
def filter_by_state_ids(
self,
runtime: Optional[Any] = None,
*,
state_ids: List[str],
node_type: Optional[str] = None,
include_bootstrap_blocked: bool = False,
action_hint: Optional[str] = None,
match_fields: Optional[Set[str]] = None,
) -> List[SyncNode]:
"""
按状态机状态名(Sxx)筛选节点。
说明:
- 状态定义来源于 `runtime` 关联的 YAML 配置;
- 默认排除 `bootstrap_blocked` 隔离节点;
- `action_hint` 可用于匹配类似 E40 场景的动作分支。
"""
runtime_obj = runtime or self._sm_runtime
if runtime_obj is None:
raise RuntimeError("state-machine runtime is not set for collection")
candidates = self.filter(node_type=node_type)
matched: List[SyncNode] = []
for node in candidates:
if not include_bootstrap_blocked and self.is_bootstrap_blocked(node):
continue
if any(
self._node_matches_state_id(
runtime_obj,
node=node,
state_id=state_id,
action_hint=action_hint,
match_fields=match_fields,
)
for state_id in state_ids
):
matched.append(node)
return matched
def check_depends(self, node_ids: List[str]) -> bool:
"""
检查指定的 node_id 列表是否存在,且状态是否不是 FAILED。
Args:
node_ids: 依赖的 node_id 列表
Returns:
bool: 所有依赖均满足则返回 True
"""
for nid in node_ids:
node = self.get(nid)
if not node:
return False
if node.status == SyncStatus.FAILED:
return False
return True
async def load_from_persistence(self):
"""
从后端加载数据到内存
使用 DomainRegistry 恢复正确的 SyncNode 类型。
如果 node_type 未注册,会抛出 ValueError。
**加载阶段**
- 原样恢复持久化状态(binding_status/action/status/error
- 清空 sync_log(避免跨轮次累积日志)
- data/origin_data 保留(由 DataSource 覆盖)
**初始化归并(E01)**:
- 由 Pipeline 在加载后调用 run_reset() 触发 E01
- CREATE 僵尸节点进入 S15 并删除
- 其余节点进入 S00UNCHECKED/NONE/PENDING
"""
if not self.persistence:
return
self._nodes = {}
self._data_id_to_node_id = {}
self._deleted_node_ids.clear()
node_dicts = await self.persistence.load_nodes(self.collection_id)
from .types import SyncAction, SyncStatus, BindingStatus
from .registry import DomainRegistry
for node_dict in node_dicts:
node_type = node_dict["node_type"]
# 从注册中心获取正确的 SyncNode 类(未注册会抛出 ValueError
node_class = DomainRegistry.get_node_class(node_type)
assert issubclass(node_class, SyncNode)
# 严格恢复 action/status/binding_status;非法持久化值进入隔离态(S17 语义)
parse_errors: list[str] = []
# 原样恢复 action 字段(可能是字符串或枚举)
raw_action = node_dict.get("action", SyncAction.NONE)
if isinstance(raw_action, str):
try:
action_value = SyncAction(raw_action)
except ValueError:
parse_errors.append(f"invalid action={raw_action}")
action_value = SyncAction.NONE
elif isinstance(raw_action, SyncAction):
action_value = raw_action
else:
if raw_action is None:
action_value = SyncAction.NONE
else:
parse_errors.append(f"invalid action type={type(raw_action).__name__}")
action_value = SyncAction.NONE
# 原样恢复 status 字段(可能是字符串或枚举)
raw_status = node_dict.get("status", SyncStatus.PENDING)
if isinstance(raw_status, str):
try:
status_value = SyncStatus(raw_status)
except ValueError:
parse_errors.append(f"invalid status={raw_status}")
status_value = SyncStatus.FAILED
elif isinstance(raw_status, SyncStatus):
status_value = raw_status
else:
if raw_status is None:
status_value = SyncStatus.PENDING
else:
parse_errors.append(f"invalid status type={type(raw_status).__name__}")
status_value = SyncStatus.FAILED
# 原样恢复 binding_status 字段(可能是字符串或枚举)
raw_binding_status = node_dict.get("binding_status", BindingStatus.UNCHECKED)
if isinstance(raw_binding_status, str):
try:
binding_status_value = BindingStatus(raw_binding_status)
except ValueError:
parse_errors.append(f"invalid binding_status={raw_binding_status}")
binding_status_value = BindingStatus.ABNORMAL
elif isinstance(raw_binding_status, BindingStatus):
binding_status_value = raw_binding_status
else:
if raw_binding_status is None:
binding_status_value = BindingStatus.UNCHECKED
else:
parse_errors.append(
f"invalid binding_status type={type(raw_binding_status).__name__}"
)
binding_status_value = BindingStatus.ABNORMAL
raw_context = node_dict.get("context", {})
context_value = raw_context if isinstance(raw_context, dict) else {}
error_value = node_dict.get("error")
if parse_errors:
context_value = dict(context_value)
context_value["bootstrap_blocked"] = True
context_value["bootstrap_block_reason"] = "invalid_persisted_enum"
context_value["bootstrap_block_errors"] = parse_errors
binding_status_value = BindingStatus.ABNORMAL
action_value = SyncAction.NONE
status_value = SyncStatus.FAILED
details = "; ".join(parse_errors)
error_value = f"LOAD_INVALID_ENUM: {details}"
# 使用正确的类型创建节点(保留持久化状态,后续由 E01 统一归并)
try:
node = node_class(
node_id=node_dict["node_id"],
data_id=node_dict.get("data_id", ""),
depend_ids=node_dict.get("depend_ids", []),
data=node_dict.get("data"), # 保留,由 DataSource 覆盖
origin_data=node_dict.get("origin_data"), # 保留,由 DataSource 覆盖
action=action_value,
status=status_value,
binding_status=binding_status_value,
error=error_value,
sync_log=None, # 初始化加载时清空上一轮流转日志
context=context_value,
)
node.context["_loaded_from_persistence"] = True
except Exception:
import logging
logger = logging.getLogger(__name__)
logger.error(
f"Load failed for node_type={node_type}, node_id={node_dict.get('node_id')}",
exc_info=True
)
logger.error(f"Offending data: {node_dict.get('data')}")
raise
self._nodes[node.node_id] = node
if node.data_id and node.data_id != "":
existing_node_id = self._data_id_to_node_id.get(node.data_id)
if existing_node_id is not None and existing_node_id != node.node_id:
raise ValueError(
f"Duplicate data_id detected while loading: {node.data_id} already bound to node_id={existing_node_id}"
)
self._data_id_to_node_id[node.data_id] = node.node_id
node_types = {node.node_type for node in self._nodes.values()}
for node_type in node_types:
override_map = await self.persistence.load_bind_data_id_overrides(node_type)
for node in self.filter(node_type=node_type):
bind_data_id = override_map.get(node.node_id)
if bind_data_id:
node.context["bind_data_id"] = bind_data_id
else:
node.context.pop("bind_data_id", None)
async def persist(self) -> None:
"""将当前 collection 全量持久化到后端"""
if not self.persistence:
return
# 先落库删除(用于 auto_persist=False 的场景)
if self._deleted_node_ids:
await self.persistence.delete_nodes_bulk(self.collection_id, list(self._deleted_node_ids))
self._deleted_node_ids.clear()
if not self._nodes:
return
await self.persistence.save_nodes_bulk(self.collection_id, list(self._nodes.values()))
async def filter_by_project_ids(self, data_id_filter: List[str]) -> int:
"""按目标项目 data_id 集合过滤当前 collection,返回删除数量。"""
target_set = set(data_id_filter or [])
if not target_set:
return 0
nodes = self.filter()
deleted_count = 0
for node in nodes:
should_delete = False
if node.node_type == "project":
if node.data_id not in target_set:
should_delete = True
else:
data = node.get_data() or {}
project_id = data.get("project_id")
if project_id and project_id not in target_set:
should_delete = True
if should_delete:
await self.delete(node.node_id)
deleted_count += 1
return deleted_count