first commit

This commit is contained in:
strepsiades
2026-03-09 16:31:42 +08:00
commit 4a9c444b10
486 changed files with 52479 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
from .dispatcher import Decision, StateMachineRuntime
from .events import (
e01_bootstrap,
e10_bind_core,
decision_note,
e20_create_prepare,
e21_create_prepare,
e22_delete_prepare,
e30_update_prepare,
e41_post_create_ready,
e15_bind_dependency,
e16_bind_auto,
e40_sync_execute,
)
from .model import Outcome, StateMachineConfig, Transition
from .state_apply import apply_state_to_node
__all__ = [
"Decision",
"Outcome",
"StateMachineConfig",
"StateMachineRuntime",
"Transition",
"apply_state_to_node",
"e01_bootstrap",
"e10_bind_core",
"decision_note",
"e20_create_prepare",
"e21_create_prepare",
"e22_delete_prepare",
"e30_update_prepare",
"e41_post_create_ready",
"e15_bind_dependency",
"e16_bind_auto",
"e40_sync_execute",
]
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Mapping, Optional
from .evaluator import guard_matches
from .invariants import InvariantViolation
from .model import StateMachineConfig
@dataclass(frozen=True)
class Decision:
event_id: str
from_state: str
outcome_index: int
to_state: Optional[str]
emit: Optional[Mapping[str, Any]]
class StateMachineRuntime:
def __init__(self, config: StateMachineConfig):
self._config = config
def dispatch(
self,
*,
current_state: str,
event_id: str,
context: Mapping[str, Any],
) -> Optional[Decision]:
transition = self._config.transitions.get(event_id)
if transition is None:
raise KeyError(f"Unknown event: {event_id}")
if transition.from_states and current_state not in transition.from_states and "*" not in transition.from_states:
return None
for index, outcome in enumerate(transition.outcomes):
if guard_matches(outcome.when, context):
return Decision(
event_id=event_id,
from_state=current_state,
outcome_index=index,
to_state=outcome.to,
emit=outcome.emit,
)
return None
def check_invariants(self, snapshot: Mapping[str, Any]) -> list[InvariantViolation]:
def _match_all(cond: Mapping[str, Any]) -> bool:
for key, value in cond.items():
if snapshot.get(key) != value:
return False
return True
violations: list[InvariantViolation] = []
for invariant_id, idef in self._config.invariants.items():
if not isinstance(idef, dict):
continue
should_check = False
when = idef.get("when")
when_any = idef.get("when_any")
if isinstance(when, dict):
should_check = _match_all(when)
elif isinstance(when_any, list):
for cond in when_any:
if isinstance(cond, dict) and _match_all(cond):
should_check = True
break
if not should_check:
continue
require = idef.get("require")
if not isinstance(require, dict):
continue
for rk, rv in require.items():
if snapshot.get(rk) != rv:
violations.append(
InvariantViolation(
invariant_id=str(invariant_id),
message=f"require {rk}={rv}, got {snapshot.get(rk)}",
)
)
return violations
+10
View File
@@ -0,0 +1,10 @@
from __future__ import annotations
from typing import Any, Mapping
def guard_matches(guard: Mapping[str, Any], context: Mapping[str, Any]) -> bool:
for key, expected in guard.items():
if context.get(key) != expected:
return False
return True
+675
View File
@@ -0,0 +1,675 @@
from __future__ import annotations
from enum import Enum
from typing import Any, Mapping, Optional, Sequence, TYPE_CHECKING, Set
from .dispatcher import Decision, StateMachineRuntime
from .semantics import classify_core_pair
from .state_apply import apply_state_to_node
if TYPE_CHECKING:
from ..common.sync_node import SyncNode
class CoreBindingResult(str, Enum):
NORMAL = "NORMAL"
WARNING = "WARNING"
ABNORMAL = "ABNORMAL"
class AutoBindOutcome(str, Enum):
ONE_TO_ONE = "ONE_TO_ONE"
AMBIGUOUS = "AMBIGUOUS"
ZERO = "ZERO"
KEY_MISSING = "KEY_MISSING"
KEY_ID_RESOLVE_FAIL = "KEY_ID_RESOLVE_FAIL"
DATA_MISSING = "DATA_MISSING"
class ExecuteAction(str, Enum):
CREATE = "CREATE"
UPDATE = "UPDATE"
DELETE = "DELETE"
class HandlerResult(str, Enum):
SUCCESS = "SUCCESS"
FAILED = "FAILED"
SKIPPED = "SKIPPED"
IN_PROGRESS = "IN_PROGRESS"
def _as_upper_text(value: Any, *, field_name: str) -> str:
if isinstance(value, Enum):
raw = value.value
else:
raw = value
if not isinstance(raw, str):
raise RuntimeError(f"{field_name} must be str/Enum, got {type(raw).__name__}")
return raw.upper()
def decision_note(decision: Decision) -> str:
if not decision.emit:
return ""
note = decision.emit.get("note")
return note if isinstance(note, str) else ""
def _apply_decision(
runtime: StateMachineRuntime,
*,
node: "SyncNode",
decision: Decision,
fields: Optional[Set[str]] = None,
reason_override: Optional[str] = None,
) -> str:
reason = reason_override if reason_override is not None else decision_note(decision)
if decision.to_state:
reason_text = reason or "<no-reason>"
node.append_log(
f"状态机流转: {decision.from_state} --{decision.event_id}[{decision.outcome_index}]--> {decision.to_state} | reason={reason_text}"
)
apply_state_to_node(runtime, node, state_id=decision.to_state, reason=reason, fields=fields)
if decision.to_state:
snapshot = {
"binding_status": node.binding_status.value,
"action": node.action.value,
"status": node.status.value,
"data_id_exist": bool(node.data_id),
}
violations = runtime.check_invariants(snapshot)
if violations:
details = "; ".join(f"{v.invariant_id}: {v.message}" for v in violations)
raise RuntimeError(f"Invariant violation after {decision.event_id}->{decision.to_state}: {details}")
return reason
def _format_dependency_reason(
*,
base_reason: str,
dep_errors: Optional[list[str]] = None,
semantic_errors: Optional[list[str]] = None,
) -> str:
details: list[str] = []
for dep_err in dep_errors or []:
parts = dep_err.split("|")
tag = parts[0] if parts else "unknown"
if tag == "not_found" and len(parts) >= 4:
details.append(f"{parts[1]}:{parts[2]} data_id={parts[3]} -> not_found")
elif tag == "bad_status" and len(parts) >= 3:
details.append(f"{parts[1]} status={parts[2]} -> not_normal")
elif tag == "no_data_id" and len(parts) >= 3:
details.append(f"{parts[1]} {parts[2]} -> no_data_id")
else:
details.append(dep_err)
if semantic_errors:
details.extend([e for e in semantic_errors if e])
if not details:
return base_reason
return f"{base_reason} | depends=[{'; '.join(details)}]"
def _expected_matches_actual(expected: Any, actual: Any) -> bool:
if isinstance(expected, list):
return any(_expected_matches_actual(x, actual) for x 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(c).lower() == expected_lower for c in actual_candidates)
return expected == actual
def _state_matches_node(
runtime: StateMachineRuntime,
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 k, actual in snapshot.items():
if k not in selected_fields:
continue
if k not in state_def:
continue
expected = state_def[k]
if not _expected_matches_actual(expected, actual):
return False
return True
def _resolve_current_state(
runtime: StateMachineRuntime,
node: "SyncNode",
*,
candidate_states: Sequence[str],
action_hint: Optional[str] = None,
match_fields: Optional[Set[str]] = None,
) -> str:
matches = [
s
for s in candidate_states
if _state_matches_node(runtime, node, s, action_hint=action_hint, match_fields=match_fields)
]
if len(matches) == 1:
return matches[0]
if not matches:
raise RuntimeError(
f"cannot resolve current state for node={node.node_id}, candidates={list(candidate_states)}"
)
raise RuntimeError(
f"ambiguous current state for node={node.node_id}, candidates={list(candidate_states)}, matches={matches}"
)
def _config_from_states(runtime: StateMachineRuntime, event_id: str) -> list[str]:
transition = runtime._config.transitions.get(event_id)
if transition is None:
raise RuntimeError(f"unknown event: {event_id}")
from_states = [s for s in transition.from_states if s != "*"]
if not from_states:
raise RuntimeError(f"event {event_id} has no concrete from states")
return from_states
def _assert_expected_from_states(
*,
event_id: str,
config_from_states: Sequence[str],
expected_from_states: Optional[Sequence[str]],
) -> None:
if expected_from_states is None:
return
config_set = set(config_from_states)
expected_set = set(expected_from_states)
if config_set != expected_set:
raise RuntimeError(
f"event {event_id} from-states mismatch with config: "
f"config={sorted(config_set)}, expected={sorted(expected_set)}"
)
def _dispatch_on_resolved_state(
runtime: StateMachineRuntime,
*,
node: "SyncNode",
current_state: str,
event_id: str,
context: Mapping[str, Any],
) -> Decision:
decision = runtime.dispatch(current_state=current_state, event_id=event_id, context=context)
if decision is None:
raise RuntimeError(
f"state-machine miss: node={node.node_id}, state={current_state}, event={event_id}, context={dict(context)}"
)
return decision
def _dispatch_required(
runtime: StateMachineRuntime,
*,
node: "SyncNode",
event_id: str,
context: Mapping[str, Any],
action_hint: Optional[str] = None,
match_fields: Optional[Set[str]] = None,
expected_from_states: Optional[Sequence[str]] = None,
) -> Decision:
from_states = _config_from_states(runtime, event_id)
_assert_expected_from_states(
event_id=event_id,
config_from_states=from_states,
expected_from_states=expected_from_states,
)
current_state = _resolve_current_state(
runtime,
node,
candidate_states=from_states,
action_hint=action_hint,
match_fields=match_fields,
)
return _dispatch_on_resolved_state(
runtime,
node=node,
current_state=current_state,
event_id=event_id,
context=context,
)
def e01_bootstrap(
runtime: StateMachineRuntime,
*,
node: "SyncNode",
is_create_zombie: bool,
) -> Decision:
context = {"is_create_zombie": is_create_zombie}
decision = _dispatch_required(
runtime,
node=node,
event_id="E01",
context=context,
expected_from_states=["S16"],
)
if decision.to_state == "S00":
_apply_decision(runtime, node=node, decision=decision, fields={"binding_status", "action", "status"})
node.error = None
return decision
if decision.to_state == "S15":
reason_text = decision_note(decision) or "<no-reason>"
node.append_log(
f"状态机流转: {decision.from_state} --{decision.event_id}[{decision.outcome_index}]--> {decision.to_state} | reason={reason_text}"
)
return decision
raise RuntimeError(f"unexpected bootstrap state: {decision.to_state}")
def e10_bind_core(
runtime: StateMachineRuntime,
*,
node: "SyncNode",
has_binding_record: bool,
core_binding_result: Optional[str] = None,
peer_core_binding_result: Optional[str] = None,
local_data: Optional[Any] = None,
peer_data: Optional[Any] = None,
local_valid: bool = True,
peer_valid: bool = True,
) -> Decision:
if has_binding_record and (core_binding_result is None or peer_core_binding_result is None):
local_result, peer_result = classify_core_pair(
has_binding_record=has_binding_record,
local_data=local_data,
peer_data=peer_data,
local_valid=local_valid,
peer_valid=peer_valid,
)
core_binding_result = local_result.value
peer_core_binding_result = peer_result.value
normalized_core = _as_upper_text(core_binding_result, field_name="core_binding_result") if core_binding_result is not None else None
normalized_peer_core = (
_as_upper_text(peer_core_binding_result, field_name="peer_core_binding_result")
if peer_core_binding_result is not None
else None
)
context: dict[str, Any] = {
"has_binding_record": has_binding_record,
}
if normalized_core is not None:
context["core_binding_result"] = normalized_core
if normalized_peer_core is not None:
context["peer_core_binding_result"] = normalized_peer_core
decision = _dispatch_required(
runtime,
node=node,
event_id="E10",
context=context,
expected_from_states=["S00"],
)
_apply_decision(runtime, node=node, decision=decision, fields={"binding_status"})
return decision
def e20_create_prepare(
runtime: StateMachineRuntime,
*,
node: "SyncNode",
spawn_success: bool,
) -> Decision:
if not spawn_success:
node.append_log("状态机流转: S13 --E20[-1]--> <no-transition> | reason=创建副作用未完成")
return Decision(
event_id="E20",
from_state="S13",
outcome_index=-1,
to_state=None,
emit={"note": "创建副作用未完成"},
)
context = {
"spawn_success": spawn_success,
}
decision = _dispatch_required(
runtime,
node=node,
event_id="E20",
context=context,
expected_from_states=["S13"],
)
_apply_decision(runtime, node=node, decision=decision, fields={"binding_status", "action", "status"})
return decision
def e21_create_prepare(
runtime: StateMachineRuntime,
*,
node: "SyncNode",
create_enabled: bool,
data_present: bool,
create_id_resolve_ok: bool,
) -> Decision:
context = {
"create_enabled": create_enabled,
"data_present": data_present,
"create_id_resolve_ok": create_id_resolve_ok,
}
decision = _dispatch_required(
runtime,
node=node,
event_id="E21",
context=context,
expected_from_states=["S02"],
)
_apply_decision(runtime, node=node, decision=decision, fields={"binding_status"})
return decision
def e22_delete_prepare(
runtime: StateMachineRuntime,
*,
node: "SyncNode",
) -> Decision:
decision = _dispatch_required(
runtime,
node=node,
event_id="E22",
context={},
expected_from_states=["S02"],
)
_apply_decision(runtime, node=node, decision=decision, fields={"binding_status", "action", "status"})
return decision
def e30_update_prepare(
runtime: StateMachineRuntime,
*,
source_node: "SyncNode",
target_node: "SyncNode",
update_enabled: bool,
target_has_data_id: bool,
update_id_resolve_ok: bool,
has_diff: bool,
id_resolve_detail: Optional[str] = None,
) -> Decision:
context = {
"update_enabled": update_enabled,
"target_has_data_id": target_has_data_id,
"update_id_resolve_ok": update_id_resolve_ok,
"has_diff": has_diff,
}
decision = _dispatch_required(
runtime,
node=source_node,
event_id="E30",
context=context,
expected_from_states=["S01"],
)
if decision.to_state not in {"S07", "S08"}:
raise RuntimeError(f"unexpected update-prepare state: {decision.to_state}")
reason_override: Optional[str] = None
if decision.to_state == "S08":
base_reason = decision_note(decision)
details: list[str] = []
if not target_has_data_id:
details.append(f"target_data_id_missing=true")
details.append(f"target_node_id={target_node.node_id}")
if target_has_data_id and not update_id_resolve_ok:
if id_resolve_detail:
details.append(f"update_id_resolve_fail=true")
details.append(f"update_id_resolve_detail={id_resolve_detail}")
else:
details.append("update_id_resolve_fail=true")
if details:
reason_override = f"{base_reason} | {'; '.join(details)}"
target_node.error = reason_override
_apply_decision(
runtime,
node=target_node,
decision=decision,
fields={"action", "status"},
reason_override=reason_override,
)
return decision
def e41_post_create_ready(
runtime: StateMachineRuntime,
*,
node: "SyncNode",
execute_action: str = "CREATE",
) -> Decision:
execute_action_norm = _as_upper_text(execute_action, field_name="execute_action")
context = {
"execute_action": execute_action_norm,
}
decision = _dispatch_required(
runtime,
node=node,
event_id="E41",
context=context,
expected_from_states=["S11C"],
)
_apply_decision(runtime, node=node, decision=decision, fields={"action", "status"})
return decision
def e15_bind_dependency(
runtime: StateMachineRuntime,
*,
node: "SyncNode",
data_present: bool,
has_depend_fields: bool = True,
dep_satisfied: bool = True,
dep_errors: Optional[list[str]] = None,
semantic_errors: Optional[list[str]] = None,
) -> Decision:
if data_present and dep_satisfied:
node.append_log("状态机流转: S02 --E15[-1]--> <no-transition> | reason=依赖满足")
return Decision(
event_id="E15",
from_state="S02",
outcome_index=-1,
to_state=None,
emit={"note": "依赖满足"},
)
context = {
"data_present": data_present,
"has_depend_fields": has_depend_fields,
"dep_satisfied": dep_satisfied,
}
decision = _dispatch_required(
runtime,
node=node,
event_id="E15",
context=context,
expected_from_states=["S02"],
)
reason = decision_note(decision)
if decision.to_state in {"S03", "S04", "S05"}:
reason = _format_dependency_reason(
base_reason=reason,
dep_errors=dep_errors,
semantic_errors=semantic_errors,
)
node.error = reason
_apply_decision(
runtime,
node=node,
decision=decision,
fields={"binding_status"},
reason_override=reason,
)
return decision
def e16_bind_auto(
runtime: StateMachineRuntime,
*,
node: "SyncNode",
auto_bind_enabled: bool,
auto_bind_outcome: Optional[str] = None,
create_enabled: Optional[bool] = None,
id_resolve_detail: Optional[str] = None,
) -> Decision:
auto_bind_outcome_norm = (
_as_upper_text(auto_bind_outcome, field_name="auto_bind_outcome")
if auto_bind_outcome is not None
else None
)
context: dict[str, Any] = {"auto_bind_enabled": auto_bind_enabled}
if auto_bind_outcome_norm is not None:
context["auto_bind_outcome"] = auto_bind_outcome_norm
if create_enabled is not None:
context["create_enabled"] = create_enabled
decision = _dispatch_required(
runtime,
node=node,
event_id="E16",
context=context,
expected_from_states=["S02"],
)
reason_override: Optional[str] = None
if auto_bind_outcome_norm == "KEY_ID_RESOLVE_FAIL" and id_resolve_detail:
base_reason = decision_note(decision)
reason_override = f"{base_reason} | key_id_resolve_fail=true; key_id_resolve_detail={id_resolve_detail}"
node.error = reason_override
_apply_decision(
runtime,
node=node,
decision=decision,
fields={"binding_status", "action", "status"},
reason_override=reason_override,
)
return decision
def e40_sync_execute(
runtime: StateMachineRuntime,
*,
node: "SyncNode",
handler_result: str,
action: str,
poll_timeout: bool,
result_data_id: Optional[str] = None,
) -> Optional[Decision]:
action_norm = _as_upper_text(action, field_name="action")
if action_norm == "CREATE":
start_event_id = "E40C_START"
start_states = ["S06"]
result_event_id = "E40C"
result_states = ["S10C"]
elif action_norm == "UPDATE":
start_event_id = "E40U_START"
start_states = ["S07"]
result_event_id = "E40U"
result_states = ["S10U"]
elif action_norm == "DELETE":
start_event_id = "E40D_START"
start_states = ["S09"]
result_event_id = "E40D"
result_states = ["S10D"]
else:
return None
normalized_result = "FAILED" if poll_timeout else _as_upper_text(handler_result, field_name="handler_result")
if normalized_result == "SKIPPED" and action_norm != "UPDATE":
return None
start_context = {
"poll_timeout": poll_timeout,
"handler_result": "IN_PROGRESS",
"execute_action": action_norm,
}
result_context = {
"poll_timeout": poll_timeout,
"handler_result": normalized_result,
"execute_action": action_norm,
}
decision: Optional[Decision] = None
in_entry_state = any(_state_matches_node(runtime, node, s) for s in start_states)
if in_entry_state:
start_decision = _dispatch_required(
runtime,
node=node,
event_id=start_event_id,
context=start_context,
expected_from_states=start_states,
)
_apply_decision(runtime, node=node, decision=start_decision, fields={"action", "status"})
if normalized_result == "IN_PROGRESS":
return start_decision
if normalized_result == "IN_PROGRESS":
return None
if normalized_result not in {"SUCCESS", "FAILED", "SKIPPED"}:
return None
decision = _dispatch_required(
runtime,
node=node,
event_id=result_event_id,
context=result_context,
action_hint="UPDATE" if action_norm == "UPDATE" else None,
expected_from_states=result_states,
)
if decision is None:
return None
apply_fields: Set[str] = {"action", "status"}
apply_data_id: Optional[str] = None
apply_data_id_field = False
if decision.to_state == "S11C":
if action_norm == "CREATE" and not result_data_id:
raise RuntimeError("E40 SUCCESS for CREATE requires non-empty result_data_id")
if result_data_id:
apply_data_id = result_data_id
apply_data_id_field = True
_apply_decision(runtime, node=node, decision=decision, fields=apply_fields)
if apply_data_id_field:
apply_state_to_node(runtime, node, state_id=decision.to_state, reason=decision_note(decision), fields={"data_id"}, data_id=apply_data_id)
return decision
+10
View File
@@ -0,0 +1,10 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class InvariantViolation:
invariant_id: str
message: str
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Mapping, Optional
import yaml
@dataclass(frozen=True)
class Outcome:
when: Mapping[str, Any]
to: Optional[str] = None
emit: Optional[Mapping[str, Any]] = None
@dataclass(frozen=True)
class Transition:
event_id: str
stage: str
desc: str
from_states: List[str]
outcomes: List[Outcome]
@dataclass(frozen=True)
class StateMachineConfig:
path: Path
raw: Mapping[str, Any]
transitions: Mapping[str, Transition]
invariants: Mapping[str, Mapping[str, Any]]
@staticmethod
def load(path: Path) -> "StateMachineConfig":
with path.open("r", encoding="utf-8") as f:
raw = yaml.safe_load(f)
if not isinstance(raw, dict):
raise TypeError(f"Invalid state machine yaml: {path}")
transitions_raw = raw.get("transitions")
if not isinstance(transitions_raw, dict):
raise TypeError("'transitions' must be a mapping")
parsed: Dict[str, Transition] = {}
for event_id, tdef in transitions_raw.items():
if not isinstance(tdef, dict):
continue
outcomes: List[Outcome] = []
for o in tdef.get("outcomes", []):
if not isinstance(o, dict):
continue
when = o.get("when", {})
if not isinstance(when, dict):
continue
outcomes.append(
Outcome(
when=when,
to=o.get("to") if isinstance(o.get("to"), str) else None,
emit=o.get("emit") if isinstance(o.get("emit"), dict) else None,
)
)
parsed[str(event_id)] = Transition(
event_id=str(event_id),
stage=str(tdef.get("stage", "")),
desc=str(tdef.get("desc", "")),
from_states=[s for s in tdef.get("from", []) if isinstance(s, str)],
outcomes=outcomes,
)
invariants = raw.get("invariants") if isinstance(raw.get("invariants"), dict) else {}
return StateMachineConfig(path=path, raw=raw, transitions=parsed, invariants=invariants)
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
from enum import Enum
from typing import TYPE_CHECKING, Any, List, Optional, Tuple
from ..common.types import BindingStatus
if TYPE_CHECKING:
from ..common.sync_node import SyncNode
class CoreBindingResult(str, Enum):
NORMAL = "NORMAL"
WARNING = "WARNING"
ABNORMAL = "ABNORMAL"
def classify_core_pair(
*,
has_binding_record: bool,
local_data: Optional[Any],
peer_data: Optional[Any],
local_valid: bool = True,
peer_valid: bool = True,
) -> Tuple[CoreBindingResult, CoreBindingResult]:
if not has_binding_record:
raise ValueError("classify_core_pair requires has_binding_record=True")
local_exists = local_data is not None
peer_exists = peer_data is not None
if local_exists and local_valid and peer_exists and peer_valid:
return CoreBindingResult.NORMAL, CoreBindingResult.NORMAL
if local_exists and local_valid and (not peer_exists or not peer_valid):
return CoreBindingResult.WARNING, CoreBindingResult.ABNORMAL
if (not local_exists or not local_valid) and peer_exists and peer_valid:
return CoreBindingResult.ABNORMAL, CoreBindingResult.WARNING
return CoreBindingResult.ABNORMAL, CoreBindingResult.ABNORMAL
def check_dependency_satisfied(
dependency_nodes: List[Optional["SyncNode"]],
field_values: Optional[List[str]] = None,
*,
allow_empty_dependency_ref: bool = True,
allow_missing_dep_data_id: bool = True,
) -> bool:
"""
判断依赖是否满足(供 strategy 组装 E15 上下文)。
该函数本质是“业务语义判断”,状态机只消费最终上下文:
- dep_satisfied (bool)
- dep_errors (结构化错误列表)
关键语义:
1) 空依赖引用可放行(allow_empty_dependency_ref=True
- 例如 contract_id="",表示该字段本轮不参与依赖约束。
2) 依赖节点 data_id 为空可放行(allow_missing_dep_data_id=True
- 这不是严格意义上的“无错误”,而是当前业务策略下的“可继续”。
- 原因:刚创建/回填中的依赖节点可能暂时无 data_id,但业务仍希望继续同步。
- 未来可通过 strategy 配置将其切换为严格阻断。
"""
if not dependency_nodes:
return True
values = field_values or []
for i, dep_node in enumerate(dependency_nodes):
dep_value = values[i] if i < len(values) else ""
dep_value_str = "" if dep_value is None else str(dep_value)
if dep_node is None:
if allow_empty_dependency_ref and dep_value_str == "":
continue
return False
if dep_node.binding_status != BindingStatus.NORMAL:
return False
if not dep_node.data_id and not allow_missing_dep_data_id:
return False
return True
def collect_dependency_errors(
dependency_nodes: List[Optional["SyncNode"]],
field_names: List[str],
field_types: List[str],
field_values: List[str],
*,
allow_empty_dependency_ref: bool = True,
allow_missing_dep_data_id: bool = True,
) -> List[str]:
"""
收集依赖错误(结构化错误码),由 strategy 传给状态机上下文。
设计原则:
- 该函数负责“业务层事实归纳”,不是状态迁移本身。
- 状态机只关心:是否满足(dep_satisfied) 与错误列表(dep_errors)。
关于“依赖项缺乏数据不一定是错”:
- 某些业务场景里,父节点可能刚创建或尚未回填 data_id;
但子节点仍需要进入同步队列,等待后续补齐。
- 因此默认 allow_missing_dep_data_id=True:不产出 no_data_id 错误。
- 后续可由 strategy 配置切换为严格模式(False),届时 no_data_id 将参与阻断。
"""
errors: List[str] = []
for i, dep_node in enumerate(dependency_nodes):
field_name = field_names[i]
field_value = field_values[i] if i < len(field_values) else ""
field_value_str = "" if field_value is None else str(field_value)
if dep_node is None:
if allow_empty_dependency_ref and field_value_str == "":
continue
errors.append(f"not_found|{field_name}|{field_types[i]}|{field_values[i]}")
continue
if dep_node.binding_status != BindingStatus.NORMAL:
errors.append(f"bad_status|{field_name}|{dep_node.binding_status.value}")
continue
if not dep_node.data_id and not allow_missing_dep_data_id:
status_info = f"status={dep_node.status.value}" if dep_node.status else "status=未知"
errors.append(f"no_data_id|{field_name}|{status_info}")
return errors
+118
View File
@@ -0,0 +1,118 @@
from __future__ import annotations
from enum import Enum
from typing import Any, Optional, Set, Type, TypeVar, cast
from ..common.sync_node import SyncNode
from ..common.types import BindingStatus, SyncAction, SyncStatus
from .dispatcher import StateMachineRuntime
E = TypeVar("E", bound=Enum)
def _state_def(runtime: StateMachineRuntime, state_id: str) -> dict[str, Any]:
states = runtime._config.raw.get("states")
if not isinstance(states, dict):
raise RuntimeError("invalid state-machine config: missing states")
state_def = states.get(state_id)
if not isinstance(state_def, dict):
raise RuntimeError(f"unknown state in config: {state_id}")
return state_def
def _to_enum(enum_cls: Type[E], raw_value: Any, field_name: str, state_id: str) -> E:
if not isinstance(raw_value, str):
raise RuntimeError(
f"invalid {field_name} for {state_id}: expected str/list[str], got {type(raw_value).__name__}"
)
try:
return cast(E, enum_cls(raw_value))
except Exception:
raw_lower = str(raw_value).lower()
for member in enum_cls:
if str(member.value).lower() == raw_lower or str(member.name).lower() == raw_lower:
return cast(E, member)
raise RuntimeError(f"invalid {field_name} value for {state_id}: {raw_value}")
def _resolve_enum_value(
*,
state_id: str,
field_name: str,
raw_value: Any,
enum_cls: Type[E],
current_value: E,
) -> E:
if isinstance(raw_value, str):
return _to_enum(enum_cls, raw_value, field_name, state_id)
if isinstance(raw_value, list):
candidates = [_to_enum(enum_cls, v, field_name, state_id) for v in raw_value]
if not candidates:
raise RuntimeError(f"empty {field_name} candidates for {state_id}")
if current_value in candidates:
return current_value
if len(candidates) == 1:
return candidates[0]
raise RuntimeError(
f"ambiguous {field_name} for {state_id}: candidates={[c.value for c in candidates]}, "
f"current={current_value.value}"
)
raise RuntimeError(
f"invalid {field_name} for {state_id}: expected str/list[str], got {type(raw_value).__name__}"
)
def apply_state_to_node(
runtime: StateMachineRuntime,
node: SyncNode,
*,
state_id: Optional[str],
reason: str = "",
fields: Optional[Set[str]] = None,
data_id: Optional[str] = None,
) -> bool:
if not state_id:
return False
state_def = _state_def(runtime, state_id)
selected_fields = fields or {"binding_status", "action", "status"}
with node.allow_core_state_write(source="state_machine"):
if "binding_status" in selected_fields and "binding_status" in state_def:
target_binding = _resolve_enum_value(
state_id=state_id,
field_name="binding_status",
raw_value=state_def["binding_status"],
enum_cls=BindingStatus,
current_value=node.binding_status,
)
node.set_binding_status(target_binding, reason)
if "action" in selected_fields and "action" in state_def:
target_action = _resolve_enum_value(
state_id=state_id,
field_name="action",
raw_value=state_def["action"],
enum_cls=SyncAction,
current_value=node.action,
)
node.set_action(target_action, reason)
if "status" in selected_fields and "status" in state_def:
target_status = _resolve_enum_value(
state_id=state_id,
field_name="status",
raw_value=state_def["status"],
enum_cls=SyncStatus,
current_value=node.status,
)
node.set_status(target_status, reason)
if "data_id" in selected_fields:
if data_id is None:
raise RuntimeError(f"state {state_id} requested data_id update but no data_id provided")
node.set_data_id(data_id, reason)
return True