first commit
This commit is contained in:
@@ -0,0 +1,540 @@
|
||||
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_id(O(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 并删除
|
||||
- 其余节点进入 S00(UNCHECKED/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, target_project_ids: List[str]) -> int:
|
||||
"""按目标项目集合过滤当前 collection,返回删除数量。"""
|
||||
target_set = set(target_project_ids 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
|
||||
Reference in New Issue
Block a user